diff --git a/.gitignore b/.gitignore index 1deb572e..b022edfa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ .claude build src/test/resources/docker/aggregator/mongo-data + +# Genesis the integration stack generates per run +src/test/resources/integration/data/ diff --git a/README.md b/README.md index 4691c4b6..052be61c 100644 --- a/README.md +++ b/README.md @@ -188,4 +188,5 @@ For questions about the Unicity Labs, visit [unicity-labs.com](https://unicity-l - Built on the Unicity network protocol - Uses Jackson for CBOR encoding - Uses Bouncy Castle for cryptographic operations -- Uses OkHttp for Android-compatible HTTP operations \ No newline at end of file +- Uses OkHttp for Android-compatible HTTP operations + diff --git a/build.gradle.kts b/build.gradle.kts index 1571de81..7f17e70d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,12 +7,12 @@ plugins { } group = "org.unicitylabs" -// Use version property if provided, otherwise use default -version = if (project.hasProperty("version")) { - project.property("version").toString() -} else { - "1.1-SNAPSHOT" -} +// Use version property if provided, otherwise use default. hasProperty("version") is always true +// because Gradle defines it, so check for the "unspecified" it holds when -Pversion was not passed. +version = project.property("version") + ?.takeIf { it.toString() != "unspecified" } + ?.toString() + ?: "3.0-SNAPSHOT" repositories { mavenCentral() @@ -57,9 +57,10 @@ dependencies { testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.testcontainers:testcontainers:1.19.8") - testImplementation("org.testcontainers:junit-jupiter:1.19.8") - testImplementation("org.testcontainers:mongodb:1.19.8") + // Core only: the junit-jupiter and mongodb modules were declared but never used, and + // Testcontainers 2.x does not publish them. 2.x carries docker-java 3.7.1, which speaks the + // API version Docker 29 requires; 1.19.8 negotiated 1.32 and could not connect at all. + testImplementation("org.testcontainers:testcontainers:2.0.5") testImplementation("org.awaitility:awaitility:4.2.0") testImplementation("org.slf4j:slf4j-simple:2.0.13") testImplementation("com.google.guava:guava:33.0.0-jre") diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java index 38d71c16..2154fad5 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java @@ -17,33 +17,39 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Certification data. */ public class CertificationData { public static final long CBOR_TAG = 39031; - private static final int VERSION = 1; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 6; private final EncodedPredicate lockScript; private final DataHash sourceStateHash; private final DataHash transactionHash; + private final Long expiresAt; private final byte[] unlockScript; CertificationData( EncodedPredicate lockScript, DataHash sourceStateHash, DataHash transactionHash, + Long expiresAt, byte[] unlockScript ) { this.lockScript = lockScript; this.sourceStateHash = sourceStateHash; this.transactionHash = transactionHash; + this.expiresAt = expiresAt; this.unlockScript = Arrays.copyOf(unlockScript, unlockScript.length); } public int getVersion() { - return CertificationData.VERSION; + return VERSION; } /** @@ -73,6 +79,15 @@ public DataHash getTransactionHash() { return this.transactionHash; } + /** + * Get the exclusive certification request deadline in Unix seconds. + * + * @return request deadline, empty when the Unicity Service assigns one + */ + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); + } + /** * Get unlock script used for certification. * @@ -93,10 +108,10 @@ public static CertificationData fromCbor(byte[] bytes) { if (tag.getTag() != CertificationData.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 5); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != CertificationData.VERSION) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } @@ -104,7 +119,9 @@ public static CertificationData fromCbor(byte[] bytes) { EncodedPredicate.fromCbor(data.get(1)), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(2))), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(3))), - CborDeserializer.decodeByteString(data.get(4)) + CborDeserializer.decodeNullable( + data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()), + CborDeserializer.decodeByteString(data.get(5)) ); } @@ -157,6 +174,7 @@ public static CertificationData fromTransaction(Transaction transaction, byte[] transaction.getLockScript(), transaction.getSourceStateHash(), transaction.calculateTransactionHash(), + transaction.getExpiresAt().orElse(null), unlockScript ); } @@ -170,12 +188,12 @@ public byte[] toCbor() { return CborSerializer.encodeTag( CertificationData.CBOR_TAG, CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(CertificationData.VERSION), + CborSerializer.encodeUnsignedInteger(VERSION), this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), CborSerializer.encodeByteString(this.transactionHash.getData()), - CborSerializer.encodeByteString(this.unlockScript) - ) + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger), + CborSerializer.encodeByteString(this.unlockScript)) ); } @@ -188,19 +206,21 @@ public boolean equals(Object o) { return Objects.equals(this.lockScript, that.lockScript) && Objects.equals(this.sourceStateHash, that.sourceStateHash) && Objects.equals(this.transactionHash, that.transactionHash) + && Objects.equals(this.expiresAt, that.expiresAt) && Arrays.equals(this.unlockScript, that.unlockScript); } @Override public int hashCode() { - return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, Arrays.hashCode(this.unlockScript)); + return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, this.expiresAt, + Arrays.hashCode(this.unlockScript)); } @Override public String toString() { return String.format( - "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, unlockScript=%s}", - this.lockScript, this.sourceStateHash, this.transactionHash, + "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, expiresAt=%s, unlockScript=%s}", + this.lockScript, this.sourceStateHash, this.transactionHash, this.expiresAt, HexConverter.encode(this.unlockScript)); } } diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java index 74d28b77..36d045a7 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java @@ -54,6 +54,15 @@ public final class CertificationStatus { * The certification request failed because request was sent to invalid shard. */ public static final CertificationStatus INVALID_SHARD = new CertificationStatus("INVALID_SHARD"); + /** + * The round's reference time had already reached the request's timeout. + */ + public static final CertificationStatus REQUEST_EXPIRED = + new CertificationStatus("REQUEST_EXPIRED"); + + /** The aggregation service has not obtained a consensus reference time yet. */ + public static final CertificationStatus SERVICE_NOT_READY = + new CertificationStatus("SERVICE_NOT_READY"); private static final CertificationStatus[] VALUES = { SUCCESS, @@ -64,7 +73,9 @@ public final class CertificationStatus { INVALID_SOURCE_STATE_HASH_FORMAT, INVALID_TRANSACTION_HASH_FORMAT, UNSUPPORTED_ALGORITHM, - INVALID_SHARD + INVALID_SHARD, + REQUEST_EXPIRED, + SERVICE_NOT_READY }; private final String value; diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index 89445872..c1435964 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -14,26 +14,36 @@ */ public class InclusionProof { public static final long CBOR_TAG = 39033; - private static final int VERSION = 1; + static final int VERSION = 1; private final InclusionCertificate inclusionCertificate; private final CertificationData certificationData; + private final long referenceTime; private final UnicityCertificate unicityCertificate; + /** + * An InclusionProof describes a certified leaf, so every field is present. The aggregator's + * answer for a state it has not certified yet is not an InclusionProof at all — see + * {@link InclusionProofResponse}, which is the type that can express it. + */ InclusionProof( CertificationData certificationData, + long referenceTime, InclusionCertificate inclusionCertificate, UnicityCertificate unicityCertificate ) { + Objects.requireNonNull(certificationData, "Certification data cannot be null."); + Objects.requireNonNull(inclusionCertificate, "Inclusion certificate cannot be null."); Objects.requireNonNull(unicityCertificate, "Unicity certificate cannot be null."); this.inclusionCertificate = inclusionCertificate; this.certificationData = certificationData; + this.referenceTime = referenceTime; this.unicityCertificate = unicityCertificate; } public int getVersion() { - return InclusionProof.VERSION; + return VERSION; } /** @@ -55,12 +65,25 @@ public UnicityCertificate getUnicityCertificate() { } /** - * Get certification data on inclusion proof, null on non inclusion proof. + * Get certification data of the certified leaf. + * + * @return certification data + */ + public CertificationData getCertificationData() { + return this.certificationData; + } + + /** + * Get the reference time of the round the certified leaf was created in, in Unix seconds. + * + *

It cannot be recovered from the certificate chain: an aggregator serves proofs against the + * current certified root, whose input record time is that of the latest round rather than the + * one the leaf was created under. * - * @return authenticator + * @return reference time */ - public Optional getCertificationData() { - return Optional.ofNullable(this.certificationData); + public long getReferenceTime() { + return this.referenceTime; } /** @@ -74,19 +97,30 @@ public static InclusionProof fromCbor(byte[] bytes) { if (tag.getTag() != InclusionProof.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 4); + List data = CborDeserializer.decodeArray(tag.getData(), 5); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != InclusionProof.VERSION) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } + CertificationData certificationData = + CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor); + Long referenceTime = CborDeserializer.decodeNullable(data.get(2), value -> + CborDeserializer.decodeUnsignedInteger(value).asLong()); + InclusionCertificate inclusionCertificate = + CborDeserializer.decodeNullable(data.get(3), (certificate) -> + InclusionCertificate.decode(CborDeserializer.decodeByteString(certificate))); + if (certificationData == null || referenceTime == null || inclusionCertificate == null) { + throw new CborSerializationException( + "Expected a certified leaf, but the inclusion proof describes none."); + } + return new InclusionProof( - CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor), - CborDeserializer.decodeNullable(data.get(2), (inclusionCertificate) -> - InclusionCertificate.decode(CborDeserializer.decodeByteString(inclusionCertificate)) - ), - UnicityCertificate.fromCbor(data.get(3)) + certificationData, + referenceTime, + inclusionCertificate, + UnicityCertificate.fromCbor(data.get(4)) ); } @@ -96,16 +130,14 @@ public static InclusionProof fromCbor(byte[] bytes) { * @return CBOR bytes */ public byte[] toCbor() { + byte[] payload = CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(VERSION), + this.certificationData.toCbor(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), + CborSerializer.encodeByteString(this.inclusionCertificate.encode()), + this.unicityCertificate.toCbor()); return CborSerializer.encodeTag( InclusionProof.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(InclusionProof.VERSION), - CborSerializer.encodeNullable(this.certificationData, CertificationData::toCbor), - CborSerializer.encodeNullable(this.inclusionCertificate, (inclusionCertificate) -> - CborSerializer.encodeByteString(inclusionCertificate.encode()) - ), - this.unicityCertificate.toCbor() - ) + payload ); } @@ -115,20 +147,21 @@ public boolean equals(Object o) { return false; } InclusionProof that = (InclusionProof) o; - return Objects.equals(this.inclusionCertificate, that.inclusionCertificate) && Objects.equals(this.certificationData, that.certificationData) && Objects.equals(this.unicityCertificate, that.unicityCertificate); + return Objects.equals(this.inclusionCertificate, that.inclusionCertificate) && Objects.equals(this.certificationData, that.certificationData) && Objects.equals(this.referenceTime, that.referenceTime) && Objects.equals(this.unicityCertificate, that.unicityCertificate); } @Override public int hashCode() { - return Objects.hash(this.inclusionCertificate, this.certificationData, this.unicityCertificate); + return Objects.hash(this.inclusionCertificate, this.certificationData, this.referenceTime, this.unicityCertificate); } @Override public String toString() { return String.format( - "InclusionProof{certificationData=%s, inclusionCertificate=%s, unicityCertificate=%s}", - this.inclusionCertificate, + "InclusionProof{certificationData=%s, referenceTime=%s, inclusionCertificate=%s, unicityCertificate=%s}", this.certificationData, + this.referenceTime, + this.inclusionCertificate, this.unicityCertificate ); } diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java index d53e22e2..2b8f9a45 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java @@ -1,35 +1,78 @@ package org.unicitylabs.sdk.api; +import org.unicitylabs.sdk.api.bft.UnicityCertificate; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; /** - * Inclusion proof response. + * What the aggregator answers when asked about a state. + * + *

This is the wire shape, and it has two forms: a certified leaf, or the absence of one. + * Keeping that distinction here rather than inside {@link InclusionProof} is what lets the proof + * itself be complete by construction — a verifier holding one never has to ask whether it + * describes a leaf. */ public class InclusionProofResponse { private final long blockNumber; private final InclusionProof inclusionProof; + private final UnicityCertificate unicityCertificate; - /** - * Create inclison proof response. - * - * @param inclusionProof inclusion proof - */ - InclusionProofResponse( + private InclusionProofResponse( long blockNumber, - InclusionProof inclusionProof + InclusionProof inclusionProof, + UnicityCertificate unicityCertificate ) { this.blockNumber = blockNumber; this.inclusionProof = inclusionProof; + this.unicityCertificate = unicityCertificate; } /** - * Get inclusion proof. + * The aggregator has certified this state. + * + *

The round it was served against is the proof's own, so there is no second certificate to + * supply and none that could disagree with it. * - * @return inclusion proof + * @param blockNumber block number the answer was served at + * @param inclusionProof the certified leaf + * @return the response + */ + public static InclusionProofResponse certified(long blockNumber, InclusionProof inclusionProof) { + return new InclusionProofResponse(blockNumber, inclusionProof, + inclusionProof.getUnicityCertificate()); + } + + /** + * The aggregator has not certified this state yet, so only the round is meaningful. + * + * @param blockNumber block number the answer was served at + * @param unicityCertificate certificate of the round the answer was served against + * @return the response + */ + public static InclusionProofResponse notCertified(long blockNumber, + UnicityCertificate unicityCertificate) { + return new InclusionProofResponse(blockNumber, null, unicityCertificate); + } + + /** + * Get the certificate of the round this answer was served against. Present either way. + * + * @return unicity certificate + */ + public UnicityCertificate getUnicityCertificate() { + return this.unicityCertificate; + } + + /** + * Get the certified leaf, or null when the state is not certified yet. + * + * @return inclusion proof, or null */ public InclusionProof getInclusionProof() { return this.inclusionProof; @@ -43,10 +86,44 @@ public InclusionProof getInclusionProof() { */ public static InclusionProofResponse fromCbor(byte[] bytes) { List data = CborDeserializer.decodeArray(bytes, 2); - return new InclusionProofResponse( - CborDeserializer.decodeUnsignedInteger(data.get(0)).asLong(), - InclusionProof.fromCbor(data.get(1)) - ); + long blockNumber = CborDeserializer.decodeUnsignedInteger(data.get(0)).asLong(); + + CborDeserializer.CborTag tag = CborDeserializer.decodeTag(data.get(1)); + if (tag.getTag() != InclusionProof.CBOR_TAG) { + throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); + } + List proof = CborDeserializer.decodeArray(tag.getData(), 5); + int version = CborDeserializer.decodeUnsignedInteger(proof.get(0)).asInt(); + if (version != InclusionProof.VERSION) { + throw new CborSerializationException(String.format("Unsupported version: %s", version)); + } + + CertificationData certificationData = + CborDeserializer.decodeNullable(proof.get(1), CertificationData::fromCbor); + Long referenceTime = CborDeserializer.decodeNullable(proof.get(2), value -> + CborDeserializer.decodeUnsignedInteger(value).asLong()); + InclusionCertificate inclusionCertificate = + CborDeserializer.decodeNullable(proof.get(3), (certificate) -> + InclusionCertificate.decode(CborDeserializer.decodeByteString(certificate))); + UnicityCertificate unicityCertificate = UnicityCertificate.fromCbor(proof.get(4)); + + // The three leaf fields travel together: all present once the request has been included in a + // certified round, all absent while it is still pending. Anything in between is a protocol + // violation, and rejecting it here is what lets InclusionProof require all three. + long present = Stream.of(certificationData, referenceTime, inclusionCertificate) + .filter(Objects::nonNull) + .count(); + if (present == 0) { + return InclusionProofResponse.notCertified(blockNumber, unicityCertificate); + } + if (present != 3) { + throw new CborSerializationException( + "InclusionProof must carry certification data, reference time and inclusion " + + "certificate together, or none of them."); + } + + return InclusionProofResponse.certified(blockNumber, new InclusionProof(certificationData, + referenceTime, inclusionCertificate, unicityCertificate)); } /** @@ -57,8 +134,26 @@ public static InclusionProofResponse fromCbor(byte[] bytes) { public byte[] toCbor() { return CborSerializer.encodeArray( CborSerializer.encodeUnsignedInteger(this.blockNumber), - this.inclusionProof.toCbor() + this.inclusionProof == null + ? this.encodeNoCertifiedLeaf() + : this.inclusionProof.toCbor() ); } + /** + * Encode the wire form for a state with no certified leaf: the three leaf fields absent, the + * round's certificate still present. + * + * @return CBOR bytes + */ + private byte[] encodeNoCertifiedLeaf() { + return CborSerializer.encodeTag( + InclusionProof.CBOR_TAG, + CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(InclusionProof.VERSION), + CborSerializer.encodeNull(), + CborSerializer.encodeNull(), + CborSerializer.encodeNull(), + this.unicityCertificate.toCbor())); + } } diff --git a/src/main/java/org/unicitylabs/sdk/api/LeafValue.java b/src/main/java/org/unicitylabs/sdk/api/LeafValue.java new file mode 100644 index 00000000..8a90562a --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/api/LeafValue.java @@ -0,0 +1,41 @@ +package org.unicitylabs.sdk.api; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +/** + * Sparse Merkle tree leaf value recorded by the Unicity Service for an accepted + * certification request. + * + *

The value binds the reference time the request was validated under, not the transaction + * hash alone. The tree is append-only, so a leaf can be certified afresh against any later + * root and a later inclusion proof carries a later round's reference time. Binding the + * reference time into the leaf value fixes the value the transition was validated under, for + * any proof of that leaf. + */ +public final class LeafValue { + + private LeafValue() { + } + + /** + * Calculate the leaf value for a certified request. + * + * @param transactionHash transaction hash of the certified request + * @param referenceTime reference time of the round the request was validated in + * + * @return leaf value + */ + public static DataHash calculate(DataHash transactionHash, long referenceTime) { + return new DataHasher(HashAlgorithm.SHA256) + .update( + CborSerializer.encodeArray( + CborSerializer.encodeByteString(transactionHash.getData()), + CborSerializer.encodeUnsignedInteger(referenceTime) + ) + ) + .digest(); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java index e2b185f0..64193040 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java @@ -35,21 +35,44 @@ public class TokenSplit { private TokenSplit() { } + /** Split with a service-assigned burn deadline and a random burn state mask. */ + public static SplitResult split( + Token token, + PaymentDataDeserializer paymentDataDeserializer, + List requests + ) throws LeafExistsException { + return split(token, paymentDataDeserializer, requests, StateMask.generate(), null); + } + + /** Split with a service-assigned burn deadline and the supplied burn state mask. */ + public static SplitResult split( + Token token, + PaymentDataDeserializer paymentDataDeserializer, + List requests, + StateMask burnStateMask + ) throws LeafExistsException { + return split(token, paymentDataDeserializer, requests, burnStateMask, null); + } + /** * Split a token into new outputs with a random burn state mask. * * @param token source token to split (the token being burned) * @param paymentDataDeserializer decoder for the source token's payment data * @param requests per-output mint requests; each carries its own payment data + * @param burnExpiresAt exclusive request deadline of the burn transaction, may be null to let + * the Unicity Service assign one * @return burn predicate, burn transaction and split tokens ready to mint * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, - List requests + List requests, + Long burnExpiresAt ) throws LeafExistsException { - return TokenSplit.split(token, paymentDataDeserializer, requests, StateMask.generate()); + return TokenSplit.split(token, paymentDataDeserializer, requests, StateMask.generate(), + burnExpiresAt); } /** @@ -61,6 +84,8 @@ public static SplitResult split( * @param burnStateMask state mask for the burn transaction; callers needing a crash-resumable * (re-buildable) split supply a deterministically derived mask so the identical burn * transaction can be reconstructed after a failure + * @param burnExpiresAt exclusive request deadline of the burn transaction, may be null to let + * the Unicity Service assign one * @return burn predicate, burn transaction and split tokens ready to mint * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ @@ -68,7 +93,8 @@ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests, - StateMask burnStateMask + StateMask burnStateMask, + Long burnExpiresAt ) throws LeafExistsException { Objects.requireNonNull(token, "Token cannot be null"); Objects.requireNonNull(paymentDataDeserializer, "Payment data deserializer cannot be null"); @@ -140,7 +166,8 @@ public static SplitResult split( token, burnPredicate, burnStateMask, - manifestBytes + manifestBytes, + burnExpiresAt ); List tokens = new ArrayList<>(requestsByTokenId.size()); diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java index cb081297..6bf0d583 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java @@ -62,8 +62,10 @@ public static DefaultBuiltInPredicateVerifier create() { @Override public VerificationResult verify(EncodedPredicate predicate, + long referenceTime, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript) { + DataHash transactionHash, + byte[] unlockScript) { BuiltInPredicateType type = BuiltInPredicateType.fromId( CborDeserializer.decodeUnsignedInteger(predicate.encodeCode()).asInt()); @@ -72,6 +74,6 @@ public VerificationResult verify(EncodedPredicate predicate, throw new IllegalArgumentException("No verifier registered for predicate type: " + type); } - return verifier.verify(predicate, sourceStateHash, transactionHash, unlockScript); + return verifier.verify(predicate, referenceTime, sourceStateHash, transactionHash, unlockScript); } } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java index f6182752..79b44a29 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java @@ -22,11 +22,13 @@ public interface BuiltInPredicateVerifier { * Verifies that the provided unlock script satisfies the predicate in the current context. * * @param predicate the predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being validated * @param unlockScript unlock script bytes provided for the predicate * @return verification result with status and optional diagnostics */ - VerificationResult verify(EncodedPredicate predicate, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript); + VerificationResult verify(EncodedPredicate predicate, long referenceTime, + DataHash sourceStateHash, DataHash transactionHash, + byte[] unlockScript); } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java index 5848c6e5..72981f45 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java @@ -31,6 +31,7 @@ public BuiltInPredicateType getType() { @Override public VerificationResult verify(EncodedPredicate encodedPredicate, + long referenceTime, DataHash sourceStateHash, DataHash transactionHash, byte[] unlockScriptBytes) { diff --git a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java index 915eb097..446c9eeb 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java @@ -22,11 +22,13 @@ public interface PredicateVerifier { * Verifies a predicate in the context of a source state, transaction, and unlock script. * * @param predicate predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being validated * @param unlockScript unlock script bytes * @return verification result with status and diagnostics */ - VerificationResult verify(EncodedPredicate predicate, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript); + VerificationResult verify(EncodedPredicate predicate, long referenceTime, + DataHash sourceStateHash, DataHash transactionHash, + byte[] unlockScript); } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java index 42b5416d..cf35d555 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java @@ -55,6 +55,7 @@ public PredicateVerifierService addVerifier(PredicateVerifier verifier) { * Verifies a predicate by dispatching to a verifier registered for its engine. * * @param predicate predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being verified * @param unlockScript unlock script bytes @@ -63,6 +64,7 @@ public PredicateVerifierService addVerifier(PredicateVerifier verifier) { */ public VerificationResult verify( EncodedPredicate predicate, + long referenceTime, DataHash sourceStateHash, DataHash transactionHash, byte[] unlockScript @@ -73,6 +75,6 @@ public VerificationResult verify( "No verifier registered for predicate engine: " + predicate.getEngine()); } - return verifier.verify(predicate, sourceStateHash, transactionHash, unlockScript); + return verifier.verify(predicate, referenceTime, sourceStateHash, transactionHash, unlockScript); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index b7ccf95f..1159d264 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -7,6 +7,7 @@ import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; @@ -104,6 +105,21 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + @Override + public Optional getExpiresAt() { + return this.transaction.getExpiresAt(); + } + /** + * Get the reference time of the round the leaf was created in, in Unix seconds. + * + *

Read from the proof, which is the authenticated source for it. + * + * @return reference time in Unix seconds + */ + public long getReferenceTime() { + return this.inclusionProof.getReferenceTime(); + } + /** * Deserializes a certified mint transaction from CBOR. * @@ -112,8 +128,8 @@ public InclusionProof getInclusionProof() { */ public static CertifiedMintTransaction fromCbor(byte[] bytes) { List data = CborDeserializer.decodeArray(bytes, 2); - return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), - InclusionProof.fromCbor(data.get(1))); + InclusionProof proof = InclusionProof.fromCbor(data.get(1)); + return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), proof); } /** @@ -167,7 +183,7 @@ public byte[] toCbor() { @Override public String toString() { - return String.format("CertifiedMintTransaction{transaction=%s, inclusionProof=%s}", - this.transaction, this.inclusionProof); + return String.format("CertifiedMintTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", + this.transaction, this.getReferenceTime(), this.inclusionProof); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 70eedb0d..09619edf 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -6,6 +6,7 @@ import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; @@ -66,20 +67,38 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + @Override + public Optional getExpiresAt() { + return this.transaction.getExpiresAt(); + } + /** + * Get the reference time of the round the leaf was created in, in Unix seconds. + * + *

Read from the proof, which is the authenticated source for it. + * + * @return reference time in Unix seconds + */ + public long getReferenceTime() { + return this.inclusionProof.getReferenceTime(); + } + /** * Deserialize a certified transfer transaction from CBOR bytes. * * @param bytes CBOR encoded certified transfer transaction - * @param token token providing the source state for the deserialized transfer + * @param sourceStateHash hash of the state the transfer spends + * @param lockScript lock script the transfer unlocks * * @return certified transfer transaction */ - public static CertifiedTransferTransaction fromCbor(byte[] bytes, Token token) { + public static CertifiedTransferTransaction fromCbor(byte[] bytes, DataHash sourceStateHash, + EncodedPredicate lockScript) { List data = CborDeserializer.decodeArray(bytes, 2); + InclusionProof proof = InclusionProof.fromCbor(data.get(1)); return new CertifiedTransferTransaction( - TransferTransaction.fromCbor(data.get(0), token), - InclusionProof.fromCbor(data.get(1)) + TransferTransaction.fromCbor(data.get(0), sourceStateHash, lockScript), + proof ); } @@ -154,7 +173,7 @@ public byte[] toCbor() { @Override public String toString() { - return String.format("CertifiedTransferTransaction{transaction=%s, inclusionProof=%s}", - this.transaction, this.inclusionProof); + return String.format("CertifiedTransferTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", + this.transaction, this.getReferenceTime(), this.inclusionProof); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java b/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java new file mode 100644 index 00000000..e27791e1 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java @@ -0,0 +1,35 @@ +package org.unicitylabs.sdk.transaction; + +/** + * Validation for the exclusive request deadline carried by a certification request. + */ +public final class ExpiresAt { + + private ExpiresAt() { + } + + /** + * Validate an exclusive request deadline at the boundary that accepts it. + * + *

Zero encodes fine but is expired by construction, since every reference time is at or past + * it. The accepted range is 1 to {@link Long#MAX_VALUE}; the wire admits up to 2^64-1, but a + * value at or above 2^63 arrives as a negative long and is rejected rather than reinterpreted. + * + * @param expiresAt deadline in Unix seconds, or null to let the service assign one + * @return the validated deadline, unchanged + * @throws IllegalArgumentException if the deadline is not a positive number of Unix seconds + */ + public static Long validate(Long expiresAt) { + if (expiresAt == null) { + return null; + } + + if (expiresAt <= 0L) { + throw new IllegalArgumentException( + String.format("Request deadline must be a positive number of Unix seconds, got %s.", + expiresAt)); + } + + return expiresAt; + } +} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java index 01608bfd..ef43a4e2 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -31,7 +31,9 @@ */ public class MintTransaction implements Transaction { public static final long CBOR_TAG = 39041; - private static final int VERSION = 1; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 8; private final MintTransactionState sourceStateHash; private final EncodedPredicate lockScript; @@ -40,6 +42,7 @@ public class MintTransaction implements Transaction { private final TokenSalt salt; private final TokenType tokenType; private final TokenId tokenId; + private final Long expiresAt; private final byte[] justification; private final byte[] data; @@ -51,6 +54,7 @@ private MintTransaction( TokenSalt salt, TokenType tokenType, TokenId tokenId, + Long expiresAt, byte[] justification, byte[] data ) { @@ -61,12 +65,14 @@ private MintTransaction( this.salt = salt; this.tokenType = tokenType; this.tokenId = tokenId; + this.expiresAt = ExpiresAt.validate(expiresAt); this.justification = justification; this.data = data; } - public int getVersion() { - return MintTransaction.VERSION; + @Override + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); } @@ -141,180 +147,125 @@ public StateMask getStateMask() { } /** - * Create a mint transaction. + * Start building a mint transaction. * * @param networkId network identifier * @param recipient recipient predicate - * @param data payload bytes, may be null - * @param tokenType token type identifier - * @param salt mint-transaction salt - * @param justification mint justification bytes, may be null * - * @return mint transaction + * @return mint transaction builder */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - byte[] data, - TokenType tokenType, - TokenSalt salt, - byte[] justification - ) { - Objects.requireNonNull(networkId, "Network id cannot be null"); - Objects.requireNonNull(recipient, "Recipient cannot be null"); - Objects.requireNonNull(tokenType, "Token type cannot be null"); - Objects.requireNonNull(salt, "Salt cannot be null"); - - TokenId tokenId = TokenId.fromSalt(networkId, salt); - SigningService signingService = MintSigningService.create(tokenId); - return new MintTransaction( - MintTransactionState.create(tokenId), - EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)), - networkId, - EncodedPredicate.fromPredicate(recipient), - salt, - tokenType, - tokenId, - justification != null ? Arrays.copyOf(justification, justification.length) : null, - data != null ? Arrays.copyOf(data, data.length) : null - ); + public static Builder builder(NetworkId networkId, Predicate recipient) { + return new Builder(networkId, recipient); } /** - * Create a mint transaction without a justification. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param data payload bytes, may be null - * @param tokenType token type identifier - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - byte[] data, - TokenType tokenType, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, data, tokenType, salt, null); - } - - /** - * Create a mint transaction with a fresh random salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param data payload bytes, may be null - * @param tokenType token type identifier - * - * @return mint transaction + * Builds a {@link MintTransaction}. The network id and recipient are required and are supplied + * to {@link MintTransaction#builder}; everything else is optional and named, so adding a further + * optional field later does not disturb existing call sites. */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - byte[] data, - TokenType tokenType - ) { - return MintTransaction.create(networkId, recipient, data, tokenType, TokenSalt.generate()); - } + public static final class Builder { + + private final NetworkId networkId; + private final Predicate recipient; + private TokenType tokenType; + private TokenSalt salt; + private byte[] data; + private byte[] justification; + private Long expiresAt; + + private Builder(NetworkId networkId, Predicate recipient) { + this.networkId = Objects.requireNonNull(networkId, "Network id cannot be null"); + this.recipient = Objects.requireNonNull(recipient, "Recipient cannot be null"); + } - /** - * Create a mint transaction with a generated token type. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param data payload bytes, may be null - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - byte[] data, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, data, TokenType.generate(), salt); - } + /** + * Sets the token type. Defaults to a freshly generated type. + * + * @param tokenType token type identifier + * + * @return this builder + */ + public Builder tokenType(TokenType tokenType) { + this.tokenType = tokenType; + return this; + } - /** - * Create a mint transaction with no data. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param tokenType token type identifier - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - TokenType tokenType, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, (byte[]) null, tokenType, salt); - } + /** + * Sets the mint-transaction salt. Defaults to a freshly generated salt. + * + * @param salt mint-transaction salt + * + * @return this builder + */ + public Builder salt(TokenSalt salt) { + this.salt = salt; + return this; + } - /** - * Create a mint transaction with a generated token type and salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param data payload bytes, may be null - * - * @return mint transaction - */ - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data) { - return MintTransaction.create(networkId, recipient, data, TokenType.generate()); - } + /** + * Sets the payload bytes. + * + * @param data payload bytes, may be null + * + * @return this builder + */ + public Builder data(byte[] data) { + this.data = data != null ? Arrays.copyOf(data, data.length) : null; + return this; + } - /** - * Create a mint transaction with no data and a generated salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param tokenType token type identifier - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - TokenType tokenType - ) { - return MintTransaction.create(networkId, recipient, (byte[]) null, tokenType); - } + /** + * Sets the mint justification bytes. + * + * @param justification mint justification bytes, may be null + * + * @return this builder + */ + public Builder justification(byte[] justification) { + this.justification = + justification != null ? Arrays.copyOf(justification, justification.length) : null; + return this; + } - /** - * Create a mint transaction with no data and a generated token type. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, TokenType.generate(), salt); - } + /** + * Sets the exclusive certification request deadline, in Unix seconds. + * + *

Leave it unset, or pass null, to let the Unicity Service assign a deadline from consensus + * time. That requires no local clock, and the assigned value is not recorded in the token. + * + * @param expiresAt exclusive request deadline, may be null + * + * @return this builder + */ + public Builder expiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + return this; + } - /** - * Create a mint transaction with no data, generated token type and salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * - * @return mint transaction - */ - public static MintTransaction create(NetworkId networkId, Predicate recipient) { - return MintTransaction.create(networkId, recipient, (byte[]) null); + /** + * Builds the mint transaction, deriving the token id, lock script and mint state. + * + * @return mint transaction + */ + public MintTransaction build() { + TokenType type = this.tokenType != null ? this.tokenType : TokenType.generate(); + TokenSalt mintSalt = this.salt != null ? this.salt : TokenSalt.generate(); + + TokenId tokenId = TokenId.fromSalt(this.networkId, mintSalt); + SigningService signingService = MintSigningService.create(tokenId); + return new MintTransaction( + MintTransactionState.create(tokenId), + EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)), + this.networkId, + EncodedPredicate.fromPredicate(this.recipient), + mintSalt, + type, + tokenId, + this.expiresAt, + this.justification, + this.data + ); + } } /** @@ -329,21 +280,27 @@ public static MintTransaction fromCbor(byte[] bytes) { if (tag.getTag() != MintTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 7); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != MintTransaction.VERSION) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } - return MintTransaction.create( - NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), - EncodedPredicate.fromCbor(data.get(2)), - CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString), - TokenType.fromCbor(data.get(4)), - TokenSalt.fromCbor(data.get(3)), - CborDeserializer.decodeNullable(data.get(5), CborDeserializer::decodeByteString) - ); + return MintTransaction + .builder( + NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), + EncodedPredicate.fromCbor(data.get(2))) + .salt(TokenSalt.fromCbor(data.get(3))) + .tokenType(TokenType.fromCbor(data.get(4))) + .justification( + CborDeserializer.decodeNullable(data.get(5), CborDeserializer::decodeByteString)) + .data(CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString)) + .expiresAt( + CborDeserializer.decodeNullable( + data.get(7), + value -> CborDeserializer.decodeUnsignedInteger(value).asLong())) + .build(); } /** @@ -383,14 +340,14 @@ public byte[] toCbor() { return CborSerializer.encodeTag( MintTransaction.CBOR_TAG, CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(MintTransaction.VERSION), + CborSerializer.encodeUnsignedInteger(VERSION), CborSerializer.encodeUnsignedInteger(this.networkId.getId()), this.recipient.toCbor(), this.salt.toCbor(), this.tokenType.toCbor(), CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) - ) + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger)) ); } @@ -415,8 +372,8 @@ public CertifiedMintTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, data=%s}", + "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, expiresAt=%s, data=%s}", this.sourceStateHash, this.lockScript, this.networkId, this.recipient, this.salt, - this.tokenType, this.tokenId, HexConverter.encode(this.data)); + this.tokenType, this.tokenId, this.expiresAt, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Token.java b/src/main/java/org/unicitylabs/sdk/transaction/Token.java index 7ae82ac5..621bc8b9 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Token.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Token.java @@ -20,7 +20,13 @@ */ public final class Token { public static final long CBOR_TAG = 39040; - private static final int VERSION = 1; + /** + * The only accepted wire version. Bumped with the certified-transaction element counts and the + * transaction encodings below them: without it a token written by an older SDK passes the + * version check here and then dies deeper down on a CBOR array-length error that never mentions + * versioning. + */ + private static final int VERSION = 2; private final CertifiedMintTransaction genesis; private final List transactions; @@ -108,9 +114,16 @@ public static Token fromCbor(byte[] bytes) { CertifiedMintTransaction genesis = CertifiedMintTransaction.fromCbor(data.get(1)); List transactionsCbor = CborDeserializer.decodeArray(data.get(2)); + // Each transfer spends the state the previous one produced. Deriving that here, rather than + // handing the decoder a half-built token to read it back off, is what makes the chain + // explicit — and lets a Token be constructed once, from a finished list. List transactions = new ArrayList<>(); + Transaction previous = genesis; for (byte[] transaction : transactionsCbor) { - transactions.add(CertifiedTransferTransaction.fromCbor(transaction, new Token(genesis, transactions))); + CertifiedTransferTransaction decoded = CertifiedTransferTransaction.fromCbor( + transaction, previous.calculateStateHash(), previous.getRecipient()); + transactions.add(decoded); + previous = decoded; } return new Token(genesis, transactions); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java index 87bc0695..fafe381c 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java @@ -45,6 +45,15 @@ public interface Transaction { */ StateMask getStateMask(); + /** + * Exclusive certification request deadline in Unix seconds, or empty when the Unicity Service + * assigns one from consensus time. It occupies a fixed position in the encoding and is committed + * by the transaction hash either way. + * + * @return request deadline + */ + Optional getExpiresAt(); + /** * Calculates the resulting state hash. * diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index cc90be53..446099fe 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -22,11 +22,14 @@ */ public class TransferTransaction implements Transaction { public static final long CBOR_TAG = 39045; - private static final int VERSION = 1; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 5; private final DataHash sourceStateHash; private final EncodedPredicate lockScript; private final EncodedPredicate recipient; + private final Long expiresAt; private final StateMask stateMask; private final byte[] data; @@ -34,21 +37,18 @@ private TransferTransaction( DataHash sourceStateHash, EncodedPredicate lockScript, EncodedPredicate recipient, + Long expiresAt, StateMask stateMask, byte[] data ) { this.sourceStateHash = sourceStateHash; this.lockScript = lockScript; this.recipient = recipient; + this.expiresAt = ExpiresAt.validate(expiresAt); this.stateMask = stateMask; this.data = data; } - public int getVersion() { - return TransferTransaction.VERSION; - } - - @Override public Optional getData() { return Optional.ofNullable(this.data != null ? Arrays.copyOf(this.data, this.data.length) : null); @@ -74,6 +74,11 @@ public StateMask getStateMask() { return this.stateMask; } + @Override + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); + } + /** * Creates a transfer transaction from the latest state of the provided token. * @@ -81,43 +86,70 @@ public StateMask getStateMask() { * @param recipient recipient predicate * @param stateMask transaction randomness component * @param data transfer payload + * @param expiresAt exclusive request deadline, may be null to let the service assign one * @return created transfer transaction */ public static TransferTransaction create(Token token, Predicate recipient, - StateMask stateMask, byte[] data) { + StateMask stateMask, byte[] data, Long expiresAt) { Transaction transaction = token.getLatestTransaction(); return new TransferTransaction( transaction.calculateStateHash(), transaction.getRecipient(), EncodedPredicate.fromPredicate(recipient), + expiresAt, stateMask, data ); } + /** + * Creates a transfer whose deadline is assigned by the Unicity Service, which requires no local + * clock. + * + * @param token token whose latest transaction is used as the source + * @param recipient recipient predicate + * @param stateMask transaction randomness component + * @param data transfer payload + * @return created transfer transaction + */ + public static TransferTransaction create(Token token, Predicate recipient, + StateMask stateMask, byte[] data) { + return create(token, recipient, stateMask, data, null); + } + /** * Deserializes a transfer transaction from CBOR bytes. * + *

The state being spent and the lock script over it are chain context rather than part of the + * encoded transfer, so the caller supplies them. Both are checked against the certification data + * during verification, so a wrong value fails there rather than yielding a transaction that looks + * valid. + * * @param bytes CBOR-encoded transfer transaction - * @param token token providing the source state for the deserialized transfer + * @param sourceStateHash hash of the state the transaction spends + * @param lockScript lock script the transaction unlocks * @return decoded transfer transaction */ - public static TransferTransaction fromCbor(byte[] bytes, Token token) { + public static TransferTransaction fromCbor(byte[] bytes, DataHash sourceStateHash, + EncodedPredicate lockScript) { CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); if (tag.getTag() != TransferTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 4); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != TransferTransaction.VERSION) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } - return TransferTransaction.create( - token, + return new TransferTransaction( + sourceStateHash, + lockScript, EncodedPredicate.fromCbor(data.get(1)), + CborDeserializer.decodeNullable( + data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()), StateMask.fromCbor(data.get(2)), CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) ); @@ -147,11 +179,11 @@ public byte[] toCbor() { return CborSerializer.encodeTag( TransferTransaction.CBOR_TAG, CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(TransferTransaction.VERSION), + CborSerializer.encodeUnsignedInteger(VERSION), EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) - ) + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger)) ); } @@ -179,8 +211,8 @@ public CertifiedTransferTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, stateMask=%s, data=%s}", - this.sourceStateHash, this.lockScript, this.recipient, this.stateMask, + "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, expiresAt=%s, stateMask=%s, data=%s}", + this.sourceStateHash, this.lockScript, this.recipient, this.expiresAt, this.stateMask, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java index 675efb54..02a9af79 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java @@ -53,10 +53,7 @@ public static VerificationResult verify( EncodedPredicate expectedLockScript = EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)); VerificationResult result = expectedLockScript .equals( - transaction.getInclusionProof() - .getCertificationData() - .map(CertificationData::getLockScript) - .orElse(null) + transaction.getInclusionProof().getCertificationData().getLockScript() ) ? new VerificationResult<>("IsLockScriptValidVerificationRule", VerificationStatus.OK) : new VerificationResult<>("IsLockScriptValidVerificationRule", VerificationStatus.FAIL); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java index 02d59610..5b6c6014 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -1,7 +1,9 @@ package org.unicitylabs.sdk.transaction.verification; import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.InclusionCertificate; import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.LeafValue; import org.unicitylabs.sdk.api.StateId; import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.api.bft.verification.UnicityCertificateVerification; @@ -41,18 +43,8 @@ public class InclusionProofVerificationRule { public static VerificationResult verify(RootTrustBase trustBase, PredicateVerifierService predicateVerifier, InclusionProof inclusionProof, Transaction transaction) { - if (inclusionProof.getInclusionCertificate() == null) { - return new VerificationResult<>( - "InclusionProofVerificationRule", - InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING - ); - } - - CertificationData certificationData = inclusionProof.getCertificationData().orElse(null); - if (certificationData == null) { - return new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA); - } + CertificationData certificationData = inclusionProof.getCertificationData(); + long referenceTime = inclusionProof.getReferenceTime(); if (!certificationData.getTransactionHash().equals(transaction.calculateTransactionHash())) { return new VerificationResult<>("InclusionProofVerificationRule", @@ -60,13 +52,33 @@ public static VerificationResult verify(RootTr } if (!certificationData.getLockScript().equals(transaction.getLockScript()) - || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash())) { + || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash()) + || !certificationData.getExpiresAt().equals(transaction.getExpiresAt())) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH); } + // Admissible only in a round strictly below the deadline; both sides are Unix seconds of + // consensus time. Unsigned: a CBOR uint at or above 2^63 arrives as a negative long, and a + // signed comparison would wave an expired request through. + if (transaction.getExpiresAt().isPresent() + && Long.compareUnsigned(referenceTime, transaction.getExpiresAt().get()) >= 0) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.REQUEST_EXPIRED); + } + + // A leaf cannot postdate the round that certified it, and consensus signs that timestamp. + // One-sided: it does not detect back-dating. See aggregator-go#186. + if (Long.compareUnsigned( + referenceTime, + inclusionProof.getUnicityCertificate().getInputRecord().getTimestamp()) > 0) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND); + } + StateId stateId = StateId.fromTransaction(transaction); - if (!inclusionProof.getInclusionCertificate().verify(stateId, certificationData.getTransactionHash(), new DataHash(HashAlgorithm.SHA256, inclusionProof.getUnicityCertificate().getInputRecord().getHash()))) { + DataHash leafValue = LeafValue.calculate(certificationData.getTransactionHash(), referenceTime); + if (!inclusionProof.getInclusionCertificate().verify(stateId, leafValue, new DataHash(HashAlgorithm.SHA256, inclusionProof.getUnicityCertificate().getInputRecord().getHash()))) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.PATH_INVALID); } @@ -96,6 +108,7 @@ public static VerificationResult verify(RootTr result = predicateVerifier.verify( transaction.getLockScript(), + referenceTime, transaction.getSourceStateHash(), certificationData.getTransactionHash(), certificationData.getUnlockScript() diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java index 07acdda2..13822bfe 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -6,18 +6,22 @@ public enum InclusionProofVerificationStatus { /** The provided trust base is invalid or cannot be used for verification. */ INVALID_TRUSTBASE, - /** Certification data required for verification is missing. */ - MISSING_CERTIFICATION_DATA, /** Certification lock script or source state hash does not match the reconstructed transaction. */ CERTIFICATION_DATA_MISMATCH, /** Transaction hash does not match the value referenced by the proof. */ TRANSACTION_HASH_MISMATCH, + /** The round's reference time had already reached the request's timeout. */ + /** + * The leaf claims a reference time later than the round that certified it, which no honest + * service can produce. + */ + REFERENCE_TIME_AFTER_ROUND, + REQUEST_EXPIRED, /** Proof authentication failed. */ NOT_AUTHENTICATED, /** Proof path is not included in the committed tree state. */ PATH_NOT_INCLUDED, - INCLUSION_CERTIFICATE_MISSING, /** Proof path structure or hashes are invalid. */ PATH_INVALID, /** Shard id of the unicity certificate does not match the transaction state id. */ diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index 43880682..4da35238 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -97,26 +97,28 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { - VerificationResult result = InclusionProofVerificationRule.verify( - trustBase, predicateVerifier, response.getInclusionProof(), transaction); - switch (result.getStatus()) { - case OK: - future.complete(response.getInclusionProof()); - break; - case INCLUSION_CERTIFICATE_MISSING: - CompletableFuture.delayedExecutor(intervalMillis, TimeUnit.MILLISECONDS) - .execute(() -> checkInclusionProof(client, trustBase, predicateVerifier, transaction, - future, startTime, - timeoutMillis, - intervalMillis)); - break; - default: - future.completeExceptionally( - new VerificationException("Inclusion proof verification failed", result)); + InclusionProof inclusionProof = response.getInclusionProof(); + // The aggregator has not certified this state yet. The response type is what says so; by the + // time a proof exists it is complete, so there is nothing else to poll through. + if (inclusionProof == null) { + CompletableFuture.delayedExecutor(intervalMillis, TimeUnit.MILLISECONDS) + .execute(() -> checkInclusionProof(client, trustBase, predicateVerifier, transaction, + future, startTime, timeoutMillis, intervalMillis)); + return; + } + + VerificationResult result = + InclusionProofVerificationRule.verify( + trustBase, predicateVerifier, inclusionProof, transaction); + if (result.getStatus() == InclusionProofVerificationStatus.OK) { + future.complete(inclusionProof); + } else { + future.completeExceptionally( + new VerificationException("Inclusion proof verification failed", result)); } }).exceptionally(e -> { future.completeExceptionally(e); return null; }); } -} \ No newline at end of file +} diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 3f0d7467..81cf9a1e 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -20,7 +20,14 @@ public class TestAggregatorClient implements AggregatorClient { private final PredicateVerifierService predicateVerifier; private final SparseMerkleTree sparseMerkleTree; private final HashMap requests = new HashMap<>(); + private final HashMap referenceTimes = new HashMap<>(); private final SigningService signingService; + /** + * Reference time of the current round. Every accepted request is its own round here, so a + * proof served later is anchored to a certificate whose input record time is past the one + * the leaf was built from, exactly as it is against a live aggregator. + */ + private long referenceTime = System.currentTimeMillis() / 1000; private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService) { this.sparseMerkleTree = smt; @@ -29,6 +36,7 @@ private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService this.predicateVerifier = PredicateVerifierService.create(); } + public RootTrustBase getTrustBase() { return this.trustBase; } @@ -61,6 +69,7 @@ public CompletableFuture submitCertificationRequest(Certi VerificationResult result = this.predicateVerifier.verify( certificationData.getLockScript(), + this.referenceTime, certificationData.getSourceStateHash(), certificationData.getTransactionHash(), certificationData.getUnlockScript() @@ -70,10 +79,19 @@ public CompletableFuture submitCertificationRequest(Certi return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SIGNATURE_VERIFICATION_FAILED)); } + if (certificationData.getExpiresAt().isPresent() + && this.referenceTime >= certificationData.getExpiresAt().get()) { + return CompletableFuture.completedFuture( + CertificationResponse.create(CertificationStatus.REQUEST_EXPIRED)); + } + if (!this.requests.containsKey(stateId)) { - DataHash leafValue = certificationData.getTransactionHash(); + DataHash leafValue = + LeafValue.calculate(certificationData.getTransactionHash(), this.referenceTime); this.sparseMerkleTree.addLeaf(stateId.getData(), leafValue.getData()); this.requests.put(stateId, certificationData); + this.referenceTimes.put(stateId, this.referenceTime); + this.referenceTime += 1; } return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SUCCESS)); @@ -87,7 +105,8 @@ public CompletableFuture getInclusionProof(StateId state SparseMerkleTreeRootNode root = this.sparseMerkleTree.calculateRoot(); if (!requests.containsKey(stateId)) { - return CompletableFuture.completedFuture(InclusionProofFixture.createResponse(null, null, root.getHash(), this.signingService)); + return CompletableFuture.completedFuture(InclusionProofFixture.createPendingResponse( + root.getHash(), this.signingService, this.referenceTime)); } CertificationData certificationData = requests.get(stateId); @@ -95,9 +114,11 @@ public CompletableFuture getInclusionProof(StateId state return CompletableFuture.completedFuture( InclusionProofFixture.createResponse( certificationData, + this.referenceTimes.get(stateId), InclusionCertificate.create(root, stateId.getData()), root.getHash(), - this.signingService + this.signingService, + this.referenceTime ) ); } diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java index 6bcff265..eba9cd3b 100644 --- a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; +import org.unicitylabs.sdk.utils.ExpiresAt; public class TestApiKeyIntegration { @@ -41,10 +42,9 @@ void setUp() throws Exception { SigningService signingService = new SigningService( HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); - MintTransaction transaction = MintTransaction.create( - NetworkId.LOCAL, - SignaturePredicate.fromSigningService(signingService) - ); + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .expiresAt(ExpiresAt.expiresAt()) + .build(); certificationData = CertificationData.fromMintTransaction(transaction); } diff --git a/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java new file mode 100644 index 00000000..646cfed3 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java @@ -0,0 +1,61 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.util.HexConverter; + +/** + * Pins the certification-data encoding against the vectors the TypeScript and Rust SDKs and the + * aggregator assert on. The bytes must be identical across implementations, both when the sender + * chooses a deadline and when it leaves the choice to the Unicity Service. + */ +public class CrossSdkEncodingTest { + + private static final byte[] PUBLIC_KEY = + HexConverter.decode("02ce9f22e51333c97a8fb1f807a229ece3a8765a16af5fc1a13e30834be3280026"); + private static final long EXPIRES_AT = 1755000000L; + + private static final String EXPLICIT = + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + + "5820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc" + + "0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb04" + + "7c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00"; + private static final String ABSENT = + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + + "5820c034e096d7bdf71ba759558663b5cafb7279ecb7e284443e5e6cbce0461aceeef6584154" + + "ca6b19a7dbcae7a6adc38af5c8672f81943ecaf51345436684299b4b7ac81a57db2653f32048" + + "981e37913db4749ca08d998d1fac4a52ab5579988bc2c50de900"; + + private static MintTransaction mint(Long expiresAt) { + return MintTransaction + .builder(NetworkId.MAINNET, SignaturePredicate.create(PUBLIC_KEY)) + .tokenType(new TokenType(new byte[32])) + .salt(TokenSalt.fromBytes(new byte[32])) + .expiresAt(expiresAt) + .build(); + } + + @Test + public void explicitDeadlineMatchesTheSharedVector() { + MintTransaction transaction = mint(EXPIRES_AT); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertEquals(EXPIRES_AT, transaction.getExpiresAt().orElseThrow(AssertionError::new)); + Assertions.assertEquals(EXPLICIT, HexConverter.encode(certificationData.toCbor())); + } + + @Test + public void absentDeadlineMatchesTheSharedVector() { + MintTransaction transaction = mint(null); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertFalse(transaction.getExpiresAt().isPresent()); + Assertions.assertEquals(ABSENT, HexConverter.encode(certificationData.toCbor())); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java index 38c5ce35..d71dd710 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java @@ -1,18 +1,27 @@ package org.unicitylabs.sdk.api; +import org.unicitylabs.sdk.api.bft.UnicityCertificate; import org.unicitylabs.sdk.api.bft.UnicityCertificateUtils; import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; public class InclusionProofFixture { - public static InclusionProofResponse createResponse(CertificationData certificationData, InclusionCertificate inclusionCertificate, DataHash root, SigningService signingService) { - return new InclusionProofResponse( + public static InclusionProofResponse createResponse(CertificationData certificationData, + long referenceTime, InclusionCertificate inclusionCertificate, DataHash root, + SigningService signingService, long certificateTimestamp) { + UnicityCertificate unicityCertificate = UnicityCertificateUtils.generateCertificate( + signingService, root, certificateTimestamp); + + return InclusionProofResponse.certified( 1L, - new InclusionProof( - certificationData, - inclusionCertificate, - UnicityCertificateUtils.generateCertificate(signingService, root) - ) - ); + new InclusionProof(certificationData, referenceTime, inclusionCertificate, + unicityCertificate)); + } + + /** The answer for a state the aggregator has not certified yet. */ + public static InclusionProofResponse createPendingResponse(DataHash root, + SigningService signingService, long certificateTimestamp) { + return InclusionProofResponse.notCertified(1L, + UnicityCertificateUtils.generateCertificate(signingService, root, certificateTimestamp)); } } diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index 2a1a070c..20452e86 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -1,13 +1,18 @@ package org.unicitylabs.sdk.api; +import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.unicitylabs.sdk.api.NetworkId; import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.api.bft.RootTrustBaseUtils; import org.unicitylabs.sdk.api.bft.ShardId; import org.unicitylabs.sdk.api.bft.UnicityCertificate; +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.api.bft.UnicityCertificateUtils; import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; @@ -21,10 +26,13 @@ import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; import org.unicitylabs.sdk.util.HexConverter; +import org.unicitylabs.sdk.utils.ExpiresAt; @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class InclusionProofTest { + static final long REFERENCE_TIME = 1755000000L; + MintTransaction transaction; PredicateVerifierService predicateVerifier; StateId stateId; @@ -32,6 +40,7 @@ public class InclusionProofTest { CertificationData certificationData; RootTrustBase trustBase; UnicityCertificate unicityCertificate; + DataHash rootHash; @BeforeAll public void createMerkleTreePath() throws Exception { @@ -39,18 +48,19 @@ public void createMerkleTreePath() throws Exception { HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); - transaction = MintTransaction.create( - NetworkId.LOCAL, - SignaturePredicate.fromSigningService(signingService) - ); + transaction = MintTransaction.builder(NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .expiresAt(ExpiresAt.expiresAt()) + .build(); certificationData = CertificationData.fromMintTransaction(transaction); stateId = StateId.fromCertificationData(certificationData); SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); - smt.addLeaf(stateId.getData(), certificationData.getTransactionHash().getData()); + smt.addLeaf(stateId.getData(), + LeafValue.calculate(certificationData.getTransactionHash(), REFERENCE_TIME).getData()); SparseMerkleTreeRootNode root = smt.calculateRoot(); + rootHash = root.getHash(); inclusionCertificate = InclusionCertificate.create(root, stateId.getData()); // Reuse user signing service as unicity certificate signing service. trustBase = RootTrustBaseUtils.generateRootTrustBase(signingService.getPublicKey()); @@ -62,6 +72,7 @@ public void createMerkleTreePath() throws Exception { public void testCborSerialization() { InclusionProof inclusionProof = new InclusionProof( certificationData, + REFERENCE_TIME, inclusionCertificate, unicityCertificate ); @@ -69,11 +80,54 @@ public void testCborSerialization() { Assertions.assertEquals(inclusionProof, InclusionProof.fromCbor(inclusionProof.toCbor())); } + /** + * A proof either establishes a leaf or reports that there is none yet. The aggregators emit all + * three leaf fields together or none of them, so a partially present proof is a protocol + * violation and is rejected at decode rather than surfacing as an empty Optional downstream. + */ + @Test + public void rejectsAPartiallyPresentProof() { + byte[] complete = new InclusionProof(certificationData, REFERENCE_TIME, inclusionCertificate, + unicityCertificate).toCbor(); + List fields = CborDeserializer.decodeArray( + CborDeserializer.decodeTag(complete).getData(), 5); + byte[] nul = CborSerializer.encodeNull(); + + byte[][][] partial = { + {fields.get(1), fields.get(2), nul}, + {fields.get(1), nul, fields.get(3)}, + {nul, fields.get(2), fields.get(3)}, + {nul, nul, fields.get(3)}, + }; + + for (byte[][] combination : partial) { + byte[] encoded = CborSerializer.encodeTag( + InclusionProof.CBOR_TAG, + CborSerializer.encodeArray(fields.get(0), combination[0], combination[1], + combination[2], fields.get(4))); + + Assertions.assertThrows( + CborSerializationException.class, + () -> InclusionProof.fromCbor(encoded)); + } + } + + // The wire form also expresses "no leaf yet". That is not an InclusionProof — the response is + // the type that carries it, and asking for a proof anyway is an error rather than a null. + @Test + public void decodesTheAbsentFormAsAnAbsence() { + byte[] encoded = CborDeserializer.decodeArray( + InclusionProofResponse.notCertified(1L, unicityCertificate).toCbor(), 2).get(1); + + Assertions.assertThrows(CborSerializationException.class, () -> InclusionProof.fromCbor(encoded)); + } + @Test public void testStructure() { Assertions.assertThrows(NullPointerException.class, () -> new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, null ) @@ -81,13 +135,7 @@ public void testStructure() { Assertions.assertInstanceOf(InclusionProof.class, new InclusionProof( this.certificationData, - this.inclusionCertificate, - this.unicityCertificate - ) - ); - Assertions.assertInstanceOf(InclusionProof.class, - new InclusionProof( - null, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ) @@ -98,6 +146,7 @@ public void testStructure() { public void testItVerifies() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -118,8 +167,10 @@ public void testItVerifies() { DataHash.fromImprint( HexConverter.decode("00000000000000000000000000000000000000000000000000000000000000000001") ), + this.certificationData.getExpiresAt().orElse(null), this.certificationData.getUnlockScript() ), + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -142,11 +193,13 @@ public void testItNotAuthenticated() { this.certificationData.getLockScript(), this.certificationData.getSourceStateHash(), this.certificationData.getTransactionHash(), + this.certificationData.getExpiresAt().orElse(null), SignaturePredicateUnlockScript.create( this.transaction, new SigningService(SigningService.generatePrivateKey()) ).encode() ), + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -180,6 +233,7 @@ public void testItFailsWithShardIdMismatch() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, mismatchingCertificate ); @@ -195,10 +249,109 @@ public void testItFailsWithShardIdMismatch() { ); } + @Test + public void testVerificationFailsWhenReferenceTimeReachesTheTimeout() throws Exception { + // A leaf whose deadline the round it was created in had already reached. The deadline is + // exclusive, so equality is already too late. + SigningService signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); + MintTransaction expired = MintTransaction.builder( + NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .salt(this.transaction.getSalt()) + .tokenType(this.transaction.getTokenType()) + .expiresAt(REFERENCE_TIME) + .build(); + CertificationData expiredData = CertificationData.fromMintTransaction(expired); + StateId expiredStateId = StateId.fromCertificationData(expiredData); + + SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); + smt.addLeaf(expiredStateId.getData(), + LeafValue.calculate(expiredData.getTransactionHash(), REFERENCE_TIME).getData()); + SparseMerkleTreeRootNode root = smt.calculateRoot(); + + InclusionProof inclusionProof = new InclusionProof( + expiredData, + REFERENCE_TIME, + InclusionCertificate.create(root, expiredStateId.getData()), + UnicityCertificateUtils.generateCertificate(signingService, root.getHash()) + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.REQUEST_EXPIRED, + InclusionProofVerificationRule.verify( + this.trustBase, + this.predicateVerifier, + inclusionProof, + expired + ).getStatus() + ); + } + + // A leaf cannot postdate the round that certified it, and consensus signs that round's + // timestamp, so a leaf claiming to be newer than its own round is an impossible pairing. + @Test + public void testVerificationFailsWhenLeafPostdatesItsCertifyingRound() { + SigningService signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); + InclusionProof inclusionProof = new InclusionProof( + this.certificationData, + REFERENCE_TIME, + this.inclusionCertificate, + UnicityCertificateUtils.generateCertificate( + signingService, this.rootHash, REFERENCE_TIME - 1) + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND, + InclusionProofVerificationRule.verify( + this.trustBase, + this.predicateVerifier, + inclusionProof, + this.transaction + ).getStatus() + ); + } + + @Test + public void testAcceptsALeafBackDatedByADishonestService() throws Exception { + long deadline = REFERENCE_TIME; + long backDated = deadline - 1; + SigningService signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); + MintTransaction late = MintTransaction.builder( + NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .salt(this.transaction.getSalt()) + .tokenType(this.transaction.getTokenType()) + .expiresAt(deadline) + .build(); + CertificationData lateData = CertificationData.fromMintTransaction(late); + StateId lateStateId = StateId.fromCertificationData(lateData); + + // Built now, but claiming to have been created before the deadline. + SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); + smt.addLeaf(lateStateId.getData(), + LeafValue.calculate(lateData.getTransactionHash(), backDated).getData()); + SparseMerkleTreeRootNode root = smt.calculateRoot(); + + Assertions.assertEquals( + InclusionProofVerificationStatus.OK, + InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, + new InclusionProof( + lateData, + backDated, + InclusionCertificate.create(root, lateStateId.getData()), + // A round certified long after the deadline had passed. + UnicityCertificateUtils.generateCertificate( + signingService, root.getHash(), deadline + 4000)), + late).getStatus() + ); + } + @Test public void testVerificationFailsWithInvalidTrustbase() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); diff --git a/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java b/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java new file mode 100644 index 00000000..babe5f77 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java @@ -0,0 +1,35 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.util.HexConverter; + +public class LeafValueTest { + + // Shared across the Go, Rust and TypeScript implementations: the leaf value is SHA-256 over + // the deterministic CBOR array [transactionHash, referenceTime]. + private static final DataHash TRANSACTION_HASH = new DataHash( + HashAlgorithm.SHA256, + HexConverter.decode("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + ); + private static final long REFERENCE_TIME = 1755000000L; + private static final String EXPECTED = + "0235bd52cfa10c9785dfa01942bc396f201fe715dbc3896ee117a97e895e1e36"; + + @Test + public void matchesTheSharedTestVector() { + Assertions.assertEquals( + EXPECTED, + HexConverter.encode(LeafValue.calculate(TRANSACTION_HASH, REFERENCE_TIME).getData())); + } + + @Test + public void changesWithTheReferenceTime() { + Assertions.assertNotEquals( + EXPECTED, + HexConverter.encode( + LeafValue.calculate(TRANSACTION_HASH, REFERENCE_TIME + 1).getData())); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java index 1ea13549..68022d57 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java @@ -14,18 +14,45 @@ public class UnicityCertificateUtils { + /** + * Reference time the fixtures pin a certified leaf to. + * + *

A real service sets the round's input record timestamp to the very reference time its + * leaves are built from, so a fixture certificate defaults to certifying a round with this + * clock. Pairing a leaf with a round whose timestamp precedes it is not something any aggregator + * can produce, and the verification rule rejects it. + */ + public static final long REFERENCE_TIME = 1755000000L; + public static UnicityCertificate generateCertificate( SigningService signingService, DataHash rootHash + ) { + return generateCertificate(signingService, rootHash, REFERENCE_TIME); + } + + public static UnicityCertificate generateCertificate( + SigningService signingService, + DataHash rootHash, + long timestamp ) { return generateCertificate(signingService, rootHash, - ShardId.decode(new byte[]{(byte) 0b10000000})); + ShardId.decode(new byte[]{(byte) 0b10000000}), timestamp); } public static UnicityCertificate generateCertificate( SigningService signingService, DataHash rootHash, ShardId shardId + ) { + return generateCertificate(signingService, rootHash, shardId, REFERENCE_TIME); + } + + public static UnicityCertificate generateCertificate( + SigningService signingService, + DataHash rootHash, + ShardId shardId, + long timestamp ) { InputRecord inputRecord = new InputRecord( 0, @@ -33,7 +60,7 @@ public static UnicityCertificate generateCertificate( null, rootHash.getData(), new byte[10], - 0, + timestamp, new byte[10], 0, new byte[10] @@ -83,7 +110,7 @@ public static UnicityCertificate generateCertificate( ); return new UnicityCertificate( - new InputRecord(0, 0, null, rootHash.getData(), new byte[10], 0, + new InputRecord(0, 0, null, rootHash.getData(), new byte[10], timestamp, new byte[10], 0, new byte[10]), technicalRecordHash, shardConfigurationHash, diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index fcf588c5..b9df9fcd 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -24,6 +24,7 @@ import org.unicitylabs.sdk.util.InclusionProofUtils; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.utils.TokenUtils; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * M-03: the inclusion-proof rule must bind the certification lock script and source state hash to @@ -57,8 +58,8 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception SignaturePredicate recipient = SignaturePredicate.fromSigningService(SigningService.generate()); StateMask stateMask = StateMask.generate(); - TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, null); - TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, null); + TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, null, ExpiresAt.expiresAt()); + TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, null, ExpiresAt.expiresAt()); Assertions.assertEquals( transferA.calculateTransactionHash(), transferB.calculateTransactionHash(), @@ -76,11 +77,12 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception InclusionProof proofA = InclusionProofUtils.waitInclusionProof( client, trustBase, predicateVerifier, transferA).get(); + long referenceTime = proofA.getReferenceTime(); + // A's certification data verifies against A... Assertions.assertEquals( InclusionProofVerificationStatus.OK, - InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA) - .getStatus()); + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA).getStatus()); // ...but must be rejected when substituted onto B, which shares the transaction hash but has a // different lock script and source state hash. diff --git a/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java b/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java new file mode 100644 index 00000000..922457fc --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java @@ -0,0 +1,105 @@ +package org.unicitylabs.sdk.functional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.CertificationStatus; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.utils.ExpiresAt; + +/** + * The Unicity Service admits a request only in a round whose reference time is strictly below the + * request's deadline. A request that carries no deadline is admitted under a service-assigned one, + * which is not recorded and is not re-checked by a verifier. + */ +public class ExpiresAtTest { + + private final TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + private final StateTransitionClient client = new StateTransitionClient(this.aggregatorClient); + private final SignaturePredicate recipient = + SignaturePredicate.fromSigningService(SigningService.generate()); + + private CertificationStatus submit(Long expiresAt) throws Exception { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .expiresAt(expiresAt) + .build(); + + return this.client.submitCertificationRequest( + CertificationData.fromMintTransaction(transaction)).get().getStatus(); + } + + @Test + public void acceptsARequestWhoseDeadlineIsAheadOfTheRoundReferenceTime() throws Exception { + Assertions.assertEquals(CertificationStatus.SUCCESS, + this.submit(ExpiresAt.expiresAt())); + } + + @Test + public void rejectsARequestWhoseDeadlineTheRoundReferenceTimeHasReached() throws Exception { + Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, + this.submit(ExpiresAt.expiredExpiresAt())); + } + + @Test + public void bindsTheDeadlineIntoTheTransactionHash() { + TokenType tokenType = TokenType.generate(); + TokenSalt salt = TokenSalt.generate(); + + MintTransaction first = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .tokenType(tokenType) + .salt(salt) + .expiresAt(1755000000L) + .build(); + MintTransaction second = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .tokenType(tokenType) + .salt(salt) + .expiresAt(1755000001L) + .build(); + + Assertions.assertNotEquals(first.calculateTransactionHash(), + second.calculateTransactionHash()); + } + + @Test + public void omittingTheDeadlineNeedsNoClockAndKeepsTheSameWireShape() { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient).build(); + MintTransaction decoded = MintTransaction.fromCbor(transaction.toCbor()); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertFalse(transaction.getExpiresAt().isPresent()); + Assertions.assertFalse(decoded.getExpiresAt().isPresent()); + Assertions.assertArrayEquals(transaction.toCbor(), decoded.toCbor()); + Assertions.assertFalse(certificationData.getExpiresAt().isPresent()); + + // The absent deadline holds its slot as CBOR null rather than shortening the + // array, so both profiles are the same version with the same field count. + Assertions.assertEquals(MintTransaction.VERSION, transaction.toCbor()[4]); + Assertions.assertEquals(CertificationData.VERSION, certificationData.toCbor()[4]); + } + + @Test + public void rejectsAnyVersionOtherThanTheCurrentOne() { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .expiresAt(1_755_000_000L) + .build(); + + for (byte badVersion : new byte[] {1, 3}) { + byte[] mismatched = transaction.toCbor(); + Assertions.assertEquals(2, mismatched[4], "fixture version offset"); + mismatched[4] = badVersion; + + Assertions.assertThrows( + CborSerializationException.class, + () -> MintTransaction.fromCbor(mismatched) + ); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java index f3a8a60b..c91b34c2 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * End-to-end functional test for the token split flow: mint a source token, split it, burn the @@ -71,7 +72,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset2))) ); - SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests); + SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests, StateMask.generate(), ExpiresAt.expiresAt()); Token burnToken = TokenUtils.transferToken( client, @@ -114,7 +115,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset1))) ); - SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests); + SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests, StateMask.generate(), ExpiresAt.expiresAt()); Token secondBurnToken = TokenUtils.transferToken( client, @@ -176,9 +177,9 @@ public void rebuildsByteIdenticalBurnTransactionFromSuppliedStateMask() throws E ); StateMask burnStateMask = StateMask.generate(); - SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); - SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); - SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests); + SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask, ExpiresAt.expiresAt()); + SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask, ExpiresAt.expiresAt()); + SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests, StateMask.generate(), ExpiresAt.expiresAt()); byte[] firstBurn = first.getBurnTransaction().toCbor(); Assertions.assertArrayEquals(firstBurn, second.getBurnTransaction().toCbor()); diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java index 511702d8..b89e8ea5 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java @@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * Regression for the split inflation exploit: a hand-built single-asset split that mirrors @@ -78,8 +79,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { // Issuer hands the token over to the attacker. The attacker now legitimately HOLDS a token // they did not mint - its 500 value is inherited from the issuer, not self-declared. - TransferTransaction handover = TransferTransaction.create( - token, attackerPredicate, StateMask.generate(), null); + TransferTransaction handover = TransferTransaction.create(token, attackerPredicate, StateMask.generate(), null, ExpiresAt.expiresAt()); token = TokenUtils.transferToken( client, context, @@ -113,8 +113,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { SparseMerkleSumTreeRootNode root = tree.calculateRoot(); byte[] manifestBytes = SplitManifest.create(List.of(root.getHash())).toCbor(); byte[] burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest().getData(); - TransferTransaction burnTransaction = TransferTransaction.create( - token, BurnPredicate.create(burnReason), StateMask.generate(), manifestBytes); + TransferTransaction burnTransaction = TransferTransaction.create(token, BurnPredicate.create(burnReason), StateMask.generate(), manifestBytes, ExpiresAt.expiresAt()); // The burn is a genuine, network-certified transfer signed by the attacker (the current owner). token = TokenUtils.transferToken( diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java index 1029a2e9..947be76f 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java @@ -19,6 +19,7 @@ import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.StateMask; import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; @@ -28,6 +29,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.List; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * Unit tests for the precondition branches of {@link TokenSplit#split}. @@ -68,14 +70,10 @@ public void setupFixture() throws Exception { public void splitFailsWhenAssetCountsDiffer() { TokenAssetCountMismatchException exception = Assertions.assertThrows( TokenAssetCountMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1)) - )) - ) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token and split tokens asset counts differ.", exception.getMessage()); } @@ -87,14 +85,10 @@ public void splitFailsWhenAssetIsMissingFromSource() { TokenAssetMissingException exception = Assertions.assertThrows( TokenAssetMissingException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1, unknownAsset)) - )) - ) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals( String.format("Token did not contain asset %s.", unknownAsset.getId()), @@ -105,15 +99,11 @@ public void splitFailsWhenAssetIsMissingFromSource() { public void splitFailsWhenAssetTreeAmountIsLess() { TokenAssetValueMismatchException exception = Assertions.assertThrows( TokenAssetValueMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) - )) - ) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 400", exception.getMessage()); @@ -123,15 +113,11 @@ this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) public void splitFailsWhenAssetTreeAmountIsMore() { TokenAssetValueMismatchException exception = Assertions.assertThrows( TokenAssetValueMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(1500)))) - )) - ) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 1500", exception.getMessage()); diff --git a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java new file mode 100644 index 00000000..1d0585fa --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java @@ -0,0 +1,291 @@ +package org.unicitylabs.sdk.integration; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.unicitylabs.sdk.api.bft.RootTrustBase; + +/** + * The aggregator stack the integration suite runs against: a BFT root node, mongodb, redis and a + * pinned aggregator build, from the same compose file the TypeScript SDK uses. + * + *

The chain starts empty on every run and the aggregator takes an ephemeral port, so concurrent + * runs cannot collide. There is deliberately no way to point the suite at a stack it did not + * start. + */ +public final class AggregatorStack implements AutoCloseable { + + /** The aggregator's own port inside the container; the host port is ephemeral. */ + private static final int AGGREGATOR_PORT = 3000; + /** Genesis, a replica-set election and the first certified round, on a cold start. */ + private static final Duration STARTUP = Duration.ofMinutes(4); + + private static final Path COMPOSE_DIR = Paths.get("src", "test", "resources", "integration"); + private static final Path DATA_DIR = COMPOSE_DIR.resolve("data"); + + private final ComposeContainer environment; + private final String url; + private final int port; + private final String networkName; + + private AggregatorStack(ComposeContainer environment, String url, int port, String networkName) { + this.environment = environment; + this.url = url; + this.port = port; + this.networkName = networkName; + } + + /** + * Start the stack and block until consensus is certifying rounds. + * + *

A healthy container is not a usable service: until consensus hands the aggregator a + * reference time it answers every certification request with SERVICE_NOT_READY, so waiting on + * the healthcheck alone would hand the tests a service that rejects everything. + * + * @return the running stack + * @throws IOException if the genesis directories cannot be prepared + * @throws InterruptedException if the wait is interrupted + */ + public static AggregatorStack start() throws IOException, InterruptedException { + // Genesis is bind-mounted and survives a container teardown. Reusing it against the fresh + // mongodb and redis volumes below would pair a chain that remembers nothing with a root node + // that remembers everything. + deleteRecursively(DATA_DIR); + Files.createDirectories(DATA_DIR.resolve("genesis")); + Files.createDirectories(DATA_DIR.resolve("genesis-root")); + + String project = "stsdk" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + ComposeContainer environment = new ComposeContainer( + project, + new File(COMPOSE_DIR.resolve("docker-compose.yml").toString())) + // Containerised compose rather than a local CLI: the build does not need docker on + // its PATH, only a reachable daemon. + // Port 0 publishes on an ephemeral host port, so concurrent runs and CI jobs cannot + // collide on a fixed one. + .withEnv("AGGREGATOR_PORT", "0") + .withEnv("USER_UID", String.valueOf(currentUid())) + .withEnv("USER_GID", String.valueOf(currentGid())) + .withExposedService("aggregator", AGGREGATOR_PORT, + Wait.forHttp("/health").forStatusCode(200).withStartupTimeout(STARTUP)) + .withStartupTimeout(STARTUP); + environment.start(); + + // Everything below can fail on a stack that is already up — readiness can time out, and the + // lookups can throw. Nothing holds the environment until the constructor runs, so close() can + // never be reached: without this, a failed startup leaves the whole stack and its genesis + // behind, and the next run tries to delete a directory that running containers have mounted. + try { + int port = environment.getServicePort("aggregator", AGGREGATOR_PORT); + String url = "http://" + environment.getServiceHost("aggregator", AGGREGATOR_PORT) + ":" + + port; + waitForCertification(url); + + String containerId = environment.getContainerByServiceName("aggregator") + .orElseThrow(() -> new IllegalStateException("no aggregator service in the stack")) + .getContainerId(); + + return new AggregatorStack(environment, url, port, networkOf(containerId)); + } catch (IOException | InterruptedException | RuntimeException e) { + stopQuietly(environment, e); + throw e; + } + } + + /** + * Tear down a stack whose startup failed, without losing the failure that caused it. + * + * @param environment the stack to stop + * @param failure the failure being propagated, to attach any cleanup failure to + */ + private static void stopQuietly(ComposeContainer environment, Exception failure) { + try { + environment.stop(); + } catch (RuntimeException e) { + failure.addSuppressed(e); + } + try { + deleteRecursively(DATA_DIR); + } catch (IOException e) { + failure.addSuppressed(e); + } + } + + /** + * Get the aggregator endpoint. + * + * @return base URL + */ + public String getUrl() { + return this.url; + } + + /** + * Get the host port the aggregator is published on. + * + * @return host port + */ + public int getPort() { + return this.port; + } + + /** + * Get the name of the network the stack's services share. + * + *

A container that needs to talk to the aggregator joins this and addresses it as + * {@code aggregator:3000}, which needs no published port and no host-gateway assumption. + * + *

Read off the running container rather than derived from the compose project name: how + * Testcontainers names the network it creates is its business, and guessing it produced a name + * that did not exist. + * + * @return docker network name + */ + public String getNetworkName() { + return this.networkName; + } + + /** + * Get the path of the trust base the BFT root node generated for this run. + * + * @return trust base path + */ + public Path getTrustBasePath() { + return DATA_DIR.resolve("genesis").resolve("trust-base.json"); + } + + /** + * Read the trust base the BFT root node generated for this run. + * + * @return trust base + * @throws IOException if the generated genesis cannot be read + */ + public RootTrustBase getTrustBase() throws IOException { + return RootTrustBase.fromJson(new String( + Files.readAllBytes(getTrustBasePath()), StandardCharsets.UTF_8)); + } + + @Override + public void close() { + this.environment.stop(); + try { + deleteRecursively(DATA_DIR); + } catch (IOException e) { + // Best effort: the next start deletes it again before generating genesis. + } + } + + /** + * Ask docker which network a container is attached to. + * + * @param containerId container to inspect + * @return the single network name + * @throws IOException if docker cannot be run + * @throws InterruptedException if the wait is interrupted + */ + private static String networkOf(String containerId) throws IOException, InterruptedException { + Process process = new ProcessBuilder( + "docker", "inspect", "-f", + "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}", containerId) + .redirectErrorStream(true) + .start(); + String name; + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + name = reader.readLine(); + } + process.waitFor(30, TimeUnit.SECONDS); + if (name == null || name.trim().isEmpty()) { + throw new IllegalStateException("could not read the network of container " + containerId); + } + + return name.trim(); + } + + private static void waitForCertification(String url) throws InterruptedException { + OkHttpClient client = new OkHttpClient.Builder() + .callTimeout(5, TimeUnit.SECONDS) + .build(); + long deadline = System.currentTimeMillis() + STARTUP.toMillis(); + while (System.currentTimeMillis() < deadline) { + if (blockHeightAboveZero(client, url)) { + return; + } + Thread.sleep(1000); + } + + throw new IllegalStateException("Aggregator at " + url + " did not certify a block in time"); + } + + private static boolean blockHeightAboveZero(OkHttpClient client, String url) { + Request request = new Request.Builder() + .url(url) + .post(RequestBody.create( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"get_block_height\",\"params\":{}}", + MediaType.get("application/json"))) + .build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + return false; + } + String body = response.body().string(); + int at = body.indexOf("\"blockNumber\":\""); + if (at < 0) { + return false; + } + String value = body.substring(at + 15, body.indexOf('"', at + 15)); + + return !value.isEmpty() && !"0".equals(value); + } catch (IOException e) { + return false; + } + } + + private static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + try (java.util.stream.Stream paths = Files.walk(path)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + // Leftovers are harmless; the compose stack recreates what it needs. + } + }); + } + } + + private static long currentUid() { + return posixId("uid"); + } + + private static long currentGid() { + return posixId("gid"); + } + + private static long posixId(String which) { + try { + Process process = new ProcessBuilder("id", "-" + which.charAt(0)).start(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + return Long.parseLong(reader.readLine().trim()); + } + } catch (Exception e) { + // The compose file defaults to 1001, which is what CI runners use. + return 1001L; + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java new file mode 100644 index 00000000..6e0e7181 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java @@ -0,0 +1,145 @@ +package org.unicitylabs.sdk.integration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.api.JsonRpcAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.CertificationStatus; +import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.bft.RootTrustBase; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.util.InclusionProofUtils; +import org.unicitylabs.sdk.utils.ExpiresAt; + +/** + * Request-deadline behaviour against a real aggregator. + * + *

The unit suite covers the same ground against {@link org.unicitylabs.sdk.TestAggregatorClient}, + * which derives leaf values with the code under test; only a real service can tell whether the two + * still agree. Run with {@code ./gradlew integrationTest}. + */ +@Tag("integration") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RequestDeadlineIntegrationTest { + + private AggregatorStack stack; + private StateTransitionClient client; + private RootTrustBase trustBase; + private PredicateVerifierService predicateVerifier; + + @BeforeAll + void startStack() throws Exception { + this.stack = AggregatorStack.start(); + this.client = new StateTransitionClient(new JsonRpcAggregatorClient(this.stack.getUrl())); + this.trustBase = this.stack.getTrustBase(); + this.predicateVerifier = PredicateVerifierService.create(); + } + + @AfterAll + void stopStack() { + if (this.stack != null) { + this.stack.close(); + } + } + + private MintTransaction mint(Long deadline) { + return MintTransaction.builder( + this.trustBase.getNetworkId(), + SignaturePredicate.fromSigningService(SigningService.generate())) + .expiresAt(deadline) + .build(); + } + + private CertificationStatus submit(MintTransaction transaction) throws Exception { + return this.client.submitCertificationRequest( + CertificationData.fromMintTransaction(transaction)).get().getStatus(); + } + + private InclusionProof certify(Long deadline) throws Exception { + MintTransaction transaction = mint(deadline); + Assertions.assertEquals(CertificationStatus.SUCCESS, submit(transaction)); + + return InclusionProofUtils.waitInclusionProof( + this.client, this.trustBase, this.predicateVerifier, transaction).get(); + } + + @Test + void acceptsADeadlineAheadOfTheRoundReferenceTime() throws Exception { + Assertions.assertEquals(CertificationStatus.SUCCESS, submit(mint(ExpiresAt.expiresAt()))); + } + + @Test + void acceptsARequestThatLeavesTheDeadlineToTheService() throws Exception { + Assertions.assertEquals(CertificationStatus.SUCCESS, submit(mint(null))); + } + + @Test + void rejectsADeadlineThatHasAlreadyPassed() throws Exception { + Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, + submit(mint(ExpiresAt.expiredExpiresAt()))); + } + + @Test + void rejectsADeadlineEqualToAReferenceTimeAlreadyReached() throws Exception { + // A reference time the service has already certified a leaf under, so it is at or behind the + // reference time the next round pins. The deadline is exclusive, so equality is already late. + InclusionProof proof = certify(ExpiresAt.expiresAt()); + long reached = proof.getReferenceTime(); + + Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, submit(mint(reached))); + } + + @Test + void bindsAServiceAssignedDeadlineWithoutRecordingIt() throws Exception { + InclusionProof proof = certify(null); + + // The service derives a deadline from consensus time for a request that omits one. That value + // is service metadata: never written to the leaf, so a later verifier sees the same absence + // the requester sent and has nothing to re-check. + Assertions.assertFalse( + proof.getCertificationData().getExpiresAt().isPresent()); + Assertions.assertTrue(true); + } + + @Test + void servesBackTheExplicitDeadlineTheTransactionHashCommitsTo() throws Exception { + long deadline = ExpiresAt.expiresAt(); + InclusionProof proof = certify(deadline); + + Assertions.assertEquals(deadline, + proof.getCertificationData() + .getExpiresAt().orElseThrow(AssertionError::new)); + // Admission is what the deadline governs, and it is exclusive: the leaf could only be created + // in a round strictly before it. + Assertions.assertTrue(proof.getReferenceTime() < deadline); + } + + @Test + void reportsAReferenceTimeNoLaterThanTheRoundThatCertifiedIt() throws Exception { + InclusionProof proof = certify(ExpiresAt.expiresAt()); + + // The service sets the round's input record timestamp to the very reference time its leaves + // are built from, so for the certifying round the two are equal and the bound the + // verification rule enforces is exact. + Assertions.assertEquals( + proof.getReferenceTime(), + proof.getUnicityCertificate().getInputRecord().getTimestamp()); + } + + @Test + void reportsALeaflessProofForARequestThatWasNeverSubmitted() throws Exception { + MintTransaction never = mint(ExpiresAt.expiresAt()); + + // Nothing was certified, and the response is the type that says so. + Assertions.assertNull(this.client.getInclusionProof( + org.unicitylabs.sdk.api.StateId.fromTransaction(never)).get().getInclusionProof()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java new file mode 100644 index 00000000..bf58130e --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java @@ -0,0 +1,127 @@ +package org.unicitylabs.sdk.interop; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.unicitylabs.sdk.integration.AggregatorStack; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; +import org.unicitylabs.sdk.util.HexConverter; +import org.unicitylabs.sdk.util.verification.VerificationStatus; + +/** + * Cross-implementation check: a token minted by the published TypeScript SDK, verified here. + * + *

The golden CertificationData vectors both SDKs carry cover what reaches the aggregator, not + * Token or the certified transactions inside it — they stayed green while neither SDK could read + * the other's tokens. Carrying a real token across the boundary is what covers those. + * + *

Minted against the aggregator this suite starts, by the npm artifact rather than the other + * repo's source tree, so nothing is committed and no vector can go stale. + */ +@Tag("integration") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JsSdkInteropIntegrationTest { + + /** npm install plus a mint and a transfer, both waiting on certification. */ + private static final long TIMEOUT_MINUTES = 5; + private static final String NODE_IMAGE = "node:22-alpine"; + + private AggregatorStack stack; + private String tokenHex; + private long expiresAt; + + @BeforeAll + void mintWithTheTypeScriptSdk() throws Exception { + this.stack = AggregatorStack.start(); + + String interop = Paths.get("src", "test", "resources", "interop").toAbsolutePath().toString(); + List command = new ArrayList<>(List.of( + "docker", "run", "--rm", + // Join the stack's own network, so the aggregator is reachable by service name. + "--network", this.stack.getNetworkName(), + "-v", interop + ":/interop:ro", + "-v", this.stack.getTrustBasePath().toAbsolutePath() + ":/trust-base.json:ro", + "-e", "AGGREGATOR_URL=http://aggregator:3000", + "-e", "TRUST_BASE_PATH=/trust-base.json", + "-w", "/work", + NODE_IMAGE, + "sh", "-c", + // The script runs from /work so node resolves the SDK against the node_modules + // installed there; /interop is mounted read-only. + "cp /interop/* /work/ && npm install --silent --no-audit --no-fund" + + " && node /work/mint-token.mjs")); + + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + } + if (!process.waitFor(TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + process.destroyForcibly(); + throw new IllegalStateException("the TypeScript SDK did not finish minting in time"); + } + if (process.exitValue() != 0) { + throw new IllegalStateException( + "minting with the TypeScript SDK failed:\n" + output); + } + + this.tokenHex = value(output.toString(), "TOKEN_HEX="); + this.expiresAt = Long.parseLong(value(output.toString(), "EXPIRES_AT=")); + } + + @AfterAll + void stopStack() { + if (this.stack != null) { + this.stack.close(); + } + } + + private static String value(String output, String prefix) { + for (String line : output.split("\\R")) { + if (line.startsWith(prefix)) { + return line.substring(prefix.length()).trim(); + } + } + + throw new AssertionError("the TypeScript SDK printed no " + prefix + "; output was:\n" + output); + } + + @Test + void verifiesATokenMintedByThePublishedTypeScriptSdk() throws Exception { + Token token = Token.fromCbor(HexConverter.decode(this.tokenHex)); + + VerificationContext context = new VerificationContext( + this.stack.getTrustBase(), + PredicateVerifierService.create(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); + + Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus(), + "a token the published TypeScript SDK produced must verify here unchanged"); + + // The deadline is committed by the transaction hash, so it has to survive the crossing. + Assertions.assertEquals(this.expiresAt, + token.getGenesis().getExpiresAt().orElseThrow(AssertionError::new)); + Assertions.assertEquals(1, token.getTransactions().size()); + Assertions.assertEquals(this.expiresAt, + token.getTransactions().get(0).getExpiresAt().orElseThrow(AssertionError::new)); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java index dd86c316..b53b8cc1 100644 --- a/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java +++ b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java @@ -14,6 +14,8 @@ public class SignaturePredicateVerifierTest { + static final long REFERENCE_TIME = 1755000000L; + private final SignaturePredicateVerifier verifier = new SignaturePredicateVerifier(); private final SigningService signingService = SigningService.generate(); private final EncodedPredicate encodedPredicate = EncodedPredicate.fromPredicate( @@ -42,7 +44,8 @@ public void shouldAcceptValidUnlockScript() { Assertions.assertEquals( VerificationStatus.OK, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, signature.encode()).getStatus()); } @@ -53,7 +56,8 @@ public void shouldRejectTamperedRecoveryByte() { Assertions.assertEquals( VerificationStatus.FAIL, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, tampered).getStatus()); } @@ -64,7 +68,8 @@ public void shouldFailWhenRecoveryByteMakesSignatureUnrecoverable() { Assertions.assertEquals( VerificationStatus.FAIL, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, tampered).getStatus()); } } diff --git a/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java new file mode 100644 index 00000000..eec25951 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java @@ -0,0 +1,46 @@ +package org.unicitylabs.sdk.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.MintTransaction; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +class InclusionProofUtilsTest { + + @Test + void retriesPendingProofWithoutReferenceTime() throws Exception { + SigningService signingService = SigningService.generate(); + TestAggregatorClient aggregator = TestAggregatorClient.create(); + StateTransitionClient client = new StateTransitionClient(aggregator); + MintTransaction transaction = MintTransaction.builder( + NetworkId.LOCAL, + SignaturePredicate.fromSigningService(signingService)) + .expiresAt(System.currentTimeMillis() / 1000 + 60) + .build(); + + var proofFuture = InclusionProofUtils.waitInclusionProof( + client, + aggregator.getTrustBase(), + PredicateVerifierService.create(), + transaction, + Duration.ofSeconds(2), + Duration.ofMillis(10)); + + aggregator.submitCertificationRequest(CertificationData.fromMintTransaction(transaction)).get(); + + InclusionProof proof = proofFuture.get(2, TimeUnit.SECONDS); + // A proof that exists is complete; there is nothing left to assert about presence. + Assertions.assertNotNull(proof.getCertificationData()); + Assertions.assertNotNull(proof.getInclusionCertificate()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java b/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java new file mode 100644 index 00000000..8d07da6a --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java @@ -0,0 +1,29 @@ +package org.unicitylabs.sdk.utils; + +/** + * Certification request deadlines for tests. + */ +public final class ExpiresAt { + + private ExpiresAt() { + } + + /** + * A deadline an hour ahead of the current wall clock, so no test run can reach it while the + * request is in flight. + * + * @return request deadline in Unix seconds + */ + public static Long expiresAt() { + return System.currentTimeMillis() / 1000 + 3600; + } + + /** + * A deadline that has already passed, for exercising the expiry path. + * + * @return request deadline in Unix seconds + */ + public static Long expiredExpiresAt() { + return System.currentTimeMillis() / 1000 - 3600; + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index 8692af3f..1583b0d4 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -82,14 +82,12 @@ public static Token mintToken( TokenSalt salt, byte[] justification ) throws Exception { - MintTransaction transaction = MintTransaction.create( - networkId, - recipient, - data, - tokenType, - salt, - justification - ); + MintTransaction transaction = MintTransaction.builder(networkId, recipient) + .tokenType(tokenType) + .salt(salt) + .data(data) + .justification(justification) + .build(); CertificationData certificationData = CertificationData.fromMintTransaction(transaction); @@ -135,12 +133,7 @@ public static Token transferToken( Token token = Token.fromCbor(tokenBytes); Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus()); - TransferTransaction transaction = TransferTransaction.create( - token, - recipient, - StateMask.generate(), - null - ); + TransferTransaction transaction = TransferTransaction.create(token, recipient, StateMask.generate(), null); return TokenUtils.transferToken( client, diff --git a/src/test/resources/integration/docker-compose.yml b/src/test/resources/integration/docker-compose.yml new file mode 100644 index 00000000..83ae8af6 --- /dev/null +++ b/src/test/resources/integration/docker-compose.yml @@ -0,0 +1,195 @@ +# Local aggregator stack the integration suite runs against. +# +# Kept byte-for-byte in step with the TypeScript SDK's copy at +# tests/integration/docker/docker-compose.yml. The two suites must exercise the +# same service build, or "passes against a real aggregator" means something +# different in each repo. +# +# Mirrors the topology of aggregator-go's own docker-compose.yml, with two +# deliberate differences: the aggregator runs from a pinned prebuilt image +# instead of a local rocksdb build, and DEFAULT_REQUEST_TTL is short enough +# that the service-assigned request deadline can be observed within a test. +# +# Testcontainers drives it, from AggregatorStack.java: that creates the writable +# genesis directories this file mounts, publishes the aggregator on an ephemeral +# port, and waits for consensus to produce a reference time before any test runs. + +x-bft: &bft-base + platform: linux/amd64 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + # https://github.com/unicitynetwork/bft-core/pkgs/container/bft-core + image: ghcr.io/unicitynetwork/bft-core:ceceacd11b7a735de74ce17884a3a45e0db1748d + +services: + bft-root: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + healthcheck: + test: ["CMD", "nc", "-zv", "bft-root", "8002"] + interval: 2s + timeout: 3s + retries: 30 + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/root/node-info.json ] && [ -f /genesis/trust-base.json ] && [ -f /genesis/root/trust-base-signed.json ]; then + echo "Genesis files already exist, skipping initialization." + else + echo "Creating root genesis..." && + ubft root-node init --home /genesis/root -g && + echo "Creating root trust base..." && + ubft trust-base generate --home /genesis --network-id 3 --node-info /genesis/root/node-info.json && + echo "Signing root trust base..." && + ubft trust-base sign --home /genesis/root --trust-base /genesis/trust-base.json + fi + echo "Starting root node..." && + exec ubft root-node run --home /genesis/root --address "/ip4/$(hostname -i)/tcp/8000" --trust-base /genesis/trust-base.json --rpc-server-address "$(hostname -i):8002" + + bft-aggregator-genesis-gen: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + depends_on: + bft-root: + condition: service_healthy + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/aggregator/node-info.json ] && [ -f /genesis/shard-conf-7_0.json ]; then + echo "Aggregator genesis and config already exist, skipping initialization." + else + echo "Creating aggregator genesis..." && + ubft shard-node init --home /genesis/aggregator --generate && + echo "Creating aggregator partition configuration..." && + ubft shard-conf generate --home /genesis --t2-timeout 5000 --network-id 3 --partition-id 7 --partition-type-id 7 --epoch-start 10 --node-info=/genesis/aggregator/node-info.json + fi + chmod -R 755 /genesis/aggregator + chmod 644 /genesis/shard-conf-7_0.json + chmod 644 /genesis/trust-base.json + chmod -R 755 /genesis/root + echo "Genesis ready." + + upload-configurations: + image: curlimages/curl:8.13.0 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + depends_on: + bft-root: + condition: service_healthy + bft-aggregator-genesis-gen: + condition: service_completed_successfully + restart: on-failure + volumes: + - ./data/genesis:/genesis + command: | + /bin/sh -c " + echo Uploading aggregator configuration && + curl -sf -X PUT -H 'Content-Type: application/json' -d @/genesis/shard-conf-7_0.json http://bft-root:8002/api/v1/configurations + " + + redis: + image: redis:7-alpine + command: redis-server --save "" --appendonly no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 15 + + mongodb: + image: mongo:7.0 + command: ["--replSet", "rs0", "--bind_ip_all", "--noauth"] + healthcheck: + # Initiates the replica set on the first probe, then reports healthy only + # once this node has actually been elected primary — rs.status() answers + # well before the set can accept writes, and the aggregator's storage + # init times out against a set that is still electing. + test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status() } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) } if (!db.hello().isWritablePrimary) { quit(1) }"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + + aggregator: + image: ghcr.io/unicitynetwork/aggregator-go:${AGGREGATOR_IMAGE_TAG:-sha-ae08165} + restart: on-failure + ports: + - "${AGGREGATOR_PORT:-3000}:3000" + volumes: + - ./data/genesis:/app/bft-config + environment: + PORT: "3000" + HOST: "0.0.0.0" + CONCURRENCY_LIMIT: "1000" + ENABLE_CORS: "true" + + MONGODB_URI: "mongodb://mongodb:27017/aggregator?replicaSet=rs0&directConnection=true" + MONGODB_DATABASE: "aggregator" + # Generous enough to ride out the replica-set election on a cold start. + # The aggregator creates its indexes during storage init and exits if that + # times out; the defaults give up while the fresh set is still electing. + MONGODB_CONNECT_TIMEOUT: "30s" + MONGODB_SERVER_SELECTION_TIMEOUT: "30s" + + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_DB: "0" + + USE_REDIS_FOR_COMMITMENTS: "true" + REDIS_FLUSH_INTERVAL: "50ms" + + SMT_BACKEND: "memory" + + # Deadline the service assigns to a request that omits expiresAt. Short + # enough that a test can watch such a request expire; the aggregator + # rejects anything below one whole second. + DEFAULT_REQUEST_TTL: "${DEFAULT_REQUEST_TTL:-30s}" + + DISABLE_HIGH_AVAILABILITY: "false" + LOCK_TTL_SECONDS: "30" + LEADER_HEARTBEAT_INTERVAL: "10s" + LEADER_ELECTION_POLLING_INTERVAL: "5s" + BLOCK_SYNC_INTERVAL: "1s" + + LOG_LEVEL: "${LOG_LEVEL:-info}" + LOG_FORMAT: "json" + LOG_ENABLE_JSON: "true" + + BATCH_LIMIT: "1000" + MAX_COMMITMENTS_PER_ROUND: "10000" + + SIGNING_KEY_FILE: "/app/bft-config/aggregator/keys.json" + + BFT_ENABLED: "true" + BFT_SHARD_CONF_FILE: "/app/bft-config/shard-conf-7_0.json" + BFT_TRUST_BASE_FILES: "/app/bft-config/trust-base.json" + BFT_RPC_ADDRESS: "http://127.0.0.1:8002" + entrypoint: ["/bin/sh", "-c"] + command: + - | + ROOT_NODE_ID=$$(grep -o '"nodeId": "[^"]*"' /app/bft-config/trust-base.json | head -1 | cut -d'"' -f4) + if [ -z "$$ROOT_NODE_ID" ]; then + echo "Error: could not read root nodeId from /app/bft-config/trust-base.json" + exit 1 + fi + export BFT_BOOTSTRAP_ADDRESSES="/dns4/bft-root/tcp/8000/p2p/$$ROOT_NODE_ID" + exec /app/aggregator + depends_on: + bft-aggregator-genesis-gen: + condition: service_completed_successfully + upload-configurations: + condition: service_completed_successfully + redis: + condition: service_healthy + mongodb: + condition: service_healthy + healthcheck: + # A real GET, not --spider: busybox wget spiders with HEAD, and /health is + # registered GET-only, so the image's own HEALTHCHECK never passes. + test: ["CMD", "wget", "--quiet", "--tries=1", "--output-document=/dev/null", "http://localhost:3000/health"] + interval: 2s + timeout: 5s + retries: 45 + start_period: 5s diff --git a/src/test/resources/interop/mint-token.mjs b/src/test/resources/interop/mint-token.mjs new file mode 100644 index 00000000..746ff0a2 --- /dev/null +++ b/src/test/resources/interop/mint-token.mjs @@ -0,0 +1,102 @@ +// Mint a token and transfer it once, using the PUBLISHED TypeScript SDK against the aggregator +// the Java integration suite started, then print the token as hex. +// +// This runs inside a node container, against the npm artifact rather than the TypeScript repo's +// source tree — the same bytes a consumer installs. The token comes back over stdout so nothing +// has to be bind-mounted or copied out. +import { readFileSync } from 'node:fs'; + +import { AggregatorClient } from '@unicitylabs/state-transition-sdk/lib/api/AggregatorClient.js'; +import { UnicitySealQuorumSignaturesVerificationRule } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.js'; +import { UnicityCertificateVerifier } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/UnicityCertificateVerifier.js'; +import { VerifiedSealCache } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/VerifiedSealCache.js'; +import { RootTrustBase } from '@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js'; +import { CertificationData } from '@unicitylabs/state-transition-sdk/lib/api/CertificationData.js'; +import { Secp256k1SignatureVerifier } from '@unicitylabs/state-transition-sdk/lib/crypto/secp256k1/Secp256k1SignatureVerifier.js'; +import { SigningService } from '@unicitylabs/state-transition-sdk/lib/crypto/secp256k1/SigningService.js'; +import { SignaturePredicate } from '@unicitylabs/state-transition-sdk/lib/predicate/builtin/SignaturePredicate.js'; +import { SignaturePredicateUnlockScript } from '@unicitylabs/state-transition-sdk/lib/predicate/builtin/SignaturePredicateUnlockScript.js'; +import { PredicateVerifierService } from '@unicitylabs/state-transition-sdk/lib/predicate/verification/PredicateVerifierService.js'; +import { StateTransitionClient } from '@unicitylabs/state-transition-sdk/lib/StateTransitionClient.js'; +import { MintTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/MintTransaction.js'; +import { StateMask } from '@unicitylabs/state-transition-sdk/lib/transaction/StateMask.js'; +import { Token } from '@unicitylabs/state-transition-sdk/lib/transaction/Token.js'; +import { TransferTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/TransferTransaction.js'; +import { MintJustificationVerifierService } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/MintJustificationVerifierService.js'; +import { TokenIssuanceVerifierService } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/TokenIssuanceVerifierService.js'; +import { VerificationContext } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/VerificationContext.js'; +import { waitInclusionProof } from '@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js'; + +const aggregatorUrl = process.env.AGGREGATOR_URL; +const trustBasePath = process.env.TRUST_BASE_PATH; +if (!aggregatorUrl || !trustBasePath) { + throw new Error('AGGREGATOR_URL and TRUST_BASE_PATH must be set'); +} + +const trustBase = RootTrustBase.fromJSON(JSON.parse(readFileSync(trustBasePath, 'utf-8'))); +const aggregatorClient = new AggregatorClient(aggregatorUrl, null); +const client = new StateTransitionClient(aggregatorClient); +const predicateVerifier = PredicateVerifierService.create(); +const unicityCertificateVerifier = new UnicityCertificateVerifier( + new UnicitySealQuorumSignaturesVerificationRule(new Secp256k1SignatureVerifier(), new VerifiedSealCache(256)), +); +const context = new VerificationContext( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), +); + +// An hour out, so the request cannot expire while it is in flight. +const expiresAt = BigInt(Math.floor(Date.now() / 1000)) + 3600n; +const alice = SigningService.generate(); +const bob = SigningService.generate(); + +const submit = async (certificationData) => { + const { status } = await client.submitCertificationRequest(certificationData); + if (status !== 'SUCCESS') { + throw new Error(`certification request failed: ${status}`); + } +}; + +const mint = await MintTransaction.create(trustBase.networkId, SignaturePredicate.fromSigningService(alice), { + expiresAt, +}); +await submit(await CertificationData.fromMintTransaction(mint)); +const minted = await Token.mint( + await mint.toCertifiedTransaction( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + await waitInclusionProof(client, trustBase, predicateVerifier, unicityCertificateVerifier, mint), + ), + context, +); + +const transfer = await TransferTransaction.create( + minted, + SignaturePredicate.fromSigningService(bob), + StateMask.generate(), + { expiresAt }, +); +await submit(await CertificationData.fromTransaction(transfer, await SignaturePredicateUnlockScript.create(transfer, alice))); +const token = await minted.transfer( + await transfer.toCertifiedTransaction( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + await waitInclusionProof(client, trustBase, predicateVerifier, unicityCertificateVerifier, transfer), + ), + context, +); + +// Verified by the producing SDK before it leaves, so a failure on the Java side is a +// cross-implementation disagreement and not a token that was never valid. +const result = await token.verify(context); +if (result.status !== 'OK') { + throw new Error(`the TypeScript SDK could not verify its own token: ${result.status}`); +} + +const hex = Buffer.from(token.toCBOR()).toString('hex'); +process.stdout.write(`TOKEN_HEX=${hex}\nEXPIRES_AT=${expiresAt}\n`); diff --git a/src/test/resources/interop/package.json b/src/test/resources/interop/package.json new file mode 100644 index 00000000..0056f764 --- /dev/null +++ b/src/test/resources/interop/package.json @@ -0,0 +1,9 @@ +{ + "name": "js-sdk-interop", + "private": true, + "type": "module", + "description": "Mints a token with the published TypeScript SDK so the Java suite can verify it.", + "dependencies": { + "@unicitylabs/state-transition-sdk": "3.0.1" + } +}