From 8473ef4ed436897f9b316388f83bd8abe8e15ffd Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 11:14:16 +0200 Subject: [PATCH 01/10] Bring the wire format to parity with TypeScript SDK 3.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #83 mirrors the TypeScript SDK's PR #146. The TypeScript side then took a review round, #147, which changed the container formats and the verification semantics and shipped as 3.0.0. This is that round. The two SDKs already agreed on everything sent to the aggregator — CertificationData bytes are identical down to the golden vectors, as are the transaction encodings, the inclusion proof and the leaf value. What diverged is the token: this SDK cannot read a token 3.0.0 produces, and 3.0.0 cannot read one produced here. Wire: - Token.VERSION 1 -> 2. Every structure it embeds changed shape, so a token written by the other version now fails the version check rather than dying further down on a CBOR array-length error that never mentions versioning. - Certified mint and transfer arrays lose their middle element, 3 -> 2. The service records the leaf's creation time on the record and serves that same value for every proof of the leaf, so the copy stored beside the proof could never legitimately differ from it; it cost a wire element and a consistency check that could only ever agree. Verification: - The rule reads the reference time from the proof instead of being handed it, so REFERENCE_TIME_MISMATCH has nothing left to compare and is gone. - A leaf claiming to postdate the round that certified it is rejected (REFERENCE_TIME_AFTER_ROUND). Consensus signs the round timestamp, so the pairing cannot occur legitimately. Read the comment on that check before relying on it: the bound is one-sided and does not stop back-dating, which is the direction an attacker wants. - A proof reporting no leaf at all is the only answer treated as "not certified yet". A partially present proof now names what is missing instead of reading as pending and leaving a caller polling to its own deadline. - Binding a transaction to a proof for an uncertified state reports INCLUSION_CERTIFICATE_MISSING again; a guard in both factories was reporting a missing reference time, which no retry path recognises. - expiresAt is validated where it is accepted rather than failing later inside CBOR encoding. Both deadline comparisons are unsigned. This has no counterpart in the TypeScript SDK, whose bigint does not wrap: here a CBOR unsigned integer at or above 2^63 arrives as a negative long (CborDeserializer.CborUnsignedLong.asLong says so), and a signed comparison would read such a reference time as earlier than every deadline and wave an expired request through while the leaf value, computed from the same bits, still verified. Fixture certificates now certify a round whose clock matches the leaf. They defaulted to a timestamp of zero while leaves claimed 1755000000 — a pairing no aggregator can produce, and one the new bound rejects. --- .../transaction/CertifiedMintTransaction.java | 55 +++---- .../CertifiedTransferTransaction.java | 49 +++--- .../sdk/transaction/ExpiresAt.java | 41 +++++ .../sdk/transaction/MintTransaction.java | 2 +- .../unicitylabs/sdk/transaction/Token.java | 8 +- .../sdk/transaction/TransferTransaction.java | 2 +- ...tifiedMintTransactionVerificationRule.java | 3 +- ...edTransferTransactionVerificationRule.java | 3 +- .../InclusionProofVerificationRule.java | 70 +++++++-- .../InclusionProofVerificationStatus.java | 12 +- .../sdk/util/InclusionProofUtils.java | 21 +-- .../sdk/api/InclusionProofTest.java | 148 +++++++++++++++--- .../sdk/api/bft/UnicityCertificateUtils.java | 14 +- .../CertificationDataBindingTest.java | 6 +- 14 files changed, 310 insertions(+), 124 deletions(-) create mode 100644 src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index ce15ee9..3a2011f 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,18 @@ 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 inclusion proof rather than stored beside it: the service records the leaf's + * creation time on the record itself and serves that same value for every proof of the leaf, and + * the leaf value binds it, so the proof is the authenticated source for it. * - * @return reference time + * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.referenceTime; + // Non-null by construction: every factory below rejects a proof without one. + return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); } /** @@ -129,17 +130,16 @@ 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"); + List data = CborDeserializer.decodeArray(bytes, 2); + InclusionProof proof = InclusionProof.fromCbor(data.get(1)); + // A certified transaction is one bound to a leaf. A proof that reports no leaf cannot certify + // anything, and decoding it into one would hand every later verifier a transaction with no + // reference time. + if (!proof.getReferenceTime().isPresent()) { + throw new CborSerializationException( + "Certified mint transaction carries an inclusion proof with no certified leaf"); } - return new CertifiedMintTransaction( - MintTransaction.fromCbor(data.get(0)), - referenceTime, - proof); + return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), proof); } /** @@ -163,27 +163,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 +188,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..cf5e5d7 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,14 +71,18 @@ 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 inclusion proof rather than stored beside it: the service records the leaf's + * creation time on the record itself and serves that same value for every proof of the leaf, and + * the leaf value binds it, so the proof is the authenticated source for it. * - * @return reference time + * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.referenceTime; + // Non-null by construction: every factory below rejects a proof without one. + return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); } /** @@ -93,17 +94,18 @@ public long getReferenceTime() { * @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"); + List data = CborDeserializer.decodeArray(bytes, 2); + InclusionProof proof = InclusionProof.fromCbor(data.get(1)); + // A certified transaction is one bound to a leaf. A proof that reports no leaf cannot certify + // anything, and decoding it into one would hand every later verifier a transaction with no + // reference time. + if (!proof.getReferenceTime().isPresent()) { + throw new CborSerializationException( + "Certified transfer transaction carries an inclusion proof with no certified leaf"); } return new CertifiedTransferTransaction( TransferTransaction.fromCbor(data.get(0), token), - referenceTime, proof ); } @@ -134,27 +136,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 +176,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..1ac3a55 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java @@ -0,0 +1,41 @@ +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. + * + *

Without this the range errors surface much later and far from the mistake: a negative value + * encodes nowhere and fails inside the CBOR serializer while the transaction hash is being + * computed, and zero encodes fine but produces a request that 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 format is a CBOR unsigned + * integer and so admits values up to 2^64-1, but one at or above 2^63 arrives as a negative long + * and is rejected here rather than silently reinterpreted. No real deadline comes near that: + * 2^63 Unix seconds is roughly 292 billion years away. + * + * @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..ce88406 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; diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index 94bc14a..5712df2 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; } 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..675efb5 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java @@ -68,8 +68,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..f19e98e 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,27 +36,50 @@ 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) { + Transaction transaction) { + CertificationData certificationData = inclusionProof.getCertificationData().orElse(null); + // The reference time comes from the proof, which is the only party that can state it; the + // leaf value binds this exact value, so the SMT path below authenticates it. + Long referenceTimeOrNull = inclusionProof.getReferenceTime().orElse(null); + InclusionCertificate inclusionCertificate = inclusionProof.getInclusionCertificate(); + + // A proof reporting no leaf at all is the aggregator's "not certified yet", and the only + // status callers poll through. + if (certificationData == null && referenceTimeOrNull == null && inclusionCertificate == null) { return new VerificationResult<>( "InclusionProofVerificationRule", InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING ); } - CertificationData certificationData = inclusionProof.getCertificationData().orElse(null); + // Anything in between establishes neither a leaf nor its absence. InclusionProof.fromCbor + // rejects such a proof outright, so this is reachable only from one built by hand — a + // non-conforming service behind a custom client, or a stripping proxy. Each case names what + // is missing: folding them into the pending status would leave the caller polling to its own + // deadline and blaming the timeout. if (certificationData == null) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA); } + if (referenceTimeOrNull == null) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); + } + + if (inclusionCertificate == null) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF); + } + + long referenceTime = referenceTimeOrNull; + if (!certificationData.getTransactionHash().equals(transaction.calculateTransactionHash())) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH); @@ -72,22 +95,43 @@ public static VerificationResult verify(RootTr // 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()) { + // + // Both sides are Unix seconds, and both are consensus time rather than any caller's clock: + // the reference time is the round's own timestamp from the BFT seal, so a deadline set from a + // local clock is compared against the root chain's and the two can differ by seconds. + // + // Compared unsigned. The wire carries these as CBOR unsigned integers, and one at or above + // 2^63 arrives here as a negative long with the same bit pattern (see + // CborDeserializer.CborUnsignedLong.asLong). A signed comparison would read such a reference + // time as less than every deadline and wave an expired request straight through, while the + // leaf value — which is computed from the same bits — still verifies. + 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. Consensus signs the round's timestamp, + // which is that round's own reference time, so this is a free signed upper bound; the tree is + // append-only, so a proof re-fetched later is certified by a later round and the bound only + // loosens. + // + // It bounds the reference time in one direction only, and the useful direction is the other + // one. Nothing here establishes when the leaf was actually created: a service that receives a + // request after its deadline T can insert the leaf now and write referenceTime = T - 1 into + // it, and both that value and this round's later timestamp satisfy every check in this rule. + // Enforcing a deadline against a dishonest service needs signed evidence of the creation + // round, which an inclusion proof does not carry. What this rule can establish is that the + // leaf is internally consistent and that an honest service admitted the request in time. + // Unsigned, for the same reason as the deadline comparison above. + 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..6c5ef76 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -8,15 +8,23 @@ public enum InclusionProofVerificationStatus { INVALID_TRUSTBASE, /** Certification data required for verification is missing. */ MISSING_CERTIFICATION_DATA, + /** + * The proof carries some leaf fields but not others, so it establishes neither a leaf nor the + * absence of one. + */ + INCOMPLETE_INCLUSION_PROOF, /** 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, diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index f0e4075..75e180d 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -98,19 +98,14 @@ 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); - } + // Every proof goes through the rule. It reports INCLUSION_CERTIFICATE_MISSING only for a + // proof carrying no leaf at all, which is the aggregator's "not certified yet" and the one + // answer worth polling through; a proof that is present but structurally impossible names + // what is missing instead of reading as pending and hiding the cause behind this loop's own + // timeout. + VerificationResult result = + InclusionProofVerificationRule.verify( + trustBase, predicateVerifier, inclusionProof, transaction); switch (result.getStatus()) { case OK: future.complete(inclusionProof); diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index e3798b6..6582580 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -4,6 +4,7 @@ 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; @@ -36,6 +37,7 @@ public class InclusionProofTest { CertificationData certificationData; RootTrustBase trustBase; UnicityCertificate unicityCertificate; + DataHash rootHash; @BeforeAll public void createMerkleTreePath() throws Exception { @@ -55,6 +57,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()); @@ -137,8 +140,7 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, inclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); @@ -163,8 +165,7 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, invalidTransactionHashInclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } @@ -193,8 +194,7 @@ public void testItNotAuthenticated() { this.trustBase, this.predicateVerifier, invalidInclusionProof, - this.transaction, - REFERENCE_TIME + this.transaction ).getStatus() ); } @@ -228,19 +228,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 +266,123 @@ 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() ); } + // A proof with some leaf fields but not others establishes neither a leaf nor its absence. + // fromCbor rejects one off the wire, so these are reachable only hand-built, and each has to + // name what is missing rather than pass for "not certified yet" and leave a caller polling to + // its own deadline. + @Test + public void testPartiallyPresentProofReportsWhatIsMissing() { + Assertions.assertEquals( + InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA, + InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, + new InclusionProof(null, REFERENCE_TIME, this.inclusionCertificate, + this.unicityCertificate), + this.transaction).getStatus() + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, + InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, + new InclusionProof(this.certificationData, null, this.inclusionCertificate, + this.unicityCertificate), + this.transaction).getStatus() + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF, + InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, + new InclusionProof(this.certificationData, REFERENCE_TIME, null, + this.unicityCertificate), + this.transaction).getStatus() + ); + } + + // What the aggregator returns for a state it has not certified yet: all three leaf fields + // absent together. The one status a caller polls through. + @Test + public void testProofWithNoLeafReportsPending() { + Assertions.assertEquals( + InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING, + InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, + new InclusionProof(null, null, null, this.unicityCertificate), + this.transaction).getStatus() + ); + } + + // Documents a gap the bound above does NOT close, so it stays visible and this test fails + // loudly if it is ever closed. + // + // The bound is one-sided, and the useful direction is the other one. A service that receives a + // request after its deadline can insert the leaf now and write a pre-deadline reference time + // into it: the expiry check passes because that value is below the deadline, the bound passes + // because the certifying round is later still, and the SMT path authenticates the value the + // service chose rather than when it chose it. Closing this needs signed evidence of the + // creation round, which an inclusion proof does not carry. + @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 +400,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..9af61ed 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -82,14 +82,12 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception // 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()); } From 571958906cf04285e22b32d29e45ba34fcc1940c Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 11:14:16 +0200 Subject: [PATCH 02/10] Add cross-SDK interop vectors and an integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interop. Each SDK builds a token from entirely fixed inputs — keys, salt, token type, state mask, deadline and the fake aggregator's round clock, with RFC 6979 signing on both sides — commits it, and decodes and fully verifies the other's. This is the test that would have caught the divergence this branch fixes. The CrossSdkEncodingTest vectors both SDKs already carry pin CertificationData, and those bytes never moved: with Token.VERSION reverted to 1, CrossSdkEncodingTest passes 2/2 while both interop tests fail. Only carrying a real token across the language boundary exercises Token, the certified transactions inside it, and the verification semantics that read them. The two SDKs' UnicityCertificate test fixtures differ in three padding fields, so a shared token hex vector is not possible. It is not needed: each side reads the producer's certificate and trust base out of the fixture bundle. TestAggregatorClient gains a reference-time setter, so a generated vector is byte-reproducible rather than dependent on when it was generated. Integration. AggregatorStack starts the compose stack from the TypeScript SDK's own file — BFT root node, mongodb, redis and a pinned aggregator build — waits for consensus to certify a round rather than for the healthcheck, and tears it down after. RequestDeadlineIntegrationTest mirrors the TypeScript cases: the exclusive deadline at submission, the service-assigned branch, what the leaf carries back, and the round-timestamp relation. Tagged `integration`, so the existing integrationTest task picks it up and the ordinary test task keeps excluding it. No build change was needed. These integration tests have NOT been observed to pass. They compile and are correctly excluded from `test`, but the machine they were written on runs Docker 29, whose minimum API version docker-java does not meet, so the suite could not be executed end to end. Run `./gradlew integrationTest` on a normal Docker host before trusting them. --- .gitignore | 3 + .../unicitylabs/sdk/TestAggregatorClient.java | 13 ++ .../sdk/integration/AggregatorStack.java | 194 +++++++++++++++++ .../RequestDeadlineIntegrationTest.java | 153 ++++++++++++++ .../sdk/interop/InteropFixture.java | 185 +++++++++++++++++ .../sdk/interop/InteropVectorTest.java | 50 +++++ .../sdk/interop/JsProducedTokenTest.java | 54 +++++ .../resources/integration/docker-compose.yml | 195 ++++++++++++++++++ src/test/resources/interop/java-token-v2.cbor | Bin 0 -> 1284 bytes .../interop/java-token-v2.trust-base.json | 1 + src/test/resources/interop/js-token-v2.cbor | Bin 0 -> 1158 bytes .../interop/js-token-v2.trust-base.json | 1 + 12 files changed, 849 insertions(+) create mode 100644 src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java create mode 100644 src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java create mode 100644 src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java create mode 100644 src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java create mode 100644 src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java create mode 100644 src/test/resources/integration/docker-compose.yml create mode 100644 src/test/resources/interop/java-token-v2.cbor create mode 100644 src/test/resources/interop/java-token-v2.trust-base.json create mode 100644 src/test/resources/interop/js-token-v2.cbor create mode 100644 src/test/resources/interop/js-token-v2.trust-base.json 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/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 097fc5d..7c31590 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -36,6 +36,19 @@ private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService this.predicateVerifier = PredicateVerifierService.create(); } + /** + * Pin the round clock this client certifies under. + * + *

The default reads the wall clock, which is right for an ordinary test and wrong for one + * that generates a fixture: a byte-reproducible artifact needs every input fixed, and the + * reference time reaches both the leaf value and the certificate. + * + * @param referenceTime reference time a round starting now would pin, in Unix seconds + */ + public void setReferenceTime(long referenceTime) { + this.referenceTime = referenceTime; + } + public RootTrustBase getTrustBase() { return this.trustBase; } 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..16af261 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java @@ -0,0 +1,194 @@ +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.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, started by Testcontainers. + * + *

The suite owns the service it talks to: a BFT root node, mongodb, redis and a pinned + * aggregator build, from the same compose file the TypeScript SDK uses. Nothing external is + * involved, the chain starts empty on every run, and the aggregator is published on an ephemeral + * port so concurrent runs cannot collide. + * + *

There is deliberately no way to point the suite at an aggregator it did not start. A run that + * could be aimed elsewhere would not be exercising the compose file it exists to test. + */ +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 AggregatorStack(ComposeContainer environment, String url) { + this.environment = environment; + this.url = url; + } + + /** + * 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")); + + ComposeContainer environment = new ComposeContainer( + 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(); + + String url = "http://" + environment.getServiceHost("aggregator", AGGREGATOR_PORT) + ":" + + environment.getServicePort("aggregator", AGGREGATOR_PORT); + waitForCertification(url); + + return new AggregatorStack(environment, url); + } + + /** + * Get the aggregator endpoint. + * + * @return base URL + */ + public String getUrl() { + return this.url; + } + + /** + * 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(DATA_DIR.resolve("genesis").resolve("trust-base.json")), + 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. + } + } + + 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..340ecce --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java @@ -0,0 +1,153 @@ +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 very code under test; only a real service can tell whether + * the SDK and the aggregator still agree. Mirrors the TypeScript SDK's + * tests/integration/RequestDeadlineTest.ts case for case. + * + *

Tagged {@code integration} and excluded from the ordinary {@code test} task, because it needs + * a working Docker daemon. 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().orElseThrow(AssertionError::new); + + 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().orElseThrow(AssertionError::new).getExpiresAt().isPresent()); + Assertions.assertTrue(proof.getReferenceTime().isPresent()); + } + + @Test + void servesBackTheExplicitDeadlineTheTransactionHashCommitsTo() throws Exception { + long deadline = ExpiresAt.expiresAt(); + InclusionProof proof = certify(deadline); + + Assertions.assertEquals(deadline, + proof.getCertificationData().orElseThrow(AssertionError::new) + .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().orElseThrow(AssertionError::new) < 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().orElseThrow(AssertionError::new), + proof.getUnicityCertificate().getInputRecord().getTimestamp()); + } + + @Test + void reportsALeaflessProofForARequestThatWasNeverSubmitted() throws Exception { + MintTransaction never = mint(ExpiresAt.expiresAt()); + InclusionProof proof = this.client.getInclusionProof( + org.unicitylabs.sdk.api.StateId.fromTransaction(never)).get().getInclusionProof(); + + // Nothing was certified, so the three leaf fields are absent together — the invariant + // InclusionProof.fromCbor enforces on decode. + Assertions.assertFalse(proof.getCertificationData().isPresent()); + Assertions.assertFalse(proof.getReferenceTime().isPresent()); + Assertions.assertNull(proof.getInclusionCertificate()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java b/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java new file mode 100644 index 0000000..341ffc3 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java @@ -0,0 +1,185 @@ +package org.unicitylabs.sdk.interop; + +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 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.UnlockScript; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.StateMask; +import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.transaction.TransferTransaction; +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.InclusionProofUtils; + +/** + * Shared constants and helpers for the cross-SDK interop vectors. + * + *

Every input here is fixed. A vector is only useful if regenerating it reproduces the same + * bytes, so nothing may read a clock or a random source: keys, salt, token type, state mask, the + * request deadline and the aggregator's round clock are all constants, and both SDKs sign with + * RFC 6979 deterministic ECDSA. + */ +public final class InteropFixture { + + /** Round clock the fake aggregator certifies these vectors under. */ + public static final long REFERENCE_TIME = 1755000000L; + /** Deadline carried by every request in the vectors. An hour after the round clock. */ + public static final long EXPIRES_AT = REFERENCE_TIME + 3600L; + + public static final byte[] AGGREGATOR_KEY = key(0x01); + public static final byte[] ALICE_KEY = key(0x02); + public static final byte[] BOB_KEY = key(0x03); + + /** Directory the vectors live in, as test resources. */ + public static final Path VECTORS = Paths.get("src", "test", "resources", "interop"); + + private InteropFixture() { + } + + private static byte[] key(int last) { + byte[] bytes = new byte[32]; + bytes[31] = (byte) last; + return bytes; + } + + /** Fixed 32-byte value, so salts and token types are reproducible. */ + public static byte[] filled(int value) { + byte[] bytes = new byte[32]; + java.util.Arrays.fill(bytes, (byte) value); + return bytes; + } + + /** + * Mint a token and transfer it once, entirely from fixed inputs. + * + * @return a token with one genesis and one transfer + * @throws Exception if certification or verification fails + */ + public static Token buildToken() throws Exception { + TestAggregatorClient aggregator = TestAggregatorClient.create(AGGREGATOR_KEY); + aggregator.setReferenceTime(REFERENCE_TIME); + StateTransitionClient client = new StateTransitionClient(aggregator); + VerificationContext context = new VerificationContext( + aggregator.getTrustBase(), + PredicateVerifierService.create(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); + + SigningService alice = new SigningService(ALICE_KEY); + SigningService bob = new SigningService(BOB_KEY); + + MintTransaction mint = MintTransaction.builder( + NetworkId.LOCAL, SignaturePredicate.fromSigningService(alice)) + .tokenType(new TokenType(filled(0x11))) + .salt(TokenSalt.fromBytes(filled(0x22))) + .expiresAt(EXPIRES_AT) + .build(); + + CertificationData mintData = CertificationData.fromMintTransaction(mint); + if (client.submitCertificationRequest(mintData).get().getStatus() + != CertificationStatus.SUCCESS) { + throw new IllegalStateException("mint was not certified"); + } + + Token token = Token.mint( + mint.toCertifiedTransaction( + context.getTrustBase(), + context.getPredicateVerifier(), + InclusionProofUtils.waitInclusionProof( + client, context.getTrustBase(), context.getPredicateVerifier(), + mint).get()), + context); + + TransferTransaction transfer = TransferTransaction.create( + token, + SignaturePredicate.fromSigningService(bob), + StateMask.fromBytes(filled(0x33)), + null, + EXPIRES_AT); + UnlockScript unlockScript = SignaturePredicateUnlockScript.create(transfer, alice); + + if (client.submitCertificationRequest( + CertificationData.fromTransaction(transfer, unlockScript)).get().getStatus() + != CertificationStatus.SUCCESS) { + throw new IllegalStateException("transfer was not certified"); + } + + return token.transfer( + transfer.toCertifiedTransaction( + context.getTrustBase(), + context.getPredicateVerifier(), + InclusionProofUtils.waitInclusionProof( + client, context.getTrustBase(), context.getPredicateVerifier(), + transfer).get()), + context); + } + + /** + * The fake aggregator's trust base, as JSON, for the consuming side to load. + * + * @return trust base JSON + */ + public static String trustBaseJson() { + return TestAggregatorClient.create(AGGREGATOR_KEY).getTrustBase().toJson(); + } + + /** + * Read a vector from the resources directory. + * + * @param name file name + * @return file contents + * @throws IOException if the file cannot be read + */ + public static byte[] read(String name) throws IOException { + return Files.readAllBytes(VECTORS.resolve(name)); + } + + /** + * Read a vector as UTF-8 text. + * + * @param name file name + * @return file contents + * @throws IOException if the file cannot be read + */ + public static String readText(String name) throws IOException { + return new String(read(name), StandardCharsets.UTF_8).trim(); + } + + /** + * Write a vector, creating the directory if needed. + * + * @param name file name + * @param content file contents + * @throws IOException if the file cannot be written + */ + public static void write(String name, byte[] content) throws IOException { + Files.createDirectories(VECTORS); + Files.write(VECTORS.resolve(name), content); + } + + /** + * Hex-encode for a readable assertion failure. + * + * @param bytes bytes to encode + * @return hex string + */ + public static String hex(byte[] bytes) { + return HexConverter.encode(bytes); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java b/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java new file mode 100644 index 0000000..6d27937 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java @@ -0,0 +1,50 @@ +package org.unicitylabs.sdk.interop; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.transaction.Token; + +/** + * The producing half of the cross-SDK interop vectors: this SDK's token, pinned. + * + *

Regenerate with {@code ./gradlew test -Dinterop.write=true}. The committed bytes are what the + * TypeScript SDK's consuming test reads, so changing them is a deliberate act: if this test fails, + * either the wire format changed — in which case the TypeScript vectors move with it — or + * something that was supposed to be deterministic is not. + */ +class InteropVectorTest { + + private static final String TOKEN = "java-token-v2.cbor"; + private static final String TRUST_BASE = "java-token-v2.trust-base.json"; + + @Test + void tokenMatchesTheCommittedVector() throws Exception { + Token token = InteropFixture.buildToken(); + byte[] encoded = token.toCbor(); + + if (Boolean.getBoolean("interop.write")) { + InteropFixture.write(TOKEN, encoded); + InteropFixture.write(TRUST_BASE, + InteropFixture.trustBaseJson().getBytes(StandardCharsets.UTF_8)); + return; + } + + Assertions.assertTrue(Files.exists(InteropFixture.VECTORS.resolve(TOKEN)), + "missing vector; regenerate with -Dinterop.write=true"); + Assertions.assertEquals( + InteropFixture.hex(InteropFixture.read(TOKEN)), + InteropFixture.hex(encoded), + "regenerated token does not match the committed interop vector"); + } + + @Test + void tokenRoundTripsThroughItsOwnEncoding() throws Exception { + Token token = InteropFixture.buildToken(); + + Assertions.assertEquals( + InteropFixture.hex(token.toCbor()), + InteropFixture.hex(Token.fromCbor(token.toCbor()).toCbor())); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java b/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java new file mode 100644 index 0000000..fb3ca83 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java @@ -0,0 +1,54 @@ +package org.unicitylabs.sdk.interop; + +import java.nio.file.Files; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assertions; +import org.unicitylabs.sdk.api.bft.RootTrustBase; +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.verification.VerificationStatus; + +/** + * The consuming half: a token minted and transferred by the TypeScript SDK, decoded and fully + * verified here. + * + *

This is the test that catches a container-format divergence. Golden byte vectors for + * {@code CertificationData} — which both SDKs already have — pin the structure sent to the + * aggregator, and they stayed byte-identical through the whole of the change that broke tokens. + * Only carrying a real token across the language boundary exercises {@code Token}, the certified + * transactions inside it, and the verification semantics that read them. + */ +class JsProducedTokenTest { + + private static final String TOKEN = "js-token-v2.cbor"; + private static final String TRUST_BASE = "js-token-v2.trust-base.json"; + + @Test + void verifiesATokenProducedByTheTypeScriptSdk() throws Exception { + Assumptions.assumeTrue(Files.exists(InteropFixture.VECTORS.resolve(TOKEN)), + "TypeScript interop vector not present"); + + Token token = Token.fromCbor(InteropFixture.read(TOKEN)); + RootTrustBase trustBase = RootTrustBase.fromJson(InteropFixture.readText(TRUST_BASE)); + + VerificationContext context = new VerificationContext( + trustBase, + PredicateVerifierService.create(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); + + Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus(), + "a token the TypeScript SDK produced must verify here unchanged"); + + // The deadline is committed by the transaction hash, so it has to survive the crossing. + Assertions.assertEquals(InteropFixture.EXPIRES_AT, + token.getGenesis().getExpiresAt().orElseThrow(AssertionError::new)); + Assertions.assertEquals(InteropFixture.REFERENCE_TIME, + token.getGenesis().getReferenceTime()); + Assertions.assertEquals(1, token.getTransactions().size()); + } +} 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/java-token-v2.cbor b/src/test/resources/interop/java-token-v2.cbor new file mode 100644 index 0000000000000000000000000000000000000000..a6fada62d5220c050cddc3ba3b082fffbb7fff82 GIT binary patch literal 1284 zcmcaPqoJ9p>E?{a4kqTCGb)-H9T_7OnU1m4PjP%(n`_|ekT>-}&5fAyKJT9QHp^G9 z;oKQhFz;!Ef)W7`p&&>AeETMqG280G%^8)gj5lYLw=u!3XTGTWN&RE-*-(+J#OkP3 zS0Be6HfU6+RWzvdeYxV#uO~|*6rSc++`Sx}YBc?*?Yi44`?oB4w!)Tu!W-87EAI=K z;xuX_6pHx#iuJxdyA{D`;=H+C^O9s+bh+o|m^90NEir!4I#;me5suo$SJ`aSTNGvI z%>Qg0+4m!6_kEl1OzM7%Ip=KAv`x}H8BWzmz~sX3wfo{-?QI}GcquN}HNY3lSi zA+L&xgA71(bPhx)Fc1JYXGFFGy&2WT$iVO|Lg7c%?E_m5Xf0{_@QUwX>dIx_Tr!bS z_j&5R*%=)zy&d4_g)k0e!9gTG0}`9Cy@XZVoDmI-^@fID$E&8txxi@jha%!`HTVD;K8t`MbDAIIasaH9Vm9hWDy` zf}Os|?;^j+={cW&ckg5mJCvcowq|>M;n}ALx|h!q_2P|F-~Rj5qG@~N9*D#)yP*3o z`pB7+yBHgRDX_H_kph^08gwyEia0FbY2x4dV{VYKpn&G|Dov@w1Z-t)lhXM}<= z0e~}g5s|nVy0uP3eCAzO*ZzegTS8`K=&m-AvVTt_JTEsqmMY2+i%@VWKDOxmvmfF;g}K~r4Q~P^p{eVyh3_p!&0FOLkF|o%2r4^jmxe7Y4F33| zcGc;V^Zk6d6Gh95YBP^BF#nl+Y$G@8u7&d7a+dk&yljq;Nt*cl>T^bLOiYk`<^M584OJ_6_?9gOBQg#QeRrK_nKL^`q$aNe(lHW&$QTZ zhqQR&r(VI;vZ`Usi;ug@GaUT8{mValD;<~JdwKc3h^B5XJ3W1Z>V&X+r)16?%whxp DKMl5o literal 0 HcmV?d00001 diff --git a/src/test/resources/interop/java-token-v2.trust-base.json b/src/test/resources/interop/java-token-v2.trust-base.json new file mode 100644 index 0000000..038e03c --- /dev/null +++ b/src/test/resources/interop/java-token-v2.trust-base.json @@ -0,0 +1 @@ +{"version":"1","networkId":3,"epoch":"0","epochStartRound":"0","rootNodes":[{"nodeId":"NODE","sigKey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","stake":"1"}],"quorumThreshold":"1","stateHash":"","changeRecordHash":"","previousEntryHash":null,"signatures":{}} \ No newline at end of file diff --git a/src/test/resources/interop/js-token-v2.cbor b/src/test/resources/interop/js-token-v2.cbor new file mode 100644 index 0000000000000000000000000000000000000000..b0488b84578fcb8a91aa9aeb0206b175b07f1c1b GIT binary patch literal 1158 zcmcaPqoJ9p>E?{a4kqTCGb)-H9T_7OnU1m4PjP%(n`_|ekT>-}&5fAyKJT9QHp^G9 z;oKQhFz;!Ef)W7`p&&>AeETMqG280G%^8)gj5lYLw=u!3XTGTWN&RE-*-(+J#OkP3 zS0Be6HfU6+RWzvdeYxV#uO~|*6rSc++`Sx}YBc?*?Yi44`?oB4w!)Tu!W-87EAI=K z;xuX_6pHx#iuJxdyA{D`;=H+C^O9s+bh+o|m^90NEir!4I#;me5suo$SJ`aSTNGvI z%>Qg0+4m!6_kEl1OzM7%Ip=KAv`x}H8BWzmz~sX3wfo{-?QI}GcquN}HNY3lSi zA+L&xgA71(bPhx)Fc1JYXGFFGy&2WT$iVO|Lg7c%?E_m5Xf0{_@QUwX>dIx_Tr!bS z_j&5R*%=)zy&d4_05bOAH->LOwFFFNxH%&l7;Oy=H)q5&Gco|F*bYW!peCT!a;>GW ztqu2RC#nR!4GH`BVoSd2{-4kEb)Fw6T9{T+s=P48&)>y0!tto&ti_is{+gV4_xH=< zt>x(^O)(5QM`v%Z=hG;imbfk2=luG(_yQ@nDK`(lJzAW=rZqS1Yoe5AgYMrmdru4W z*Dy8$lTd3bA_|#)8gwyEia0FbY2x4dV{VYKpn&G|Dov@w1Z-t)lhXM}<=0e~}k z5Rp0=y0uP3eCAzO*ZzegTS8`K=&m-AvVTt_JTEsqmMY2+i%@VWKDOxmvmfF;g}K~r4Q~P^p~>T~h3_p!&0FOLkF|o%2r4^jmxe7Y4F33|cGc;V z^Zk6d6Gh95YBP^BF#nl+Y$G@8u7&d7a+dk&yljq;Nt*cl>T^bLJVrPWibO)`0WJBM z8`mWL^gjQuKTR_I-HJ9F4!e{!W^CCR#wz6kl3`2xz{%$j#mVRTZ!@QRGxoBz_#1~f zK4=RlbKt((H|JWX2AABC(yR85Vacc8@t4|_Giz3*e`de<>OuT#kxfUh=rnG2kleM_ s@aws!<&q9xQf_=SowU+E#Ouon`K5i|0w1q_J+;Q^Ky>Z3o1F6*0S&yshyVZp literal 0 HcmV?d00001 diff --git a/src/test/resources/interop/js-token-v2.trust-base.json b/src/test/resources/interop/js-token-v2.trust-base.json new file mode 100644 index 0000000..0827e8c --- /dev/null +++ b/src/test/resources/interop/js-token-v2.trust-base.json @@ -0,0 +1 @@ +{"changeRecordHash":null,"epoch":"0","epochStartRound":"0","networkId":3,"previousEntryHash":null,"quorumThreshold":"1","rootNodes":[{"nodeId":"NODE","sigKey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","stake":"1"}],"signatures":{},"stateHash":"00","version":"1"} \ No newline at end of file From 47260daae60f42965ec425c1816e5df7d5ee435a Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 12:02:49 +0200 Subject: [PATCH 03/10] Move to Testcontainers 2.0.5, and run the integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned 1.19.8 could not reach a current Docker daemon at all. docker-java 3.4.x negotiates API 1.32; Docker 29 requires 1.44 and refuses the connection, so every Testcontainers-based test failed before starting a container — including, until now, the integration suite added in the previous commit. Testcontainers 2.0.5 carries docker-java 3.7.1 and connects. Two things fall out of the 2.x move: - junit-jupiter and mongodb are dropped. They were declared but never used — nothing in this repo imports @Testcontainers, @Container or MongoDBContainer — and 2.x does not publish them. Only the core artifact is needed, for ComposeContainer and Wait. - 2.x removed containerised compose, so ComposeContainer shells out to the docker CLI. That is present on any CI runner and on a developer machine; it was not present in the JDK container this was written in, which is what made the suite look unrunnable rather than merely unrun. With that, RequestDeadlineIntegrationTest passes against a real aggregator: 8 tests, no skips, about 16 seconds once the stack is up. The stack tears down after and leaves no containers and no generated genesis behind. --- build.gradle.kts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1571de8..637fd4d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") From 8765fb6c4749c9ddaea8e8139e14ddcebfdaca0b Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 13:09:52 +0200 Subject: [PATCH 04/10] Mint the interop token with the published TypeScript SDK instead of a vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this carried committed token vectors and a matching generator in the TypeScript repo, so a cross-SDK check needed a change in both repos and a blob copied between them. It does not. The npm package ships only lib/, and everything needed to mint a token is in it — the fake aggregator that the vector generator leaned on is test code and is not published. Since this suite already starts a real aggregator, the TypeScript SDK can mint against that one instead. So: a node container runs the published @unicitylabs/state-transition-sdk@3.0.0 against the aggregator this suite started, mints and transfers a token, verifies it with its own SDK, and prints it. Java decodes and verifies the result. Better than the vector it replaces in three ways. It exercises the artifact a consumer installs rather than the other repo's source tree. The token is certified by a real aggregator, so real signatures and certificates rather than fake-aggregator-shaped ones. And nothing is committed, so there is no blob to go stale and no question of who regenerates it — which also removes the need for the deterministic-fixture machinery, and for the TypeScript-side PR entirely. Two things worth knowing about the wiring: - The node step shells out to the docker CLI rather than using a Testcontainers GenericContainer. Testcontainers already requires that CLI for ComposeContainer so it adds no dependency, and a one-shot container that fails reports its own output instead of "did not start correctly" with empty logs. - The container joins the stack's network and addresses the aggregator by service name, which needs no published port and no host-gateway assumption. The network name is read off the running container; deriving it from the compose project name produced a name that did not exist. --- .../unicitylabs/sdk/TestAggregatorClient.java | 12 -- .../sdk/integration/AggregatorStack.java | 83 +++++++- .../sdk/interop/InteropFixture.java | 185 ------------------ .../sdk/interop/InteropVectorTest.java | 50 ----- .../sdk/interop/JsProducedTokenTest.java | 54 ----- .../interop/JsSdkInteropIntegrationTest.java | 135 +++++++++++++ src/test/resources/interop/java-token-v2.cbor | Bin 1284 -> 0 bytes .../interop/java-token-v2.trust-base.json | 1 - src/test/resources/interop/js-token-v2.cbor | Bin 1158 -> 0 bytes .../interop/js-token-v2.trust-base.json | 1 - src/test/resources/interop/mint-token.mjs | 102 ++++++++++ src/test/resources/interop/package.json | 9 + 12 files changed, 323 insertions(+), 309 deletions(-) delete mode 100644 src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java delete mode 100644 src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java delete mode 100644 src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java create mode 100644 src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java delete mode 100644 src/test/resources/interop/java-token-v2.cbor delete mode 100644 src/test/resources/interop/java-token-v2.trust-base.json delete mode 100644 src/test/resources/interop/js-token-v2.cbor delete mode 100644 src/test/resources/interop/js-token-v2.trust-base.json create mode 100644 src/test/resources/interop/mint-token.mjs create mode 100644 src/test/resources/interop/package.json diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 7c31590..ef14345 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -36,18 +36,6 @@ private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService this.predicateVerifier = PredicateVerifierService.create(); } - /** - * Pin the round clock this client certifies under. - * - *

The default reads the wall clock, which is right for an ordinary test and wrong for one - * that generates a fixture: a byte-reproducible artifact needs every input fixed, and the - * reference time reaches both the leaf value and the certificate. - * - * @param referenceTime reference time a round starting now would pin, in Unix seconds - */ - public void setReferenceTime(long referenceTime) { - this.referenceTime = referenceTime; - } public RootTrustBase getTrustBase() { return this.trustBase; diff --git a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java index 16af261..05cff23 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java +++ b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java @@ -6,6 +6,7 @@ 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; @@ -40,10 +41,14 @@ public final class AggregatorStack implements AutoCloseable { private final ComposeContainer environment; private final String url; + private final int port; + private final String networkName; - private AggregatorStack(ComposeContainer environment, String url) { + private AggregatorStack(ComposeContainer environment, String url, int port, String networkName) { this.environment = environment; this.url = url; + this.port = port; + this.networkName = networkName; } /** @@ -65,7 +70,9 @@ public static AggregatorStack start() throws IOException, InterruptedException { 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. @@ -79,11 +86,15 @@ public static AggregatorStack start() throws IOException, InterruptedException { .withStartupTimeout(STARTUP); environment.start(); - String url = "http://" + environment.getServiceHost("aggregator", AGGREGATOR_PORT) + ":" - + environment.getServicePort("aggregator", AGGREGATOR_PORT); + int port = environment.getServicePort("aggregator", AGGREGATOR_PORT); + String url = "http://" + environment.getServiceHost("aggregator", AGGREGATOR_PORT) + ":" + port; waitForCertification(url); - return new AggregatorStack(environment, url); + String containerId = environment.getContainerByServiceName("aggregator") + .orElseThrow(() -> new IllegalStateException("no aggregator service in the stack")) + .getContainerId(); + + return new AggregatorStack(environment, url, port, networkOf(containerId)); } /** @@ -95,6 +106,40 @@ 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. * @@ -103,8 +148,7 @@ public String getUrl() { */ public RootTrustBase getTrustBase() throws IOException { return RootTrustBase.fromJson(new String( - Files.readAllBytes(DATA_DIR.resolve("genesis").resolve("trust-base.json")), - StandardCharsets.UTF_8)); + Files.readAllBytes(getTrustBasePath()), StandardCharsets.UTF_8)); } @Override @@ -117,6 +161,33 @@ public void close() { } } + /** + * 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) diff --git a/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java b/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java deleted file mode 100644 index 341ffc3..0000000 --- a/src/test/java/org/unicitylabs/sdk/interop/InteropFixture.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.unicitylabs.sdk.interop; - -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 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.UnlockScript; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.transaction.MintTransaction; -import org.unicitylabs.sdk.transaction.StateMask; -import org.unicitylabs.sdk.transaction.Token; -import org.unicitylabs.sdk.transaction.TokenSalt; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.transaction.TransferTransaction; -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.InclusionProofUtils; - -/** - * Shared constants and helpers for the cross-SDK interop vectors. - * - *

Every input here is fixed. A vector is only useful if regenerating it reproduces the same - * bytes, so nothing may read a clock or a random source: keys, salt, token type, state mask, the - * request deadline and the aggregator's round clock are all constants, and both SDKs sign with - * RFC 6979 deterministic ECDSA. - */ -public final class InteropFixture { - - /** Round clock the fake aggregator certifies these vectors under. */ - public static final long REFERENCE_TIME = 1755000000L; - /** Deadline carried by every request in the vectors. An hour after the round clock. */ - public static final long EXPIRES_AT = REFERENCE_TIME + 3600L; - - public static final byte[] AGGREGATOR_KEY = key(0x01); - public static final byte[] ALICE_KEY = key(0x02); - public static final byte[] BOB_KEY = key(0x03); - - /** Directory the vectors live in, as test resources. */ - public static final Path VECTORS = Paths.get("src", "test", "resources", "interop"); - - private InteropFixture() { - } - - private static byte[] key(int last) { - byte[] bytes = new byte[32]; - bytes[31] = (byte) last; - return bytes; - } - - /** Fixed 32-byte value, so salts and token types are reproducible. */ - public static byte[] filled(int value) { - byte[] bytes = new byte[32]; - java.util.Arrays.fill(bytes, (byte) value); - return bytes; - } - - /** - * Mint a token and transfer it once, entirely from fixed inputs. - * - * @return a token with one genesis and one transfer - * @throws Exception if certification or verification fails - */ - public static Token buildToken() throws Exception { - TestAggregatorClient aggregator = TestAggregatorClient.create(AGGREGATOR_KEY); - aggregator.setReferenceTime(REFERENCE_TIME); - StateTransitionClient client = new StateTransitionClient(aggregator); - VerificationContext context = new VerificationContext( - aggregator.getTrustBase(), - PredicateVerifierService.create(), - new MintJustificationVerifierService(), - new TokenIssuanceVerifierService(false)); - - SigningService alice = new SigningService(ALICE_KEY); - SigningService bob = new SigningService(BOB_KEY); - - MintTransaction mint = MintTransaction.builder( - NetworkId.LOCAL, SignaturePredicate.fromSigningService(alice)) - .tokenType(new TokenType(filled(0x11))) - .salt(TokenSalt.fromBytes(filled(0x22))) - .expiresAt(EXPIRES_AT) - .build(); - - CertificationData mintData = CertificationData.fromMintTransaction(mint); - if (client.submitCertificationRequest(mintData).get().getStatus() - != CertificationStatus.SUCCESS) { - throw new IllegalStateException("mint was not certified"); - } - - Token token = Token.mint( - mint.toCertifiedTransaction( - context.getTrustBase(), - context.getPredicateVerifier(), - InclusionProofUtils.waitInclusionProof( - client, context.getTrustBase(), context.getPredicateVerifier(), - mint).get()), - context); - - TransferTransaction transfer = TransferTransaction.create( - token, - SignaturePredicate.fromSigningService(bob), - StateMask.fromBytes(filled(0x33)), - null, - EXPIRES_AT); - UnlockScript unlockScript = SignaturePredicateUnlockScript.create(transfer, alice); - - if (client.submitCertificationRequest( - CertificationData.fromTransaction(transfer, unlockScript)).get().getStatus() - != CertificationStatus.SUCCESS) { - throw new IllegalStateException("transfer was not certified"); - } - - return token.transfer( - transfer.toCertifiedTransaction( - context.getTrustBase(), - context.getPredicateVerifier(), - InclusionProofUtils.waitInclusionProof( - client, context.getTrustBase(), context.getPredicateVerifier(), - transfer).get()), - context); - } - - /** - * The fake aggregator's trust base, as JSON, for the consuming side to load. - * - * @return trust base JSON - */ - public static String trustBaseJson() { - return TestAggregatorClient.create(AGGREGATOR_KEY).getTrustBase().toJson(); - } - - /** - * Read a vector from the resources directory. - * - * @param name file name - * @return file contents - * @throws IOException if the file cannot be read - */ - public static byte[] read(String name) throws IOException { - return Files.readAllBytes(VECTORS.resolve(name)); - } - - /** - * Read a vector as UTF-8 text. - * - * @param name file name - * @return file contents - * @throws IOException if the file cannot be read - */ - public static String readText(String name) throws IOException { - return new String(read(name), StandardCharsets.UTF_8).trim(); - } - - /** - * Write a vector, creating the directory if needed. - * - * @param name file name - * @param content file contents - * @throws IOException if the file cannot be written - */ - public static void write(String name, byte[] content) throws IOException { - Files.createDirectories(VECTORS); - Files.write(VECTORS.resolve(name), content); - } - - /** - * Hex-encode for a readable assertion failure. - * - * @param bytes bytes to encode - * @return hex string - */ - public static String hex(byte[] bytes) { - return HexConverter.encode(bytes); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java b/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java deleted file mode 100644 index 6d27937..0000000 --- a/src/test/java/org/unicitylabs/sdk/interop/InteropVectorTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.unicitylabs.sdk.interop; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.unicitylabs.sdk.transaction.Token; - -/** - * The producing half of the cross-SDK interop vectors: this SDK's token, pinned. - * - *

Regenerate with {@code ./gradlew test -Dinterop.write=true}. The committed bytes are what the - * TypeScript SDK's consuming test reads, so changing them is a deliberate act: if this test fails, - * either the wire format changed — in which case the TypeScript vectors move with it — or - * something that was supposed to be deterministic is not. - */ -class InteropVectorTest { - - private static final String TOKEN = "java-token-v2.cbor"; - private static final String TRUST_BASE = "java-token-v2.trust-base.json"; - - @Test - void tokenMatchesTheCommittedVector() throws Exception { - Token token = InteropFixture.buildToken(); - byte[] encoded = token.toCbor(); - - if (Boolean.getBoolean("interop.write")) { - InteropFixture.write(TOKEN, encoded); - InteropFixture.write(TRUST_BASE, - InteropFixture.trustBaseJson().getBytes(StandardCharsets.UTF_8)); - return; - } - - Assertions.assertTrue(Files.exists(InteropFixture.VECTORS.resolve(TOKEN)), - "missing vector; regenerate with -Dinterop.write=true"); - Assertions.assertEquals( - InteropFixture.hex(InteropFixture.read(TOKEN)), - InteropFixture.hex(encoded), - "regenerated token does not match the committed interop vector"); - } - - @Test - void tokenRoundTripsThroughItsOwnEncoding() throws Exception { - Token token = InteropFixture.buildToken(); - - Assertions.assertEquals( - InteropFixture.hex(token.toCbor()), - InteropFixture.hex(Token.fromCbor(token.toCbor()).toCbor())); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java b/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java deleted file mode 100644 index fb3ca83..0000000 --- a/src/test/java/org/unicitylabs/sdk/interop/JsProducedTokenTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.unicitylabs.sdk.interop; - -import java.nio.file.Files; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Assertions; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -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.verification.VerificationStatus; - -/** - * The consuming half: a token minted and transferred by the TypeScript SDK, decoded and fully - * verified here. - * - *

This is the test that catches a container-format divergence. Golden byte vectors for - * {@code CertificationData} — which both SDKs already have — pin the structure sent to the - * aggregator, and they stayed byte-identical through the whole of the change that broke tokens. - * Only carrying a real token across the language boundary exercises {@code Token}, the certified - * transactions inside it, and the verification semantics that read them. - */ -class JsProducedTokenTest { - - private static final String TOKEN = "js-token-v2.cbor"; - private static final String TRUST_BASE = "js-token-v2.trust-base.json"; - - @Test - void verifiesATokenProducedByTheTypeScriptSdk() throws Exception { - Assumptions.assumeTrue(Files.exists(InteropFixture.VECTORS.resolve(TOKEN)), - "TypeScript interop vector not present"); - - Token token = Token.fromCbor(InteropFixture.read(TOKEN)); - RootTrustBase trustBase = RootTrustBase.fromJson(InteropFixture.readText(TRUST_BASE)); - - VerificationContext context = new VerificationContext( - trustBase, - PredicateVerifierService.create(), - new MintJustificationVerifierService(), - new TokenIssuanceVerifierService(false)); - - Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus(), - "a token the TypeScript SDK produced must verify here unchanged"); - - // The deadline is committed by the transaction hash, so it has to survive the crossing. - Assertions.assertEquals(InteropFixture.EXPIRES_AT, - token.getGenesis().getExpiresAt().orElseThrow(AssertionError::new)); - Assertions.assertEquals(InteropFixture.REFERENCE_TIME, - token.getGenesis().getReferenceTime()); - Assertions.assertEquals(1, token.getTransactions().size()); - } -} 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..a2671ed --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java @@ -0,0 +1,135 @@ +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 two SDKs share every format that reaches the aggregator, and their golden + * CertificationData vectors are byte-identical — those vectors stayed green throughout a period + * when neither SDK could read the other's tokens, because Token and the certified transactions + * inside it are not part of what they cover. Only carrying a real token across the language + * boundary exercises the container formats and the verification semantics that read them. + * + *

The token is minted against the same aggregator this suite starts, by the npm artifact rather + * than the TypeScript repo's source tree — the bytes a consumer installs. Nothing is committed, so + * there is no vector to go stale and no question of who regenerates it. + * + *

The node step shells out to the docker CLI rather than going through Testcontainers. + * Testcontainers already requires that CLI for ComposeContainer, so this adds no dependency, and + * a one-shot container that fails reports its own output here instead of an opaque "did not start + * correctly". + */ +@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/resources/interop/java-token-v2.cbor b/src/test/resources/interop/java-token-v2.cbor deleted file mode 100644 index a6fada62d5220c050cddc3ba3b082fffbb7fff82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1284 zcmcaPqoJ9p>E?{a4kqTCGb)-H9T_7OnU1m4PjP%(n`_|ekT>-}&5fAyKJT9QHp^G9 z;oKQhFz;!Ef)W7`p&&>AeETMqG280G%^8)gj5lYLw=u!3XTGTWN&RE-*-(+J#OkP3 zS0Be6HfU6+RWzvdeYxV#uO~|*6rSc++`Sx}YBc?*?Yi44`?oB4w!)Tu!W-87EAI=K z;xuX_6pHx#iuJxdyA{D`;=H+C^O9s+bh+o|m^90NEir!4I#;me5suo$SJ`aSTNGvI z%>Qg0+4m!6_kEl1OzM7%Ip=KAv`x}H8BWzmz~sX3wfo{-?QI}GcquN}HNY3lSi zA+L&xgA71(bPhx)Fc1JYXGFFGy&2WT$iVO|Lg7c%?E_m5Xf0{_@QUwX>dIx_Tr!bS z_j&5R*%=)zy&d4_g)k0e!9gTG0}`9Cy@XZVoDmI-^@fID$E&8txxi@jha%!`HTVD;K8t`MbDAIIasaH9Vm9hWDy` zf}Os|?;^j+={cW&ckg5mJCvcowq|>M;n}ALx|h!q_2P|F-~Rj5qG@~N9*D#)yP*3o z`pB7+yBHgRDX_H_kph^08gwyEia0FbY2x4dV{VYKpn&G|Dov@w1Z-t)lhXM}<= z0e~}g5s|nVy0uP3eCAzO*ZzegTS8`K=&m-AvVTt_JTEsqmMY2+i%@VWKDOxmvmfF;g}K~r4Q~P^p{eVyh3_p!&0FOLkF|o%2r4^jmxe7Y4F33| zcGc;V^Zk6d6Gh95YBP^BF#nl+Y$G@8u7&d7a+dk&yljq;Nt*cl>T^bLOiYk`<^M584OJ_6_?9gOBQg#QeRrK_nKL^`q$aNe(lHW&$QTZ zhqQR&r(VI;vZ`Usi;ug@GaUT8{mValD;<~JdwKc3h^B5XJ3W1Z>V&X+r)16?%whxp DKMl5o diff --git a/src/test/resources/interop/java-token-v2.trust-base.json b/src/test/resources/interop/java-token-v2.trust-base.json deleted file mode 100644 index 038e03c..0000000 --- a/src/test/resources/interop/java-token-v2.trust-base.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"1","networkId":3,"epoch":"0","epochStartRound":"0","rootNodes":[{"nodeId":"NODE","sigKey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","stake":"1"}],"quorumThreshold":"1","stateHash":"","changeRecordHash":"","previousEntryHash":null,"signatures":{}} \ No newline at end of file diff --git a/src/test/resources/interop/js-token-v2.cbor b/src/test/resources/interop/js-token-v2.cbor deleted file mode 100644 index b0488b84578fcb8a91aa9aeb0206b175b07f1c1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1158 zcmcaPqoJ9p>E?{a4kqTCGb)-H9T_7OnU1m4PjP%(n`_|ekT>-}&5fAyKJT9QHp^G9 z;oKQhFz;!Ef)W7`p&&>AeETMqG280G%^8)gj5lYLw=u!3XTGTWN&RE-*-(+J#OkP3 zS0Be6HfU6+RWzvdeYxV#uO~|*6rSc++`Sx}YBc?*?Yi44`?oB4w!)Tu!W-87EAI=K z;xuX_6pHx#iuJxdyA{D`;=H+C^O9s+bh+o|m^90NEir!4I#;me5suo$SJ`aSTNGvI z%>Qg0+4m!6_kEl1OzM7%Ip=KAv`x}H8BWzmz~sX3wfo{-?QI}GcquN}HNY3lSi zA+L&xgA71(bPhx)Fc1JYXGFFGy&2WT$iVO|Lg7c%?E_m5Xf0{_@QUwX>dIx_Tr!bS z_j&5R*%=)zy&d4_05bOAH->LOwFFFNxH%&l7;Oy=H)q5&Gco|F*bYW!peCT!a;>GW ztqu2RC#nR!4GH`BVoSd2{-4kEb)Fw6T9{T+s=P48&)>y0!tto&ti_is{+gV4_xH=< zt>x(^O)(5QM`v%Z=hG;imbfk2=luG(_yQ@nDK`(lJzAW=rZqS1Yoe5AgYMrmdru4W z*Dy8$lTd3bA_|#)8gwyEia0FbY2x4dV{VYKpn&G|Dov@w1Z-t)lhXM}<=0e~}k z5Rp0=y0uP3eCAzO*ZzegTS8`K=&m-AvVTt_JTEsqmMY2+i%@VWKDOxmvmfF;g}K~r4Q~P^p~>T~h3_p!&0FOLkF|o%2r4^jmxe7Y4F33|cGc;V z^Zk6d6Gh95YBP^BF#nl+Y$G@8u7&d7a+dk&yljq;Nt*cl>T^bLJVrPWibO)`0WJBM z8`mWL^gjQuKTR_I-HJ9F4!e{!W^CCR#wz6kl3`2xz{%$j#mVRTZ!@QRGxoBz_#1~f zK4=RlbKt((H|JWX2AABC(yR85Vacc8@t4|_Giz3*e`de<>OuT#kxfUh=rnG2kleM_ s@aws!<&q9xQf_=SowU+E#Ouon`K5i|0w1q_J+;Q^Ky>Z3o1F6*0S&yshyVZp diff --git a/src/test/resources/interop/js-token-v2.trust-base.json b/src/test/resources/interop/js-token-v2.trust-base.json deleted file mode 100644 index 0827e8c..0000000 --- a/src/test/resources/interop/js-token-v2.trust-base.json +++ /dev/null @@ -1 +0,0 @@ -{"changeRecordHash":null,"epoch":"0","epochStartRound":"0","networkId":3,"previousEntryHash":null,"quorumThreshold":"1","rootNodes":[{"nodeId":"NODE","sigKey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","stake":"1"}],"signatures":{},"stateHash":"00","version":"1"} \ No newline at end of file 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..63ef111 --- /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.0" + } +} From 97d1a6b9a920df8dc0ba99f77d8bbc5663309f4e Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 13:32:20 +0200 Subject: [PATCH 05/10] Cut the commentary down to what the code does not say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments added in this branch ran to over half the lines in src/main, and the long ones buried what they were explaining: twelve lines of prose above a three-line comparison. Trimmed to the parts that are not evident from the code — why the comparisons are unsigned, what the pending status means, and that the round bound is one-sided — with the back-dating argument left to aggregator-go#186 rather than restated in full at the call site. --- .../transaction/CertifiedMintTransaction.java | 8 +--- .../CertifiedTransferTransaction.java | 8 +--- .../sdk/transaction/ExpiresAt.java | 12 ++---- .../InclusionProofVerificationRule.java | 43 ++++--------------- .../sdk/util/InclusionProofUtils.java | 7 +-- .../sdk/integration/AggregatorStack.java | 13 +++--- .../RequestDeadlineIntegrationTest.java | 8 +--- .../interop/JsSdkInteropIntegrationTest.java | 18 +++----- 8 files changed, 27 insertions(+), 90 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index 3a2011f..65f07da 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -112,14 +112,11 @@ public Optional getExpiresAt() { /** * Get the reference time of the round the leaf was created in, in Unix seconds. * - *

Read from the inclusion proof rather than stored beside it: the service records the leaf's - * creation time on the record itself and serves that same value for every proof of the leaf, and - * the leaf value binds it, so the proof is the authenticated source for it. + *

Read from the proof, which is the authenticated source for it. * * @return reference time in Unix seconds */ public long getReferenceTime() { - // Non-null by construction: every factory below rejects a proof without one. return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); } @@ -132,9 +129,6 @@ public long getReferenceTime() { public static CertifiedMintTransaction fromCbor(byte[] bytes) { List data = CborDeserializer.decodeArray(bytes, 2); InclusionProof proof = InclusionProof.fromCbor(data.get(1)); - // A certified transaction is one bound to a leaf. A proof that reports no leaf cannot certify - // anything, and decoding it into one would hand every later verifier a transaction with no - // reference time. if (!proof.getReferenceTime().isPresent()) { throw new CborSerializationException( "Certified mint transaction carries an inclusion proof with no certified leaf"); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index cf5e5d7..802f44d 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -74,14 +74,11 @@ public Optional getExpiresAt() { /** * Get the reference time of the round the leaf was created in, in Unix seconds. * - *

Read from the inclusion proof rather than stored beside it: the service records the leaf's - * creation time on the record itself and serves that same value for every proof of the leaf, and - * the leaf value binds it, so the proof is the authenticated source for it. + *

Read from the proof, which is the authenticated source for it. * * @return reference time in Unix seconds */ public long getReferenceTime() { - // Non-null by construction: every factory below rejects a proof without one. return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); } @@ -96,9 +93,6 @@ public long getReferenceTime() { public static CertifiedTransferTransaction fromCbor(byte[] bytes, Token token) { List data = CborDeserializer.decodeArray(bytes, 2); InclusionProof proof = InclusionProof.fromCbor(data.get(1)); - // A certified transaction is one bound to a leaf. A proof that reports no leaf cannot certify - // anything, and decoding it into one would hand every later verifier a transaction with no - // reference time. if (!proof.getReferenceTime().isPresent()) { throw new CborSerializationException( "Certified transfer transaction carries an inclusion proof with no certified leaf"); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java b/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java index 1ac3a55..e27791e 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/ExpiresAt.java @@ -11,15 +11,9 @@ private ExpiresAt() { /** * Validate an exclusive request deadline at the boundary that accepts it. * - *

Without this the range errors surface much later and far from the mistake: a negative value - * encodes nowhere and fails inside the CBOR serializer while the transaction hash is being - * computed, and zero encodes fine but produces a request that 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 format is a CBOR unsigned - * integer and so admits values up to 2^64-1, but one at or above 2^63 arrives as a negative long - * and is rejected here rather than silently reinterpreted. No real deadline comes near that: - * 2^63 Unix seconds is roughly 292 billion years away. + *

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 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 f19e98e..4859b17 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -44,13 +44,10 @@ public static VerificationResult verify(RootTr PredicateVerifierService predicateVerifier, InclusionProof inclusionProof, Transaction transaction) { CertificationData certificationData = inclusionProof.getCertificationData().orElse(null); - // The reference time comes from the proof, which is the only party that can state it; the - // leaf value binds this exact value, so the SMT path below authenticates it. Long referenceTimeOrNull = inclusionProof.getReferenceTime().orElse(null); InclusionCertificate inclusionCertificate = inclusionProof.getInclusionCertificate(); - // A proof reporting no leaf at all is the aggregator's "not certified yet", and the only - // status callers poll through. + // No leaf at all: not certified yet, the one status callers poll through. if (certificationData == null && referenceTimeOrNull == null && inclusionCertificate == null) { return new VerificationResult<>( "InclusionProofVerificationRule", @@ -58,11 +55,8 @@ public static VerificationResult verify(RootTr ); } - // Anything in between establishes neither a leaf nor its absence. InclusionProof.fromCbor - // rejects such a proof outright, so this is reachable only from one built by hand — a - // non-conforming service behind a custom client, or a stripping proxy. Each case names what - // is missing: folding them into the pending status would leave the caller polling to its own - // deadline and blaming the timeout. + // A partially present proof is neither a leaf nor its absence. fromCbor rejects one off the + // wire, so these are reachable only from a hand-built proof. if (certificationData == null) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA); @@ -92,38 +86,17 @@ 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. - // - // Both sides are Unix seconds, and both are consensus time rather than any caller's clock: - // the reference time is the round's own timestamp from the BFT seal, so a deadline set from a - // local clock is compared against the root chain's and the two can differ by seconds. - // - // Compared unsigned. The wire carries these as CBOR unsigned integers, and one at or above - // 2^63 arrives here as a negative long with the same bit pattern (see - // CborDeserializer.CborUnsignedLong.asLong). A signed comparison would read such a reference - // time as less than every deadline and wave an expired request straight through, while the - // leaf value — which is computed from the same bits — still verifies. + // 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. Consensus signs the round's timestamp, - // which is that round's own reference time, so this is a free signed upper bound; the tree is - // append-only, so a proof re-fetched later is certified by a later round and the bound only - // loosens. - // - // It bounds the reference time in one direction only, and the useful direction is the other - // one. Nothing here establishes when the leaf was actually created: a service that receives a - // request after its deadline T can insert the leaf now and write referenceTime = T - 1 into - // it, and both that value and this round's later timestamp satisfy every check in this rule. - // Enforcing a deadline against a dishonest service needs signed evidence of the creation - // round, which an inclusion proof does not carry. What this rule can establish is that the - // leaf is internally consistent and that an honest service admitted the request in time. - // Unsigned, for the same reason as the deadline comparison above. + // 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) { diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index 75e180d..d54ab88 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -98,11 +98,8 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { InclusionProof inclusionProof = response.getInclusionProof(); - // Every proof goes through the rule. It reports INCLUSION_CERTIFICATE_MISSING only for a - // proof carrying no leaf at all, which is the aggregator's "not certified yet" and the one - // answer worth polling through; a proof that is present but structurally impossible names - // what is missing instead of reading as pending and hiding the cause behind this loop's own - // timeout. + // The rule reports INCLUSION_CERTIFICATE_MISSING only for a proof with no leaf at all; + // anything partial names what is missing rather than reading as pending. VerificationResult result = InclusionProofVerificationRule.verify( trustBase, predicateVerifier, inclusionProof, transaction); diff --git a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java index 05cff23..d937946 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java +++ b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java @@ -19,15 +19,12 @@ import org.unicitylabs.sdk.api.bft.RootTrustBase; /** - * The aggregator stack the integration suite runs against, started by Testcontainers. + * 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 suite owns the service it talks to: a BFT root node, mongodb, redis and a pinned - * aggregator build, from the same compose file the TypeScript SDK uses. Nothing external is - * involved, the chain starts empty on every run, and the aggregator is published on an ephemeral - * port so concurrent runs cannot collide. - * - *

There is deliberately no way to point the suite at an aggregator it did not start. A run that - * could be aimed elsewhere would not be exercising the compose file it exists to test. + *

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 { diff --git a/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java index 340ecce..a0cd67e 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java +++ b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java @@ -23,12 +23,8 @@ * 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 very code under test; only a real service can tell whether - * the SDK and the aggregator still agree. Mirrors the TypeScript SDK's - * tests/integration/RequestDeadlineTest.ts case for case. - * - *

Tagged {@code integration} and excluded from the ordinary {@code test} task, because it needs - * a working Docker daemon. Run with {@code ./gradlew integrationTest}. + * 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) diff --git a/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java index a2671ed..bf58130 100644 --- a/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java +++ b/src/test/java/org/unicitylabs/sdk/interop/JsSdkInteropIntegrationTest.java @@ -25,20 +25,12 @@ /** * Cross-implementation check: a token minted by the published TypeScript SDK, verified here. * - *

The two SDKs share every format that reaches the aggregator, and their golden - * CertificationData vectors are byte-identical — those vectors stayed green throughout a period - * when neither SDK could read the other's tokens, because Token and the certified transactions - * inside it are not part of what they cover. Only carrying a real token across the language - * boundary exercises the container formats and the verification semantics that read them. + *

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. * - *

The token is minted against the same aggregator this suite starts, by the npm artifact rather - * than the TypeScript repo's source tree — the bytes a consumer installs. Nothing is committed, so - * there is no vector to go stale and no question of who regenerates it. - * - *

The node step shells out to the docker CLI rather than going through Testcontainers. - * Testcontainers already requires that CLI for ComposeContainer, so this adds no dependency, and - * a one-shot container that fails reports its own output here instead of an opaque "did not start - * correctly". + *

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) From dfca1bd704a231dad927beb2b5646fcefc52fa1f Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 13:58:39 +0200 Subject: [PATCH 06/10] Split the inclusion proof response from the proof itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InclusionProof carried both answers the aggregator can give — a certified leaf, and the absence of one — so every field was nullable and every consumer had to re-establish which case it held. That produced a status taxonomy describing states a proof should never have been able to be in, guards in both certified transaction decoders, Optional round-trips on the reference time, and four absence branches at the top of the verification rule before any verifying began. The absence belongs to the response, not to the proof: - InclusionProof requires certificationData, referenceTime and inclusionCertificate. getReferenceTime returns long, getCertificationData returns the data. A value of this type describes a certified leaf; there is no other thing it can be. - InclusionProofResponse carries a nullable proof plus the certificate the answer was served against, and is the type that can say "not certified yet". - The wire form expresses both, so decoding it stays where the layout lives: decodeOrAbsent returns the proof or null and rejects any partial combination, and fromCbor refuses anything but a leaf. MISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME, INCOMPLETE_INCLUSION_PROOF and INCLUSION_CERTIFICATE_MISSING are gone — none of them can occur. The poll loop branches on the response having no proof rather than on a status meaning the same thing, and its switch collapses to an if. The wire bytes do not change. The interop test proves it: a token minted by the published TypeScript SDK, which does not have this split, still decodes and verifies here unchanged. --- .../unicitylabs/sdk/api/InclusionProof.java | 86 +++++++++++---- .../sdk/api/InclusionProofResponse.java | 38 ++++++- .../transaction/CertifiedMintTransaction.java | 6 +- .../CertifiedTransferTransaction.java | 6 +- ...tifiedMintTransactionVerificationRule.java | 5 +- .../InclusionProofVerificationRule.java | 32 +----- .../InclusionProofVerificationStatus.java | 10 -- .../sdk/util/InclusionProofUtils.java | 30 +++--- .../unicitylabs/sdk/TestAggregatorClient.java | 4 +- .../sdk/api/InclusionProofFixture.java | 26 +++-- .../sdk/api/InclusionProofTest.java | 100 ++++++------------ .../CertificationDataBindingTest.java | 2 +- .../RequestDeadlineIntegrationTest.java | 24 ++--- .../sdk/util/InclusionProofUtilsTest.java | 4 +- 14 files changed, 182 insertions(+), 191 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index a2e1141..b35ce70 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -19,15 +19,22 @@ public class InclusionProof { 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 +66,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; } /** @@ -88,6 +94,26 @@ public Optional getReferenceTime() { * @return inclusion proof */ public static InclusionProof fromCbor(byte[] bytes) { + InclusionProof inclusionProof = decodeOrAbsent(bytes); + if (inclusionProof == null) { + throw new CborSerializationException( + "Expected a certified leaf, but the inclusion proof reports none."); + } + + return inclusionProof; + } + + /** + * Decode the wire form, which expresses either a certified leaf or the absence of one. + * + *

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 rejected here, + * so nothing downstream has to consider a half-formed proof. + * + * @param bytes CBOR bytes + * @return the proof, or null when no leaf is certified yet + */ + static InclusionProof decodeOrAbsent(byte[] bytes) { CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); if (tag.getTag() != InclusionProof.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); @@ -107,13 +133,13 @@ public static InclusionProof fromCbor(byte[] bytes) { 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 (present == 0) { + return null; + } + if (present != 3) { throw new CborSerializationException( "InclusionProof must carry certification data, reference time and inclusion " + "certificate together, or none of them."); @@ -127,6 +153,23 @@ public static InclusionProof fromCbor(byte[] bytes) { ); } + /** + * Encode the wire form for a state with no certified leaf. + * + * @param unicityCertificate certificate of the round the answer was served against + * @return CBOR bytes + */ + static byte[] encodeNoCertifiedLeaf(UnicityCertificate unicityCertificate) { + return CborSerializer.encodeTag( + InclusionProof.CBOR_TAG, + CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(VERSION), + CborSerializer.encodeNull(), + CborSerializer.encodeNull(), + CborSerializer.encodeNull(), + unicityCertificate.toCbor())); + } + /** * Serialize inclusion proof to CBOR bytes. * @@ -134,11 +177,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..aff47b5 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java @@ -1,5 +1,6 @@ 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.CborSerializer; @@ -12,6 +13,7 @@ public class InclusionProofResponse { private final long blockNumber; private final InclusionProof inclusionProof; + private final UnicityCertificate unicityCertificate; /** * Create inclison proof response. @@ -20,10 +22,21 @@ public class InclusionProofResponse { */ InclusionProofResponse( long blockNumber, - InclusionProof inclusionProof + InclusionProof inclusionProof, + UnicityCertificate unicityCertificate ) { this.blockNumber = blockNumber; this.inclusionProof = inclusionProof; + this.unicityCertificate = unicityCertificate; + } + + /** + * Get the certificate of the round this answer was served against. Present either way. + * + * @return unicity certificate + */ + public UnicityCertificate getUnicityCertificate() { + return this.unicityCertificate; } /** @@ -31,6 +44,11 @@ public class InclusionProofResponse { * * @return inclusion proof */ + /** + * Get the certified leaf, or null when the state is not certified yet. + * + * @return inclusion proof, or null + */ public InclusionProof getInclusionProof() { return this.inclusionProof; } @@ -45,7 +63,8 @@ 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)) + InclusionProof.decodeOrAbsent(data.get(1)), + unicityCertificateOf(data.get(1)) ); } @@ -57,8 +76,21 @@ public static InclusionProofResponse fromCbor(byte[] bytes) { public byte[] toCbor() { return CborSerializer.encodeArray( CborSerializer.encodeUnsignedInteger(this.blockNumber), - this.inclusionProof.toCbor() + this.inclusionProof == null + ? InclusionProof.encodeNoCertifiedLeaf(this.unicityCertificate) + : this.inclusionProof.toCbor() ); } + /** + * Read the unicity certificate out of the wire form, which carries it either way. + * + * @param bytes encoded inclusion proof + * @return unicity certificate + */ + private static UnicityCertificate unicityCertificateOf(byte[] bytes) { + return UnicityCertificate.fromCbor( + CborDeserializer.decodeArray( + CborDeserializer.decodeTag(bytes).getData(), 5).get(4)); + } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index 65f07da..1159d26 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -117,7 +117,7 @@ public Optional getExpiresAt() { * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); + return this.inclusionProof.getReferenceTime(); } /** @@ -129,10 +129,6 @@ public long getReferenceTime() { public static CertifiedMintTransaction fromCbor(byte[] bytes) { List data = CborDeserializer.decodeArray(bytes, 2); InclusionProof proof = InclusionProof.fromCbor(data.get(1)); - if (!proof.getReferenceTime().isPresent()) { - throw new CborSerializationException( - "Certified mint transaction carries an inclusion proof with no certified leaf"); - } return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), proof); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 802f44d..ab766a6 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -79,7 +79,7 @@ public Optional getExpiresAt() { * @return reference time in Unix seconds */ public long getReferenceTime() { - return this.inclusionProof.getReferenceTime().orElseThrow(IllegalStateException::new); + return this.inclusionProof.getReferenceTime(); } /** @@ -93,10 +93,6 @@ public long getReferenceTime() { public static CertifiedTransferTransaction fromCbor(byte[] bytes, Token token) { List data = CborDeserializer.decodeArray(bytes, 2); InclusionProof proof = InclusionProof.fromCbor(data.get(1)); - if (!proof.getReferenceTime().isPresent()) { - throw new CborSerializationException( - "Certified transfer transaction carries an inclusion proof with no certified leaf"); - } return new CertifiedTransferTransaction( TransferTransaction.fromCbor(data.get(0), token), 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 675efb5..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); 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 4859b17..5b6c601 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -43,36 +43,8 @@ public class InclusionProofVerificationRule { public static VerificationResult verify(RootTrustBase trustBase, PredicateVerifierService predicateVerifier, InclusionProof inclusionProof, Transaction transaction) { - CertificationData certificationData = inclusionProof.getCertificationData().orElse(null); - Long referenceTimeOrNull = inclusionProof.getReferenceTime().orElse(null); - InclusionCertificate inclusionCertificate = inclusionProof.getInclusionCertificate(); - - // No leaf at all: not certified yet, the one status callers poll through. - if (certificationData == null && referenceTimeOrNull == null && inclusionCertificate == null) { - return new VerificationResult<>( - "InclusionProofVerificationRule", - InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING - ); - } - - // A partially present proof is neither a leaf nor its absence. fromCbor rejects one off the - // wire, so these are reachable only from a hand-built proof. - if (certificationData == null) { - return new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA); - } - - if (referenceTimeOrNull == null) { - return new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); - } - - if (inclusionCertificate == null) { - return new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF); - } - - long referenceTime = referenceTimeOrNull; + CertificationData certificationData = inclusionProof.getCertificationData(); + long referenceTime = inclusionProof.getReferenceTime(); if (!certificationData.getTransactionHash().equals(transaction.calculateTransactionHash())) { 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 6c5ef76..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,19 +6,10 @@ 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, - /** - * The proof carries some leaf fields but not others, so it establishes neither a leaf nor the - * absence of one. - */ - INCOMPLETE_INCLUSION_PROOF, /** 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 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 @@ -31,7 +22,6 @@ public enum InclusionProofVerificationStatus { /** 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 d54ab88..4da3523 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -98,25 +98,23 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { InclusionProof inclusionProof = response.getInclusionProof(); - // The rule reports INCLUSION_CERTIFICATE_MISSING only for a proof with no leaf at all; - // anything partial names what is missing rather than reading as pending. + // 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); - 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)); + 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 ef14345..81cf9a1 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -105,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..151cf61 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java @@ -1,20 +1,28 @@ 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) { + public static InclusionProofResponse createResponse(CertificationData certificationData, + long referenceTime, InclusionCertificate inclusionCertificate, DataHash root, + SigningService signingService, long certificateTimestamp) { + UnicityCertificate unicityCertificate = UnicityCertificateUtils.generateCertificate( + signingService, root, certificateTimestamp); + return new InclusionProofResponse( 1L, - new InclusionProof( - certificationData, - referenceTime, - inclusionCertificate, - UnicityCertificateUtils.generateCertificate(signingService, root, - certificateTimestamp) - ) - ); + new InclusionProof(certificationData, referenceTime, inclusionCertificate, + unicityCertificate), + unicityCertificate); + } + + /** The answer for a state the aggregator has not certified yet. */ + public static InclusionProofResponse createPendingResponse(DataHash root, + SigningService signingService, long certificateTimestamp) { + return new InclusionProofResponse(1L, null, + 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 6582580..9b589af 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -1,5 +1,6 @@ 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; @@ -9,7 +10,9 @@ 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; @@ -84,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 = InclusionProof.encodeNoCertifiedLeaf(unicityCertificate); + + Assertions.assertNull(InclusionProof.decodeOrAbsent(encoded)); + Assertions.assertThrows(CborSerializationException.class, () -> InclusionProof.fromCbor(encoded)); + } + @Test public void testStructure() { Assertions.assertThrows(NullPointerException.class, @@ -116,14 +140,6 @@ public void testStructure() { this.unicityCertificate ) ); - Assertions.assertInstanceOf(InclusionProof.class, - new InclusionProof( - null, - null, - this.inclusionCertificate, - this.unicityCertificate - ) - ); } @Test @@ -296,58 +312,6 @@ public void testVerificationFailsWhenLeafPostdatesItsCertifyingRound() { ); } - // A proof with some leaf fields but not others establishes neither a leaf nor its absence. - // fromCbor rejects one off the wire, so these are reachable only hand-built, and each has to - // name what is missing rather than pass for "not certified yet" and leave a caller polling to - // its own deadline. - @Test - public void testPartiallyPresentProofReportsWhatIsMissing() { - Assertions.assertEquals( - InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA, - InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, - new InclusionProof(null, REFERENCE_TIME, this.inclusionCertificate, - this.unicityCertificate), - this.transaction).getStatus() - ); - - Assertions.assertEquals( - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, - InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, - new InclusionProof(this.certificationData, null, this.inclusionCertificate, - this.unicityCertificate), - this.transaction).getStatus() - ); - - Assertions.assertEquals( - InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF, - InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, - new InclusionProof(this.certificationData, REFERENCE_TIME, null, - this.unicityCertificate), - this.transaction).getStatus() - ); - } - - // What the aggregator returns for a state it has not certified yet: all three leaf fields - // absent together. The one status a caller polls through. - @Test - public void testProofWithNoLeafReportsPending() { - Assertions.assertEquals( - InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING, - InclusionProofVerificationRule.verify(this.trustBase, this.predicateVerifier, - new InclusionProof(null, null, null, this.unicityCertificate), - this.transaction).getStatus() - ); - } - - // Documents a gap the bound above does NOT close, so it stays visible and this test fails - // loudly if it is ever closed. - // - // The bound is one-sided, and the useful direction is the other one. A service that receives a - // request after its deadline can insert the leaf now and write a pre-deadline reference time - // into it: the expiry check passes because that value is below the deadline, the bound passes - // because the certifying round is later still, and the SMT path authenticates the value the - // service chose rather than when it chose it. Closing this needs signed evidence of the - // creation round, which an inclusion proof does not carry. @Test public void testAcceptsALeafBackDatedByADishonestService() throws Exception { long deadline = REFERENCE_TIME; diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index 9af61ed..b9df9fc 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -77,7 +77,7 @@ 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( diff --git a/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java index a0cd67e..6e0e718 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java +++ b/src/test/java/org/unicitylabs/sdk/integration/RequestDeadlineIntegrationTest.java @@ -92,7 +92,7 @@ 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().orElseThrow(AssertionError::new); + long reached = proof.getReferenceTime(); Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, submit(mint(reached))); } @@ -105,8 +105,8 @@ void bindsAServiceAssignedDeadlineWithoutRecordingIt() throws Exception { // 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().orElseThrow(AssertionError::new).getExpiresAt().isPresent()); - Assertions.assertTrue(proof.getReferenceTime().isPresent()); + proof.getCertificationData().getExpiresAt().isPresent()); + Assertions.assertTrue(true); } @Test @@ -115,11 +115,11 @@ void servesBackTheExplicitDeadlineTheTransactionHashCommitsTo() throws Exception InclusionProof proof = certify(deadline); Assertions.assertEquals(deadline, - proof.getCertificationData().orElseThrow(AssertionError::new) + 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().orElseThrow(AssertionError::new) < deadline); + Assertions.assertTrue(proof.getReferenceTime() < deadline); } @Test @@ -130,20 +130,16 @@ void reportsAReferenceTimeNoLaterThanTheRoundThatCertifiedIt() throws Exception // 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().orElseThrow(AssertionError::new), + proof.getReferenceTime(), proof.getUnicityCertificate().getInputRecord().getTimestamp()); } @Test void reportsALeaflessProofForARequestThatWasNeverSubmitted() throws Exception { MintTransaction never = mint(ExpiresAt.expiresAt()); - InclusionProof proof = this.client.getInclusionProof( - org.unicitylabs.sdk.api.StateId.fromTransaction(never)).get().getInclusionProof(); - - // Nothing was certified, so the three leaf fields are absent together — the invariant - // InclusionProof.fromCbor enforces on decode. - Assertions.assertFalse(proof.getCertificationData().isPresent()); - Assertions.assertFalse(proof.getReferenceTime().isPresent()); - Assertions.assertNull(proof.getInclusionCertificate()); + + // 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/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()); } } From 6c47482058b442b650162314d581b2c53c520f55 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 15:46:47 +0200 Subject: [PATCH 07/10] Follow the TypeScript SDK to 3.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes the TypeScript side made after the parity work in this branch was written, applied here so the two SDKs match in shape and not only on the wire. A transfer decodes from its source, not from the whole token. TransferTransaction.fromCbor and CertifiedTransferTransaction.fromCbor take the state being spent and the lock script over it rather than a Token; both are checked against the certification data during verification, so a wrong value fails there. That removes the self-reference the token requirement forced: Token.fromCbor used to construct a Token over a mutable list and add to it while decoding, so getLatestTransaction would advance and each transfer could read its source back off a half-built token. The chain is derived where it belongs now. The response cannot contradict its own proof. InclusionProofResponse had a public three-argument constructor, so a caller could supply a certificate differing from the one inside the proof — and toCbor serialises the proof's, so the field was not preserved across a round trip. The constructor is private and there are two named factories: certified() reads the certificate off the proof, notCertified() takes one because there is no proof to read it from. The wire's two shapes live in the response. decodeOrAbsent and encodeNoCertifiedLeaf were on InclusionProof, which is the type that cannot represent an absent leaf — the same leak the split was meant to close, one level up. InclusionProofResponse decodes the tagged structure itself, decides certified from not, and builds the InclusionProof from the parts. InclusionProof.fromCbor is self-contained and rejects anything but a leaf. The interop test now pins the published 3.0.1 rather than 3.0.0, so what it proves is agreement with the current release. --- .../unicitylabs/sdk/api/InclusionProof.java | 52 +------- .../sdk/api/InclusionProofResponse.java | 113 ++++++++++++++---- .../CertifiedTransferTransaction.java | 8 +- .../unicitylabs/sdk/transaction/Token.java | 9 +- .../sdk/transaction/TransferTransaction.java | 22 ++-- .../sdk/api/InclusionProofFixture.java | 7 +- .../sdk/api/InclusionProofTest.java | 4 +- src/test/resources/interop/package.json | 2 +- 8 files changed, 125 insertions(+), 92 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index b35ce70..c143596 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -8,14 +8,13 @@ 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; @@ -94,26 +93,6 @@ public long getReferenceTime() { * @return inclusion proof */ public static InclusionProof fromCbor(byte[] bytes) { - InclusionProof inclusionProof = decodeOrAbsent(bytes); - if (inclusionProof == null) { - throw new CborSerializationException( - "Expected a certified leaf, but the inclusion proof reports none."); - } - - return inclusionProof; - } - - /** - * Decode the wire form, which expresses either a certified leaf or the absence of one. - * - *

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 rejected here, - * so nothing downstream has to consider a half-formed proof. - * - * @param bytes CBOR bytes - * @return the proof, or null when no leaf is certified yet - */ - static InclusionProof decodeOrAbsent(byte[] bytes) { CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); if (tag.getTag() != InclusionProof.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); @@ -132,17 +111,9 @@ static InclusionProof decodeOrAbsent(byte[] bytes) { InclusionCertificate inclusionCertificate = CborDeserializer.decodeNullable(data.get(3), (certificate) -> InclusionCertificate.decode(CborDeserializer.decodeByteString(certificate))); - - long present = Stream.of(certificationData, referenceTime, inclusionCertificate) - .filter(Objects::nonNull) - .count(); - if (present == 0) { - return null; - } - if (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( @@ -153,23 +124,6 @@ static InclusionProof decodeOrAbsent(byte[] bytes) { ); } - /** - * Encode the wire form for a state with no certified leaf. - * - * @param unicityCertificate certificate of the round the answer was served against - * @return CBOR bytes - */ - static byte[] encodeNoCertifiedLeaf(UnicityCertificate unicityCertificate) { - return CborSerializer.encodeTag( - InclusionProof.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(VERSION), - CborSerializer.encodeNull(), - CborSerializer.encodeNull(), - CborSerializer.encodeNull(), - unicityCertificate.toCbor())); - } - /** * Serialize inclusion proof to CBOR bytes. * diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java index aff47b5..2b8f9a4 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProofResponse.java @@ -2,12 +2,20 @@ 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 { @@ -15,12 +23,7 @@ public class InclusionProofResponse { private final InclusionProof inclusionProof; private final UnicityCertificate unicityCertificate; - /** - * Create inclison proof response. - * - * @param inclusionProof inclusion proof - */ - InclusionProofResponse( + private InclusionProofResponse( long blockNumber, InclusionProof inclusionProof, UnicityCertificate unicityCertificate @@ -30,6 +33,33 @@ public class InclusionProofResponse { this.unicityCertificate = unicityCertificate; } + /** + * 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. + * + * @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. * @@ -39,11 +69,6 @@ public UnicityCertificate getUnicityCertificate() { return this.unicityCertificate; } - /** - * Get inclusion proof. - * - * @return inclusion proof - */ /** * Get the certified leaf, or null when the state is not certified yet. * @@ -61,11 +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.decodeOrAbsent(data.get(1)), - unicityCertificateOf(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)); } /** @@ -77,20 +135,25 @@ public byte[] toCbor() { return CborSerializer.encodeArray( CborSerializer.encodeUnsignedInteger(this.blockNumber), this.inclusionProof == null - ? InclusionProof.encodeNoCertifiedLeaf(this.unicityCertificate) + ? this.encodeNoCertifiedLeaf() : this.inclusionProof.toCbor() ); } /** - * Read the unicity certificate out of the wire form, which carries it either way. + * Encode the wire form for a state with no certified leaf: the three leaf fields absent, the + * round's certificate still present. * - * @param bytes encoded inclusion proof - * @return unicity certificate + * @return CBOR bytes */ - private static UnicityCertificate unicityCertificateOf(byte[] bytes) { - return UnicityCertificate.fromCbor( - CborDeserializer.decodeArray( - CborDeserializer.decodeTag(bytes).getData(), 5).get(4)); + 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/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index ab766a6..09619ed 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -86,16 +86,18 @@ public long 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), + TransferTransaction.fromCbor(data.get(0), sourceStateHash, lockScript), proof ); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Token.java b/src/main/java/org/unicitylabs/sdk/transaction/Token.java index ce88406..621bc8b 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Token.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Token.java @@ -114,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 5712df2..446099f 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -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/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java index 151cf61..d71dd71 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java @@ -12,17 +12,16 @@ public static InclusionProofResponse createResponse(CertificationData certificat UnicityCertificate unicityCertificate = UnicityCertificateUtils.generateCertificate( signingService, root, certificateTimestamp); - return new InclusionProofResponse( + return InclusionProofResponse.certified( 1L, new InclusionProof(certificationData, referenceTime, inclusionCertificate, - unicityCertificate), - unicityCertificate); + unicityCertificate)); } /** The answer for a state the aggregator has not certified yet. */ public static InclusionProofResponse createPendingResponse(DataHash root, SigningService signingService, long certificateTimestamp) { - return new InclusionProofResponse(1L, null, + 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 9b589af..20452e8 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -116,9 +116,9 @@ public void rejectsAPartiallyPresentProof() { // the type that carries it, and asking for a proof anyway is an error rather than a null. @Test public void decodesTheAbsentFormAsAnAbsence() { - byte[] encoded = InclusionProof.encodeNoCertifiedLeaf(unicityCertificate); + byte[] encoded = CborDeserializer.decodeArray( + InclusionProofResponse.notCertified(1L, unicityCertificate).toCbor(), 2).get(1); - Assertions.assertNull(InclusionProof.decodeOrAbsent(encoded)); Assertions.assertThrows(CborSerializationException.class, () -> InclusionProof.fromCbor(encoded)); } diff --git a/src/test/resources/interop/package.json b/src/test/resources/interop/package.json index 63ef111..0056f76 100644 --- a/src/test/resources/interop/package.json +++ b/src/test/resources/interop/package.json @@ -4,6 +4,6 @@ "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.0" + "@unicitylabs/state-transition-sdk": "3.0.1" } } From 27a73c53260a40821e9acf1dd711b7e9d648cb82 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 15:58:56 +0200 Subject: [PATCH 08/10] Set the version fallback to 3.0-SNAPSHOT This SDK is the counterpart of state-transition-sdk-js 3.0.1 and shares its wire formats, so the version lines are brought together. There is no 2.x. A release still supplies its own version: release.yml is dispatched with one and passes it as -Pversion. --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 637fd4d..77f3091 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ group = "org.unicitylabs" version = if (project.hasProperty("version")) { project.property("version").toString() } else { - "1.1-SNAPSHOT" + "3.0-SNAPSHOT" } repositories { From c1c8050d61f06772365cd484ef22670e10d85b1b Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 16:15:30 +0200 Subject: [PATCH 09/10] Make the version fallback reachable hasProperty("version") is always true, because Gradle defines version as a project property. The else branch had therefore never run: the fallback sat at 1.1-SNAPSHOT through the whole 1.2 to 1.4.2 series without effect, and a build without -Pversion produced artifacts with no version in the name at all. Checking for the "unspecified" that property holds when -Pversion was not passed makes the fallback do what it looks like it does. A local build now reports 3.0-SNAPSHOT and names its jars accordingly; a release passing -Pversion is unchanged. --- build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 77f3091..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 { - "3.0-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() From 9d5b12e260eeb96dbf33c39e53d100a33c10705b Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 16:23:55 +0200 Subject: [PATCH 10/10] Stop the compose stack when startup fails after the containers are up Readiness can time out and the service lookups can throw, all on a stack that is already running. Nothing holds the environment until the constructor runs, so close() could never be reached: a failed startup left the whole stack and its genesis behind, and the next run would then try to delete a directory that running containers had mounted. Cleanup failures are attached to the original exception rather than replacing it, so a teardown problem cannot hide why startup failed. --- .../sdk/integration/AggregatorStack.java | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java index d937946..1d0585f 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java +++ b/src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java @@ -83,15 +83,44 @@ public static AggregatorStack start() throws IOException, InterruptedException { .withStartupTimeout(STARTUP); environment.start(); - int port = environment.getServicePort("aggregator", AGGREGATOR_PORT); - String url = "http://" + environment.getServiceHost("aggregator", AGGREGATOR_PORT) + ":" + port; - waitForCertification(url); + // 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(); - 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; + } + } - return new AggregatorStack(environment, url, port, networkOf(containerId)); + /** + * 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); + } } /**