Skip to content

Bind service time, enforce request timeouts, and match the shipped SDKs on the wire - #18

Merged
ristik merged 12 commits into
mainfrom
service-time
Aug 31, 2026
Merged

Bind service time, enforce request timeouts, and match the shipped SDKs on the wire#18
ristik merged 12 commits into
mainfrom
service-time

Conversation

@ristik

@ristik ristik commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

  • verify the reference-time-bound leaf value SHA-256(CBOR([txhash, tau]))
  • carry the fixed reference time in inclusion proofs and certified transactions
  • carry an exclusive request deadline, expires_at, in the transaction and certification wire formats
  • keep the deadline optional: one wire shape per structure, with expires_at written as CBOR null when the caller did not supply one
  • take Option<u64> instead of pairing every constructor with a *_with_timeout twin
  • correct two wire divergences that made this crate unreadable by the shipped SDKs
  • make InclusionProof total, mirroring js#151 and java#84
  • release as 3.0.1

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

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.

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.

tests/vectors/transition_flow.json is regenerated from state-transition-sdk-js
v3.0.1, and tests/vectors/ now carries the generator and instructions for
pinning it to a tag.

Inclusion proof

InclusionProof requires every field; the absence of a certified leaf belongs to
the aggregator's answer. InclusionProofResponse owns the wire's two shapes:

pub enum InclusionProofResponse {
    Certified { block_number: u64, proof: InclusionProof },
    NotCertified { block_number: u64, unicity_certificate: UnicityCertificate },
}

That removes the four Option fields, the casts on the reference time, and the
absence branches at the top of both verification rules.
VerificationError::InclusionCertificateMissing and CertificationDataMissing
are gone because neither can occur.

Wire format

One version and one element count per structure. expires_at occupies a fixed
position and is uint | null, using the encode_nullable / Decoder::nullable
helpers that already encode data and justification in these same arrays.

MintTransaction      [2, networkId, recipient, salt, tokenType, justification, data, expiresAt]
TransferTransaction  [2, recipient, stateMask, data, expiresAt]
CertificationData    [2, lockScript, sourceStateHash, transactionHash, expiresAt, witness]
Token                [2, genesis, transfers]
CertifiedTransaction [transaction, inclusionProof]

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 Option is how it spells
optional. Taking Option<u64> as a trailing parameter 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.

Validation

  • cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings
  • cargo 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-unknown

The http transport tests are gated behind a non-default feature, so cargo test
never compiled them and CI never ran them. CI now runs cargo test --all-features.

Cross-implementation agreement checked against shipped artifacts:

  • the leaf value vector is byte-identical across this crate, state-transition-sdk-js 3.0.1, state-transition-sdk-java 3.0.1, aggregator-go and the rugregator, and matches an independent recomputation
  • the CertificationData vectors 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-js v3.0.1 decode, round-trip byte for byte, and verify here
  • Java's CrossSdkEncodingTest and LeafValueTest pass against the same vectors

Refs #16
Refs #17

ristik added 3 commits August 20, 2026 13:04
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
@ristik ristik changed the title Service time Bind service time and enforce request timeouts Aug 20, 2026
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
ristik added 3 commits August 20, 2026 21:49
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.
ristik added 4 commits August 21, 2026 00:08
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.
@ristik ristik changed the title Bind service time and enforce request timeouts Bind service time, enforce request timeouts, and match the shipped SDKs on the wire Aug 31, 2026
@ristik
ristik merged commit 635011b into main Aug 31, 2026
2 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant