Skip to content

Split the inclusion proof response from the proof itself - #151

Merged
MastaP merged 4 commits into
mainfrom
inclusion-proof/split-dto-from-domain
Aug 27, 2026
Merged

MastaP merged 4 commits into
mainfrom
inclusion-proof/split-dto-from-domain

Conversation

@MastaP

@MastaP MastaP commented Aug 27, 2026

Copy link
Copy Markdown
Member

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 cost, before this change:

  • four absence branches at the top of InclusionProofVerificationRule, before any verifying began
  • a status taxonomy describing states a proof should never have been able to be in: MISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME, INCOMPLETE_INCLUSION_PROOF, INCLUSION_CERTIFICATE_MISSING
  • a guard in each certified-transaction decoder rejecting a proof with no leaf
  • as bigint casts on referenceTime in both certified transactions
  • ?. on certificationData in the mint verification rule

The split

The absence belongs to the response, not to the proof.

class InclusionProof {                    // a certified leaf; there is nothing else it can be
  certificationData: CertificationData    // was | null
  referenceTime: bigint                   // was | null
  inclusionCertificate: InclusionCertificate  // was | null
  unicityCertificate: UnicityCertificate
}

class InclusionProofResponse {            // what the aggregator answers
  blockNumber: bigint
  inclusionProof: InclusionProof | null   // null = not certified yet
  unicityCertificate: UnicityCertificate
}

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

What falls out

All four statuses are gone — none of them can occur. waitInclusionProof polls on the response having no proof rather than on a status that meant the same thing. Both certified-transaction decoders drop their guards, and their reference-time getters drop the casts.

Net 126 lines removed (−325/+199). The only nullability left in the whole area is inside the wire decoder, which is where the wire's two shapes actually are.

Two tests changed shape rather than being deleted, because what they asserted became unrepresentable: constructing a partial proof no longer compiles, so the partial-combination case is now exercised on encoded bytes at the decode boundary, which is the only place it can arise.

Also

The verification rule's comments are cut back to what the code doesn't already say — the back-dating argument lives in the README rather than being restated at the call site.

Verification

build, build:check and lint clean. 185 tests pass.

@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: 44a699fdff

ℹ️ 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/api/InclusionProofResponse.ts Outdated
return CborSerializer.encodeArray(
CborSerializer.encodeUnsignedInteger(this.blockNumber),
this.inclusionProof.toCBOR(),
this.inclusionProof?.toCBOR() ?? encodeNoCertifiedLeaf(this.unicityCertificate),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the response certificate consistent when serializing

When a caller constructs a response with a non-null proof and a different unicityCertificate, the public property reports the supplied response certificate, but toCBOR() silently serializes the certificate embedded in inclusionProof. Consequently, InclusionProofResponse.fromCBOR(response.toCBOR()).unicityCertificate changes value, potentially associating the response's block with the wrong round. Derive the certificate from the proof for certified responses or reject inconsistent constructor arguments.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in cde4e97 — the local review flagged the same thing, so it's addressed on the rebased branch.

Rather than validate the two certificates against each other, the second one is gone. The constructor is private and there are two named factories:

InclusionProofResponse.certified(blockNumber, proof)     // reads the certificate off the proof
InclusionProofResponse.notCertified(blockNumber, cert)   // no proof to read it from

Neither can express a disagreement, so the round trip is exact for both shapes. Call sites also now say which of the two answers they are building, instead of leaving it to whether an argument was null.

Comment thread src/api/InclusionProof.ts Outdated
* @returns {object} The proof, or `null` when no leaf is certified yet, and the unicity certificate.
* @throws {CborError} On a wrong tag, an unsupported version, or a partially present proof.
*/
export function decodeInclusionProofOrAbsence(bytes: Uint8Array): {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Move into response and make inclusion proof take response as input for creation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed the two exported free functions are the wrong shape — they live in InclusionProof.ts but exist only for the response, which is the leak you're pointing at.

I want to check which of two readings you mean before I move it, because they end up in different places.

(a) The response owns the whole tagged codec. decodeInclusionProofOrAbsence and encodeNoCertifiedLeaf move into InclusionProofResponse, and InclusionProof.fromCBOR goes with them — so InclusionProof has no knowledge of the wire's two shapes at all, only its own fields. The catch: CertifiedMintTransaction/CertifiedTransferTransaction decode an embedded proof, so they would import that decode from the response module.

(b) InclusionProof is constructed from the decoded response. The response does the tag/version/field decoding itself and hands the parts to InclusionProof; InclusionProof keeps toCBOR for embedding but has no decoder. Costs ~8 lines of tag-and-version handling duplicated between the two, and buys InclusionProof.ts knowing nothing about absence.

I read your comment as closer to (b) — "take response as input for creation" — but (a) is the one that removes more. Which did you have in mind? Happy to do either; I just don't want to guess at a large move and have you re-review the wrong one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, B as you called it — 65f1e1f.

InclusionProofResponse.fromCBOR now decodes the tagged structure itself, decides certified from not, and constructs the InclusionProof from the parts. It encodes the absent form itself too. InclusionProof.ts exports the class and nothing else, and mentions absence only to point at where it lives.

The tag and version check ends up in both decoders. I left it that way on purpose rather than sharing a helper: the only way to share it without the certified transactions importing their decoder from the response module — the dependency the wrong way round — would be another free function, which is the thing you were pointing at in the first place. Eight lines seemed the better trade.

185 tests pass; build, typecheck and lint clean.

MastaP added 2 commits August 27, 2026 14:41
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, `as bigint` casts 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 now requires certificationData, referenceTime and
  inclusionCertificate. A value of this type describes a certified leaf; there
  is no other thing it can be.
- InclusionProofResponse carries `InclusionProof | null` 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:
  decodeInclusionProofOrAbsence returns the proof or the absence and rejects any
  partial combination, and InclusionProof.fromCBOR refuses anything but a leaf.

What falls out: MISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME,
INCOMPLETE_INCLUSION_PROOF and INCLUSION_CERTIFICATE_MISSING are gone — none of
them can occur. waitInclusionProof polls on the response having no proof rather
than on a status meaning the same thing. Both certified transaction decoders
drop their guards, and their reference time getters drop the casts.

Net 126 lines removed. The only nullability left is inside the wire decoder,
which is where the wire's two shapes actually are.

The comments in the verification rule are cut back to what the code does not
already say; the back-dating argument lives in the README rather than being
restated at the call site.
Review of the split found that InclusionProofResponse could hold a
unicityCertificate contradicting the one inside its own inclusionProof, and
that toCBOR serialises the proof's — so the field was not preserved across a
round trip.

Rather than validate the two against each other, remove the second one. The
constructor is private and there are two named factories: certified() takes the
proof and reads the certificate off it, notCertified() takes the certificate
because there is no proof to read it from. Neither can express a disagreement,
and the round trip is exact for both shapes.

The factories also say at each call site which of the two answers is being
built, which the three-argument constructor left to whether an argument was
null.
@MastaP
MastaP force-pushed the inclusion-proof/split-dto-from-domain branch from 3a3d06f to cde4e97 Compare August 27, 2026 12:42
MastaP added 2 commits August 27, 2026 15:11
The split left InclusionProof.ts exporting two free functions that existed only
for the response — the absent-leaf shape decoded and encoded in the module of
the type that cannot represent it. That is the leak the split was meant to
close, one level up.

InclusionProofResponse now decodes the tagged structure itself, decides
certified from not, and constructs the InclusionProof from the parts. It encodes
the absent form itself too. InclusionProof.ts exports the class and nothing
else, and mentions absence only to say where it lives.

The tag and version check appears in both decoders now. That is deliberate: the
alternative was for the certified transactions, which decode an embedded proof,
to import their decoder from the response module — a dependency the wrong way
round for eight lines.
3.0.0 shipped a day ago and was not adopted, so its rough edges are corrected in
place rather than carried forward. The wire formats do not move — tokens and
proofs pass between the two versions — but the API does, so this is not a
drop-in patch despite the version. README lists what changed and 3.0.0 is
deprecated on npm to point at it.

Breaking against 3.0.0: TransferTransaction.fromCBOR and
CertifiedTransferTransaction.fromCBOR take the source rather than the token and
are synchronous; expiresAtFromCBOR is gone; InclusionProofResponse is built
through certified()/notCertified(); InclusionProof requires every field and its
accessors are no longer nullable; four InclusionProofVerificationStatus members
are removed because a complete-by-construction proof cannot reach them.
@MastaP
MastaP merged commit f5f0737 into main Aug 27, 2026
3 checks passed
@MastaP
MastaP deleted the inclusion-proof/split-dto-from-domain branch August 27, 2026 13:24
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