Skip to content

Bind service time and enforce request timeouts - #83

Merged
MastaP merged 18 commits into
mainfrom
service-time
Aug 27, 2026
Merged

Bind service time and enforce request timeouts#83
MastaP merged 18 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, expiresAt, in the transaction and certification wire formats
  • keep the deadline optional: one wire shape per structure, with expiresAt written as CBOR null when the caller did not supply one
  • replace the 18 MintTransaction.create overloads with a builder

Wire format

One version and one element count per structure. expiresAt occupies a fixed
position and is uint | null, which is how data and justification are
already encoded 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]

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.
CertificationData also regains the fixed-length decode check it had given up to
accommodate 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 zero long: zero is a legal instant, so the
sentinel could not express "no deadline". The accessor is Optional<Long> getExpiresAt(), matching Optional<byte[]> getData() on the same interface.

The deadline was the fifth optional parameter on MintTransaction.create, and
adding it had doubled an already combinatorial overload set from 9 to 18. A
builder takes the required arguments and names the rest:

MintTransaction.builder(networkId, recipient)
    .tokenType(tokenType)
    .salt(salt)
    .data(data)
    .expiresAt(1755000000L)
    .build();

Validation

  • ./gradlew build (138 tests, checkstyle)

Cross-implementation: CrossSdkEncodingTest pins the encoding for both a present
and an absent deadline against the bytes the TypeScript SDK produces. The Rust
SDK's transition_flow vectors and rugregator's golden request vector assert on
the same bytes.

Refs #81
Refs #82

ristik added 4 commits August 20, 2026 11:26
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
ristik added 2 commits August 20, 2026 21:42
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.
@ristik
ristik requested a review from MastaP August 24, 2026 12:02
ristik and others added 3 commits August 25, 2026 13:20
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.
MastaP and others added 9 commits August 27, 2026 12:02
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
MastaP merged commit b193383 into main Aug 27, 2026
1 check passed
@MastaP
MastaP deleted the service-time branch August 27, 2026 14:35
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.

2 participants