Bind service time and enforce request timeouts - #83
Merged
Merged
Conversation
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
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
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
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<Long> getExpiresAt(), matching Optional<byte[]> 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.
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.
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.
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.
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.
… vector 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.
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.
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.
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.
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.
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.
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.
Bring the wire format and API to parity with TypeScript SDK 3.0.1
MastaP
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
expiresAt, in the transaction and certification wire formatsexpiresAtwritten as CBOR null when the caller did not supply oneMintTransaction.createoverloads with a builderWire format
One version and one element count per structure.
expiresAtoccupies a fixedposition and is
uint | null, which is howdataandjustificationarealready encoded in these same arrays.
The explicit-deadline bytes are unchanged from the earlier two-profile encoding.
Only the absent case moves, from a shorter array to a null in the same slot.
CertificationDataalso regains the fixed-length decode check it had given up toaccommodate two shapes.
An explicit deadline is committed by the transaction hash and enforced as an
exclusive bound. When it is absent the service assigns a deadline from consensus
reference time; that value stays service metadata, outside the leaf and outside
the signature, and no later verifier checks it. Omitting the deadline needs no
client clock.
API
Absence is a null
Long, not a zerolong: zero is a legal instant, so thesentinel could not express "no deadline". The accessor is
Optional<Long> getExpiresAt(), matchingOptional<byte[]> getData()on the same interface.The deadline was the fifth optional parameter on
MintTransaction.create, andadding it had doubled an already combinatorial overload set from 9 to 18. A
builder takes the required arguments and names the rest:
Validation
Cross-implementation:
CrossSdkEncodingTestpins the encoding for both a presentand an absent deadline against the bytes the TypeScript SDK produces. The Rust
SDK's
transition_flowvectors and rugregator's golden request vector assert onthe same bytes.
Refs #81
Refs #82