Bind service time, enforce request timeouts, and match the shipped SDKs on the wire - #18
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. The cross-SDK fixture is regenerated from the TypeScript SDK at the matching commit. Wire changes are not backward compatible: InclusionProof [version, certData, tau, cert, uc] certified transaction [transaction, tau, inclusionProof] Refs #16
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.
verify_inclusion_proof_for requires tau < tau_Q from the certification
data, and verify_inclusion_proof rejects a proof whose certification
data declares a different timeout.
The cross-SDK fixture and the golden certification vectors are
regenerated from the TypeScript SDK at the matching commit.
Wire changes are 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 #17
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]
Refs #17
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 each decoder had to pair a version with an element count by hand. Use one shape per structure. The deadline keeps a fixed position and is encoded as CBOR null when the caller did not supply one, using the existing encode_nullable/Decoder::nullable helpers that already encode `data` and `justification` in these same arrays. Version 2 is the only accepted version and the array length is fixed, so array(Some(N)) checks it once. The explicit-deadline bytes are unchanged; only the absent case moves, from a shorter array to a null in the same slot. Rename timeout to expires_at: the value is an absolute exclusive instant in Unix seconds, not a duration. Take Option<u64> as a plain trailing parameter instead of pairing every constructor with a *_with_timeout twin. Rust has no overloading and no default arguments, and Option is how it spells optional, so this deletes create_with_timeout, new_with_timeout, mint_with_timeout, transfer_with_timeout, split_with_timeout, split_unchecked_with_timeout, and the mint_impl/transfer_impl bodies that existed only to hold the shared code. It also removes the post-construction field mutation in create_with_timeout, which assigned the field after create had already derived the token id, lock script and mint state. Regenerate tests/vectors/transition_flow.json from the TypeScript SDK. The vector now covers a token whose deadline is absent and one whose deadline is set, which are the same version with the same element count.
The examples are only built under --all-features, so the constructor signature change did not surface locally.
Two of the three defects the TypeScript review found apply here. The redundant presence guard does not: `reference_time != Some(x)` was already the idiomatic comparison, and the decoders already returned Error::UnexpectedValue rather than a generic error. - VerificationError::MissingReferenceTime fires when the proof's reference time differs from the one the transition carries; its only site is that inequality. Renamed to ReferenceTimeMismatch, which is what it detects. An absent reference time still lands there, since it cannot match. - 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. from_cbor now rejects any proof carrying some but not all of them.
Two things kept the HTTP client tied to rugregator. The pending signal. Polling accepted only rugregator's explicit INCLUSION_PENDING status and treated a successful response with absent leaf fields as a protocol error. That empty response is how aggregator-go reports a leaf that is not certified yet, so the client failed on the first poll against it. Accept both. get_inclusion_proof.v2 never answers with a non-inclusion proof -- that is get_non_inclusion_proof.v1 -- so an empty response on this method is unambiguous. Only the explicit status carries the extra information, letting the client separate "not yet" from "no such state"; against a server without it an unknown state id polls to the attempt limit, which is the pre-existing behaviour. The codes. -32001, -32002 and -32003 are inside the range aggregator-go reserves for its own errors, where they mean CommitmentExists, CommitmentNotFound and BlockNotFound. None are emitted today, so nothing was misreading them yet, but a BlockNotFound would have been swallowed as "pending" and polled to timeout. Move to -32020..-32022, leaving -32000..-32019 to implementation-private codes and reserving -32020..-32039 for states that describe the protocol, so another aggregator can adopt them verbatim. Non-inclusion proofs remain unsupported by aggregator-go; that is a missing method there, not an incompatibility here.
The cross-SDK fixture this crate tests against had been regenerated from an unreleased intermediate state of the TypeScript SDK rather than from a tag, so it drifted together with the code and every test kept passing. Regenerating it from state-transition-sdk-js v3.0.1 shows two divergences no test could have caught, because the fixture agreed with the bug. Token was still at wire version 1. The TypeScript and Java SDKs moved it to 2 in 3.0.0 alongside MintTransaction, TransferTransaction and CertificationData, and Token.fromCBOR rejects a version-1 token outright. Anything this crate minted was unreadable by either of them, and anything they minted was unreadable here. Certified transactions carried three elements, [transaction, referenceTime, inclusionProof]. Both other SDKs encode two and read the reference time off the proof, which is the only copy consensus certified. The separate slot could only agree with the proof or be wrong, and both decoders here held a guard checking exactly that. The slot is gone and reference_time() reads the proof. InclusionProof now requires every field, mirroring js#151 and java#84. The absence of a certified leaf belongs to the aggregator's answer, not to the proof, so InclusionProofResponse owns the wire's two shapes: it decodes the tagged structure, decides certified from not, rejects a partially present proof, and builds the InclusionProof from the parts. That removes the four Option fields, the casts on the reference time, and the absence branches at the top of both verification rules. InclusionCertificateMissing and CertificationDataMissing are gone from VerificationError because neither can occur. The http transport tests are gated behind a non-default feature, so `cargo test` never compiled them and CI never ran them; they went stale unnoticed. CI now runs `cargo test --all-features` as well. tests/vectors/ gains the generator and a README, because regenerating a fixture by hand from whatever the reference SDK happened to be is how this drifted. Refs #16 Refs #17
Jumps from 0.1.0 to 3.0.1. There is no 1.x or 2.x: the version line is aligned with state-transition-sdk-js and state-transition-sdk-java, which this crate shares its wire formats with, so a version tells you which SDKs a Rust client interoperates with. 3.0.1 rather than 3.0.0 because the inclusion-proof API matches theirs at 3.0.1, not the shape 3.0.0 shipped. This is the first release that interoperates with either of them. Earlier builds encoded a version-1 token whose certified transactions carried a third element, and neither shape was readable by the shipped SDKs, so tokens this crate produced before now have to be re-minted regardless of which aggregator certified them. README gains an "Upgrading to 3.0" section covering the reference-time leaf value, the four wire versions that move, the two-element certified transaction, request deadlines and what they do not guarantee, and the inclusion-proof split. Cargo.toml's repository URL pointed at state-transition-sdk-rs, which does not exist.
This was referenced Aug 31, 2026
ristik
added a commit
that referenced
this pull request
Aug 31, 2026
Bind service time, enforce request timeouts, and match the shipped SDKs on the wire
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
expires_at, in the transaction and certification wire formatsexpires_atwritten as CBOR null when the caller did not supply oneOption<u64>instead of pairing every constructor with a*_with_timeouttwinInclusionProoftotal, mirroring js#151 and java#84Cross-compatibility corrections
The cross-SDK fixture this crate tests against had been regenerated from an
unreleased intermediate state of the TypeScript SDK rather than from a tag. The
fixture and the code drifted together, so the whole suite passed while the crate
encoded two structures no other SDK could read.
Tokenwas still at wire version 1. The TypeScript and Java SDKs moved it to 2in 3.0.0 alongside
MintTransaction,TransferTransactionandCertificationData, andToken.fromCBORrejects a version-1 token outright.Certified transactions carried three elements,
[transaction, referenceTime, inclusionProof]. Both other SDKs encode two and read the reference time off theproof, which is the only copy consensus certified. The separate slot could only
agree with the proof or be wrong, and both decoders here held a guard checking
exactly that. The slot is gone and
reference_time()reads the proof.tests/vectors/transition_flow.jsonis regenerated fromstate-transition-sdk-jsv3.0.1, and
tests/vectors/now carries the generator and instructions forpinning it to a tag.
Inclusion proof
InclusionProofrequires every field; the absence of a certified leaf belongs tothe aggregator's answer.
InclusionProofResponseowns the wire's two shapes:That removes the four
Optionfields, the casts on the reference time, and theabsence branches at the top of both verification rules.
VerificationError::InclusionCertificateMissingandCertificationDataMissingare gone because neither can occur.
Wire format
One version and one element count per structure.
expires_atoccupies a fixedposition and is
uint | null, using theencode_nullable/Decoder::nullablehelpers that already encode
dataandjustificationin these same arrays.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
Rust has no overloading and no default arguments, and
Optionis how it spellsoptional. Taking
Option<u64>as a trailing parameter deletescreate_with_timeout,new_with_timeout,mint_with_timeout,transfer_with_timeout,split_with_timeout,split_unchecked_with_timeout,and the
mint_impl/transfer_implbodies that existed only to hold the sharedcode. It also removes the post-construction field mutation in
create_with_timeout, which assigned the field aftercreatehad alreadyderived the token id, lock script and mint state.
Validation
cargo fmt --all --check,cargo clippy --all-targets --all-features -- -D warningscargo test(113 + 10),cargo test --all-features(122 + 19 + 10),cargo test --no-default-features --features alloc(80 + 10)cargo build --no-default-features --features alloc --target wasm32-unknown-unknownThe
httptransport tests are gated behind a non-default feature, socargo testnever compiled them and CI never ran them. CI now runs
cargo test --all-features.Cross-implementation agreement checked against shipped artifacts:
state-transition-sdk-js3.0.1,state-transition-sdk-java3.0.1,aggregator-goand the rugregator, and matches an independent recomputationCertificationDatavectors for an explicit and an absent deadline are byte-identical across this crate, the TypeScript SDK and the Java SDKstate-transition-sdk-jsv3.0.1 decode, round-trip byte for byte, and verify hereCrossSdkEncodingTestandLeafValueTestpass against the same vectorsRefs #16
Refs #17