Consensus time becomes part of the protocol here too, and this is the first release of the Rust SDK that interoperates with the shipped TypeScript and Java SDKs.
The version 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, so a version tells you which SDKs a Rust client interoperates with. It is 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.
Requires an aggregator at ghcr.io/unicitynetwork/aggregator-go:sha-ae08165 or later. Tokens produced by earlier builds of this crate cannot be read, and neither can tokens minted by 2.x-era services.
It now interoperates, which it did not before
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 every test passed while the crate encoded two structures no other SDK could read.
Token was still at wire version 1. The TypeScript and Java SDKs moved it to 2 in 3.0.0, and Token.fromCBOR rejects a version-1 token outright.
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.
Both are corrected. The fixture is regenerated from state-transition-sdk-js v3.0.1 and the cross-SDK test decodes it, round-trips it byte for byte, and verifies it. tests/vectors/ now carries the generator and instructions for pinning it to a tag, because regenerating by hand from whatever the reference SDK happened to be is how this drifted.
Request deadlines
A request carries an exclusive deadline in Unix seconds. The service admits it only to a round whose reference time is strictly below that deadline:
let token = client::mint(
&aggregator, &trust_base, network_id, &recipient, token_type, salt,
/* data */ None, /* justification */ None,
/* expires_at */ Some(now + 3600),
)?;The deadline exists because submission and commitment are separated in time. A request that sits queued while conditions change should expire rather than execute late. Submitting past the deadline is answered with REQUEST_EXPIRED.
Pass None and the service derives a deadline from consensus time instead. That is the branch for a caller with no trustworthy clock: the assigned value governs admission but is never recorded in the leaf, never alters the transaction hash, and is never re-checked by a later verifier. An explicit deadline is the opposite: the transaction hash commits to it, so it travels with the token and every verifier re-checks it.
Both the deadline and a round's reference time are wall-clock instants in Unix seconds, not round numbers or block heights, and both are consensus time rather than any caller's clock. Leave margin for the difference and for time spent queued. Hour-scale deadlines are unaffected, second-scale ones are not.
There are no *_with_timeout constructors. Rust has no overloading and no default arguments, and Option is how it spells optional, so the deadline is a trailing parameter on the one constructor. That removed create_with_timeout, new_with_timeout, mint_with_timeout, transfer_with_timeout, split_with_timeout and split_unchecked_with_timeout, along with the post-construction field mutation in create_with_timeout that assigned the deadline after create had already derived the token id, lock script and mint state.
What a deadline does and does not guarantee
Admission is enforced by the aggregator when it accepts the request. A later verifier confirms that the leaf's recorded reference time is internally consistent and precedes the deadline, but it cannot establish when the leaf was actually created. The reference time is chosen by the aggregator, and the inclusion proof authenticates the value it chose rather than the moment it chose it. An aggregator that accepted a request after its deadline and recorded an earlier reference time produces a proof that verifies.
So expires_at is an instruction to an honest service, and the guarantee that a late request is dropped rather than executed rests on the same consensus that secures the aggregator. Verification does reject a leaf claiming to postdate the round that certified it, which is an impossible pairing, but that bound is one-sided and does not cover back-dating. Tracked as unicitynetwork/aggregator-go#186.
Wire changes
The certified leaf value binds the reference time the request was validated under:
v = SHA-256( CBOR([ transactionHash, referenceTime ]) )
rather than the transaction hash alone, so a 3.0 client cannot verify proofs from an older service and vice versa. This value is pinned by a shared test vector that the Go, Java, TypeScript and Rust implementations and the rugregator all assert on.
| Structure | earlier | 3.0.1 |
|---|---|---|
Token |
1 | 2 |
MintTransaction |
1 | 2 |
TransferTransaction |
1 | 2 |
CertificationData |
1 | 2 |
CertificationRequest |
1 | 1 (unchanged) |
InclusionProof |
1 | 1 (unchanged) |
expiresAt occupies a fixed position in MintTransaction, TransferTransaction and CertificationData, holding uint | null, so the element count never depends on whether a deadline was supplied and the array length is checked once.
An inclusion proof describes a certified leaf, and nothing else
InclusionProof requires every field. certification_data, reference_time and inclusion_certificate are no longer Option. The aggregator's answer for a state it has not certified yet is not a proof at all, and InclusionProofResponse carries that case:
pub enum InclusionProofResponse {
Certified { block_number: u64, proof: InclusionProof },
NotCertified { block_number: u64, unicity_certificate: UnicityCertificate },
}The response 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 removed the four Option fields, the casts on the reference time, and the absence branches at the top of both verification rules. VerificationError::InclusionCertificateMissing and VerificationError::CertificationDataMissing are gone, because neither can occur.
AggregatorClient::get_inclusion_proof still returns an InclusionProof rather than the response. The polling contract already guarantees a certified leaf, and an implementor signals "not yet" through its own error type. Decode an aggregator's raw answer with InclusionProofResponse::from_cbor.
Testing
cargo test never compiled the http transport tests, because they are gated behind a non-default feature and CI ran only the default and no_std configurations. They had gone stale unnoticed. CI now runs cargo test --all-features as well, and the suite is 122 unit tests, 19 transport tests and 10 cross-SDK tests.
Cross-implementation agreement was checked against the shipped artifacts rather than against this crate's own fixtures:
- the leaf value vector is byte-identical across this crate,
state-transition-sdk-js3.0.1,state-transition-sdk-java3.0.1,aggregator-goand the rugregator, and matches an independent recomputation; - the
CertificationDatavectors for an explicit and an absent deadline are byte-identical across this crate, the TypeScript SDK and the Java SDK; - the certification-request envelope is byte-identical to the rugregator's golden request;
- tokens minted and transferred by
state-transition-sdk-jsv3.0.1 decode, round-trip byte for byte, and verify here.
Breaking against 0.1.x
| Change | What breaks |
|---|---|
Token wire version 1 to 2 |
existing tokens do not load and have to be re-minted |
| certified transactions are two elements | tokens encoded by 0.1.x do not load |
InclusionProof fields no longer Option |
pattern matches and constructors on the proof |
InclusionProofResponse |
new type; decode aggregator answers through it |
VerificationError loses two variants |
matches on InclusionCertificateMissing / CertificationDataMissing |
*_with_timeout constructors removed |
pass a trailing Option<u64> to the base constructor |
CertifiedMintTransaction::reference_time() |
reads the proof rather than a stored field; value unchanged |