diff --git a/.gitignore b/.gitignore index 1deb572..b022edf 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/build.gradle.kts b/build.gradle.kts index 1571de8..7f17e70 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/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index a2e1141..c143596 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -8,26 +8,32 @@ import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.stream.Stream; /** * Represents a proof of inclusion or non-inclusion in a sparse merkle tree. */ 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 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, + 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; @@ -59,26 +65,25 @@ public UnicityCertificate getUnicityCertificate() { } /** - * Get certification data on inclusion proof, null on non inclusion proof. + * Get certification data of the certified leaf. * - * @return authenticator + * @return certification data */ - public Optional getCertificationData() { - return Optional.ofNullable(this.certificationData); + public CertificationData getCertificationData() { + return this.certificationData; } /** - * Get the reference time of the round the certified leaf was created in, empty on a - * non-inclusion proof. + * 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. + *

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 reference time */ - public Optional getReferenceTime() { - return Optional.ofNullable(this.referenceTime); + public long getReferenceTime() { + return this.referenceTime; } /** @@ -106,17 +111,9 @@ public static InclusionProof fromCbor(byte[] bytes) { InclusionCertificate inclusionCertificate = CborDeserializer.decodeNullable(data.get(3), (certificate) -> InclusionCertificate.decode(CborDeserializer.decodeByteString(certificate))); - - // A proof either establishes a leaf or reports that there is none yet. A partially present - // proof is neither, and would let a caller reach a leaf check with a reference time nothing - // certified. - long present = Stream.of(certificationData, referenceTime, inclusionCertificate) - .filter(Objects::nonNull) - .count(); - if (present != 0 && present != 3) { + if (certificationData == null || referenceTime == null || inclusionCertificate == null) { throw new CborSerializationException( - "InclusionProof must carry certification data, reference time and inclusion " - + "certificate together, or none of them."); + "Expected a certified leaf, but the inclusion proof describes none."); } return new InclusionProof( @@ -134,11 +131,10 @@ public static InclusionProof fromCbor(byte[] bytes) { */ public byte[] toCbor() { byte[] payload = CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(VERSION), - CborSerializer.encodeNullable(this.certificationData, CertificationData::toCbor), - CborSerializer.encodeNullable(this.referenceTime, - CborSerializer::encodeUnsignedInteger), - CborSerializer.encodeNullable(this.inclusionCertificate, certificate -> - CborSerializer.encodeByteString(certificate.encode())), this.unicityCertificate.toCbor()); + this.certificationData.toCbor(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), + CborSerializer.encodeByteString(this.inclusionCertificate.encode()), + this.unicityCertificate.toCbor()); return CborSerializer.encodeTag( InclusionProof.CBOR_TAG, payload diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java index d53e22e..2b8f9a4 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/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index ce15ee9..1159d26 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -24,13 +24,10 @@ public class CertifiedMintTransaction implements Transaction { private final MintTransaction transaction; - private final long referenceTime; private final InclusionProof inclusionProof; - private CertifiedMintTransaction(MintTransaction transaction, long referenceTime, - InclusionProof inclusionProof) { + private CertifiedMintTransaction(MintTransaction transaction, InclusionProof inclusionProof) { this.transaction = transaction; - this.referenceTime = referenceTime; this.inclusionProof = inclusionProof; } @@ -112,14 +109,15 @@ public InclusionProof getInclusionProof() { public Optional getExpiresAt() { return this.transaction.getExpiresAt(); } - /** - * Get the reference time this transition was validated under. + * 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 + * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.referenceTime; + return this.inclusionProof.getReferenceTime(); } /** @@ -129,17 +127,9 @@ public long getReferenceTime() { * @return decoded certified mint transaction */ public static CertifiedMintTransaction fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 3); - InclusionProof proof = InclusionProof.fromCbor(data.get(2)); - long referenceTime = CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(); - // An absent reference time on the proof also fails this comparison. - if (!proof.getReferenceTime().equals(Optional.of(referenceTime))) { - throw new CborSerializationException("Certified mint transaction reference time mismatch"); - } - return new CertifiedMintTransaction( - MintTransaction.fromCbor(data.get(0)), - referenceTime, - proof); + List data = CborDeserializer.decodeArray(bytes, 2); + InclusionProof proof = InclusionProof.fromCbor(data.get(1)); + return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), proof); } /** @@ -163,27 +153,17 @@ public static CertifiedMintTransaction fromTransaction( Objects.requireNonNull(transaction, "transaction cannot be null"); Objects.requireNonNull(inclusionProof, "inclusionProof cannot be null"); - // The reference time is fixed here, at the moment the transaction is bound to its first - // proof. Later verifiers use the carried value: a proof fetched later may be issued - // against a later root and would then carry a different input record time. - long referenceTime = inclusionProof.getReferenceTime() - .orElseThrow(() -> new VerificationException( - "Inclusion proof verification failed", - new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME))); - VerificationResult result = InclusionProofVerificationRule.verify( trustBase, predicateVerifier, inclusionProof, - transaction, - referenceTime + transaction ); if (result.getStatus() != InclusionProofVerificationStatus.OK) { throw new VerificationException("Inclusion proof verification failed", result); } - return new CertifiedMintTransaction(transaction, referenceTime, inclusionProof); + return new CertifiedMintTransaction(transaction, inclusionProof); } @Override @@ -198,13 +178,12 @@ public DataHash calculateTransactionHash() { @Override public byte[] toCbor() { - return CborSerializer.encodeArray(this.transaction.toCbor(), - CborSerializer.encodeUnsignedInteger(this.referenceTime), this.inclusionProof.toCbor()); + return CborSerializer.encodeArray(this.transaction.toCbor(), this.inclusionProof.toCbor()); } @Override public String toString() { return String.format("CertifiedMintTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", - this.transaction, this.referenceTime, this.inclusionProof); + 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 b28b644..09619ed 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -23,16 +23,13 @@ public class CertifiedTransferTransaction implements Transaction { private final TransferTransaction transaction; - private final long referenceTime; private final InclusionProof inclusionProof; private CertifiedTransferTransaction( TransferTransaction transaction, - long referenceTime, InclusionProof inclusionProof ) { this.transaction = transaction; - this.referenceTime = referenceTime; this.inclusionProof = inclusionProof; } @@ -74,36 +71,33 @@ public InclusionProof getInclusionProof() { public Optional getExpiresAt() { return this.transaction.getExpiresAt(); } - /** - * Get the reference time this transition was validated under. + * 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 + * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.referenceTime; + 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) { - List data = CborDeserializer.decodeArray(bytes, 3); - InclusionProof proof = InclusionProof.fromCbor(data.get(2)); - long referenceTime = CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(); - // An absent reference time on the proof also fails this comparison. - if (!proof.getReferenceTime().equals(Optional.of(referenceTime))) { - throw new CborSerializationException("Certified transfer transaction reference time mismatch"); - } + 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), - referenceTime, + TransferTransaction.fromCbor(data.get(0), sourceStateHash, lockScript), proof ); } @@ -134,27 +128,17 @@ public static CertifiedTransferTransaction fromTransaction( Objects.requireNonNull(transaction, "transaction cannot be null"); Objects.requireNonNull(inclusionProof, "inclusionProof cannot be null"); - // The reference time is fixed here, at the moment the transaction is bound to its first - // proof. Later verifiers use the carried value: a proof fetched later may be issued - // against a later root and would then carry a different input record time. - long referenceTime = inclusionProof.getReferenceTime() - .orElseThrow(() -> new VerificationException( - "Inclusion proof verification failed", - new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME))); - VerificationResult result = InclusionProofVerificationRule.verify( trustBase, predicateVerifier, inclusionProof, - transaction, - referenceTime + transaction ); if (result.getStatus() != InclusionProofVerificationStatus.OK) { throw new VerificationException("Inclusion proof verification failed", result); } - return new CertifiedTransferTransaction(transaction, referenceTime, inclusionProof); + return new CertifiedTransferTransaction(transaction, inclusionProof); } /** @@ -184,13 +168,12 @@ public DataHash calculateTransactionHash() { */ @Override public byte[] toCbor() { - return CborSerializer.encodeArray(this.transaction.toCbor(), - CborSerializer.encodeUnsignedInteger(this.referenceTime), this.inclusionProof.toCbor()); + return CborSerializer.encodeArray(this.transaction.toCbor(), this.inclusionProof.toCbor()); } @Override public String toString() { return String.format("CertifiedTransferTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", - this.transaction, this.referenceTime, this.inclusionProof); + 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 0000000..e27791e --- /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 a15e57a..ef43a4e 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -65,7 +65,7 @@ private MintTransaction( this.salt = salt; this.tokenType = tokenType; this.tokenId = tokenId; - this.expiresAt = expiresAt; + this.expiresAt = ExpiresAt.validate(expiresAt); this.justification = justification; this.data = data; } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Token.java b/src/main/java/org/unicitylabs/sdk/transaction/Token.java index 7ae82ac..621bc8b 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/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index 94bc14a..446099f 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -44,7 +44,7 @@ private TransferTransaction( this.sourceStateHash = sourceStateHash; this.lockScript = lockScript; this.recipient = recipient; - this.expiresAt = expiresAt; + this.expiresAt = ExpiresAt.validate(expiresAt); this.stateMask = stateMask; this.data = data; } @@ -121,11 +121,18 @@ public static TransferTransaction create(Token token, Predicate recipient, /** * 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())); @@ -137,13 +144,14 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } - return TransferTransaction.create( - token, + return new TransferTransaction( + sourceStateHash, + lockScript, EncodedPredicate.fromCbor(data.get(1)), - StateMask.fromCbor(data.get(2)), - CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString), CborDeserializer.decodeNullable( - data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()) + data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()), + StateMask.fromCbor(data.get(2)), + CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) ); } 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 3f0e9f4..02a9af7 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); @@ -68,8 +65,7 @@ public static VerificationResult verify( } result = InclusionProofVerificationRule.verify(context.getTrustBase(), - context.getPredicateVerifier(), transaction.getInclusionProof(), transaction, - transaction.getReferenceTime()); + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); results.add(result); if (result.getStatus() != InclusionProofVerificationStatus.OK) { return new VerificationResult<>("CertifiedMintTransactionVerificationRule", diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java index f594e89..56c4420 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java @@ -31,8 +31,7 @@ public static VerificationResult verify( ArrayList> results = new ArrayList>(); VerificationResult result = InclusionProofVerificationRule.verify(context.getTrustBase(), - context.getPredicateVerifier(), transaction.getInclusionProof(), transaction, - transaction.getReferenceTime()); + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); results.add(result); if (result.getStatus() != InclusionProofVerificationStatus.OK) { return new VerificationResult<>("CertifiedTransferTransactionVerificationRule", 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 fbe2242..5b6c601 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -1,6 +1,7 @@ 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; @@ -12,7 +13,6 @@ import org.unicitylabs.sdk.transaction.Transaction; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; -import java.util.Optional; /** * This class provides the functionality to verify an inclusion proof against a given trust base @@ -36,26 +36,15 @@ public class InclusionProofVerificationRule { * @param predicateVerifier the service responsible for evaluating transaction predicates * @param inclusionProof the inclusion proof containing certification data and merkle tree path * @param transaction the transaction that is being verified against the proof - * @param referenceTime reference time the transition was validated under * * @return a {@code VerificationResult} object containing the {@code InclusionProofVerificationStatus} * and additional details about the verification outcome */ public static VerificationResult verify(RootTrustBase trustBase, PredicateVerifierService predicateVerifier, InclusionProof inclusionProof, - Transaction transaction, long referenceTime) { - 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); - } + Transaction transaction) { + CertificationData certificationData = inclusionProof.getCertificationData(); + long referenceTime = inclusionProof.getReferenceTime(); if (!certificationData.getTransactionHash().equals(transaction.calculateTransactionHash())) { return new VerificationResult<>("InclusionProofVerificationRule", @@ -69,25 +58,25 @@ public static VerificationResult verify(RootTr InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH); } - // The request was admissible only in a round strictly before its deadline. A request that - // carried no deadline was admitted under a service-assigned one, which is not recorded and is - // not re-checked here. - if (transaction.getExpiresAt().isPresent() && referenceTime >= transaction.getExpiresAt().get()) { + // 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); } - // An absent reference time on the proof also fails this comparison. - if (!inclusionProof.getReferenceTime().equals(Optional.of(referenceTime))) { + // 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_MISMATCH); + InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND); } StateId stateId = StateId.fromTransaction(transaction); - // The leaf value binds the reference time the transition was validated under. It is taken - // from the caller, not from the proof's own unicity certificate: the tree is append-only, - // so the proof may have been issued against a later root whose input record carries a - // later reference time. 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", 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 a6d4361..13822bf 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -6,24 +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 inclusion proof does not carry the reference time its leaf value was built from. */ - MISSING_REFERENCE_TIME, - /** The inclusion proof's reference time differs from the one the transition carries. */ - REFERENCE_TIME_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 f0e4075..4da3523 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -98,33 +98,23 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { InclusionProof inclusionProof = response.getInclusionProof(); - VerificationResult result; - if (!inclusionProof.getCertificationData().isPresent() - || inclusionProof.getInclusionCertificate() == null) { - result = new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING); - } else if (!inclusionProof.getReferenceTime().isPresent()) { - result = new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); - } else { - long referenceTime = inclusionProof.getReferenceTime().get(); - result = InclusionProofVerificationRule.verify( - trustBase, predicateVerifier, inclusionProof, transaction, referenceTime); + // 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; } - switch (result.getStatus()) { - case OK: - future.complete(inclusionProof); - 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)); + + 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); diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 097fc5d..81cf9a1 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -36,6 +36,7 @@ private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService this.predicateVerifier = PredicateVerifierService.create(); } + public RootTrustBase getTrustBase() { return this.trustBase; } @@ -104,8 +105,8 @@ public CompletableFuture getInclusionProof(StateId state SparseMerkleTreeRootNode root = this.sparseMerkleTree.calculateRoot(); if (!requests.containsKey(stateId)) { - return CompletableFuture.completedFuture(InclusionProofFixture.createResponse(null, null, - null, root.getHash(), this.signingService, this.referenceTime)); + return CompletableFuture.completedFuture(InclusionProofFixture.createPendingResponse( + root.getHash(), this.signingService, this.referenceTime)); } CertificationData certificationData = requests.get(stateId); diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java index 2ec469a..d71dd71 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java @@ -1,20 +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, Long referenceTime, InclusionCertificate inclusionCertificate, DataHash root, SigningService signingService, long certificateTimestamp) { - 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, - referenceTime, - inclusionCertificate, - UnicityCertificateUtils.generateCertificate(signingService, root, - certificateTimestamp) - ) - ); + 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 e3798b6..20452e8 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -1,14 +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; @@ -36,6 +40,7 @@ public class InclusionProofTest { CertificationData certificationData; RootTrustBase trustBase; UnicityCertificate unicityCertificate; + DataHash rootHash; @BeforeAll public void createMerkleTreePath() throws Exception { @@ -55,6 +60,7 @@ public void createMerkleTreePath() throws Exception { 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()); @@ -81,20 +87,41 @@ public void testCborSerialization() { */ @Test public void rejectsAPartiallyPresentProof() { - InclusionProof[] partial = { - new InclusionProof(certificationData, REFERENCE_TIME, null, unicityCertificate), - new InclusionProof(certificationData, null, inclusionCertificate, unicityCertificate), - new InclusionProof(null, REFERENCE_TIME, inclusionCertificate, unicityCertificate), - new InclusionProof(null, null, inclusionCertificate, unicityCertificate), + 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 (InclusionProof proof : partial) { - byte[] encoded = proof.toCbor(); + 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)); + 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, @@ -113,14 +140,6 @@ public void testStructure() { this.unicityCertificate ) ); - Assertions.assertInstanceOf(InclusionProof.class, - new InclusionProof( - null, - null, - this.inclusionCertificate, - this.unicityCertificate - ) - ); } @Test @@ -137,8 +156,7 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, inclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); @@ -163,8 +181,7 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, invalidTransactionHashInclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } @@ -193,8 +210,7 @@ public void testItNotAuthenticated() { this.trustBase, this.predicateVerifier, invalidInclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } @@ -228,19 +244,36 @@ public void testItFailsWithShardIdMismatch() { RootTrustBaseUtils.generateRootTrustBase(signingService.getPublicKey()), this.predicateVerifier, inclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } @Test - public void testVerificationFailsWhenReferenceTimeReachesTheTimeout() { + 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( - this.certificationData, + expiredData, REFERENCE_TIME, - this.inclusionCertificate, - this.unicityCertificate + InclusionCertificate.create(root, expiredStateId.getData()), + UnicityCertificateUtils.generateCertificate(signingService, root.getHash()) ); Assertions.assertEquals( @@ -249,33 +282,71 @@ public void testVerificationFailsWhenReferenceTimeReachesTheTimeout() { this.trustBase, this.predicateVerifier, inclusionProof, - this.transaction, - this.transaction.getExpiresAt().orElse(null) + 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 testVerificationFailsWithWrongReferenceTime() { + public void testVerificationFailsWhenLeafPostdatesItsCertifyingRound() { + SigningService signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); InclusionProof inclusionProof = new InclusionProof( this.certificationData, REFERENCE_TIME, this.inclusionCertificate, - this.unicityCertificate + UnicityCertificateUtils.generateCertificate( + signingService, this.rootHash, REFERENCE_TIME - 1) ); Assertions.assertEquals( - InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH, + InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND, InclusionProofVerificationRule.verify( this.trustBase, this.predicateVerifier, inclusionProof, - this.transaction, - REFERENCE_TIME + 1 + 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( @@ -293,8 +364,7 @@ public void testVerificationFailsWithInvalidTrustbase() { ), this.predicateVerifier, inclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } 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 96cabd8..68022d5 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java @@ -14,11 +14,21 @@ 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, 0); + return generateCertificate(signingService, rootHash, REFERENCE_TIME); } public static UnicityCertificate generateCertificate( @@ -35,7 +45,7 @@ public static UnicityCertificate generateCertificate( DataHash rootHash, ShardId shardId ) { - return generateCertificate(signingService, rootHash, shardId, 0); + return generateCertificate(signingService, rootHash, shardId, REFERENCE_TIME); } public static UnicityCertificate generateCertificate( diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index 6fd332b..b9df9fc 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -77,19 +77,17 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception InclusionProof proofA = InclusionProofUtils.waitInclusionProof( client, trustBase, predicateVerifier, transferA).get(); - long referenceTime = proofA.getReferenceTime().orElseThrow(); + long referenceTime = proofA.getReferenceTime(); // A's certification data verifies against A... Assertions.assertEquals( InclusionProofVerificationStatus.OK, - InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA, - referenceTime).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. VerificationResult result = - InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferB, - referenceTime); + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferB); Assertions.assertEquals( InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH, result.getStatus()); } 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 0000000..1d0585f --- /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 0000000..6e0e718 --- /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 0000000..bf58130 --- /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/util/InclusionProofUtilsTest.java b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java index 70e5940..eec2595 100644 --- a/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java +++ b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java @@ -39,8 +39,8 @@ void retriesPendingProofWithoutReferenceTime() throws Exception { aggregator.submitCertificationRequest(CertificationData.fromMintTransaction(transaction)).get(); InclusionProof proof = proofFuture.get(2, TimeUnit.SECONDS); - Assertions.assertTrue(proof.getReferenceTime().isPresent()); - Assertions.assertTrue(proof.getCertificationData().isPresent()); + // 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/resources/integration/docker-compose.yml b/src/test/resources/integration/docker-compose.yml new file mode 100644 index 0000000..83ae8af --- /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 0000000..746ff0a --- /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 0000000..0056f76 --- /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" + } +}