Split the inclusion proof response from the proof itself - #151
Conversation
There was a problem hiding this comment.
💡 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".
| return CborSerializer.encodeArray( | ||
| CborSerializer.encodeUnsignedInteger(this.blockNumber), | ||
| this.inclusionProof.toCBOR(), | ||
| this.inclusionProof?.toCBOR() ?? encodeNoCertifiedLeaf(this.unicityCertificate), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 fromNeither 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.
| * @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): { |
There was a problem hiding this comment.
Move into response and make inclusion proof take response as input for creation.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
3a3d06f to
cde4e97
Compare
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.
InclusionProofcarried 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:
InclusionProofVerificationRule, before any verifying beganMISSING_CERTIFICATION_DATA,MISSING_REFERENCE_TIME,INCOMPLETE_INCLUSION_PROOF,INCLUSION_CERTIFICATE_MISSINGas bigintcasts onreferenceTimein both certified transactions?.oncertificationDatain the mint verification ruleThe split
The absence belongs to the response, not to the proof.
The wire form expresses both shapes, so decoding it stays where the layout lives:
decodeInclusionProofOrAbsencereturns the proof or the absence and rejects any partial combination;InclusionProof.fromCBORrefuses anything but a leaf.What falls out
All four statuses are gone — none of them can occur.
waitInclusionProofpolls 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:checkandlintclean. 185 tests pass.