Skip to content

Bring the wire format and API to parity with TypeScript SDK 3.0.1 - #84

Merged
MastaP merged 10 commits into
service-timefrom
review/83-js-147-parity
Aug 27, 2026
Merged

Bring the wire format and API to parity with TypeScript SDK 3.0.1#84
MastaP merged 10 commits into
service-timefrom
review/83-js-147-parity

Conversation

@MastaP

@MastaP MastaP commented Aug 27, 2026

Copy link
Copy Markdown
Member

Follow-up to #83, targeting service-time so it can be folded in before that PR merges.

#83 mirrors the TypeScript SDK's #146. The TypeScript side then took two review rounds — #147, which shipped as 3.0.0, and #150/#151, which shipped as 3.0.1. This brings the Java SDK to the 3.0.1 shape, so the two match in structure and not only on the wire.

The interop test pins the published 3.0.1, so what it proves is agreement with the current release.

Beyond the 3.0.0 parity work

  • A transfer decodes from its source, not the whole token. TransferTransaction.fromCbor and CertifiedTransferTransaction.fromCbor take the state being spent and the lock script over it; both are checked against the certification data during verification, so a wrong value fails there. That removed 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 response cannot contradict its own proof. The public three-argument constructor let a caller 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. Private constructor, plus certified() and notCertified().
  • The wire's two shapes live in the response. decodeOrAbsent and encodeNoCertifiedLeaf were on InclusionProof, the type that cannot represent an absent leaf. InclusionProofResponse decodes the tagged structure itself and builds the proof from the parts; InclusionProof.fromCbor is self-contained and rejects anything but a leaf.

What already agreed, and what did not

The two SDKs agree on everything sent to the aggregator. I verified this rather than assuming it — the CertificationData golden vectors are byte-identical in both repos, for the explicit and the absent deadline:

d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da…

MintTransaction v2 (8 fields), TransferTransaction v2 (5 fields), InclusionProof (5 elements, version 1, all-or-none leaf invariant) and the leaf value SHA256(CBOR([txhash, referenceTime])) all matched line for line too.

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 changes

before after
Token.VERSION 1 2
CertifiedMintTransaction 3 elements 2
CertifiedTransferTransaction 3 elements 2

The middle element was a copy of the leaf's reference time, stored beside a proof that already carries it. The service records that time on the record and serves the same value for every proof of the leaf, so the copy could never legitimately differ — it cost a wire element and a consistency check that could only ever agree.

Verification changes

InclusionProof used to carry both answers the aggregator can give — a certified leaf, and the absence of one — so every field was nullable and every consumer re-established which case it held. That is now split:

class InclusionProof {                       // a certified leaf; nothing else it can be
  CertificationData getCertificationData()   // was Optional
  long getReferenceTime()                    // was Optional<Long>
  InclusionCertificate getInclusionCertificate()
}

class InclusionProofResponse {               // what the aggregator answers
  InclusionProof getInclusionProof()         // null = not certified yet
  UnicityCertificate getUnicityCertificate() // present either way
}

The wire form expresses both shapes, so decoding it stays where the layout lives: decodeOrAbsent returns the proof or null and rejects any partial combination; fromCbor refuses anything but a leaf.

What falls out: the whole status taxonomy for absent fields is goneMISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME, INCOMPLETE_INCLUSION_PROOF, INCLUSION_CERTIFICATE_MISSING — because none of them can occur. Both certified-transaction decoders drop their guards. The poll loop branches on the response having no proof rather than on a status that meant the same thing, and its switch collapses to an if. There is no null handling left anywhere in the verification path.

The wire bytes do not change, and the interop test proves it rather than asserting it: a token minted by the published TypeScript SDK, which does not have this split, still decodes and verifies here unchanged.

The remaining behaviour changes:

One thing with no TypeScript counterpart

Both deadline comparisons are unsigned. TypeScript's bigint does not wrap; a Java long does. CborDeserializer.CborUnsignedLong.asLong says so in its own javadoc: a CBOR unsigned integer at or above 2^63 comes back as a negative long. A signed comparison would read such a reference time as earlier than every deadline and wave an expired request straight through — while the leaf value, computed from the same bits, still verified. Both REQUEST_EXPIRED and the round bound now use Long.compareUnsigned.

This was found by review, not by a failing test, and it would have shipped otherwise.

Interop against the published TypeScript SDK

A node container runs @unicitylabs/state-transition-sdk@3.0.0 from npm against the aggregator this suite already starts, mints and transfers a token, verifies it with its own SDK, and prints it. Java decodes and verifies the result.

The argument for it, concretely: revert Token.VERSION to 1 — the state this branch starts from — and CrossSdkEncodingTest passes 2/2 while the interop test fails. The existing golden vectors are structurally incapable of seeing a container divergence, because CertificationData bytes never move. That is exactly how this divergence survived until now.

Three properties worth noting:

  • it exercises the artifact a consumer installs, not the TypeScript repo's source tree
  • the token is certified by a real aggregator, so real signatures and certificates
  • nothing is committed — no vector to go stale, no question of who regenerates it, and no companion PR in the TypeScript repo

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 rather than "did not start correctly" with empty logs. The container joins the stack's network and addresses the aggregator by service name, so it needs no published port and no host-gateway assumption.

Integration tests

AggregatorStack starts the compose stack from the TypeScript SDK's own file — BFT root node, mongodb, redis, pinned aggregator build — waits for consensus to certify a round rather than for the healthcheck, and tears it down. RequestDeadlineIntegrationTest mirrors the TypeScript cases.

Tagged integration, so the existing integrationTest task picks it up and test keeps excluding it. No build change was needed for that.

RequestDeadlineIntegrationTest  tests=8 failures=0 skipped=0  time=13.3s
  PASS  acceptsADeadlineAheadOfTheRoundReferenceTime
  PASS  acceptsARequestThatLeavesTheDeadlineToTheService
  PASS  rejectsADeadlineThatHasAlreadyPassed
  PASS  rejectsADeadlineEqualToAReferenceTimeAlreadyReached
  PASS  bindsAServiceAssignedDeadlineWithoutRecordingIt
  PASS  servesBackTheExplicitDeadlineTheTransactionHashCommitsTo
  PASS  reportsAReferenceTimeNoLaterThanTheRoundThatCertifiedIt
  PASS  reportsALeaflessProofForARequestThatWasNeverSubmitted
JsSdkInteropIntegrationTest     tests=1 failures=0 skipped=0
  PASS  verifiesATokenMintedByThePublishedTypeScriptSdk

No containers and no generated genesis left behind afterwards.

Testcontainers 1.19.8 → 2.0.5

This is a fix, not housekeeping. The pinned 1.19.8 cannot reach a current Docker daemon at all: docker-java 3.4.x negotiates API 1.32, Docker 29 requires 1.44 and refuses, so every Testcontainers test in this repo failed before starting a container. 2.0.5 carries docker-java 3.7.1 and connects. Bumping within 1.x does not help — I tried 1.21.3 first.

junit-jupiter and mongodb are dropped: declared but never used — nothing here imports @Testcontainers, @Container or MongoDBContainer — and 2.x does not publish them. Only core is needed, for ComposeContainer and Wait.

One behavioural note for CI: 2.x removed containerised compose, so ComposeContainer shells out to the docker CLI. Present on any GitHub runner; worth knowing if the build ever moves into a container that lacks it.

Verification changes

InclusionProof used to carry both answers the aggregator can give — a certified leaf, and the absence of one — so every field was nullable and every consumer re-established which case it held. That is now split:

class InclusionProof {                       // a certified leaf; nothing else it can be
  CertificationData getCertificationData()   // was Optional
  long getReferenceTime()                    // was Optional<Long>
  InclusionCertificate getInclusionCertificate()
}

class InclusionProofResponse {               // what the aggregator answers
  InclusionProof getInclusionProof()         // null = not certified yet
  UnicityCertificate getUnicityCertificate() // present either way
}

The wire form expresses both shapes, so decoding it stays where the layout lives: decodeOrAbsent returns the proof or null and rejects any partial combination; fromCbor refuses anything but a leaf.

What falls out: the whole status taxonomy for absent fields is goneMISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME, INCOMPLETE_INCLUSION_PROOF, INCLUSION_CERTIFICATE_MISSING — because none of them can occur. Both certified-transaction decoders drop their guards. The poll loop branches on the response having no proof rather than on a status that meant the same thing, and its switch collapses to an if. There is no null handling left anywhere in the verification path.

The wire bytes do not change, and the interop test proves it rather than asserting it: a token minted by the published TypeScript SDK, which does not have this split, still decodes and verifies here unchanged.

The remaining behaviour changes:

One thing with no TypeScript counterpart

Both deadline comparisons are unsigned. TypeScript's bigint does not wrap; a Java long does. CborDeserializer.CborUnsignedLong.asLong says so in its own javadoc: a CBOR unsigned integer at or above 2^63 comes back as a negative long. A signed comparison would read such a reference time as earlier than every deadline and wave an expired request straight through — while the leaf value, computed from the same bits, still verified. Both REQUEST_EXPIRED and the round bound now use Long.compareUnsigned.

This was found by review, not by a failing test, and it would have shipped otherwise.

Interop tests — the part that would have caught this

Each SDK builds a token from entirely fixed inputs (keys, salt, token type, state mask, deadline, round clock; RFC 6979 signing on both sides), commits it, and decodes and fully verifies the other's.

The argument for them, concretely: revert Token.VERSION to 1 — the state this branch starts from —

CrossSdkEncodingTest:  0 failures / 2     ← existing cross-SDK test, blind to it
InteropVectorTest:     1 failure  / 2
JsProducedTokenTest:   1 failure  / 1

The existing golden vectors are structurally incapable of seeing a container divergence, because CertificationData bytes never move.

The two SDKs' UnicityCertificate test fixtures differ in three padding fields, so a shared token hex vector is impossible. 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.

The TypeScript half is unicitynetwork/state-transition-sdk-js#149.

Integration tests — unverified, please read

AggregatorStack starts the compose stack from the TypeScript SDK's own file — BFT root node, mongodb, redis, pinned aggregator build — waits for consensus to certify a round rather than for the healthcheck, and tears it down. RequestDeadlineIntegrationTest mirrors the TypeScript cases.

Tagged integration, so the existing integrationTest task picks it up and test keeps excluding it. No build change was needed.

These 8 tests have not been observed to pass. They compile, and I confirmed test excludes them, but the machine they were written on runs Docker 29, whose minimum API version (1.44) docker-java does not meet — it negotiates 1.32. I tried bumping Testcontainers to 1.21.3 and it did not help, so I reverted that rather than ship a change that fixes nothing. Please run ./gradlew integrationTest on a normal Docker host before trusting them.

Worth knowing separately: Testcontainers 1.19.8 as pinned here cannot talk to Docker 29 at all, so anyone on a current daemon cannot run integration tests in this repo regardless of this change.

Verification

./gradlew build142 tests, 0 failures (the branch base had 140; the split removed cases that are now unrepresentable and added decode-boundary ones), checkstyle clean. Run in a gradle:8.5-jdk21 container; there is no JDK on the machine this was written on.

./gradlew integrationTest9 tests, 0 failures, 0 skips: the 8 deadline cases plus the interop mint, all against the real aggregator the stack starts.

Both token shapes carry the 3.0.0 container prefix d99880 83 02 82 d99881 88 02 03.

MastaP added 2 commits August 27, 2026 11:14
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.
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.
@MastaP
MastaP marked this pull request as ready for review August 27, 2026 10:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47260daae6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/test/java/org/unicitylabs/sdk/integration/AggregatorStack.java Outdated
MastaP added 5 commits August 27, 2026 13:09
… 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.
@MastaP MastaP changed the title Bring the wire format to parity with TypeScript SDK 3.0.0 Bring the wire format and API to parity with TypeScript SDK 3.0.1 Aug 27, 2026
MastaP added 2 commits August 27, 2026 16:15
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.
@MastaP
MastaP merged commit 5f96ee9 into service-time Aug 27, 2026
1 check passed
@MastaP
MastaP deleted the review/83-js-147-parity branch August 27, 2026 14:29
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