From ed324e05e0310166ea378b033759bd478c4d8f90 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 11:26:27 +0300 Subject: [PATCH 01/17] Commit the round reference time in the SMT leaf value The leaf value the Unicity Service records becomes H(txhash, tau) instead of txhash alone, where tau is the reference time of the round the request was validated in. Certified transactions carry tau and verification uses the carried value. Predicate evaluation takes tau as an argument, but tau was recoverable only from the inclusion proof, as UC.IR.t. The tree is append-only, so a leaf can be certified afresh against any later root, and a later proof carries a later round's IR.t. Reference time was therefore a property of the proof rather than of the leaf, and re-presenting a leaf changed the predicate evaluation outcome. Binding it into the leaf value fixes the value the transition was validated under, for any proof of that leaf. A client learns tau from the inclusion proof, which now carries it: it cannot be recovered from the certificate chain, because an aggregator serves proofs against the current certified root rather than the one the leaf was created under. CertifiedMintTransaction and CertifiedTransferTransaction fix tau when they are first bound to a proof, and every later verification recomputes the leaf value from that carried value. Predicate verification takes tau as its second argument. No built-in predicate reads it yet; the point is that a registered engine can, and gets the same value on every re-validation. Wire changes, none backward compatible: InclusionProof [version, certData, tau, cert, uc] certified transaction [transaction, tau, inclusionProof] Refs #81 --- .../unicitylabs/sdk/api/InclusionProof.java | 36 ++++++++++--- .../org/unicitylabs/sdk/api/LeafValue.java | 41 +++++++++++++++ .../DefaultBuiltInPredicateVerifier.java | 6 ++- .../BuiltInPredicateVerifier.java | 6 ++- .../SignaturePredicateVerifier.java | 1 + .../verification/PredicateVerifier.java | 6 ++- .../PredicateVerifierService.java | 4 +- .../transaction/CertifiedMintTransaction.java | 45 +++++++++++++---- .../CertifiedTransferTransaction.java | 40 ++++++++++++--- ...tifiedMintTransactionVerificationRule.java | 3 +- ...edTransferTransactionVerificationRule.java | 3 +- .../InclusionProofVerificationRule.java | 12 ++++- .../InclusionProofVerificationStatus.java | 2 + .../sdk/util/InclusionProofUtils.java | 13 +++-- .../unicitylabs/sdk/TestAggregatorClient.java | 20 ++++++-- .../sdk/api/InclusionProofFixture.java | 6 ++- .../sdk/api/InclusionProofTest.java | 50 ++++++++++++++++--- .../unicitylabs/sdk/api/LeafValueTest.java | 35 +++++++++++++ .../sdk/api/bft/UnicityCertificateUtils.java | 23 +++++++-- .../CertificationDataBindingTest.java | 9 ++-- .../SignaturePredicateVerifierTest.java | 11 ++-- 21 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 src/main/java/org/unicitylabs/sdk/api/LeafValue.java create mode 100644 src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index 89445872..2ccfaad8 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -18,10 +18,12 @@ public class InclusionProof { private final InclusionCertificate inclusionCertificate; private final CertificationData certificationData; + private final Long referenceTime; private final UnicityCertificate unicityCertificate; InclusionProof( CertificationData certificationData, + Long referenceTime, InclusionCertificate inclusionCertificate, UnicityCertificate unicityCertificate ) { @@ -29,6 +31,7 @@ public class InclusionProof { this.inclusionCertificate = inclusionCertificate; this.certificationData = certificationData; + this.referenceTime = referenceTime; this.unicityCertificate = unicityCertificate; } @@ -63,6 +66,20 @@ public Optional getCertificationData() { return Optional.ofNullable(this.certificationData); } + /** + * Get the reference time of the round the certified leaf was created in, empty on a + * non-inclusion proof. + * + *

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); + } + /** * Deserialize inclusion proof from CBOR bytes. * @@ -74,7 +91,7 @@ public static InclusionProof fromCbor(byte[] bytes) { if (tag.getTag() != InclusionProof.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 4); + List data = CborDeserializer.decodeArray(tag.getData(), 5); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); if (version != InclusionProof.VERSION) { @@ -83,10 +100,12 @@ public static InclusionProof fromCbor(byte[] bytes) { return new InclusionProof( CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor), - CborDeserializer.decodeNullable(data.get(2), (inclusionCertificate) -> + CborDeserializer.decodeNullable(data.get(2), + (referenceTime) -> CborDeserializer.decodeUnsignedInteger(referenceTime).asLong()), + CborDeserializer.decodeNullable(data.get(3), (inclusionCertificate) -> InclusionCertificate.decode(CborDeserializer.decodeByteString(inclusionCertificate)) ), - UnicityCertificate.fromCbor(data.get(3)) + UnicityCertificate.fromCbor(data.get(4)) ); } @@ -101,6 +120,8 @@ public byte[] toCbor() { CborSerializer.encodeArray( CborSerializer.encodeUnsignedInteger(InclusionProof.VERSION), CborSerializer.encodeNullable(this.certificationData, CertificationData::toCbor), + CborSerializer.encodeNullable(this.referenceTime, + CborSerializer::encodeUnsignedInteger), CborSerializer.encodeNullable(this.inclusionCertificate, (inclusionCertificate) -> CborSerializer.encodeByteString(inclusionCertificate.encode()) ), @@ -115,20 +136,21 @@ public boolean equals(Object o) { return false; } InclusionProof that = (InclusionProof) o; - return Objects.equals(this.inclusionCertificate, that.inclusionCertificate) && Objects.equals(this.certificationData, that.certificationData) && Objects.equals(this.unicityCertificate, that.unicityCertificate); + return Objects.equals(this.inclusionCertificate, that.inclusionCertificate) && Objects.equals(this.certificationData, that.certificationData) && Objects.equals(this.referenceTime, that.referenceTime) && Objects.equals(this.unicityCertificate, that.unicityCertificate); } @Override public int hashCode() { - return Objects.hash(this.inclusionCertificate, this.certificationData, this.unicityCertificate); + return Objects.hash(this.inclusionCertificate, this.certificationData, this.referenceTime, this.unicityCertificate); } @Override public String toString() { return String.format( - "InclusionProof{certificationData=%s, inclusionCertificate=%s, unicityCertificate=%s}", - this.inclusionCertificate, + "InclusionProof{certificationData=%s, referenceTime=%s, inclusionCertificate=%s, unicityCertificate=%s}", this.certificationData, + this.referenceTime, + this.inclusionCertificate, this.unicityCertificate ); } diff --git a/src/main/java/org/unicitylabs/sdk/api/LeafValue.java b/src/main/java/org/unicitylabs/sdk/api/LeafValue.java new file mode 100644 index 00000000..8a90562a --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/api/LeafValue.java @@ -0,0 +1,41 @@ +package org.unicitylabs.sdk.api; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +/** + * Sparse Merkle tree leaf value recorded by the Unicity Service for an accepted + * certification request. + * + *

The value binds the reference time the request was validated under, not the transaction + * hash alone. The tree is append-only, so a leaf can be certified afresh against any later + * root and a later inclusion proof carries a later round's reference time. Binding the + * reference time into the leaf value fixes the value the transition was validated under, for + * any proof of that leaf. + */ +public final class LeafValue { + + private LeafValue() { + } + + /** + * Calculate the leaf value for a certified request. + * + * @param transactionHash transaction hash of the certified request + * @param referenceTime reference time of the round the request was validated in + * + * @return leaf value + */ + public static DataHash calculate(DataHash transactionHash, long referenceTime) { + return new DataHasher(HashAlgorithm.SHA256) + .update( + CborSerializer.encodeArray( + CborSerializer.encodeByteString(transactionHash.getData()), + CborSerializer.encodeUnsignedInteger(referenceTime) + ) + ) + .digest(); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java index cb081297..6bf0d583 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/DefaultBuiltInPredicateVerifier.java @@ -62,8 +62,10 @@ public static DefaultBuiltInPredicateVerifier create() { @Override public VerificationResult verify(EncodedPredicate predicate, + long referenceTime, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript) { + DataHash transactionHash, + byte[] unlockScript) { BuiltInPredicateType type = BuiltInPredicateType.fromId( CborDeserializer.decodeUnsignedInteger(predicate.encodeCode()).asInt()); @@ -72,6 +74,6 @@ public VerificationResult verify(EncodedPredicate predicate, throw new IllegalArgumentException("No verifier registered for predicate type: " + type); } - return verifier.verify(predicate, sourceStateHash, transactionHash, unlockScript); + return verifier.verify(predicate, referenceTime, sourceStateHash, transactionHash, unlockScript); } } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java index f6182752..79b44a29 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/BuiltInPredicateVerifier.java @@ -22,11 +22,13 @@ public interface BuiltInPredicateVerifier { * Verifies that the provided unlock script satisfies the predicate in the current context. * * @param predicate the predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being validated * @param unlockScript unlock script bytes provided for the predicate * @return verification result with status and optional diagnostics */ - VerificationResult verify(EncodedPredicate predicate, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript); + VerificationResult verify(EncodedPredicate predicate, long referenceTime, + DataHash sourceStateHash, DataHash transactionHash, + byte[] unlockScript); } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java index 5848c6e5..72981f45 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java @@ -31,6 +31,7 @@ public BuiltInPredicateType getType() { @Override public VerificationResult verify(EncodedPredicate encodedPredicate, + long referenceTime, DataHash sourceStateHash, DataHash transactionHash, byte[] unlockScriptBytes) { diff --git a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java index 915eb097..446c9eeb 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifier.java @@ -22,11 +22,13 @@ public interface PredicateVerifier { * Verifies a predicate in the context of a source state, transaction, and unlock script. * * @param predicate predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being validated * @param unlockScript unlock script bytes * @return verification result with status and diagnostics */ - VerificationResult verify(EncodedPredicate predicate, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript); + VerificationResult verify(EncodedPredicate predicate, long referenceTime, + DataHash sourceStateHash, DataHash transactionHash, + byte[] unlockScript); } diff --git a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java index 42b5416d..cf35d555 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/verification/PredicateVerifierService.java @@ -55,6 +55,7 @@ public PredicateVerifierService addVerifier(PredicateVerifier verifier) { * Verifies a predicate by dispatching to a verifier registered for its engine. * * @param predicate predicate to verify + * @param referenceTime reference time the transition was validated under * @param sourceStateHash hash of the source state * @param transactionHash hash of the transaction being verified * @param unlockScript unlock script bytes @@ -63,6 +64,7 @@ public PredicateVerifierService addVerifier(PredicateVerifier verifier) { */ public VerificationResult verify( EncodedPredicate predicate, + long referenceTime, DataHash sourceStateHash, DataHash transactionHash, byte[] unlockScript @@ -73,6 +75,6 @@ public VerificationResult verify( "No verifier registered for predicate engine: " + predicate.getEngine()); } - return verifier.verify(predicate, sourceStateHash, transactionHash, unlockScript); + return verifier.verify(predicate, referenceTime, sourceStateHash, transactionHash, unlockScript); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index b7ccf95f..ed056e4b 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -23,10 +23,13 @@ public class CertifiedMintTransaction implements Transaction { private final MintTransaction transaction; + private final long referenceTime; private final InclusionProof inclusionProof; - private CertifiedMintTransaction(MintTransaction transaction, InclusionProof inclusionProof) { + private CertifiedMintTransaction(MintTransaction transaction, long referenceTime, + InclusionProof inclusionProof) { this.transaction = transaction; + this.referenceTime = referenceTime; this.inclusionProof = inclusionProof; } @@ -104,6 +107,15 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + /** + * Get the reference time this transition was validated under. + * + * @return reference time + */ + public long getReferenceTime() { + return this.referenceTime; + } + /** * Deserializes a certified mint transaction from CBOR. * @@ -111,9 +123,11 @@ public InclusionProof getInclusionProof() { * @return decoded certified mint transaction */ public static CertifiedMintTransaction fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - return new CertifiedMintTransaction(MintTransaction.fromCbor(data.get(0)), - InclusionProof.fromCbor(data.get(1))); + List data = CborDeserializer.decodeArray(bytes, 3); + return new CertifiedMintTransaction( + MintTransaction.fromCbor(data.get(0)), + CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(), + InclusionProof.fromCbor(data.get(2))); } /** @@ -137,17 +151,27 @@ 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 + transaction, + referenceTime ); if (result.getStatus() != InclusionProofVerificationStatus.OK) { throw new VerificationException("Inclusion proof verification failed", result); } - return new CertifiedMintTransaction(transaction, inclusionProof); + return new CertifiedMintTransaction(transaction, referenceTime, inclusionProof); } @Override @@ -162,12 +186,15 @@ public DataHash calculateTransactionHash() { @Override public byte[] toCbor() { - return CborSerializer.encodeArray(this.transaction.toCbor(), this.inclusionProof.toCbor()); + return CborSerializer.encodeArray( + this.transaction.toCbor(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), + this.inclusionProof.toCbor()); } @Override public String toString() { - return String.format("CertifiedMintTransaction{transaction=%s, inclusionProof=%s}", - this.transaction, this.inclusionProof); + return String.format("CertifiedMintTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", + this.transaction, this.referenceTime, this.inclusionProof); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 70eedb0d..791be8e1 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -22,13 +22,16 @@ 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; } @@ -66,6 +69,15 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + /** + * Get the reference time this transition was validated under. + * + * @return reference time + */ + public long getReferenceTime() { + return this.referenceTime; + } + /** * Deserialize a certified transfer transaction from CBOR bytes. * @@ -75,11 +87,12 @@ public InclusionProof getInclusionProof() { * @return certified transfer transaction */ public static CertifiedTransferTransaction fromCbor(byte[] bytes, Token token) { - List data = CborDeserializer.decodeArray(bytes, 2); + List data = CborDeserializer.decodeArray(bytes, 3); return new CertifiedTransferTransaction( TransferTransaction.fromCbor(data.get(0), token), - InclusionProof.fromCbor(data.get(1)) + CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(), + InclusionProof.fromCbor(data.get(2)) ); } @@ -109,17 +122,27 @@ 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 + transaction, + referenceTime ); if (result.getStatus() != InclusionProofVerificationStatus.OK) { throw new VerificationException("Inclusion proof verification failed", result); } - return new CertifiedTransferTransaction(transaction, inclusionProof); + return new CertifiedTransferTransaction(transaction, referenceTime, inclusionProof); } /** @@ -149,12 +172,15 @@ public DataHash calculateTransactionHash() { */ @Override public byte[] toCbor() { - return CborSerializer.encodeArray(this.transaction.toCbor(), this.inclusionProof.toCbor()); + return CborSerializer.encodeArray( + this.transaction.toCbor(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), + this.inclusionProof.toCbor()); } @Override public String toString() { - return String.format("CertifiedTransferTransaction{transaction=%s, inclusionProof=%s}", - this.transaction, this.inclusionProof); + return String.format("CertifiedTransferTransaction{transaction=%s, referenceTime=%s, inclusionProof=%s}", + this.transaction, this.referenceTime, this.inclusionProof); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java index 675efb54..3f0e9f40 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java @@ -68,7 +68,8 @@ public static VerificationResult verify( } result = InclusionProofVerificationRule.verify(context.getTrustBase(), - context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction, + transaction.getReferenceTime()); 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 56c44206..f594e89c 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java @@ -31,7 +31,8 @@ public static VerificationResult verify( ArrayList> results = new ArrayList>(); VerificationResult result = InclusionProofVerificationRule.verify(context.getTrustBase(), - context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction, + transaction.getReferenceTime()); 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 02d59610..9b68e319 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -2,6 +2,7 @@ import org.unicitylabs.sdk.api.CertificationData; import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.LeafValue; import org.unicitylabs.sdk.api.StateId; import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.api.bft.verification.UnicityCertificateVerification; @@ -34,13 +35,14 @@ 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) { + Transaction transaction, long referenceTime) { if (inclusionProof.getInclusionCertificate() == null) { return new VerificationResult<>( "InclusionProofVerificationRule", @@ -66,7 +68,12 @@ public static VerificationResult verify(RootTr } StateId stateId = StateId.fromTransaction(transaction); - if (!inclusionProof.getInclusionCertificate().verify(stateId, certificationData.getTransactionHash(), new DataHash(HashAlgorithm.SHA256, inclusionProof.getUnicityCertificate().getInputRecord().getHash()))) { + // 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", InclusionProofVerificationStatus.PATH_INVALID); } @@ -96,6 +103,7 @@ public static VerificationResult verify(RootTr result = predicateVerifier.verify( transaction.getLockScript(), + referenceTime, transaction.getSourceStateHash(), certificationData.getTransactionHash(), certificationData.getUnlockScript() diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java index 07acdda2..1a24c0b0 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -12,6 +12,8 @@ public enum InclusionProofVerificationStatus { 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, /** Proof authentication failed. */ NOT_AUTHENTICATED, /** Proof path is not included in the committed tree state. */ diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index 43880682..fc3644e1 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -97,11 +97,18 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { - VerificationResult result = InclusionProofVerificationRule.verify( - trustBase, predicateVerifier, response.getInclusionProof(), transaction); + InclusionProof inclusionProof = response.getInclusionProof(); + // An inclusion proof always carries the reference time its leaf value was built from; + // without it nothing has been certified for this state id yet. + VerificationResult result = inclusionProof + .getReferenceTime() + .map(referenceTime -> InclusionProofVerificationRule.verify( + trustBase, predicateVerifier, inclusionProof, transaction, referenceTime)) + .orElseGet(() -> new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING)); switch (result.getStatus()) { case OK: - future.complete(response.getInclusionProof()); + future.complete(inclusionProof); break; case INCLUSION_CERTIFICATE_MISSING: CompletableFuture.delayedExecutor(intervalMillis, TimeUnit.MILLISECONDS) diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 3f0d7467..064bc64d 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -20,7 +20,14 @@ public class TestAggregatorClient implements AggregatorClient { private final PredicateVerifierService predicateVerifier; private final SparseMerkleTree sparseMerkleTree; private final HashMap requests = new HashMap<>(); + private final HashMap referenceTimes = new HashMap<>(); private final SigningService signingService; + /** + * Reference time of the current round. Every accepted request is its own round here, so a + * proof served later is anchored to a certificate whose input record time is past the one + * the leaf was built from, exactly as it is against a live aggregator. + */ + private long referenceTime = System.currentTimeMillis() / 1000; private TestAggregatorClient(SparseMerkleTree smt, SigningService signingService) { this.sparseMerkleTree = smt; @@ -61,6 +68,7 @@ public CompletableFuture submitCertificationRequest(Certi VerificationResult result = this.predicateVerifier.verify( certificationData.getLockScript(), + this.referenceTime, certificationData.getSourceStateHash(), certificationData.getTransactionHash(), certificationData.getUnlockScript() @@ -71,9 +79,12 @@ public CompletableFuture submitCertificationRequest(Certi } if (!this.requests.containsKey(stateId)) { - DataHash leafValue = certificationData.getTransactionHash(); + DataHash leafValue = LeafValue.calculate(certificationData.getTransactionHash(), + this.referenceTime); this.sparseMerkleTree.addLeaf(stateId.getData(), leafValue.getData()); this.requests.put(stateId, certificationData); + this.referenceTimes.put(stateId, this.referenceTime); + this.referenceTime += 1; } return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SUCCESS)); @@ -87,7 +98,8 @@ public CompletableFuture getInclusionProof(StateId state SparseMerkleTreeRootNode root = this.sparseMerkleTree.calculateRoot(); if (!requests.containsKey(stateId)) { - return CompletableFuture.completedFuture(InclusionProofFixture.createResponse(null, null, root.getHash(), this.signingService)); + return CompletableFuture.completedFuture(InclusionProofFixture.createResponse(null, null, + null, root.getHash(), this.signingService, this.referenceTime)); } CertificationData certificationData = requests.get(stateId); @@ -95,9 +107,11 @@ public CompletableFuture getInclusionProof(StateId state return CompletableFuture.completedFuture( InclusionProofFixture.createResponse( certificationData, + this.referenceTimes.get(stateId), InclusionCertificate.create(root, stateId.getData()), root.getHash(), - this.signingService + this.signingService, + this.referenceTime ) ); } diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java index 38c5ce35..2ec469a7 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofFixture.java @@ -5,13 +5,15 @@ import org.unicitylabs.sdk.crypto.secp256k1.SigningService; public class InclusionProofFixture { - public static InclusionProofResponse createResponse(CertificationData certificationData, InclusionCertificate inclusionCertificate, DataHash root, SigningService signingService) { + public static InclusionProofResponse createResponse(CertificationData certificationData, Long referenceTime, InclusionCertificate inclusionCertificate, DataHash root, SigningService signingService, long certificateTimestamp) { return new InclusionProofResponse( 1L, new InclusionProof( certificationData, + referenceTime, inclusionCertificate, - UnicityCertificateUtils.generateCertificate(signingService, root) + UnicityCertificateUtils.generateCertificate(signingService, root, + certificateTimestamp) ) ); } diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index 2a1a070c..bdadffe6 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -25,6 +25,8 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class InclusionProofTest { + static final long REFERENCE_TIME = 1755000000L; + MintTransaction transaction; PredicateVerifierService predicateVerifier; StateId stateId; @@ -48,7 +50,8 @@ public void createMerkleTreePath() throws Exception { stateId = StateId.fromCertificationData(certificationData); SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); - smt.addLeaf(stateId.getData(), certificationData.getTransactionHash().getData()); + smt.addLeaf(stateId.getData(), + LeafValue.calculate(certificationData.getTransactionHash(), REFERENCE_TIME).getData()); SparseMerkleTreeRootNode root = smt.calculateRoot(); inclusionCertificate = InclusionCertificate.create(root, stateId.getData()); @@ -62,6 +65,7 @@ public void createMerkleTreePath() throws Exception { public void testCborSerialization() { InclusionProof inclusionProof = new InclusionProof( certificationData, + REFERENCE_TIME, inclusionCertificate, unicityCertificate ); @@ -74,6 +78,7 @@ public void testStructure() { Assertions.assertThrows(NullPointerException.class, () -> new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, null ) @@ -81,12 +86,14 @@ public void testStructure() { Assertions.assertInstanceOf(InclusionProof.class, new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ) ); Assertions.assertInstanceOf(InclusionProof.class, new InclusionProof( + null, null, this.inclusionCertificate, this.unicityCertificate @@ -98,6 +105,7 @@ public void testStructure() { public void testItVerifies() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -107,7 +115,8 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, inclusionProof, - this.transaction + this.transaction, + REFERENCE_TIME ).getStatus() ); @@ -120,6 +129,7 @@ public void testItVerifies() { ), this.certificationData.getUnlockScript() ), + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -130,7 +140,8 @@ public void testItVerifies() { this.trustBase, this.predicateVerifier, invalidTransactionHashInclusionProof, - this.transaction + this.transaction, + REFERENCE_TIME ).getStatus() ); } @@ -147,6 +158,7 @@ public void testItNotAuthenticated() { new SigningService(SigningService.generatePrivateKey()) ).encode() ), + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -157,7 +169,8 @@ public void testItNotAuthenticated() { this.trustBase, this.predicateVerifier, invalidInclusionProof, - this.transaction + this.transaction, + REFERENCE_TIME ).getStatus() ); } @@ -180,6 +193,7 @@ public void testItFailsWithShardIdMismatch() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, mismatchingCertificate ); @@ -190,7 +204,29 @@ public void testItFailsWithShardIdMismatch() { RootTrustBaseUtils.generateRootTrustBase(signingService.getPublicKey()), this.predicateVerifier, inclusionProof, - this.transaction + this.transaction, + REFERENCE_TIME + ).getStatus() + ); + } + + @Test + public void testVerificationFailsWithWrongReferenceTime() { + InclusionProof inclusionProof = new InclusionProof( + this.certificationData, + REFERENCE_TIME, + this.inclusionCertificate, + this.unicityCertificate + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.PATH_INVALID, + InclusionProofVerificationRule.verify( + this.trustBase, + this.predicateVerifier, + inclusionProof, + this.transaction, + REFERENCE_TIME + 1 ).getStatus() ); } @@ -199,6 +235,7 @@ public void testItFailsWithShardIdMismatch() { public void testVerificationFailsWithInvalidTrustbase() { InclusionProof inclusionProof = new InclusionProof( this.certificationData, + REFERENCE_TIME, this.inclusionCertificate, this.unicityCertificate ); @@ -211,7 +248,8 @@ public void testVerificationFailsWithInvalidTrustbase() { ), this.predicateVerifier, inclusionProof, - this.transaction + this.transaction, + REFERENCE_TIME ).getStatus() ); } diff --git a/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java b/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java new file mode 100644 index 00000000..babe5f77 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/LeafValueTest.java @@ -0,0 +1,35 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.util.HexConverter; + +public class LeafValueTest { + + // Shared across the Go, Rust and TypeScript implementations: the leaf value is SHA-256 over + // the deterministic CBOR array [transactionHash, referenceTime]. + private static final DataHash TRANSACTION_HASH = new DataHash( + HashAlgorithm.SHA256, + HexConverter.decode("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + ); + private static final long REFERENCE_TIME = 1755000000L; + private static final String EXPECTED = + "0235bd52cfa10c9785dfa01942bc396f201fe715dbc3896ee117a97e895e1e36"; + + @Test + public void matchesTheSharedTestVector() { + Assertions.assertEquals( + EXPECTED, + HexConverter.encode(LeafValue.calculate(TRANSACTION_HASH, REFERENCE_TIME).getData())); + } + + @Test + public void changesWithTheReferenceTime() { + Assertions.assertNotEquals( + EXPECTED, + HexConverter.encode( + LeafValue.calculate(TRANSACTION_HASH, REFERENCE_TIME + 1).getData())); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java index 1ea13549..96cabd87 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java @@ -17,15 +17,32 @@ public class UnicityCertificateUtils { public static UnicityCertificate generateCertificate( SigningService signingService, DataHash rootHash + ) { + return generateCertificate(signingService, rootHash, 0); + } + + public static UnicityCertificate generateCertificate( + SigningService signingService, + DataHash rootHash, + long timestamp ) { return generateCertificate(signingService, rootHash, - ShardId.decode(new byte[]{(byte) 0b10000000})); + ShardId.decode(new byte[]{(byte) 0b10000000}), timestamp); } public static UnicityCertificate generateCertificate( SigningService signingService, DataHash rootHash, ShardId shardId + ) { + return generateCertificate(signingService, rootHash, shardId, 0); + } + + public static UnicityCertificate generateCertificate( + SigningService signingService, + DataHash rootHash, + ShardId shardId, + long timestamp ) { InputRecord inputRecord = new InputRecord( 0, @@ -33,7 +50,7 @@ public static UnicityCertificate generateCertificate( null, rootHash.getData(), new byte[10], - 0, + timestamp, new byte[10], 0, new byte[10] @@ -83,7 +100,7 @@ public static UnicityCertificate generateCertificate( ); return new UnicityCertificate( - new InputRecord(0, 0, null, rootHash.getData(), new byte[10], 0, + new InputRecord(0, 0, null, rootHash.getData(), new byte[10], timestamp, new byte[10], 0, new byte[10]), technicalRecordHash, shardConfigurationHash, diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index fcf588c5..fe455ebd 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -76,16 +76,19 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception InclusionProof proofA = InclusionProofUtils.waitInclusionProof( client, trustBase, predicateVerifier, transferA).get(); + long referenceTime = proofA.getReferenceTime().orElseThrow(); + // A's certification data verifies against A... Assertions.assertEquals( InclusionProofVerificationStatus.OK, - InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA) - .getStatus()); + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA, + referenceTime).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); + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferB, + referenceTime); Assertions.assertEquals( InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH, result.getStatus()); } diff --git a/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java index dd86c316..b53b8cc1 100644 --- a/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java +++ b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java @@ -14,6 +14,8 @@ public class SignaturePredicateVerifierTest { + static final long REFERENCE_TIME = 1755000000L; + private final SignaturePredicateVerifier verifier = new SignaturePredicateVerifier(); private final SigningService signingService = SigningService.generate(); private final EncodedPredicate encodedPredicate = EncodedPredicate.fromPredicate( @@ -42,7 +44,8 @@ public void shouldAcceptValidUnlockScript() { Assertions.assertEquals( VerificationStatus.OK, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, signature.encode()).getStatus()); } @@ -53,7 +56,8 @@ public void shouldRejectTamperedRecoveryByte() { Assertions.assertEquals( VerificationStatus.FAIL, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, tampered).getStatus()); } @@ -64,7 +68,8 @@ public void shouldFailWhenRecoveryByteMakesSignatureUnrecoverable() { Assertions.assertEquals( VerificationStatus.FAIL, - this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + this.verifier.verify(this.encodedPredicate, REFERENCE_TIME, this.sourceStateHash, + this.transactionHash, tampered).getStatus()); } } From 1b3130203e41c5c391ef502d643bb084a3127a2d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 11:30:30 +0300 Subject: [PATCH 02/17] Explicit unicity service request timeout A transaction now carries an exclusive timeout tau_Q. The Unicity Service accepts the request only in a round whose reference time satisfies tau < tau_Q; an expired request is rejected. The timeout constrains validation (inclusion to SMT) only: certification and delivery may occur later. Wire changes, not backward compatible: MintTransaction [version, networkId, recipient, salt, tokenType, justification, data, tau_Q] TransferTransaction [version, recipient, stateMask, data, tau_Q] CertificationData [version, lockScript, sourceStateHash, transactionHash, tau_Q, witness] Refs #82 --- .../sdk/api/CertificationData.java | 27 +++++++-- .../sdk/api/CertificationStatus.java | 8 ++- .../unicitylabs/sdk/payment/TokenSplit.java | 10 +++- .../transaction/CertifiedMintTransaction.java | 5 ++ .../CertifiedTransferTransaction.java | 5 ++ .../sdk/transaction/MintTransaction.java | 57 +++++++++++++----- .../sdk/transaction/Transaction.java | 9 +++ .../sdk/transaction/TransferTransaction.java | 22 +++++-- .../InclusionProofVerificationRule.java | 9 ++- .../InclusionProofVerificationStatus.java | 2 + .../unicitylabs/sdk/TestAggregatorClient.java | 5 ++ .../sdk/TestApiKeyIntegration.java | 3 +- .../sdk/api/InclusionProofTest.java | 26 +++++++- .../CertificationDataBindingTest.java | 5 +- .../sdk/functional/RequestTimeoutTest.java | 60 +++++++++++++++++++ .../functional/payment/SplitBuilderTest.java | 11 ++-- .../payment/SplitInflationExploitTest.java | 5 +- .../functional/payment/TokenSplitTest.java | 9 +-- .../unicitylabs/sdk/utils/RequestTimeout.java | 29 +++++++++ .../org/unicitylabs/sdk/utils/TokenUtils.java | 2 + 20 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java create mode 100644 src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java index 38d71c16..95ff15db 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java @@ -28,17 +28,20 @@ public class CertificationData { private final EncodedPredicate lockScript; private final DataHash sourceStateHash; private final DataHash transactionHash; + private final long timeout; private final byte[] unlockScript; CertificationData( EncodedPredicate lockScript, DataHash sourceStateHash, DataHash transactionHash, + long timeout, byte[] unlockScript ) { this.lockScript = lockScript; this.sourceStateHash = sourceStateHash; this.transactionHash = transactionHash; + this.timeout = timeout; this.unlockScript = Arrays.copyOf(unlockScript, unlockScript.length); } @@ -73,6 +76,15 @@ public DataHash getTransactionHash() { return this.transactionHash; } + /** + * Get the exclusive timeout of the certification request. + * + * @return request timeout + */ + public long getTimeout() { + return this.timeout; + } + /** * Get unlock script used for certification. * @@ -93,7 +105,7 @@ public static CertificationData fromCbor(byte[] bytes) { if (tag.getTag() != CertificationData.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 5); + List data = CborDeserializer.decodeArray(tag.getData(), 6); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); if (version != CertificationData.VERSION) { @@ -104,7 +116,8 @@ public static CertificationData fromCbor(byte[] bytes) { EncodedPredicate.fromCbor(data.get(1)), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(2))), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(3))), - CborDeserializer.decodeByteString(data.get(4)) + CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong(), + CborDeserializer.decodeByteString(data.get(5)) ); } @@ -157,6 +170,7 @@ public static CertificationData fromTransaction(Transaction transaction, byte[] transaction.getLockScript(), transaction.getSourceStateHash(), transaction.calculateTransactionHash(), + transaction.getTimeout(), unlockScript ); } @@ -174,6 +188,7 @@ public byte[] toCbor() { this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), CborSerializer.encodeByteString(this.transactionHash.getData()), + CborSerializer.encodeUnsignedInteger(this.timeout), CborSerializer.encodeByteString(this.unlockScript) ) ); @@ -188,19 +203,21 @@ public boolean equals(Object o) { return Objects.equals(this.lockScript, that.lockScript) && Objects.equals(this.sourceStateHash, that.sourceStateHash) && Objects.equals(this.transactionHash, that.transactionHash) + && this.timeout == that.timeout && Arrays.equals(this.unlockScript, that.unlockScript); } @Override public int hashCode() { - return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, Arrays.hashCode(this.unlockScript)); + return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, this.timeout, + Arrays.hashCode(this.unlockScript)); } @Override public String toString() { return String.format( - "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, unlockScript=%s}", - this.lockScript, this.sourceStateHash, this.transactionHash, + "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, timeout=%s, unlockScript=%s}", + this.lockScript, this.sourceStateHash, this.transactionHash, this.timeout, HexConverter.encode(this.unlockScript)); } } diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java index 74d28b77..ae67445b 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java @@ -54,6 +54,11 @@ public final class CertificationStatus { * The certification request failed because request was sent to invalid shard. */ public static final CertificationStatus INVALID_SHARD = new CertificationStatus("INVALID_SHARD"); + /** + * The round's reference time had already reached the request's timeout. + */ + public static final CertificationStatus REQUEST_EXPIRED = + new CertificationStatus("REQUEST_EXPIRED"); private static final CertificationStatus[] VALUES = { SUCCESS, @@ -64,7 +69,8 @@ public final class CertificationStatus { INVALID_SOURCE_STATE_HASH_FORMAT, INVALID_TRANSACTION_HASH_FORMAT, UNSUPPORTED_ALGORITHM, - INVALID_SHARD + INVALID_SHARD, + REQUEST_EXPIRED }; private final String value; diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java index e2b185f0..4df588b0 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java @@ -41,15 +41,18 @@ private TokenSplit() { * @param token source token to split (the token being burned) * @param paymentDataDeserializer decoder for the source token's payment data * @param requests per-output mint requests; each carries its own payment data + * @param burnTimeout exclusive certification request timeout of the burn transaction * @return burn predicate, burn transaction and split tokens ready to mint * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, - List requests + List requests, + long burnTimeout ) throws LeafExistsException { - return TokenSplit.split(token, paymentDataDeserializer, requests, StateMask.generate()); + return TokenSplit.split(token, paymentDataDeserializer, requests, burnTimeout, + StateMask.generate()); } /** @@ -58,6 +61,7 @@ public static SplitResult split( * @param token source token to split (the token being burned) * @param paymentDataDeserializer decoder for the source token's payment data * @param requests per-output mint requests; each carries its own payment data + * @param burnTimeout exclusive certification request timeout of the burn transaction * @param burnStateMask state mask for the burn transaction; callers needing a crash-resumable * (re-buildable) split supply a deterministically derived mask so the identical burn * transaction can be reconstructed after a failure @@ -68,6 +72,7 @@ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests, + long burnTimeout, StateMask burnStateMask ) throws LeafExistsException { Objects.requireNonNull(token, "Token cannot be null"); @@ -140,6 +145,7 @@ public static SplitResult split( token, burnPredicate, burnStateMask, + burnTimeout, manifestBytes ); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index ed056e4b..342d7598 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -107,6 +107,11 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + @Override + public long getTimeout() { + return this.transaction.getTimeout(); + } + /** * Get the reference time this transition was validated under. * diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 791be8e1..2a1b5521 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -69,6 +69,11 @@ public InclusionProof getInclusionProof() { return this.inclusionProof; } + @Override + public long getTimeout() { + return this.transaction.getTimeout(); + } + /** * Get the reference time this transition was validated under. * diff --git a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java index 01608bfd..528b1fdb 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -40,6 +40,7 @@ public class MintTransaction implements Transaction { private final TokenSalt salt; private final TokenType tokenType; private final TokenId tokenId; + private final long timeout; private final byte[] justification; private final byte[] data; @@ -51,6 +52,7 @@ private MintTransaction( TokenSalt salt, TokenType tokenType, TokenId tokenId, + long timeout, byte[] justification, byte[] data ) { @@ -61,10 +63,16 @@ private MintTransaction( this.salt = salt; this.tokenType = tokenType; this.tokenId = tokenId; + this.timeout = timeout; this.justification = justification; this.data = data; } + @Override + public long getTimeout() { + return this.timeout; + } + public int getVersion() { return MintTransaction.VERSION; } @@ -145,6 +153,7 @@ public StateMask getStateMask() { * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param data payload bytes, may be null * @param tokenType token type identifier * @param salt mint-transaction salt @@ -155,6 +164,7 @@ public StateMask getStateMask() { public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, byte[] data, TokenType tokenType, TokenSalt salt, @@ -175,6 +185,7 @@ public static MintTransaction create( salt, tokenType, tokenId, + timeout, justification != null ? Arrays.copyOf(justification, justification.length) : null, data != null ? Arrays.copyOf(data, data.length) : null ); @@ -185,6 +196,7 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param data payload bytes, may be null * @param tokenType token type identifier * @param salt mint-transaction salt @@ -194,11 +206,12 @@ public static MintTransaction create( public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, byte[] data, TokenType tokenType, TokenSalt salt ) { - return MintTransaction.create(networkId, recipient, data, tokenType, salt, null); + return MintTransaction.create(networkId, recipient, timeout, data, tokenType, salt, null); } /** @@ -206,6 +219,7 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param data payload bytes, may be null * @param tokenType token type identifier * @@ -214,10 +228,12 @@ public static MintTransaction create( public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, byte[] data, TokenType tokenType ) { - return MintTransaction.create(networkId, recipient, data, tokenType, TokenSalt.generate()); + return MintTransaction.create(networkId, recipient, timeout, data, tokenType, + TokenSalt.generate()); } /** @@ -225,6 +241,7 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param data payload bytes, may be null * @param salt mint-transaction salt * @@ -233,10 +250,11 @@ public static MintTransaction create( public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, byte[] data, TokenSalt salt ) { - return MintTransaction.create(networkId, recipient, data, TokenType.generate(), salt); + return MintTransaction.create(networkId, recipient, timeout, data, TokenType.generate(), salt); } /** @@ -244,6 +262,7 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param tokenType token type identifier * @param salt mint-transaction salt * @@ -252,10 +271,11 @@ public static MintTransaction create( public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, TokenType tokenType, TokenSalt salt ) { - return MintTransaction.create(networkId, recipient, (byte[]) null, tokenType, salt); + return MintTransaction.create(networkId, recipient, timeout, (byte[]) null, tokenType, salt); } /** @@ -263,12 +283,14 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param data payload bytes, may be null * * @return mint transaction */ - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data) { - return MintTransaction.create(networkId, recipient, data, TokenType.generate()); + public static MintTransaction create(NetworkId networkId, Predicate recipient, long timeout, + byte[] data) { + return MintTransaction.create(networkId, recipient, timeout, data, TokenType.generate()); } /** @@ -276,6 +298,7 @@ public static MintTransaction create(NetworkId networkId, Predicate recipient, b * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param tokenType token type identifier * * @return mint transaction @@ -283,9 +306,10 @@ public static MintTransaction create(NetworkId networkId, Predicate recipient, b public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, TokenType tokenType ) { - return MintTransaction.create(networkId, recipient, (byte[]) null, tokenType); + return MintTransaction.create(networkId, recipient, timeout, (byte[]) null, tokenType); } /** @@ -293,6 +317,7 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * @param salt mint-transaction salt * * @return mint transaction @@ -300,9 +325,10 @@ public static MintTransaction create( public static MintTransaction create( NetworkId networkId, Predicate recipient, + long timeout, TokenSalt salt ) { - return MintTransaction.create(networkId, recipient, TokenType.generate(), salt); + return MintTransaction.create(networkId, recipient, timeout, TokenType.generate(), salt); } /** @@ -310,11 +336,12 @@ public static MintTransaction create( * * @param networkId network identifier * @param recipient recipient predicate + * @param timeout exclusive timeout of the certification request * * @return mint transaction */ - public static MintTransaction create(NetworkId networkId, Predicate recipient) { - return MintTransaction.create(networkId, recipient, (byte[]) null); + public static MintTransaction create(NetworkId networkId, Predicate recipient, long timeout) { + return MintTransaction.create(networkId, recipient, timeout, (byte[]) null); } /** @@ -329,7 +356,7 @@ public static MintTransaction fromCbor(byte[] bytes) { if (tag.getTag() != MintTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 7); + List data = CborDeserializer.decodeArray(tag.getData(), 8); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); if (version != MintTransaction.VERSION) { @@ -339,6 +366,7 @@ public static MintTransaction fromCbor(byte[] bytes) { return MintTransaction.create( NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), EncodedPredicate.fromCbor(data.get(2)), + CborDeserializer.decodeUnsignedInteger(data.get(7)).asLong(), CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString), TokenType.fromCbor(data.get(4)), TokenSalt.fromCbor(data.get(3)), @@ -389,7 +417,8 @@ public byte[] toCbor() { this.salt.toCbor(), this.tokenType.toCbor(), CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeUnsignedInteger(this.timeout) ) ); } @@ -415,8 +444,8 @@ public CertifiedMintTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, data=%s}", + "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, timeout=%s, data=%s}", this.sourceStateHash, this.lockScript, this.networkId, this.recipient, this.salt, - this.tokenType, this.tokenId, HexConverter.encode(this.data)); + this.tokenType, this.tokenId, this.timeout, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java index 87bc0695..2687c764 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java @@ -45,6 +45,15 @@ public interface Transaction { */ StateMask getStateMask(); + /** + * Exclusive timeout of the certification request. The Unicity Service admits the request only + * in a round whose reference time is below this value. It is part of the transaction encoding, + * so the transaction hash commits to it and the unlock script signs it. + * + * @return request timeout + */ + long getTimeout(); + /** * Calculates the resulting state hash. * diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index cc90be53..0c1b8755 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -27,6 +27,7 @@ public class TransferTransaction implements Transaction { private final DataHash sourceStateHash; private final EncodedPredicate lockScript; private final EncodedPredicate recipient; + private final long timeout; private final StateMask stateMask; private final byte[] data; @@ -34,12 +35,14 @@ private TransferTransaction( DataHash sourceStateHash, EncodedPredicate lockScript, EncodedPredicate recipient, + long timeout, StateMask stateMask, byte[] data ) { this.sourceStateHash = sourceStateHash; this.lockScript = lockScript; this.recipient = recipient; + this.timeout = timeout; this.stateMask = stateMask; this.data = data; } @@ -74,23 +77,30 @@ public StateMask getStateMask() { return this.stateMask; } + @Override + public long getTimeout() { + return this.timeout; + } + /** * Creates a transfer transaction from the latest state of the provided token. * * @param token token whose latest transaction is used as the source * @param recipient recipient predicate * @param stateMask transaction randomness component + * @param timeout exclusive timeout of the certification request * @param data transfer payload * @return created transfer transaction */ public static TransferTransaction create(Token token, Predicate recipient, - StateMask stateMask, byte[] data) { + StateMask stateMask, long timeout, byte[] data) { Transaction transaction = token.getLatestTransaction(); return new TransferTransaction( transaction.calculateStateHash(), transaction.getRecipient(), EncodedPredicate.fromPredicate(recipient), + timeout, stateMask, data ); @@ -108,7 +118,7 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { if (tag.getTag() != TransferTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 4); + List data = CborDeserializer.decodeArray(tag.getData(), 5); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); if (version != TransferTransaction.VERSION) { @@ -119,6 +129,7 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { token, EncodedPredicate.fromCbor(data.get(1)), StateMask.fromCbor(data.get(2)), + CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong(), CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) ); } @@ -150,7 +161,8 @@ public byte[] toCbor() { CborSerializer.encodeUnsignedInteger(TransferTransaction.VERSION), EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeUnsignedInteger(this.timeout) ) ); } @@ -179,8 +191,8 @@ public CertifiedTransferTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, stateMask=%s, data=%s}", - this.sourceStateHash, this.lockScript, this.recipient, this.stateMask, + "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, timeout=%s, stateMask=%s, data=%s}", + this.sourceStateHash, this.lockScript, this.recipient, this.timeout, this.stateMask, HexConverter.encode(this.data)); } } 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 9b68e319..8e9dd457 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -62,11 +62,18 @@ public static VerificationResult verify(RootTr } if (!certificationData.getLockScript().equals(transaction.getLockScript()) - || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash())) { + || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash()) + || certificationData.getTimeout() != transaction.getTimeout()) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH); } + // The request was admissible only in a round strictly before its timeout. + if (referenceTime >= transaction.getTimeout()) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.REQUEST_EXPIRED); + } + 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, 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 1a24c0b0..f58e8131 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -14,6 +14,8 @@ public enum InclusionProofVerificationStatus { 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. */ + REQUEST_EXPIRED, /** Proof authentication failed. */ NOT_AUTHENTICATED, /** Proof path is not included in the committed tree state. */ diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 064bc64d..692afc68 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -78,6 +78,11 @@ public CompletableFuture submitCertificationRequest(Certi return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SIGNATURE_VERIFICATION_FAILED)); } + if (this.referenceTime >= certificationData.getTimeout()) { + return CompletableFuture.completedFuture( + CertificationResponse.create(CertificationStatus.REQUEST_EXPIRED)); + } + if (!this.requests.containsKey(stateId)) { DataHash leafValue = LeafValue.calculate(certificationData.getTransactionHash(), this.referenceTime); diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java index 6bcff265..0d7ad39c 100644 --- a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; +import org.unicitylabs.sdk.utils.RequestTimeout; public class TestApiKeyIntegration { @@ -44,7 +45,7 @@ void setUp() throws Exception { MintTransaction transaction = MintTransaction.create( NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService) - ); + , RequestTimeout.requestTimeout()); certificationData = CertificationData.fromMintTransaction(transaction); } diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index bdadffe6..aea47c61 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -21,6 +21,7 @@ import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; import org.unicitylabs.sdk.util.HexConverter; +import org.unicitylabs.sdk.utils.RequestTimeout; @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class InclusionProofTest { @@ -44,7 +45,7 @@ public void createMerkleTreePath() throws Exception { transaction = MintTransaction.create( NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService) - ); + , RequestTimeout.requestTimeout()); certificationData = CertificationData.fromMintTransaction(transaction); stateId = StateId.fromCertificationData(certificationData); @@ -127,6 +128,7 @@ public void testItVerifies() { DataHash.fromImprint( HexConverter.decode("00000000000000000000000000000000000000000000000000000000000000000001") ), + this.certificationData.getTimeout(), this.certificationData.getUnlockScript() ), REFERENCE_TIME, @@ -153,6 +155,7 @@ public void testItNotAuthenticated() { this.certificationData.getLockScript(), this.certificationData.getSourceStateHash(), this.certificationData.getTransactionHash(), + this.certificationData.getTimeout(), SignaturePredicateUnlockScript.create( this.transaction, new SigningService(SigningService.generatePrivateKey()) @@ -210,6 +213,27 @@ public void testItFailsWithShardIdMismatch() { ); } + @Test + public void testVerificationFailsWhenReferenceTimeReachesTheTimeout() { + InclusionProof inclusionProof = new InclusionProof( + this.certificationData, + REFERENCE_TIME, + this.inclusionCertificate, + this.unicityCertificate + ); + + Assertions.assertEquals( + InclusionProofVerificationStatus.REQUEST_EXPIRED, + InclusionProofVerificationRule.verify( + this.trustBase, + this.predicateVerifier, + inclusionProof, + this.transaction, + this.transaction.getTimeout() + ).getStatus() + ); + } + @Test public void testVerificationFailsWithWrongReferenceTime() { InclusionProof inclusionProof = new InclusionProof( diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index fe455ebd..ef9bb996 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -24,6 +24,7 @@ import org.unicitylabs.sdk.util.InclusionProofUtils; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.utils.TokenUtils; +import org.unicitylabs.sdk.utils.RequestTimeout; /** * M-03: the inclusion-proof rule must bind the certification lock script and source state hash to @@ -57,8 +58,8 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception SignaturePredicate recipient = SignaturePredicate.fromSigningService(SigningService.generate()); StateMask stateMask = StateMask.generate(); - TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, null); - TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, null); + TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, RequestTimeout.requestTimeout(), null); + TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, RequestTimeout.requestTimeout(), null); Assertions.assertEquals( transferA.calculateTransactionHash(), transferB.calculateTransactionHash(), diff --git a/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java b/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java new file mode 100644 index 00000000..df1f93d1 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java @@ -0,0 +1,60 @@ +package org.unicitylabs.sdk.functional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.CertificationStatus; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.utils.RequestTimeout; + +/** + * The Unicity Service admits a request only in a round whose reference time is strictly below + * the request's timeout. + */ +public class RequestTimeoutTest { + + private final TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + private final StateTransitionClient client = new StateTransitionClient(this.aggregatorClient); + private final SignaturePredicate recipient = + SignaturePredicate.fromSigningService(SigningService.generate()); + + private CertificationStatus submit(long timeout) throws Exception { + MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient, timeout); + + return this.client.submitCertificationRequest( + CertificationData.fromMintTransaction(transaction)).get().getStatus(); + } + + @Test + public void acceptsARequestWhoseTimeoutIsAheadOfTheRoundReferenceTime() throws Exception { + Assertions.assertEquals(CertificationStatus.SUCCESS, + this.submit(RequestTimeout.requestTimeout())); + } + + @Test + public void rejectsARequestWhoseTimeoutTheRoundReferenceTimeHasReached() throws Exception { + Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, + this.submit(RequestTimeout.expiredRequestTimeout())); + } + + @Test + public void bindsTheTimeoutIntoTheTransactionHash() { + TokenType tokenType = TokenType.generate(); + TokenSalt salt = TokenSalt.generate(); + + MintTransaction first = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1755000000L, + null, tokenType, salt); + MintTransaction second = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1755000001L, + null, tokenType, salt); + + Assertions.assertNotEquals(first.calculateTransactionHash(), + second.calculateTransactionHash()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java index f3a8a60b..66795af3 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.unicitylabs.sdk.utils.RequestTimeout; /** * End-to-end functional test for the token split flow: mint a source token, split it, burn the @@ -71,7 +72,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset2))) ); - SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests); + SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests, RequestTimeout.requestTimeout()); Token burnToken = TokenUtils.transferToken( client, @@ -114,7 +115,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset1))) ); - SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests); + SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests, RequestTimeout.requestTimeout()); Token secondBurnToken = TokenUtils.transferToken( client, @@ -176,9 +177,9 @@ public void rebuildsByteIdenticalBurnTransactionFromSuppliedStateMask() throws E ); StateMask burnStateMask = StateMask.generate(); - SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); - SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); - SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests); + SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout(), burnStateMask); + SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout(), burnStateMask); + SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout()); byte[] firstBurn = first.getBurnTransaction().toCbor(); Assertions.assertArrayEquals(firstBurn, second.getBurnTransaction().toCbor()); diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java index 511702d8..626def16 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java @@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import org.unicitylabs.sdk.utils.RequestTimeout; /** * Regression for the split inflation exploit: a hand-built single-asset split that mirrors @@ -79,7 +80,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { // Issuer hands the token over to the attacker. The attacker now legitimately HOLDS a token // they did not mint - its 500 value is inherited from the issuer, not self-declared. TransferTransaction handover = TransferTransaction.create( - token, attackerPredicate, StateMask.generate(), null); + token, attackerPredicate, StateMask.generate(), RequestTimeout.requestTimeout(), null); token = TokenUtils.transferToken( client, context, @@ -114,7 +115,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { byte[] manifestBytes = SplitManifest.create(List.of(root.getHash())).toCbor(); byte[] burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest().getData(); TransferTransaction burnTransaction = TransferTransaction.create( - token, BurnPredicate.create(burnReason), StateMask.generate(), manifestBytes); + token, BurnPredicate.create(burnReason), StateMask.generate(), RequestTimeout.requestTimeout(), manifestBytes); // The burn is a genuine, network-certified transfer signed by the attacker (the current owner). token = TokenUtils.transferToken( diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java index 1029a2e9..85f92076 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java @@ -28,6 +28,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.List; +import org.unicitylabs.sdk.utils.RequestTimeout; /** * Unit tests for the precondition branches of {@link TokenSplit#split}. @@ -75,7 +76,7 @@ public void splitFailsWhenAssetCountsDiffer() { SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1)) )) - ) + , RequestTimeout.requestTimeout()) ); Assertions.assertEquals("Token and split tokens asset counts differ.", exception.getMessage()); } @@ -94,7 +95,7 @@ public void splitFailsWhenAssetIsMissingFromSource() { SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1, unknownAsset)) )) - ) + , RequestTimeout.requestTimeout()) ); Assertions.assertEquals( String.format("Token did not contain asset %s.", unknownAsset.getId()), @@ -113,7 +114,7 @@ public void splitFailsWhenAssetTreeAmountIsLess() { new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) )) - ) + , RequestTimeout.requestTimeout()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 400", exception.getMessage()); @@ -131,7 +132,7 @@ public void splitFailsWhenAssetTreeAmountIsMore() { new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(1500)))) )) - ) + , RequestTimeout.requestTimeout()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 1500", exception.getMessage()); diff --git a/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java b/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java new file mode 100644 index 00000000..c878f0c6 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java @@ -0,0 +1,29 @@ +package org.unicitylabs.sdk.utils; + +/** + * Certification request timeouts for tests. + */ +public final class RequestTimeout { + + private RequestTimeout() { + } + + /** + * A timeout an hour ahead of the current wall clock, so no test run can reach it while the + * request is in flight. + * + * @return request timeout in Unix seconds + */ + public static long requestTimeout() { + return System.currentTimeMillis() / 1000 + 3600; + } + + /** + * A timeout that has already passed, for exercising the expiry path. + * + * @return request timeout in Unix seconds + */ + public static long expiredRequestTimeout() { + return System.currentTimeMillis() / 1000 - 3600; + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index 8692af3f..68428e26 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -85,6 +85,7 @@ public static Token mintToken( MintTransaction transaction = MintTransaction.create( networkId, recipient, + RequestTimeout.requestTimeout(), data, tokenType, salt, @@ -139,6 +140,7 @@ public static Token transferToken( token, recipient, StateMask.generate(), + RequestTimeout.requestTimeout(), null ); From 88918c11e4481c8e299da4102d506b8033751fe9 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 13:42:35 +0300 Subject: [PATCH 03/17] Document service time and request timeouts --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4691c4b6..62c68a16 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,15 @@ The SDK follows a modular architecture under `org.unicitylabs.sdk`: All certificate and transaction verification is handled internally by the SDK, requiring only the trustbase as input from the user. +Mint and transfer transactions carry an exclusive certification request timeout in Unix seconds. +The Unicity Service admits a request only when the round reference time is strictly below that +timeout. Because the timeout is part of the transaction encoding, the transaction hash commits to +it and an expired request cannot be certified as the same transaction with a later deadline. + +Certified transactions also carry the reference time of the round in which their leaf was created. +The leaf value is `SHA-256(CBOR([transactionHash, referenceTime]))`; verification uses that fixed +time even when the inclusion proof is later refreshed against a newer append-only tree root. + ## Contributing 1. Fork the repository @@ -188,4 +197,4 @@ For questions about the Unicity Labs, visit [unicity-labs.com](https://unicity-l - Built on the Unicity network protocol - Uses Jackson for CBOR encoding - Uses Bouncy Castle for cryptographic operations -- Uses OkHttp for Android-compatible HTTP operations \ No newline at end of file +- Uses OkHttp for Android-compatible HTTP operations From b19eb0e38506d348feffdc992cdf22f4323baacb Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 17:13:51 +0300 Subject: [PATCH 04/17] Make the request timeout optional Wire profiles, distinguished by the version field: MintTransaction v1 [1, networkId, recipient, salt, tokenType, justification, data] v2 [2, ..., tau_Q] TransferTransaction v1 [1, recipient, stateMask, data] v2 [2, ..., tau_Q] CertificationData v1 [1, lockScript, sourceStateHash, transactionHash, witness] v2 [2, ..., tau_Q, witness] Verification enforces tau < tau_Q only where a timeout is explicit, and requires the certification data to declare the same timeout the transaction commits to. The reference time carried by a certified transaction is unaffected by the profile and is required to agree with the reference time in the attached proof. CrossSdkEncodingTest pins both profiles against the vectors the TypeScript and Rust SDKs and the aggregator assert on. Java had no byte-level vector before. Refs #82 --- README.md | 10 +-- .../sdk/api/CertificationData.java | 34 ++++---- .../sdk/api/CertificationStatus.java | 7 +- .../unicitylabs/sdk/api/InclusionProof.java | 25 +++--- .../unicitylabs/sdk/payment/TokenSplit.java | 19 +++++ .../transaction/CertifiedMintTransaction.java | 15 ++-- .../CertifiedTransferTransaction.java | 15 ++-- .../sdk/transaction/MintTransaction.java | 80 +++++++++++++++---- .../sdk/transaction/Transaction.java | 5 +- .../sdk/transaction/TransferTransaction.java | 35 +++++--- .../InclusionProofVerificationRule.java | 8 +- .../sdk/util/InclusionProofUtils.java | 23 +++--- .../unicitylabs/sdk/TestAggregatorClient.java | 6 +- .../sdk/api/CrossSdkEncodingTest.java | 62 ++++++++++++++ .../sdk/api/InclusionProofTest.java | 2 +- .../sdk/functional/RequestTimeoutTest.java | 27 +++++++ .../org/unicitylabs/sdk/utils/TokenUtils.java | 2 - 17 files changed, 279 insertions(+), 96 deletions(-) create mode 100644 src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java diff --git a/README.md b/README.md index 62c68a16..052be61c 100644 --- a/README.md +++ b/README.md @@ -127,15 +127,6 @@ The SDK follows a modular architecture under `org.unicitylabs.sdk`: All certificate and transaction verification is handled internally by the SDK, requiring only the trustbase as input from the user. -Mint and transfer transactions carry an exclusive certification request timeout in Unix seconds. -The Unicity Service admits a request only when the round reference time is strictly below that -timeout. Because the timeout is part of the transaction encoding, the transaction hash commits to -it and an expired request cannot be certified as the same transaction with a later deadline. - -Certified transactions also carry the reference time of the round in which their leaf was created. -The leaf value is `SHA-256(CBOR([transactionHash, referenceTime]))`; verification uses that fixed -time even when the inclusion proof is later refreshed against a newer append-only tree root. - ## Contributing 1. Fork the repository @@ -198,3 +189,4 @@ For questions about the Unicity Labs, visit [unicity-labs.com](https://unicity-l - Uses Jackson for CBOR encoding - Uses Bouncy Castle for cryptographic operations - Uses OkHttp for Android-compatible HTTP operations + diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java index 95ff15db..04b48605 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java @@ -23,7 +23,8 @@ */ public class CertificationData { public static final long CBOR_TAG = 39031; - private static final int VERSION = 1; + private static final int LEGACY_VERSION = 1; + private static final int TIMEOUT_VERSION = 2; private final EncodedPredicate lockScript; private final DataHash sourceStateHash; @@ -46,7 +47,7 @@ public class CertificationData { } public int getVersion() { - return CertificationData.VERSION; + return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; } /** @@ -105,10 +106,12 @@ public static CertificationData fromCbor(byte[] bytes) { if (tag.getTag() != CertificationData.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 6); + List data = CborDeserializer.decodeArray(tag.getData()); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != CertificationData.VERSION) { + boolean hasTimeout = version == TIMEOUT_VERSION; + if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || data.size() != (hasTimeout ? 6 : 5)) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } @@ -116,8 +119,8 @@ public static CertificationData fromCbor(byte[] bytes) { EncodedPredicate.fromCbor(data.get(1)), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(2))), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(3))), - CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong(), - CborDeserializer.decodeByteString(data.get(5)) + hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong() : 0, + CborDeserializer.decodeByteString(data.get(hasTimeout ? 5 : 4)) ); } @@ -181,16 +184,19 @@ public static CertificationData fromTransaction(Transaction transaction, byte[] * @return CBOR bytes */ public byte[] toCbor() { - return CborSerializer.encodeTag( - CertificationData.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(CertificationData.VERSION), - this.lockScript.toCbor(), - CborSerializer.encodeByteString(this.sourceStateHash.getData()), + byte[] payload = this.timeout == 0 + ? CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), + this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), + CborSerializer.encodeByteString(this.transactionHash.getData()), + CborSerializer.encodeByteString(this.unlockScript)) + : CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), + this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), CborSerializer.encodeByteString(this.transactionHash.getData()), CborSerializer.encodeUnsignedInteger(this.timeout), - CborSerializer.encodeByteString(this.unlockScript) - ) + CborSerializer.encodeByteString(this.unlockScript)); + return CborSerializer.encodeTag( + CertificationData.CBOR_TAG, + payload ); } diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java index ae67445b..36d045a7 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationStatus.java @@ -60,6 +60,10 @@ public final class CertificationStatus { public static final CertificationStatus REQUEST_EXPIRED = new CertificationStatus("REQUEST_EXPIRED"); + /** The aggregation service has not obtained a consensus reference time yet. */ + public static final CertificationStatus SERVICE_NOT_READY = + new CertificationStatus("SERVICE_NOT_READY"); + private static final CertificationStatus[] VALUES = { SUCCESS, STATE_ID_MISMATCH, @@ -70,7 +74,8 @@ public final class CertificationStatus { INVALID_TRANSACTION_HASH_FORMAT, UNSUPPORTED_ALGORITHM, INVALID_SHARD, - REQUEST_EXPIRED + REQUEST_EXPIRED, + SERVICE_NOT_READY }; private final String value; diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index 2ccfaad8..6cb7d337 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -36,7 +36,7 @@ public class InclusionProof { } public int getVersion() { - return InclusionProof.VERSION; + return VERSION; } /** @@ -94,14 +94,14 @@ public static InclusionProof fromCbor(byte[] bytes) { List data = CborDeserializer.decodeArray(tag.getData(), 5); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != InclusionProof.VERSION) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } return new InclusionProof( CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor), - CborDeserializer.decodeNullable(data.get(2), - (referenceTime) -> CborDeserializer.decodeUnsignedInteger(referenceTime).asLong()), + CborDeserializer.decodeNullable(data.get(2), value -> + CborDeserializer.decodeUnsignedInteger(value).asLong()), CborDeserializer.decodeNullable(data.get(3), (inclusionCertificate) -> InclusionCertificate.decode(CborDeserializer.decodeByteString(inclusionCertificate)) ), @@ -115,18 +115,15 @@ public static InclusionProof fromCbor(byte[] bytes) { * @return CBOR 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()); return CborSerializer.encodeTag( InclusionProof.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(InclusionProof.VERSION), - CborSerializer.encodeNullable(this.certificationData, CertificationData::toCbor), - CborSerializer.encodeNullable(this.referenceTime, - CborSerializer::encodeUnsignedInteger), - CborSerializer.encodeNullable(this.inclusionCertificate, (inclusionCertificate) -> - CborSerializer.encodeByteString(inclusionCertificate.encode()) - ), - this.unicityCertificate.toCbor() - ) + payload ); } diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java index 4df588b0..f785ed17 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java @@ -35,6 +35,25 @@ public class TokenSplit { private TokenSplit() { } + /** Split using the aggregation service's default timeout and a random burn state mask. */ + public static SplitResult split( + Token token, + PaymentDataDeserializer paymentDataDeserializer, + List requests + ) throws LeafExistsException { + return split(token, paymentDataDeserializer, requests, 0, StateMask.generate()); + } + + /** Split using the aggregation service's default timeout and the supplied burn state mask. */ + public static SplitResult split( + Token token, + PaymentDataDeserializer paymentDataDeserializer, + List requests, + StateMask burnStateMask + ) throws LeafExistsException { + return split(token, paymentDataDeserializer, requests, 0, burnStateMask); + } + /** * Split a token into new outputs with a random burn state mask. * diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index 342d7598..c5ae3c00 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -129,10 +129,15 @@ public long getReferenceTime() { */ 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(); + if (!proof.getReferenceTime().isPresent() || referenceTime != proof.getReferenceTime().get()) { + throw new IllegalArgumentException("Certified mint transaction reference time mismatch"); + } return new CertifiedMintTransaction( MintTransaction.fromCbor(data.get(0)), - CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(), - InclusionProof.fromCbor(data.get(2))); + referenceTime, + proof); } /** @@ -191,10 +196,8 @@ 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(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), this.inclusionProof.toCbor()); } @Override diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 2a1b5521..91d588b7 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -93,11 +93,16 @@ public long getReferenceTime() { */ 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(); + if (!proof.getReferenceTime().isPresent() || referenceTime != proof.getReferenceTime().get()) { + throw new IllegalArgumentException("Certified transfer transaction reference time mismatch"); + } return new CertifiedTransferTransaction( TransferTransaction.fromCbor(data.get(0), token), - CborDeserializer.decodeUnsignedInteger(data.get(1)).asLong(), - InclusionProof.fromCbor(data.get(2)) + referenceTime, + proof ); } @@ -177,10 +182,8 @@ 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(), + CborSerializer.encodeUnsignedInteger(this.referenceTime), this.inclusionProof.toCbor()); } @Override diff --git a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java index 528b1fdb..f9dc72d7 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -31,7 +31,8 @@ */ public class MintTransaction implements Transaction { public static final long CBOR_TAG = 39041; - private static final int VERSION = 1; + private static final int LEGACY_VERSION = 1; + private static final int TIMEOUT_VERSION = 2; private final MintTransactionState sourceStateHash; private final EncodedPredicate lockScript; @@ -74,7 +75,7 @@ public long getTimeout() { } public int getVersion() { - return MintTransaction.VERSION; + return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; } @@ -191,6 +192,48 @@ public static MintTransaction create( ); } + /** Creates a legacy transaction whose timeout is assigned by the aggregation service. */ + public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, + TokenType tokenType, TokenSalt salt, byte[] justification) { + return create(networkId, recipient, 0, data, tokenType, salt, justification); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, + TokenType tokenType, TokenSalt salt) { + return create(networkId, recipient, data, tokenType, salt, null); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, + TokenType tokenType) { + return create(networkId, recipient, data, tokenType, TokenSalt.generate()); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, + TokenSalt salt) { + return create(networkId, recipient, data, TokenType.generate(), salt); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, + TokenType tokenType, TokenSalt salt) { + return create(networkId, recipient, (byte[]) null, tokenType, salt); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data) { + return create(networkId, recipient, data, TokenType.generate()); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, TokenType tokenType) { + return create(networkId, recipient, (byte[]) null, tokenType); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient, TokenSalt salt) { + return create(networkId, recipient, TokenType.generate(), salt); + } + + public static MintTransaction create(NetworkId networkId, Predicate recipient) { + return create(networkId, recipient, (byte[]) null); + } + /** * Create a mint transaction without a justification. * @@ -356,17 +399,19 @@ public static MintTransaction fromCbor(byte[] bytes) { if (tag.getTag() != MintTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 8); + List data = CborDeserializer.decodeArray(tag.getData()); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != MintTransaction.VERSION) { + boolean hasTimeout = version == TIMEOUT_VERSION; + if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || data.size() != (hasTimeout ? 8 : 7)) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } return MintTransaction.create( NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), EncodedPredicate.fromCbor(data.get(2)), - CborDeserializer.decodeUnsignedInteger(data.get(7)).asLong(), + hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(7)).asLong() : 0, CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString), TokenType.fromCbor(data.get(4)), TokenSalt.fromCbor(data.get(3)), @@ -408,18 +453,23 @@ public DataHash calculateTransactionHash() { */ @Override public byte[] toCbor() { - return CborSerializer.encodeTag( - MintTransaction.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(MintTransaction.VERSION), - CborSerializer.encodeUnsignedInteger(this.networkId.getId()), - this.recipient.toCbor(), - this.salt.toCbor(), - this.tokenType.toCbor(), + byte[] payload = this.timeout == 0 + ? CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), + CborSerializer.encodeUnsignedInteger(this.networkId.getId()), this.recipient.toCbor(), + this.salt.toCbor(), this.tokenType.toCbor(), + CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString)) + : CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), + CborSerializer.encodeUnsignedInteger(this.networkId.getId()), this.recipient.toCbor(), + this.salt.toCbor(), this.tokenType.toCbor(), CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), - CborSerializer.encodeUnsignedInteger(this.timeout) - ) + CborSerializer.encodeUnsignedInteger(this.timeout)); + return CborSerializer.encodeTag( + MintTransaction.CBOR_TAG, + payload ); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java index 2687c764..4fa6fa61 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java @@ -46,9 +46,8 @@ public interface Transaction { StateMask getStateMask(); /** - * Exclusive timeout of the certification request. The Unicity Service admits the request only - * in a round whose reference time is below this value. It is part of the transaction encoding, - * so the transaction hash commits to it and the unlock script signs it. + * Explicit exclusive timeout of the certification request, or zero for the service default. + * Explicit values are committed by the v2 transaction encoding. * * @return request timeout */ diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index 0c1b8755..166d91d1 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -22,7 +22,8 @@ */ public class TransferTransaction implements Transaction { public static final long CBOR_TAG = 39045; - private static final int VERSION = 1; + private static final int LEGACY_VERSION = 1; + private static final int TIMEOUT_VERSION = 2; private final DataHash sourceStateHash; private final EncodedPredicate lockScript; @@ -48,7 +49,7 @@ private TransferTransaction( } public int getVersion() { - return TransferTransaction.VERSION; + return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; } @@ -106,6 +107,12 @@ public static TransferTransaction create(Token token, Predicate recipient, ); } + /** Creates a legacy transfer whose timeout is assigned by the aggregation service. */ + public static TransferTransaction create(Token token, Predicate recipient, + StateMask stateMask, byte[] data) { + return create(token, recipient, stateMask, 0, data); + } + /** * Deserializes a transfer transaction from CBOR bytes. * @@ -118,10 +125,12 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { if (tag.getTag() != TransferTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData(), 5); + List data = CborDeserializer.decodeArray(tag.getData()); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != TransferTransaction.VERSION) { + boolean hasTimeout = version == TIMEOUT_VERSION; + if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || data.size() != (hasTimeout ? 5 : 4)) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } @@ -129,7 +138,7 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { token, EncodedPredicate.fromCbor(data.get(1)), StateMask.fromCbor(data.get(2)), - CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong(), + hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong() : 0, CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) ); } @@ -155,15 +164,17 @@ public DataHash calculateTransactionHash() { @Override public byte[] toCbor() { + byte[] payload = this.timeout == 0 + ? CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), + EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString)) + : CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), + EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeUnsignedInteger(this.timeout)); return CborSerializer.encodeTag( TransferTransaction.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(TransferTransaction.VERSION), - EncodedPredicate.fromPredicate(this.recipient).toCbor(), - this.stateMask.toCbor(), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), - CborSerializer.encodeUnsignedInteger(this.timeout) - ) + payload ); } 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 8e9dd457..0f8fc02e 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -69,11 +69,17 @@ public static VerificationResult verify(RootTr } // The request was admissible only in a round strictly before its timeout. - if (referenceTime >= transaction.getTimeout()) { + if (transaction.getTimeout() != 0 && referenceTime >= transaction.getTimeout()) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.REQUEST_EXPIRED); } + if (!inclusionProof.getReferenceTime().isPresent() + || inclusionProof.getReferenceTime().get() != referenceTime) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); + } + 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, diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index fc3644e1..0c818295 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -98,14 +98,19 @@ private static void checkInclusionProof( StateId stateId = StateId.fromTransaction(transaction); client.getInclusionProof(stateId).thenAccept(response -> { InclusionProof inclusionProof = response.getInclusionProof(); - // An inclusion proof always carries the reference time its leaf value was built from; - // without it nothing has been certified for this state id yet. - VerificationResult result = inclusionProof - .getReferenceTime() - .map(referenceTime -> InclusionProofVerificationRule.verify( - trustBase, predicateVerifier, inclusionProof, transaction, referenceTime)) - .orElseGet(() -> new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING)); + VerificationResult result; + if (!inclusionProof.getReferenceTime().isPresent()) { + result = new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); + } else if (!inclusionProof.getCertificationData().isPresent() + || inclusionProof.getInclusionCertificate() == null) { + result = new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING); + } else { + long referenceTime = inclusionProof.getReferenceTime().get(); + result = InclusionProofVerificationRule.verify( + trustBase, predicateVerifier, inclusionProof, transaction, referenceTime); + } switch (result.getStatus()) { case OK: future.complete(inclusionProof); @@ -126,4 +131,4 @@ private static void checkInclusionProof( return null; }); } -} \ No newline at end of file +} diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 692afc68..55a2d436 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -78,14 +78,14 @@ public CompletableFuture submitCertificationRequest(Certi return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SIGNATURE_VERIFICATION_FAILED)); } - if (this.referenceTime >= certificationData.getTimeout()) { + if (certificationData.getTimeout() != 0 && this.referenceTime >= certificationData.getTimeout()) { return CompletableFuture.completedFuture( CertificationResponse.create(CertificationStatus.REQUEST_EXPIRED)); } if (!this.requests.containsKey(stateId)) { - DataHash leafValue = LeafValue.calculate(certificationData.getTransactionHash(), - this.referenceTime); + DataHash leafValue = + LeafValue.calculate(certificationData.getTransactionHash(), this.referenceTime); this.sparseMerkleTree.addLeaf(stateId.getData(), leafValue.getData()); this.requests.put(stateId, certificationData); this.referenceTimes.put(stateId, this.referenceTime); diff --git a/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java new file mode 100644 index 00000000..e401bc86 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java @@ -0,0 +1,62 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.util.HexConverter; + +/** + * Pins the certification-data encoding against the vectors the TypeScript and Rust SDKs and the + * aggregator assert on. The two profiles must be byte-identical across implementations, and the + * v1 profile must reproduce the bytes produced before request timeouts existed. + */ +public class CrossSdkEncodingTest { + + private static final byte[] PUBLIC_KEY = + HexConverter.decode("02ce9f22e51333c97a8fb1f807a229ece3a8765a16af5fc1a13e30834be3280026"); + private static final long TIMEOUT = 1755000000L; + + private static final String V1 = + "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + + "5820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb6" + + "37f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c" + + "568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700"; + private static final String V2 = + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + + "5820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc" + + "0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb04" + + "7c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00"; + + private static MintTransaction mint(long timeout) { + SignaturePredicate recipient = SignaturePredicate.create(PUBLIC_KEY); + TokenType tokenType = new TokenType(new byte[32]); + TokenSalt salt = TokenSalt.fromBytes(new byte[32]); + + return timeout == 0 + ? MintTransaction.create(NetworkId.MAINNET, recipient, (byte[]) null, tokenType, salt) + : MintTransaction.create(NetworkId.MAINNET, recipient, timeout, null, tokenType, salt); + } + + @Test + public void serviceDefaultProfileMatchesTheSharedV1Vector() { + MintTransaction transaction = mint(0); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertEquals(0, transaction.getTimeout()); + Assertions.assertEquals(V1, HexConverter.encode(certificationData.toCbor())); + } + + @Test + public void explicitTimeoutProfileMatchesTheSharedV2Vector() { + MintTransaction transaction = mint(TIMEOUT); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertEquals(TIMEOUT, transaction.getTimeout()); + Assertions.assertEquals(V2, HexConverter.encode(certificationData.toCbor())); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index aea47c61..819a78e2 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -244,7 +244,7 @@ public void testVerificationFailsWithWrongReferenceTime() { ); Assertions.assertEquals( - InclusionProofVerificationStatus.PATH_INVALID, + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, InclusionProofVerificationRule.verify( this.trustBase, this.predicateVerifier, diff --git a/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java b/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java index df1f93d1..fb5280da 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java @@ -9,6 +9,7 @@ import org.unicitylabs.sdk.api.NetworkId; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.transaction.MintTransaction; import org.unicitylabs.sdk.transaction.TokenSalt; import org.unicitylabs.sdk.transaction.TokenType; @@ -57,4 +58,30 @@ public void bindsTheTimeoutIntoTheTransactionHash() { Assertions.assertNotEquals(first.calculateTransactionHash(), second.calculateTransactionHash()); } + + @Test + public void legacyCreateUsesServiceDefaultAndV1WireFormat() { + MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient); + MintTransaction decoded = MintTransaction.fromCbor(transaction.toCbor()); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertEquals(1, transaction.getVersion()); + Assertions.assertEquals(0, transaction.getTimeout()); + Assertions.assertArrayEquals(transaction.toCbor(), decoded.toCbor()); + Assertions.assertEquals(1, certificationData.getVersion()); + Assertions.assertEquals(0, certificationData.getTimeout()); + } + + @Test + public void versionMustMatchTheFieldCount() { + MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1_755_000_000L); + byte[] mismatched = transaction.toCbor(); + Assertions.assertEquals(2, mismatched[4], "fixture version offset"); + mismatched[4] = 1; + + Assertions.assertThrows( + CborSerializationException.class, + () -> MintTransaction.fromCbor(mismatched) + ); + } } diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index 68428e26..8692af3f 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -85,7 +85,6 @@ public static Token mintToken( MintTransaction transaction = MintTransaction.create( networkId, recipient, - RequestTimeout.requestTimeout(), data, tokenType, salt, @@ -140,7 +139,6 @@ public static Token transferToken( token, recipient, StateMask.generate(), - RequestTimeout.requestTimeout(), null ); From 9e6d187d4fab90ea51e34ff81d3b0ead8675065d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 21:42:43 +0300 Subject: [PATCH 05/17] Collapse the request timeout profiles and add a mint builder MintTransaction, TransferTransaction and CertificationData each carried the optional request timeout as two wire versions: version 1 without the field, version 2 with it. The version was then derived from the field rather than read, so it carried no information, and CertificationData had given up its fixed-length decode check to accommodate the two shapes. Use one shape per structure. The deadline keeps a fixed position and is encoded as CBOR null when the caller did not supply one, which is how `data` and `justification` are already encoded in these same arrays. Version 2 is the only accepted version, the element count is fixed again, and both are checked once. The explicit-deadline bytes are unchanged; only the absent case moves, from a shorter array to a null in the slot. Absence is a null Long rather than a zero long, and the accessor is Optional getExpiresAt(), matching Optional getData() on the same interface. Zero is a legal instant, so the sentinel could not express "no deadline". Rename timeout to expiresAt: the value is an absolute exclusive instant in Unix seconds, not a duration. Replace the 18 MintTransaction.create overloads with a builder. The deadline was the fifth optional parameter and adding it had doubled an already combinatorial overload set. Required arguments go to MintTransaction.builder(networkId, recipient) and optional ones are named, so a further optional field is additive. Verification is unchanged in substance: an explicit deadline is enforced as an exclusive bound, and a request that carried none was admitted under a service-assigned deadline that is not recorded and is not re-checked. CrossSdkEncodingTest now pins both cases against the bytes the TypeScript SDK produces. --- .../sdk/api/CertificationData.java | 61 ++- .../unicitylabs/sdk/payment/TokenSplit.java | 28 +- .../transaction/CertifiedMintTransaction.java | 4 +- .../CertifiedTransferTransaction.java | 4 +- .../sdk/transaction/MintTransaction.java | 400 ++++++------------ .../sdk/transaction/Transaction.java | 9 +- .../sdk/transaction/TransferTransaction.java | 69 +-- .../InclusionProofVerificationRule.java | 8 +- .../unicitylabs/sdk/TestAggregatorClient.java | 3 +- .../sdk/TestApiKeyIntegration.java | 9 +- .../sdk/api/CrossSdkEncodingTest.java | 53 ++- .../sdk/api/InclusionProofTest.java | 15 +- .../CertificationDataBindingTest.java | 6 +- .../sdk/functional/ExpiresAtTest.java | 105 +++++ .../sdk/functional/RequestTimeoutTest.java | 87 ---- .../functional/payment/SplitBuilderTest.java | 12 +- .../payment/SplitInflationExploitTest.java | 8 +- .../functional/payment/TokenSplitTest.java | 35 +- .../org/unicitylabs/sdk/utils/ExpiresAt.java | 29 ++ .../unicitylabs/sdk/utils/RequestTimeout.java | 29 -- .../org/unicitylabs/sdk/utils/TokenUtils.java | 21 +- 21 files changed, 434 insertions(+), 561 deletions(-) create mode 100644 src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java delete mode 100644 src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java create mode 100644 src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java delete mode 100644 src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java index 04b48605..2154fad5 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationData.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationData.java @@ -17,37 +17,39 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Certification data. */ public class CertificationData { public static final long CBOR_TAG = 39031; - private static final int LEGACY_VERSION = 1; - private static final int TIMEOUT_VERSION = 2; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 6; private final EncodedPredicate lockScript; private final DataHash sourceStateHash; private final DataHash transactionHash; - private final long timeout; + private final Long expiresAt; private final byte[] unlockScript; CertificationData( EncodedPredicate lockScript, DataHash sourceStateHash, DataHash transactionHash, - long timeout, + Long expiresAt, byte[] unlockScript ) { this.lockScript = lockScript; this.sourceStateHash = sourceStateHash; this.transactionHash = transactionHash; - this.timeout = timeout; + this.expiresAt = expiresAt; this.unlockScript = Arrays.copyOf(unlockScript, unlockScript.length); } public int getVersion() { - return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; + return VERSION; } /** @@ -78,12 +80,12 @@ public DataHash getTransactionHash() { } /** - * Get the exclusive timeout of the certification request. + * Get the exclusive certification request deadline in Unix seconds. * - * @return request timeout + * @return request deadline, empty when the Unicity Service assigns one */ - public long getTimeout() { - return this.timeout; + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); } /** @@ -106,12 +108,10 @@ public static CertificationData fromCbor(byte[] bytes) { if (tag.getTag() != CertificationData.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData()); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - boolean hasTimeout = version == TIMEOUT_VERSION; - if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || data.size() != (hasTimeout ? 6 : 5)) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } @@ -119,8 +119,9 @@ public static CertificationData fromCbor(byte[] bytes) { EncodedPredicate.fromCbor(data.get(1)), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(2))), new DataHash(HashAlgorithm.SHA256, CborDeserializer.decodeByteString(data.get(3))), - hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong() : 0, - CborDeserializer.decodeByteString(data.get(hasTimeout ? 5 : 4)) + CborDeserializer.decodeNullable( + data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()), + CborDeserializer.decodeByteString(data.get(5)) ); } @@ -173,7 +174,7 @@ public static CertificationData fromTransaction(Transaction transaction, byte[] transaction.getLockScript(), transaction.getSourceStateHash(), transaction.calculateTransactionHash(), - transaction.getTimeout(), + transaction.getExpiresAt().orElse(null), unlockScript ); } @@ -184,19 +185,15 @@ public static CertificationData fromTransaction(Transaction transaction, byte[] * @return CBOR bytes */ public byte[] toCbor() { - byte[] payload = this.timeout == 0 - ? CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), - this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), - CborSerializer.encodeByteString(this.transactionHash.getData()), - CborSerializer.encodeByteString(this.unlockScript)) - : CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), - this.lockScript.toCbor(), CborSerializer.encodeByteString(this.sourceStateHash.getData()), - CborSerializer.encodeByteString(this.transactionHash.getData()), - CborSerializer.encodeUnsignedInteger(this.timeout), - CborSerializer.encodeByteString(this.unlockScript)); return CborSerializer.encodeTag( CertificationData.CBOR_TAG, - payload + CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(VERSION), + this.lockScript.toCbor(), + CborSerializer.encodeByteString(this.sourceStateHash.getData()), + CborSerializer.encodeByteString(this.transactionHash.getData()), + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger), + CborSerializer.encodeByteString(this.unlockScript)) ); } @@ -209,21 +206,21 @@ public boolean equals(Object o) { return Objects.equals(this.lockScript, that.lockScript) && Objects.equals(this.sourceStateHash, that.sourceStateHash) && Objects.equals(this.transactionHash, that.transactionHash) - && this.timeout == that.timeout + && Objects.equals(this.expiresAt, that.expiresAt) && Arrays.equals(this.unlockScript, that.unlockScript); } @Override public int hashCode() { - return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, this.timeout, + return Objects.hash(this.lockScript, this.sourceStateHash, this.transactionHash, this.expiresAt, Arrays.hashCode(this.unlockScript)); } @Override public String toString() { return String.format( - "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, timeout=%s, unlockScript=%s}", - this.lockScript, this.sourceStateHash, this.transactionHash, this.timeout, + "CertificationData{lockScript=%s, sourceStateHash=%s, transactionHash=%s, expiresAt=%s, unlockScript=%s}", + this.lockScript, this.sourceStateHash, this.transactionHash, this.expiresAt, HexConverter.encode(this.unlockScript)); } } diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java index f785ed17..64193040 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java @@ -35,23 +35,23 @@ public class TokenSplit { private TokenSplit() { } - /** Split using the aggregation service's default timeout and a random burn state mask. */ + /** Split with a service-assigned burn deadline and a random burn state mask. */ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests ) throws LeafExistsException { - return split(token, paymentDataDeserializer, requests, 0, StateMask.generate()); + return split(token, paymentDataDeserializer, requests, StateMask.generate(), null); } - /** Split using the aggregation service's default timeout and the supplied burn state mask. */ + /** Split with a service-assigned burn deadline and the supplied burn state mask. */ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests, StateMask burnStateMask ) throws LeafExistsException { - return split(token, paymentDataDeserializer, requests, 0, burnStateMask); + return split(token, paymentDataDeserializer, requests, burnStateMask, null); } /** @@ -60,7 +60,8 @@ public static SplitResult split( * @param token source token to split (the token being burned) * @param paymentDataDeserializer decoder for the source token's payment data * @param requests per-output mint requests; each carries its own payment data - * @param burnTimeout exclusive certification request timeout of the burn transaction + * @param burnExpiresAt exclusive request deadline of the burn transaction, may be null to let + * the Unicity Service assign one * @return burn predicate, burn transaction and split tokens ready to mint * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ @@ -68,10 +69,10 @@ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests, - long burnTimeout + Long burnExpiresAt ) throws LeafExistsException { - return TokenSplit.split(token, paymentDataDeserializer, requests, burnTimeout, - StateMask.generate()); + return TokenSplit.split(token, paymentDataDeserializer, requests, StateMask.generate(), + burnExpiresAt); } /** @@ -80,10 +81,11 @@ public static SplitResult split( * @param token source token to split (the token being burned) * @param paymentDataDeserializer decoder for the source token's payment data * @param requests per-output mint requests; each carries its own payment data - * @param burnTimeout exclusive certification request timeout of the burn transaction * @param burnStateMask state mask for the burn transaction; callers needing a crash-resumable * (re-buildable) split supply a deterministically derived mask so the identical burn * transaction can be reconstructed after a failure + * @param burnExpiresAt exclusive request deadline of the burn transaction, may be null to let + * the Unicity Service assign one * @return burn predicate, burn transaction and split tokens ready to mint * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ @@ -91,8 +93,8 @@ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests, - long burnTimeout, - StateMask burnStateMask + StateMask burnStateMask, + Long burnExpiresAt ) throws LeafExistsException { Objects.requireNonNull(token, "Token cannot be null"); Objects.requireNonNull(paymentDataDeserializer, "Payment data deserializer cannot be null"); @@ -164,8 +166,8 @@ public static SplitResult split( token, burnPredicate, burnStateMask, - burnTimeout, - manifestBytes + manifestBytes, + burnExpiresAt ); List tokens = new ArrayList<>(requestsByTokenId.size()); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index c5ae3c00..fec38738 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -108,8 +108,8 @@ public InclusionProof getInclusionProof() { } @Override - public long getTimeout() { - return this.transaction.getTimeout(); + public Optional getExpiresAt() { + return this.transaction.getExpiresAt(); } /** diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 91d588b7..715ca440 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -70,8 +70,8 @@ public InclusionProof getInclusionProof() { } @Override - public long getTimeout() { - return this.transaction.getTimeout(); + public Optional getExpiresAt() { + return this.transaction.getExpiresAt(); } /** diff --git a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java index f9dc72d7..a15e57ae 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -31,8 +31,9 @@ */ public class MintTransaction implements Transaction { public static final long CBOR_TAG = 39041; - private static final int LEGACY_VERSION = 1; - private static final int TIMEOUT_VERSION = 2; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 8; private final MintTransactionState sourceStateHash; private final EncodedPredicate lockScript; @@ -41,7 +42,7 @@ public class MintTransaction implements Transaction { private final TokenSalt salt; private final TokenType tokenType; private final TokenId tokenId; - private final long timeout; + private final Long expiresAt; private final byte[] justification; private final byte[] data; @@ -53,7 +54,7 @@ private MintTransaction( TokenSalt salt, TokenType tokenType, TokenId tokenId, - long timeout, + Long expiresAt, byte[] justification, byte[] data ) { @@ -64,18 +65,14 @@ private MintTransaction( this.salt = salt; this.tokenType = tokenType; this.tokenId = tokenId; - this.timeout = timeout; + this.expiresAt = expiresAt; this.justification = justification; this.data = data; } @Override - public long getTimeout() { - return this.timeout; - } - - public int getVersion() { - return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); } @@ -150,241 +147,125 @@ public StateMask getStateMask() { } /** - * Create a mint transaction. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param data payload bytes, may be null - * @param tokenType token type identifier - * @param salt mint-transaction salt - * @param justification mint justification bytes, may be null - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - byte[] data, - TokenType tokenType, - TokenSalt salt, - byte[] justification - ) { - Objects.requireNonNull(networkId, "Network id cannot be null"); - Objects.requireNonNull(recipient, "Recipient cannot be null"); - Objects.requireNonNull(tokenType, "Token type cannot be null"); - Objects.requireNonNull(salt, "Salt cannot be null"); - - TokenId tokenId = TokenId.fromSalt(networkId, salt); - SigningService signingService = MintSigningService.create(tokenId); - return new MintTransaction( - MintTransactionState.create(tokenId), - EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)), - networkId, - EncodedPredicate.fromPredicate(recipient), - salt, - tokenType, - tokenId, - timeout, - justification != null ? Arrays.copyOf(justification, justification.length) : null, - data != null ? Arrays.copyOf(data, data.length) : null - ); - } - - /** Creates a legacy transaction whose timeout is assigned by the aggregation service. */ - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, - TokenType tokenType, TokenSalt salt, byte[] justification) { - return create(networkId, recipient, 0, data, tokenType, salt, justification); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, - TokenType tokenType, TokenSalt salt) { - return create(networkId, recipient, data, tokenType, salt, null); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, - TokenType tokenType) { - return create(networkId, recipient, data, tokenType, TokenSalt.generate()); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data, - TokenSalt salt) { - return create(networkId, recipient, data, TokenType.generate(), salt); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, - TokenType tokenType, TokenSalt salt) { - return create(networkId, recipient, (byte[]) null, tokenType, salt); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, byte[] data) { - return create(networkId, recipient, data, TokenType.generate()); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, TokenType tokenType) { - return create(networkId, recipient, (byte[]) null, tokenType); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient, TokenSalt salt) { - return create(networkId, recipient, TokenType.generate(), salt); - } - - public static MintTransaction create(NetworkId networkId, Predicate recipient) { - return create(networkId, recipient, (byte[]) null); - } - - /** - * Create a mint transaction without a justification. + * Start building a mint transaction. * * @param networkId network identifier * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param data payload bytes, may be null - * @param tokenType token type identifier - * @param salt mint-transaction salt * - * @return mint transaction + * @return mint transaction builder */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - byte[] data, - TokenType tokenType, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, timeout, data, tokenType, salt, null); + public static Builder builder(NetworkId networkId, Predicate recipient) { + return new Builder(networkId, recipient); } /** - * Create a mint transaction with a fresh random salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param data payload bytes, may be null - * @param tokenType token type identifier - * - * @return mint transaction + * Builds a {@link MintTransaction}. The network id and recipient are required and are supplied + * to {@link MintTransaction#builder}; everything else is optional and named, so adding a further + * optional field later does not disturb existing call sites. */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - byte[] data, - TokenType tokenType - ) { - return MintTransaction.create(networkId, recipient, timeout, data, tokenType, - TokenSalt.generate()); - } + public static final class Builder { + + private final NetworkId networkId; + private final Predicate recipient; + private TokenType tokenType; + private TokenSalt salt; + private byte[] data; + private byte[] justification; + private Long expiresAt; + + private Builder(NetworkId networkId, Predicate recipient) { + this.networkId = Objects.requireNonNull(networkId, "Network id cannot be null"); + this.recipient = Objects.requireNonNull(recipient, "Recipient cannot be null"); + } - /** - * Create a mint transaction with a generated token type. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param data payload bytes, may be null - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - byte[] data, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, timeout, data, TokenType.generate(), salt); - } + /** + * Sets the token type. Defaults to a freshly generated type. + * + * @param tokenType token type identifier + * + * @return this builder + */ + public Builder tokenType(TokenType tokenType) { + this.tokenType = tokenType; + return this; + } - /** - * Create a mint transaction with no data. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param tokenType token type identifier - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - TokenType tokenType, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, timeout, (byte[]) null, tokenType, salt); - } + /** + * Sets the mint-transaction salt. Defaults to a freshly generated salt. + * + * @param salt mint-transaction salt + * + * @return this builder + */ + public Builder salt(TokenSalt salt) { + this.salt = salt; + return this; + } - /** - * Create a mint transaction with a generated token type and salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param data payload bytes, may be null - * - * @return mint transaction - */ - public static MintTransaction create(NetworkId networkId, Predicate recipient, long timeout, - byte[] data) { - return MintTransaction.create(networkId, recipient, timeout, data, TokenType.generate()); - } + /** + * Sets the payload bytes. + * + * @param data payload bytes, may be null + * + * @return this builder + */ + public Builder data(byte[] data) { + this.data = data != null ? Arrays.copyOf(data, data.length) : null; + return this; + } - /** - * Create a mint transaction with no data and a generated salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param tokenType token type identifier - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - TokenType tokenType - ) { - return MintTransaction.create(networkId, recipient, timeout, (byte[]) null, tokenType); - } + /** + * Sets the mint justification bytes. + * + * @param justification mint justification bytes, may be null + * + * @return this builder + */ + public Builder justification(byte[] justification) { + this.justification = + justification != null ? Arrays.copyOf(justification, justification.length) : null; + return this; + } - /** - * Create a mint transaction with no data and a generated token type. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * @param salt mint-transaction salt - * - * @return mint transaction - */ - public static MintTransaction create( - NetworkId networkId, - Predicate recipient, - long timeout, - TokenSalt salt - ) { - return MintTransaction.create(networkId, recipient, timeout, TokenType.generate(), salt); - } + /** + * Sets the exclusive certification request deadline, in Unix seconds. + * + *

Leave it unset, or pass null, to let the Unicity Service assign a deadline from consensus + * time. That requires no local clock, and the assigned value is not recorded in the token. + * + * @param expiresAt exclusive request deadline, may be null + * + * @return this builder + */ + public Builder expiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + return this; + } - /** - * Create a mint transaction with no data, generated token type and salt. - * - * @param networkId network identifier - * @param recipient recipient predicate - * @param timeout exclusive timeout of the certification request - * - * @return mint transaction - */ - public static MintTransaction create(NetworkId networkId, Predicate recipient, long timeout) { - return MintTransaction.create(networkId, recipient, timeout, (byte[]) null); + /** + * Builds the mint transaction, deriving the token id, lock script and mint state. + * + * @return mint transaction + */ + public MintTransaction build() { + TokenType type = this.tokenType != null ? this.tokenType : TokenType.generate(); + TokenSalt mintSalt = this.salt != null ? this.salt : TokenSalt.generate(); + + TokenId tokenId = TokenId.fromSalt(this.networkId, mintSalt); + SigningService signingService = MintSigningService.create(tokenId); + return new MintTransaction( + MintTransactionState.create(tokenId), + EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)), + this.networkId, + EncodedPredicate.fromPredicate(this.recipient), + mintSalt, + type, + tokenId, + this.expiresAt, + this.justification, + this.data + ); + } } /** @@ -399,24 +280,27 @@ public static MintTransaction fromCbor(byte[] bytes) { if (tag.getTag() != MintTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData()); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - boolean hasTimeout = version == TIMEOUT_VERSION; - if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || data.size() != (hasTimeout ? 8 : 7)) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } - return MintTransaction.create( - NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), - EncodedPredicate.fromCbor(data.get(2)), - hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(7)).asLong() : 0, - CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString), - TokenType.fromCbor(data.get(4)), - TokenSalt.fromCbor(data.get(3)), - CborDeserializer.decodeNullable(data.get(5), CborDeserializer::decodeByteString) - ); + return MintTransaction + .builder( + NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), + EncodedPredicate.fromCbor(data.get(2))) + .salt(TokenSalt.fromCbor(data.get(3))) + .tokenType(TokenType.fromCbor(data.get(4))) + .justification( + CborDeserializer.decodeNullable(data.get(5), CborDeserializer::decodeByteString)) + .data(CborDeserializer.decodeNullable(data.get(6), CborDeserializer::decodeByteString)) + .expiresAt( + CborDeserializer.decodeNullable( + data.get(7), + value -> CborDeserializer.decodeUnsignedInteger(value).asLong())) + .build(); } /** @@ -453,23 +337,17 @@ public DataHash calculateTransactionHash() { */ @Override public byte[] toCbor() { - byte[] payload = this.timeout == 0 - ? CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), - CborSerializer.encodeUnsignedInteger(this.networkId.getId()), this.recipient.toCbor(), - this.salt.toCbor(), this.tokenType.toCbor(), - CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString)) - : CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), - CborSerializer.encodeUnsignedInteger(this.networkId.getId()), this.recipient.toCbor(), - this.salt.toCbor(), this.tokenType.toCbor(), - CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), - CborSerializer.encodeUnsignedInteger(this.timeout)); return CborSerializer.encodeTag( MintTransaction.CBOR_TAG, - payload + CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(VERSION), + CborSerializer.encodeUnsignedInteger(this.networkId.getId()), + this.recipient.toCbor(), + this.salt.toCbor(), + this.tokenType.toCbor(), + CborSerializer.encodeNullable(this.justification, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger)) ); } @@ -494,8 +372,8 @@ public CertifiedMintTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, timeout=%s, data=%s}", + "MintTransaction{sourceStateHash=%s, lockScript=%s, networkId=%s, recipient=%s, salt=%s, tokenType=%s, tokenId=%s, expiresAt=%s, data=%s}", this.sourceStateHash, this.lockScript, this.networkId, this.recipient, this.salt, - this.tokenType, this.tokenId, this.timeout, HexConverter.encode(this.data)); + this.tokenType, this.tokenId, this.expiresAt, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java index 4fa6fa61..fafe381c 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java @@ -46,12 +46,13 @@ public interface Transaction { StateMask getStateMask(); /** - * Explicit exclusive timeout of the certification request, or zero for the service default. - * Explicit values are committed by the v2 transaction encoding. + * Exclusive certification request deadline in Unix seconds, or empty when the Unicity Service + * assigns one from consensus time. It occupies a fixed position in the encoding and is committed + * by the transaction hash either way. * - * @return request timeout + * @return request deadline */ - long getTimeout(); + Optional getExpiresAt(); /** * Calculates the resulting state hash. diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index 166d91d1..94bc14a6 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -22,13 +22,14 @@ */ public class TransferTransaction implements Transaction { public static final long CBOR_TAG = 39045; - private static final int LEGACY_VERSION = 1; - private static final int TIMEOUT_VERSION = 2; + /** The only accepted wire version. One version, one element count. */ + public static final int VERSION = 2; + private static final int FIELD_COUNT = 5; private final DataHash sourceStateHash; private final EncodedPredicate lockScript; private final EncodedPredicate recipient; - private final long timeout; + private final Long expiresAt; private final StateMask stateMask; private final byte[] data; @@ -36,23 +37,18 @@ private TransferTransaction( DataHash sourceStateHash, EncodedPredicate lockScript, EncodedPredicate recipient, - long timeout, + Long expiresAt, StateMask stateMask, byte[] data ) { this.sourceStateHash = sourceStateHash; this.lockScript = lockScript; this.recipient = recipient; - this.timeout = timeout; + this.expiresAt = expiresAt; this.stateMask = stateMask; this.data = data; } - public int getVersion() { - return this.timeout == 0 ? LEGACY_VERSION : TIMEOUT_VERSION; - } - - @Override public Optional getData() { return Optional.ofNullable(this.data != null ? Arrays.copyOf(this.data, this.data.length) : null); @@ -79,8 +75,8 @@ public StateMask getStateMask() { } @Override - public long getTimeout() { - return this.timeout; + public Optional getExpiresAt() { + return Optional.ofNullable(this.expiresAt); } /** @@ -89,28 +85,37 @@ public long getTimeout() { * @param token token whose latest transaction is used as the source * @param recipient recipient predicate * @param stateMask transaction randomness component - * @param timeout exclusive timeout of the certification request * @param data transfer payload + * @param expiresAt exclusive request deadline, may be null to let the service assign one * @return created transfer transaction */ public static TransferTransaction create(Token token, Predicate recipient, - StateMask stateMask, long timeout, byte[] data) { + StateMask stateMask, byte[] data, Long expiresAt) { Transaction transaction = token.getLatestTransaction(); return new TransferTransaction( transaction.calculateStateHash(), transaction.getRecipient(), EncodedPredicate.fromPredicate(recipient), - timeout, + expiresAt, stateMask, data ); } - /** Creates a legacy transfer whose timeout is assigned by the aggregation service. */ + /** + * Creates a transfer whose deadline is assigned by the Unicity Service, which requires no local + * clock. + * + * @param token token whose latest transaction is used as the source + * @param recipient recipient predicate + * @param stateMask transaction randomness component + * @param data transfer payload + * @return created transfer transaction + */ public static TransferTransaction create(Token token, Predicate recipient, StateMask stateMask, byte[] data) { - return create(token, recipient, stateMask, 0, data); + return create(token, recipient, stateMask, data, null); } /** @@ -125,12 +130,10 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { if (tag.getTag() != TransferTransaction.CBOR_TAG) { throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); } - List data = CborDeserializer.decodeArray(tag.getData()); + List data = CborDeserializer.decodeArray(tag.getData(), FIELD_COUNT); int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - boolean hasTimeout = version == TIMEOUT_VERSION; - if ((version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || data.size() != (hasTimeout ? 5 : 4)) { + if (version != VERSION) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } @@ -138,8 +141,9 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { token, EncodedPredicate.fromCbor(data.get(1)), StateMask.fromCbor(data.get(2)), - hasTimeout ? CborDeserializer.decodeUnsignedInteger(data.get(4)).asLong() : 0, - CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) + CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString), + CborDeserializer.decodeNullable( + data.get(4), value -> CborDeserializer.decodeUnsignedInteger(value).asLong()) ); } @@ -164,17 +168,14 @@ public DataHash calculateTransactionHash() { @Override public byte[] toCbor() { - byte[] payload = this.timeout == 0 - ? CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(LEGACY_VERSION), - EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString)) - : CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(TIMEOUT_VERSION), - EncodedPredicate.fromPredicate(this.recipient).toCbor(), this.stateMask.toCbor(), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), - CborSerializer.encodeUnsignedInteger(this.timeout)); return CborSerializer.encodeTag( TransferTransaction.CBOR_TAG, - payload + CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(VERSION), + EncodedPredicate.fromPredicate(this.recipient).toCbor(), + this.stateMask.toCbor(), + CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), + CborSerializer.encodeNullable(this.expiresAt, CborSerializer::encodeUnsignedInteger)) ); } @@ -202,8 +203,8 @@ public CertifiedTransferTransaction toCertifiedTransaction( @Override public String toString() { return String.format( - "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, timeout=%s, stateMask=%s, data=%s}", - this.sourceStateHash, this.lockScript, this.recipient, this.timeout, this.stateMask, + "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, expiresAt=%s, stateMask=%s, data=%s}", + this.sourceStateHash, this.lockScript, this.recipient, this.expiresAt, this.stateMask, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java index 0f8fc02e..e9123921 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -63,13 +63,15 @@ public static VerificationResult verify(RootTr if (!certificationData.getLockScript().equals(transaction.getLockScript()) || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash()) - || certificationData.getTimeout() != transaction.getTimeout()) { + || !certificationData.getExpiresAt().equals(transaction.getExpiresAt())) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH); } - // The request was admissible only in a round strictly before its timeout. - if (transaction.getTimeout() != 0 && referenceTime >= transaction.getTimeout()) { + // 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()) { return new VerificationResult<>("InclusionProofVerificationRule", InclusionProofVerificationStatus.REQUEST_EXPIRED); } diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 55a2d436..097fc5d9 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -78,7 +78,8 @@ public CompletableFuture submitCertificationRequest(Certi return CompletableFuture.completedFuture(CertificationResponse.create(CertificationStatus.SIGNATURE_VERIFICATION_FAILED)); } - if (certificationData.getTimeout() != 0 && this.referenceTime >= certificationData.getTimeout()) { + if (certificationData.getExpiresAt().isPresent() + && this.referenceTime >= certificationData.getExpiresAt().get()) { return CompletableFuture.completedFuture( CertificationResponse.create(CertificationStatus.REQUEST_EXPIRED)); } diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java index 0d7ad39c..eba9cd3b 100644 --- a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; public class TestApiKeyIntegration { @@ -42,10 +42,9 @@ void setUp() throws Exception { SigningService signingService = new SigningService( HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); - MintTransaction transaction = MintTransaction.create( - NetworkId.LOCAL, - SignaturePredicate.fromSigningService(signingService) - , RequestTimeout.requestTimeout()); + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .expiresAt(ExpiresAt.expiresAt()) + .build(); certificationData = CertificationData.fromMintTransaction(transaction); } diff --git a/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java index e401bc86..646cfed3 100644 --- a/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/CrossSdkEncodingTest.java @@ -10,53 +10,52 @@ /** * Pins the certification-data encoding against the vectors the TypeScript and Rust SDKs and the - * aggregator assert on. The two profiles must be byte-identical across implementations, and the - * v1 profile must reproduce the bytes produced before request timeouts existed. + * aggregator assert on. The bytes must be identical across implementations, both when the sender + * chooses a deadline and when it leaves the choice to the Unicity Service. */ public class CrossSdkEncodingTest { private static final byte[] PUBLIC_KEY = HexConverter.decode("02ce9f22e51333c97a8fb1f807a229ece3a8765a16af5fc1a13e30834be3280026"); - private static final long TIMEOUT = 1755000000L; + private static final long EXPIRES_AT = 1755000000L; - private static final String V1 = - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" - + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" - + "5820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb6" - + "37f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c" - + "568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700"; - private static final String V2 = + private static final String EXPLICIT = "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + "5820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc" + "0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb04" + "7c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00"; - - private static MintTransaction mint(long timeout) { - SignaturePredicate recipient = SignaturePredicate.create(PUBLIC_KEY); - TokenType tokenType = new TokenType(new byte[32]); - TokenSalt salt = TokenSalt.fromBytes(new byte[32]); - - return timeout == 0 - ? MintTransaction.create(NetworkId.MAINNET, recipient, (byte[]) null, tokenType, salt) - : MintTransaction.create(NetworkId.MAINNET, recipient, timeout, null, tokenType, salt); + private static final String ABSENT = + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da3636283" + + "43f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241" + + "5820c034e096d7bdf71ba759558663b5cafb7279ecb7e284443e5e6cbce0461aceeef6584154" + + "ca6b19a7dbcae7a6adc38af5c8672f81943ecaf51345436684299b4b7ac81a57db2653f32048" + + "981e37913db4749ca08d998d1fac4a52ab5579988bc2c50de900"; + + private static MintTransaction mint(Long expiresAt) { + return MintTransaction + .builder(NetworkId.MAINNET, SignaturePredicate.create(PUBLIC_KEY)) + .tokenType(new TokenType(new byte[32])) + .salt(TokenSalt.fromBytes(new byte[32])) + .expiresAt(expiresAt) + .build(); } @Test - public void serviceDefaultProfileMatchesTheSharedV1Vector() { - MintTransaction transaction = mint(0); + public void explicitDeadlineMatchesTheSharedVector() { + MintTransaction transaction = mint(EXPIRES_AT); CertificationData certificationData = CertificationData.fromMintTransaction(transaction); - Assertions.assertEquals(0, transaction.getTimeout()); - Assertions.assertEquals(V1, HexConverter.encode(certificationData.toCbor())); + Assertions.assertEquals(EXPIRES_AT, transaction.getExpiresAt().orElseThrow(AssertionError::new)); + Assertions.assertEquals(EXPLICIT, HexConverter.encode(certificationData.toCbor())); } @Test - public void explicitTimeoutProfileMatchesTheSharedV2Vector() { - MintTransaction transaction = mint(TIMEOUT); + public void absentDeadlineMatchesTheSharedVector() { + MintTransaction transaction = mint(null); CertificationData certificationData = CertificationData.fromMintTransaction(transaction); - Assertions.assertEquals(TIMEOUT, transaction.getTimeout()); - Assertions.assertEquals(V2, HexConverter.encode(certificationData.toCbor())); + Assertions.assertFalse(transaction.getExpiresAt().isPresent()); + Assertions.assertEquals(ABSENT, HexConverter.encode(certificationData.toCbor())); } } diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index 819a78e2..d2090f09 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -21,7 +21,7 @@ import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; import org.unicitylabs.sdk.util.HexConverter; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class InclusionProofTest { @@ -42,10 +42,9 @@ public void createMerkleTreePath() throws Exception { HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); - transaction = MintTransaction.create( - NetworkId.LOCAL, - SignaturePredicate.fromSigningService(signingService) - , RequestTimeout.requestTimeout()); + transaction = MintTransaction.builder(NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService)) + .expiresAt(ExpiresAt.expiresAt()) + .build(); certificationData = CertificationData.fromMintTransaction(transaction); stateId = StateId.fromCertificationData(certificationData); @@ -128,7 +127,7 @@ public void testItVerifies() { DataHash.fromImprint( HexConverter.decode("00000000000000000000000000000000000000000000000000000000000000000001") ), - this.certificationData.getTimeout(), + this.certificationData.getExpiresAt().orElse(null), this.certificationData.getUnlockScript() ), REFERENCE_TIME, @@ -155,7 +154,7 @@ public void testItNotAuthenticated() { this.certificationData.getLockScript(), this.certificationData.getSourceStateHash(), this.certificationData.getTransactionHash(), - this.certificationData.getTimeout(), + this.certificationData.getExpiresAt().orElse(null), SignaturePredicateUnlockScript.create( this.transaction, new SigningService(SigningService.generatePrivateKey()) @@ -229,7 +228,7 @@ public void testVerificationFailsWhenReferenceTimeReachesTheTimeout() { this.predicateVerifier, inclusionProof, this.transaction, - this.transaction.getTimeout() + this.transaction.getExpiresAt().orElse(null) ).getStatus() ); } diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java index ef9bb996..6fd332bb 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -24,7 +24,7 @@ import org.unicitylabs.sdk.util.InclusionProofUtils; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.utils.TokenUtils; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * M-03: the inclusion-proof rule must bind the certification lock script and source state hash to @@ -58,8 +58,8 @@ public void rejectsCertificationDataFromADifferentTransaction() throws Exception SignaturePredicate recipient = SignaturePredicate.fromSigningService(SigningService.generate()); StateMask stateMask = StateMask.generate(); - TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, RequestTimeout.requestTimeout(), null); - TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, RequestTimeout.requestTimeout(), null); + TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, null, ExpiresAt.expiresAt()); + TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, null, ExpiresAt.expiresAt()); Assertions.assertEquals( transferA.calculateTransactionHash(), transferB.calculateTransactionHash(), diff --git a/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java b/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java new file mode 100644 index 00000000..922457fc --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/ExpiresAtTest.java @@ -0,0 +1,105 @@ +package org.unicitylabs.sdk.functional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.CertificationStatus; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.TokenSalt; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.utils.ExpiresAt; + +/** + * The Unicity Service admits a request only in a round whose reference time is strictly below the + * request's deadline. A request that carries no deadline is admitted under a service-assigned one, + * which is not recorded and is not re-checked by a verifier. + */ +public class ExpiresAtTest { + + private final TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + private final StateTransitionClient client = new StateTransitionClient(this.aggregatorClient); + private final SignaturePredicate recipient = + SignaturePredicate.fromSigningService(SigningService.generate()); + + private CertificationStatus submit(Long expiresAt) throws Exception { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .expiresAt(expiresAt) + .build(); + + return this.client.submitCertificationRequest( + CertificationData.fromMintTransaction(transaction)).get().getStatus(); + } + + @Test + public void acceptsARequestWhoseDeadlineIsAheadOfTheRoundReferenceTime() throws Exception { + Assertions.assertEquals(CertificationStatus.SUCCESS, + this.submit(ExpiresAt.expiresAt())); + } + + @Test + public void rejectsARequestWhoseDeadlineTheRoundReferenceTimeHasReached() throws Exception { + Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, + this.submit(ExpiresAt.expiredExpiresAt())); + } + + @Test + public void bindsTheDeadlineIntoTheTransactionHash() { + TokenType tokenType = TokenType.generate(); + TokenSalt salt = TokenSalt.generate(); + + MintTransaction first = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .tokenType(tokenType) + .salt(salt) + .expiresAt(1755000000L) + .build(); + MintTransaction second = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .tokenType(tokenType) + .salt(salt) + .expiresAt(1755000001L) + .build(); + + Assertions.assertNotEquals(first.calculateTransactionHash(), + second.calculateTransactionHash()); + } + + @Test + public void omittingTheDeadlineNeedsNoClockAndKeepsTheSameWireShape() { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient).build(); + MintTransaction decoded = MintTransaction.fromCbor(transaction.toCbor()); + CertificationData certificationData = CertificationData.fromMintTransaction(transaction); + + Assertions.assertFalse(transaction.getExpiresAt().isPresent()); + Assertions.assertFalse(decoded.getExpiresAt().isPresent()); + Assertions.assertArrayEquals(transaction.toCbor(), decoded.toCbor()); + Assertions.assertFalse(certificationData.getExpiresAt().isPresent()); + + // The absent deadline holds its slot as CBOR null rather than shortening the + // array, so both profiles are the same version with the same field count. + Assertions.assertEquals(MintTransaction.VERSION, transaction.toCbor()[4]); + Assertions.assertEquals(CertificationData.VERSION, certificationData.toCbor()[4]); + } + + @Test + public void rejectsAnyVersionOtherThanTheCurrentOne() { + MintTransaction transaction = MintTransaction.builder(NetworkId.LOCAL, this.recipient) + .expiresAt(1_755_000_000L) + .build(); + + for (byte badVersion : new byte[] {1, 3}) { + byte[] mismatched = transaction.toCbor(); + Assertions.assertEquals(2, mismatched[4], "fixture version offset"); + mismatched[4] = badVersion; + + Assertions.assertThrows( + CborSerializationException.class, + () -> MintTransaction.fromCbor(mismatched) + ); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java b/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java deleted file mode 100644 index fb5280da..00000000 --- a/src/test/java/org/unicitylabs/sdk/functional/RequestTimeoutTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.unicitylabs.sdk.functional; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.unicitylabs.sdk.StateTransitionClient; -import org.unicitylabs.sdk.TestAggregatorClient; -import org.unicitylabs.sdk.api.CertificationData; -import org.unicitylabs.sdk.api.CertificationStatus; -import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.crypto.secp256k1.SigningService; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; -import org.unicitylabs.sdk.transaction.MintTransaction; -import org.unicitylabs.sdk.transaction.TokenSalt; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.utils.RequestTimeout; - -/** - * The Unicity Service admits a request only in a round whose reference time is strictly below - * the request's timeout. - */ -public class RequestTimeoutTest { - - private final TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); - private final StateTransitionClient client = new StateTransitionClient(this.aggregatorClient); - private final SignaturePredicate recipient = - SignaturePredicate.fromSigningService(SigningService.generate()); - - private CertificationStatus submit(long timeout) throws Exception { - MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient, timeout); - - return this.client.submitCertificationRequest( - CertificationData.fromMintTransaction(transaction)).get().getStatus(); - } - - @Test - public void acceptsARequestWhoseTimeoutIsAheadOfTheRoundReferenceTime() throws Exception { - Assertions.assertEquals(CertificationStatus.SUCCESS, - this.submit(RequestTimeout.requestTimeout())); - } - - @Test - public void rejectsARequestWhoseTimeoutTheRoundReferenceTimeHasReached() throws Exception { - Assertions.assertEquals(CertificationStatus.REQUEST_EXPIRED, - this.submit(RequestTimeout.expiredRequestTimeout())); - } - - @Test - public void bindsTheTimeoutIntoTheTransactionHash() { - TokenType tokenType = TokenType.generate(); - TokenSalt salt = TokenSalt.generate(); - - MintTransaction first = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1755000000L, - null, tokenType, salt); - MintTransaction second = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1755000001L, - null, tokenType, salt); - - Assertions.assertNotEquals(first.calculateTransactionHash(), - second.calculateTransactionHash()); - } - - @Test - public void legacyCreateUsesServiceDefaultAndV1WireFormat() { - MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient); - MintTransaction decoded = MintTransaction.fromCbor(transaction.toCbor()); - CertificationData certificationData = CertificationData.fromMintTransaction(transaction); - - Assertions.assertEquals(1, transaction.getVersion()); - Assertions.assertEquals(0, transaction.getTimeout()); - Assertions.assertArrayEquals(transaction.toCbor(), decoded.toCbor()); - Assertions.assertEquals(1, certificationData.getVersion()); - Assertions.assertEquals(0, certificationData.getTimeout()); - } - - @Test - public void versionMustMatchTheFieldCount() { - MintTransaction transaction = MintTransaction.create(NetworkId.LOCAL, this.recipient, 1_755_000_000L); - byte[] mismatched = transaction.toCbor(); - Assertions.assertEquals(2, mismatched[4], "fixture version offset"); - mismatched[4] = 1; - - Assertions.assertThrows( - CborSerializationException.class, - () -> MintTransaction.fromCbor(mismatched) - ); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java index 66795af3..c91b34c2 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java @@ -31,7 +31,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * End-to-end functional test for the token split flow: mint a source token, split it, burn the @@ -72,7 +72,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset2))) ); - SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests, RequestTimeout.requestTimeout()); + SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests, StateMask.generate(), ExpiresAt.expiresAt()); Token burnToken = TokenUtils.transferToken( client, @@ -115,7 +115,7 @@ public void buildAndVerifySplitToken() throws Exception { new TestPaymentData(PaymentAssetCollection.create(asset1))) ); - SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests, RequestTimeout.requestTimeout()); + SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests, StateMask.generate(), ExpiresAt.expiresAt()); Token secondBurnToken = TokenUtils.transferToken( client, @@ -177,9 +177,9 @@ public void rebuildsByteIdenticalBurnTransactionFromSuppliedStateMask() throws E ); StateMask burnStateMask = StateMask.generate(); - SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout(), burnStateMask); - SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout(), burnStateMask); - SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests, RequestTimeout.requestTimeout()); + SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask, ExpiresAt.expiresAt()); + SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask, ExpiresAt.expiresAt()); + SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests, StateMask.generate(), ExpiresAt.expiresAt()); byte[] firstBurn = first.getBurnTransaction().toCbor(); Assertions.assertArrayEquals(firstBurn, second.getBurnTransaction().toCbor()); diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java index 626def16..b89e8ea5 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java @@ -37,7 +37,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * Regression for the split inflation exploit: a hand-built single-asset split that mirrors @@ -79,8 +79,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { // Issuer hands the token over to the attacker. The attacker now legitimately HOLDS a token // they did not mint - its 500 value is inherited from the issuer, not self-declared. - TransferTransaction handover = TransferTransaction.create( - token, attackerPredicate, StateMask.generate(), RequestTimeout.requestTimeout(), null); + TransferTransaction handover = TransferTransaction.create(token, attackerPredicate, StateMask.generate(), null, ExpiresAt.expiresAt()); token = TokenUtils.transferToken( client, context, @@ -114,8 +113,7 @@ public void mustRejectHolderInflatingReceivedToken() throws Exception { SparseMerkleSumTreeRootNode root = tree.calculateRoot(); byte[] manifestBytes = SplitManifest.create(List.of(root.getHash())).toCbor(); byte[] burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest().getData(); - TransferTransaction burnTransaction = TransferTransaction.create( - token, BurnPredicate.create(burnReason), StateMask.generate(), RequestTimeout.requestTimeout(), manifestBytes); + TransferTransaction burnTransaction = TransferTransaction.create(token, BurnPredicate.create(burnReason), StateMask.generate(), manifestBytes, ExpiresAt.expiresAt()); // The burn is a genuine, network-certified transfer signed by the attacker (the current owner). token = TokenUtils.transferToken( diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java index 85f92076..947be76f 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java @@ -19,6 +19,7 @@ import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.StateMask; import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; @@ -28,7 +29,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.List; -import org.unicitylabs.sdk.utils.RequestTimeout; +import org.unicitylabs.sdk.utils.ExpiresAt; /** * Unit tests for the precondition branches of {@link TokenSplit#split}. @@ -69,14 +70,10 @@ public void setupFixture() throws Exception { public void splitFailsWhenAssetCountsDiffer() { TokenAssetCountMismatchException exception = Assertions.assertThrows( TokenAssetCountMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1)) - )) - , RequestTimeout.requestTimeout()) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token and split tokens asset counts differ.", exception.getMessage()); } @@ -88,14 +85,10 @@ public void splitFailsWhenAssetIsMissingFromSource() { TokenAssetMissingException exception = Assertions.assertThrows( TokenAssetMissingException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create(this.asset1, unknownAsset)) - )) - , RequestTimeout.requestTimeout()) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals( String.format("Token did not contain asset %s.", unknownAsset.getId()), @@ -106,15 +99,11 @@ public void splitFailsWhenAssetIsMissingFromSource() { public void splitFailsWhenAssetTreeAmountIsLess() { TokenAssetValueMismatchException exception = Assertions.assertThrows( TokenAssetValueMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) - )) - , RequestTimeout.requestTimeout()) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 400", exception.getMessage()); @@ -124,15 +113,11 @@ this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) public void splitFailsWhenAssetTreeAmountIsMore() { TokenAssetValueMismatchException exception = Assertions.assertThrows( TokenAssetValueMismatchException.class, - () -> TokenSplit.split( - this.sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create( + () -> TokenSplit.split(this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), new TestPaymentData(PaymentAssetCollection.create( this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(1500)))) - )) - , RequestTimeout.requestTimeout()) + )), StateMask.generate(), ExpiresAt.expiresAt()) ); Assertions.assertEquals("Token contained 500 AssetId{bytes=41535345545f32} assets, but tree has 1500", exception.getMessage()); diff --git a/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java b/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java new file mode 100644 index 00000000..8d07da6a --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/ExpiresAt.java @@ -0,0 +1,29 @@ +package org.unicitylabs.sdk.utils; + +/** + * Certification request deadlines for tests. + */ +public final class ExpiresAt { + + private ExpiresAt() { + } + + /** + * A deadline an hour ahead of the current wall clock, so no test run can reach it while the + * request is in flight. + * + * @return request deadline in Unix seconds + */ + public static Long expiresAt() { + return System.currentTimeMillis() / 1000 + 3600; + } + + /** + * A deadline that has already passed, for exercising the expiry path. + * + * @return request deadline in Unix seconds + */ + public static Long expiredExpiresAt() { + return System.currentTimeMillis() / 1000 - 3600; + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java b/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java deleted file mode 100644 index c878f0c6..00000000 --- a/src/test/java/org/unicitylabs/sdk/utils/RequestTimeout.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.unicitylabs.sdk.utils; - -/** - * Certification request timeouts for tests. - */ -public final class RequestTimeout { - - private RequestTimeout() { - } - - /** - * A timeout an hour ahead of the current wall clock, so no test run can reach it while the - * request is in flight. - * - * @return request timeout in Unix seconds - */ - public static long requestTimeout() { - return System.currentTimeMillis() / 1000 + 3600; - } - - /** - * A timeout that has already passed, for exercising the expiry path. - * - * @return request timeout in Unix seconds - */ - public static long expiredRequestTimeout() { - return System.currentTimeMillis() / 1000 - 3600; - } -} diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index 8692af3f..1583b0d4 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -82,14 +82,12 @@ public static Token mintToken( TokenSalt salt, byte[] justification ) throws Exception { - MintTransaction transaction = MintTransaction.create( - networkId, - recipient, - data, - tokenType, - salt, - justification - ); + MintTransaction transaction = MintTransaction.builder(networkId, recipient) + .tokenType(tokenType) + .salt(salt) + .data(data) + .justification(justification) + .build(); CertificationData certificationData = CertificationData.fromMintTransaction(transaction); @@ -135,12 +133,7 @@ public static Token transferToken( Token token = Token.fromCbor(tokenBytes); Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus()); - TransferTransaction transaction = TransferTransaction.create( - token, - recipient, - StateMask.generate(), - null - ); + TransferTransaction transaction = TransferTransaction.create(token, recipient, StateMask.generate(), null); return TokenUtils.transferToken( client, From 21214520de5a95864007612f27a8d490985261f9 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 23:20:56 +0300 Subject: [PATCH 06/17] Mirror the JS review feedback Same three defects the TypeScript review found, in the same places: - CertifiedMintTransaction.fromCbor and CertifiedTransferTransaction .fromCbor threw IllegalArgumentException for a decode-shape failure. They throw CborSerializationException now, like every other decoder. - The `getReferenceTime().isPresent() ||` guards sat in front of an inequality that an absent value already fails, so the guard only hid what the comparison was doing. Compare the Optionals directly. - The rule's MISSING_REFERENCE_TIME fires when the proof's reference time differs from the one the transition carries, which is what testVerificationFailsWithWrongReferenceTime was exercising. That case is REFERENCE_TIME_MISMATCH; MISSING_REFERENCE_TIME stays for the genuinely absent case in the certified transactions and InclusionProofUtils. InclusionProof's certification data, reference time and inclusion certificate describe a leaf and belong together: all three are present once the request is in a certified round, and all three are absent while it is pending. fromCbor now rejects any proof carrying some but not all of them, so the invariant holds once at the decode boundary instead of being re-checked at each use. --- .../unicitylabs/sdk/api/InclusionProof.java | 30 +++++++++++++++---- .../transaction/CertifiedMintTransaction.java | 6 ++-- .../CertifiedTransferTransaction.java | 6 ++-- .../InclusionProofVerificationRule.java | 7 +++-- .../InclusionProofVerificationStatus.java | 2 ++ .../sdk/api/InclusionProofTest.java | 24 ++++++++++++++- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java index 6cb7d337..a2e1141b 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionProof.java @@ -8,6 +8,7 @@ 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. @@ -98,13 +99,30 @@ public static InclusionProof fromCbor(byte[] bytes) { throw new CborSerializationException(String.format("Unsupported version: %s", version)); } + CertificationData certificationData = + CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor); + Long referenceTime = CborDeserializer.decodeNullable(data.get(2), value -> + CborDeserializer.decodeUnsignedInteger(value).asLong()); + InclusionCertificate inclusionCertificate = + CborDeserializer.decodeNullable(data.get(3), (certificate) -> + InclusionCertificate.decode(CborDeserializer.decodeByteString(certificate))); + + // 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) { + throw new CborSerializationException( + "InclusionProof must carry certification data, reference time and inclusion " + + "certificate together, or none of them."); + } + return new InclusionProof( - CborDeserializer.decodeNullable(data.get(1), CertificationData::fromCbor), - CborDeserializer.decodeNullable(data.get(2), value -> - CborDeserializer.decodeUnsignedInteger(value).asLong()), - CborDeserializer.decodeNullable(data.get(3), (inclusionCertificate) -> - InclusionCertificate.decode(CborDeserializer.decodeByteString(inclusionCertificate)) - ), + certificationData, + referenceTime, + inclusionCertificate, UnicityCertificate.fromCbor(data.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 fec38738..ce15ee96 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -7,6 +7,7 @@ import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; @@ -131,8 +132,9 @@ 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(); - if (!proof.getReferenceTime().isPresent() || referenceTime != proof.getReferenceTime().get()) { - throw new IllegalArgumentException("Certified mint transaction reference time mismatch"); + // An absent reference time on the proof also fails this comparison. + if (!proof.getReferenceTime().equals(Optional.of(referenceTime))) { + throw new CborSerializationException("Certified mint transaction reference time mismatch"); } return new CertifiedMintTransaction( MintTransaction.fromCbor(data.get(0)), diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 715ca440..b28b6445 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -6,6 +6,7 @@ import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; @@ -95,8 +96,9 @@ 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(); - if (!proof.getReferenceTime().isPresent() || referenceTime != proof.getReferenceTime().get()) { - throw new IllegalArgumentException("Certified transfer transaction reference time mismatch"); + // 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"); } return new CertifiedTransferTransaction( 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 e9123921..fbe22425 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -12,6 +12,7 @@ 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 @@ -76,10 +77,10 @@ public static VerificationResult verify(RootTr InclusionProofVerificationStatus.REQUEST_EXPIRED); } - if (!inclusionProof.getReferenceTime().isPresent() - || inclusionProof.getReferenceTime().get() != referenceTime) { + // An absent reference time on the proof also fails this comparison. + if (!inclusionProof.getReferenceTime().equals(Optional.of(referenceTime))) { return new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); + InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH); } StateId stateId = StateId.fromTransaction(transaction); 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 f58e8131..a6d43618 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -14,6 +14,8 @@ public enum InclusionProofVerificationStatus { 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. */ REQUEST_EXPIRED, /** Proof authentication failed. */ diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index d2090f09..e3798b68 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -8,6 +8,7 @@ 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.CborSerializationException; import org.unicitylabs.sdk.api.bft.UnicityCertificateUtils; import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; @@ -73,6 +74,27 @@ public void testCborSerialization() { Assertions.assertEquals(inclusionProof, InclusionProof.fromCbor(inclusionProof.toCbor())); } + /** + * A proof either establishes a leaf or reports that there is none yet. The aggregators emit all + * three leaf fields together or none of them, so a partially present proof is a protocol + * violation and is rejected at decode rather than surfacing as an empty Optional downstream. + */ + @Test + public void rejectsAPartiallyPresentProof() { + 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), + }; + + for (InclusionProof proof : partial) { + byte[] encoded = proof.toCbor(); + Assertions.assertThrows( + CborSerializationException.class, () -> InclusionProof.fromCbor(encoded)); + } + } + @Test public void testStructure() { Assertions.assertThrows(NullPointerException.class, @@ -243,7 +265,7 @@ public void testVerificationFailsWithWrongReferenceTime() { ); Assertions.assertEquals( - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, + InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH, InclusionProofVerificationRule.verify( this.trustBase, this.predicateVerifier, From 85e16591871610a25d1ee4573dad3cd7087b24a5 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Tue, 25 Aug 2026 13:20:01 +0300 Subject: [PATCH 07/17] Retry pending inclusion proofs without reference time --- .../sdk/util/InclusionProofUtils.java | 8 ++-- .../sdk/util/InclusionProofUtilsTest.java | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java diff --git a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java index 0c818295..f0e40758 100644 --- a/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java +++ b/src/main/java/org/unicitylabs/sdk/util/InclusionProofUtils.java @@ -99,13 +99,13 @@ private static void checkInclusionProof( client.getInclusionProof(stateId).thenAccept(response -> { InclusionProof inclusionProof = response.getInclusionProof(); VerificationResult result; - if (!inclusionProof.getReferenceTime().isPresent()) { - result = new VerificationResult<>("InclusionProofVerificationRule", - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME); - } else if (!inclusionProof.getCertificationData().isPresent() + 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( diff --git a/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java new file mode 100644 index 00000000..70e59401 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/util/InclusionProofUtilsTest.java @@ -0,0 +1,46 @@ +package org.unicitylabs.sdk.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.MintTransaction; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +class InclusionProofUtilsTest { + + @Test + void retriesPendingProofWithoutReferenceTime() throws Exception { + SigningService signingService = SigningService.generate(); + TestAggregatorClient aggregator = TestAggregatorClient.create(); + StateTransitionClient client = new StateTransitionClient(aggregator); + MintTransaction transaction = MintTransaction.builder( + NetworkId.LOCAL, + SignaturePredicate.fromSigningService(signingService)) + .expiresAt(System.currentTimeMillis() / 1000 + 60) + .build(); + + var proofFuture = InclusionProofUtils.waitInclusionProof( + client, + aggregator.getTrustBase(), + PredicateVerifierService.create(), + transaction, + Duration.ofSeconds(2), + Duration.ofMillis(10)); + + aggregator.submitCertificationRequest(CertificationData.fromMintTransaction(transaction)).get(); + + InclusionProof proof = proofFuture.get(2, TimeUnit.SECONDS); + Assertions.assertTrue(proof.getReferenceTime().isPresent()); + Assertions.assertTrue(proof.getCertificationData().isPresent()); + Assertions.assertNotNull(proof.getInclusionCertificate()); + } +} From 8473ef4ed436897f9b316388f83bd8abe8e15ffd Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 27 Aug 2026 11:14:16 +0200 Subject: [PATCH 08/17] 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 ce15ee96..3a2011fb 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 b28b6445..cf5e5d78 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 00000000..1ac3a550 --- /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 a15e57ae..ef43a4e2 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 7ae82ac5..ce88406f 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 94bc14a6..5712df26 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 3f0e9f40..675efb54 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 f594e89c..56c44206 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 fbe22425..f19e98e7 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 a6d43618..6c5ef762 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 f0e40758..75e180df 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 e3798b68..65825801 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 96cabd87..68022d57 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java @@ -14,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 6fd332bb..9af61ede 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 09/17] 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 1deb572e..b022edfa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ .claude build src/test/resources/docker/aggregator/mongo-data + +# Genesis the integration stack generates per run +src/test/resources/integration/data/ diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 097fc5d9..7c31590f 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 00000000..16af2612 --- /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 00000000..340ecce2 --- /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 00000000..341ffc3a --- /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 00000000..6d279374 --- /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 00000000..fb3ca837 --- /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 00000000..83ae8af6 --- /dev/null +++ b/src/test/resources/integration/docker-compose.yml @@ -0,0 +1,195 @@ +# Local aggregator stack the integration suite runs against. +# +# Kept byte-for-byte in step with the TypeScript SDK's copy at +# tests/integration/docker/docker-compose.yml. The two suites must exercise the +# same service build, or "passes against a real aggregator" means something +# different in each repo. +# +# Mirrors the topology of aggregator-go's own docker-compose.yml, with two +# deliberate differences: the aggregator runs from a pinned prebuilt image +# instead of a local rocksdb build, and DEFAULT_REQUEST_TTL is short enough +# that the service-assigned request deadline can be observed within a test. +# +# Testcontainers drives it, from AggregatorStack.java: that creates the writable +# genesis directories this file mounts, publishes the aggregator on an ephemeral +# port, and waits for consensus to produce a reference time before any test runs. + +x-bft: &bft-base + platform: linux/amd64 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + # https://github.com/unicitynetwork/bft-core/pkgs/container/bft-core + image: ghcr.io/unicitynetwork/bft-core:ceceacd11b7a735de74ce17884a3a45e0db1748d + +services: + bft-root: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + healthcheck: + test: ["CMD", "nc", "-zv", "bft-root", "8002"] + interval: 2s + timeout: 3s + retries: 30 + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/root/node-info.json ] && [ -f /genesis/trust-base.json ] && [ -f /genesis/root/trust-base-signed.json ]; then + echo "Genesis files already exist, skipping initialization." + else + echo "Creating root genesis..." && + ubft root-node init --home /genesis/root -g && + echo "Creating root trust base..." && + ubft trust-base generate --home /genesis --network-id 3 --node-info /genesis/root/node-info.json && + echo "Signing root trust base..." && + ubft trust-base sign --home /genesis/root --trust-base /genesis/trust-base.json + fi + echo "Starting root node..." && + exec ubft root-node run --home /genesis/root --address "/ip4/$(hostname -i)/tcp/8000" --trust-base /genesis/trust-base.json --rpc-server-address "$(hostname -i):8002" + + bft-aggregator-genesis-gen: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + depends_on: + bft-root: + condition: service_healthy + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/aggregator/node-info.json ] && [ -f /genesis/shard-conf-7_0.json ]; then + echo "Aggregator genesis and config already exist, skipping initialization." + else + echo "Creating aggregator genesis..." && + ubft shard-node init --home /genesis/aggregator --generate && + echo "Creating aggregator partition configuration..." && + ubft shard-conf generate --home /genesis --t2-timeout 5000 --network-id 3 --partition-id 7 --partition-type-id 7 --epoch-start 10 --node-info=/genesis/aggregator/node-info.json + fi + chmod -R 755 /genesis/aggregator + chmod 644 /genesis/shard-conf-7_0.json + chmod 644 /genesis/trust-base.json + chmod -R 755 /genesis/root + echo "Genesis ready." + + upload-configurations: + image: curlimages/curl:8.13.0 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + depends_on: + bft-root: + condition: service_healthy + bft-aggregator-genesis-gen: + condition: service_completed_successfully + restart: on-failure + volumes: + - ./data/genesis:/genesis + command: | + /bin/sh -c " + echo Uploading aggregator configuration && + curl -sf -X PUT -H 'Content-Type: application/json' -d @/genesis/shard-conf-7_0.json http://bft-root:8002/api/v1/configurations + " + + redis: + image: redis:7-alpine + command: redis-server --save "" --appendonly no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 15 + + mongodb: + image: mongo:7.0 + command: ["--replSet", "rs0", "--bind_ip_all", "--noauth"] + healthcheck: + # Initiates the replica set on the first probe, then reports healthy only + # once this node has actually been elected primary — rs.status() answers + # well before the set can accept writes, and the aggregator's storage + # init times out against a set that is still electing. + test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status() } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) } if (!db.hello().isWritablePrimary) { quit(1) }"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + + aggregator: + image: ghcr.io/unicitynetwork/aggregator-go:${AGGREGATOR_IMAGE_TAG:-sha-ae08165} + restart: on-failure + ports: + - "${AGGREGATOR_PORT:-3000}:3000" + volumes: + - ./data/genesis:/app/bft-config + environment: + PORT: "3000" + HOST: "0.0.0.0" + CONCURRENCY_LIMIT: "1000" + ENABLE_CORS: "true" + + MONGODB_URI: "mongodb://mongodb:27017/aggregator?replicaSet=rs0&directConnection=true" + MONGODB_DATABASE: "aggregator" + # Generous enough to ride out the replica-set election on a cold start. + # The aggregator creates its indexes during storage init and exits if that + # times out; the defaults give up while the fresh set is still electing. + MONGODB_CONNECT_TIMEOUT: "30s" + MONGODB_SERVER_SELECTION_TIMEOUT: "30s" + + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_DB: "0" + + USE_REDIS_FOR_COMMITMENTS: "true" + REDIS_FLUSH_INTERVAL: "50ms" + + SMT_BACKEND: "memory" + + # Deadline the service assigns to a request that omits expiresAt. Short + # enough that a test can watch such a request expire; the aggregator + # rejects anything below one whole second. + DEFAULT_REQUEST_TTL: "${DEFAULT_REQUEST_TTL:-30s}" + + DISABLE_HIGH_AVAILABILITY: "false" + LOCK_TTL_SECONDS: "30" + LEADER_HEARTBEAT_INTERVAL: "10s" + LEADER_ELECTION_POLLING_INTERVAL: "5s" + BLOCK_SYNC_INTERVAL: "1s" + + LOG_LEVEL: "${LOG_LEVEL:-info}" + LOG_FORMAT: "json" + LOG_ENABLE_JSON: "true" + + BATCH_LIMIT: "1000" + MAX_COMMITMENTS_PER_ROUND: "10000" + + SIGNING_KEY_FILE: "/app/bft-config/aggregator/keys.json" + + BFT_ENABLED: "true" + BFT_SHARD_CONF_FILE: "/app/bft-config/shard-conf-7_0.json" + BFT_TRUST_BASE_FILES: "/app/bft-config/trust-base.json" + BFT_RPC_ADDRESS: "http://127.0.0.1:8002" + entrypoint: ["/bin/sh", "-c"] + command: + - | + ROOT_NODE_ID=$$(grep -o '"nodeId": "[^"]*"' /app/bft-config/trust-base.json | head -1 | cut -d'"' -f4) + if [ -z "$$ROOT_NODE_ID" ]; then + echo "Error: could not read root nodeId from /app/bft-config/trust-base.json" + exit 1 + fi + export BFT_BOOTSTRAP_ADDRESSES="/dns4/bft-root/tcp/8000/p2p/$$ROOT_NODE_ID" + exec /app/aggregator + depends_on: + bft-aggregator-genesis-gen: + condition: service_completed_successfully + upload-configurations: + condition: service_completed_successfully + redis: + condition: service_healthy + mongodb: + condition: service_healthy + healthcheck: + # A real GET, not --spider: busybox wget spiders with HEAD, and /health is + # registered GET-only, so the image's own HEALTHCHECK never passes. + test: ["CMD", "wget", "--quiet", "--tries=1", "--output-document=/dev/null", "http://localhost:3000/health"] + interval: 2s + timeout: 5s + retries: 45 + start_period: 5s diff --git a/src/test/resources/interop/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 00000000..038e03c6 --- /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 00000000..0827e8c4 --- /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 10/17] 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 1571de81..637fd4d8 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 11/17] 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 7c31590f..ef14345c 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 16af2612..05cff23d 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 341ffc3a..00000000 --- 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 6d279374..00000000 --- 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 fb3ca837..00000000 --- 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 00000000..a2671eda --- /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 038e03c6..00000000 --- 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 0827e8c4..00000000 --- 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 00000000..746ff0a2 --- /dev/null +++ b/src/test/resources/interop/mint-token.mjs @@ -0,0 +1,102 @@ +// Mint a token and transfer it once, using the PUBLISHED TypeScript SDK against the aggregator +// the Java integration suite started, then print the token as hex. +// +// This runs inside a node container, against the npm artifact rather than the TypeScript repo's +// source tree — the same bytes a consumer installs. The token comes back over stdout so nothing +// has to be bind-mounted or copied out. +import { readFileSync } from 'node:fs'; + +import { AggregatorClient } from '@unicitylabs/state-transition-sdk/lib/api/AggregatorClient.js'; +import { UnicitySealQuorumSignaturesVerificationRule } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.js'; +import { UnicityCertificateVerifier } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/UnicityCertificateVerifier.js'; +import { VerifiedSealCache } from '@unicitylabs/state-transition-sdk/lib/api/bft/verification/VerifiedSealCache.js'; +import { RootTrustBase } from '@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js'; +import { CertificationData } from '@unicitylabs/state-transition-sdk/lib/api/CertificationData.js'; +import { Secp256k1SignatureVerifier } from '@unicitylabs/state-transition-sdk/lib/crypto/secp256k1/Secp256k1SignatureVerifier.js'; +import { SigningService } from '@unicitylabs/state-transition-sdk/lib/crypto/secp256k1/SigningService.js'; +import { SignaturePredicate } from '@unicitylabs/state-transition-sdk/lib/predicate/builtin/SignaturePredicate.js'; +import { SignaturePredicateUnlockScript } from '@unicitylabs/state-transition-sdk/lib/predicate/builtin/SignaturePredicateUnlockScript.js'; +import { PredicateVerifierService } from '@unicitylabs/state-transition-sdk/lib/predicate/verification/PredicateVerifierService.js'; +import { StateTransitionClient } from '@unicitylabs/state-transition-sdk/lib/StateTransitionClient.js'; +import { MintTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/MintTransaction.js'; +import { StateMask } from '@unicitylabs/state-transition-sdk/lib/transaction/StateMask.js'; +import { Token } from '@unicitylabs/state-transition-sdk/lib/transaction/Token.js'; +import { TransferTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/TransferTransaction.js'; +import { MintJustificationVerifierService } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/MintJustificationVerifierService.js'; +import { TokenIssuanceVerifierService } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/TokenIssuanceVerifierService.js'; +import { VerificationContext } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/VerificationContext.js'; +import { waitInclusionProof } from '@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js'; + +const aggregatorUrl = process.env.AGGREGATOR_URL; +const trustBasePath = process.env.TRUST_BASE_PATH; +if (!aggregatorUrl || !trustBasePath) { + throw new Error('AGGREGATOR_URL and TRUST_BASE_PATH must be set'); +} + +const trustBase = RootTrustBase.fromJSON(JSON.parse(readFileSync(trustBasePath, 'utf-8'))); +const aggregatorClient = new AggregatorClient(aggregatorUrl, null); +const client = new StateTransitionClient(aggregatorClient); +const predicateVerifier = PredicateVerifierService.create(); +const unicityCertificateVerifier = new UnicityCertificateVerifier( + new UnicitySealQuorumSignaturesVerificationRule(new Secp256k1SignatureVerifier(), new VerifiedSealCache(256)), +); +const context = new VerificationContext( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), +); + +// An hour out, so the request cannot expire while it is in flight. +const expiresAt = BigInt(Math.floor(Date.now() / 1000)) + 3600n; +const alice = SigningService.generate(); +const bob = SigningService.generate(); + +const submit = async (certificationData) => { + const { status } = await client.submitCertificationRequest(certificationData); + if (status !== 'SUCCESS') { + throw new Error(`certification request failed: ${status}`); + } +}; + +const mint = await MintTransaction.create(trustBase.networkId, SignaturePredicate.fromSigningService(alice), { + expiresAt, +}); +await submit(await CertificationData.fromMintTransaction(mint)); +const minted = await Token.mint( + await mint.toCertifiedTransaction( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + await waitInclusionProof(client, trustBase, predicateVerifier, unicityCertificateVerifier, mint), + ), + context, +); + +const transfer = await TransferTransaction.create( + minted, + SignaturePredicate.fromSigningService(bob), + StateMask.generate(), + { expiresAt }, +); +await submit(await CertificationData.fromTransaction(transfer, await SignaturePredicateUnlockScript.create(transfer, alice))); +const token = await minted.transfer( + await transfer.toCertifiedTransaction( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + await waitInclusionProof(client, trustBase, predicateVerifier, unicityCertificateVerifier, transfer), + ), + context, +); + +// Verified by the producing SDK before it leaves, so a failure on the Java side is a +// cross-implementation disagreement and not a token that was never valid. +const result = await token.verify(context); +if (result.status !== 'OK') { + throw new Error(`the TypeScript SDK could not verify its own token: ${result.status}`); +} + +const hex = Buffer.from(token.toCBOR()).toString('hex'); +process.stdout.write(`TOKEN_HEX=${hex}\nEXPIRES_AT=${expiresAt}\n`); diff --git a/src/test/resources/interop/package.json b/src/test/resources/interop/package.json new file mode 100644 index 00000000..63ef111e --- /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 12/17] 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 3a2011fb..65f07dad 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 cf5e5d78..802f44d1 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 1ac3a550..e27791e1 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 f19e98e7..4859b17d 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 75e180df..d54ab88a 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 05cff23d..d9379465 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 340ecce2..a0cd67e3 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 a2671eda..bf58130e 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 13/17] 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 a2e1141b..b35ce701 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 d53e22e2..aff47b5f 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 65f07dad..1159d264 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 802f44d1..ab766a64 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 675efb54..02a9af79 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java @@ -53,10 +53,7 @@ public static VerificationResult verify( EncodedPredicate expectedLockScript = EncodedPredicate.fromPredicate(SignaturePredicate.fromSigningService(signingService)); VerificationResult result = expectedLockScript .equals( - transaction.getInclusionProof() - .getCertificationData() - .map(CertificationData::getLockScript) - .orElse(null) + transaction.getInclusionProof().getCertificationData().getLockScript() ) ? new VerificationResult<>("IsLockScriptValidVerificationRule", VerificationStatus.OK) : new VerificationResult<>("IsLockScriptValidVerificationRule", VerificationStatus.FAIL); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java index 4859b17d..5b6c6014 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 6c5ef762..13822bfe 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -6,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 d54ab88a..4da35238 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 ef14345c..81cf9a1e 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 2ec469a7..151cf613 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 65825801..9b589afe 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 9af61ede..b9df9fcd 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 a0cd67e3..6e0e7181 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 70e59401..eec25951 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 14/17] 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 b35ce701..c1435964 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 aff47b5f..2b8f9a45 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 ab766a64..09619edf 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 ce88406f..621bc8b9 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 5712df26..446099fe 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 151cf613..d71dd710 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 9b589afe..20452e86 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 63ef111e..0056f764 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 15/17] 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 637fd4d8..77f30913 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 16/17] 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 77f30913..7f17e70d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,12 +7,12 @@ plugins { } group = "org.unicitylabs" -// Use version property if provided, otherwise use default -version = if (project.hasProperty("version")) { - project.property("version").toString() -} else { - "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 17/17] 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 d9379465..1d0585fa 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); + } } /**