Skip to content

Bind service time and enforce request timeouts - #83

Open
ristik wants to merge 7 commits into
mainfrom
service-time
Open

Bind service time and enforce request timeouts#83
ristik wants to merge 7 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
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