canton: replay protection as a per-consumer digest trie#38
Merged
Conversation
bengtlofgren
approved these changes
Jul 10, 2026
bengtlofgren
left a comment
There was a problem hiding this comment.
I like the lazy splitting a lot. Obv 128 arbitrary, maybe we should measure this somehow first because i dont know how much a set grows the size of the contract, and furthermore the size of the UTXO. But logic looks good to me
This was referenced Jul 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The previous replay protection (#35) recorded each consumed VAA as a
ConsumedVAAcontract keyed(consumer, digest), with alookupByKey-then-create insideVerifyAndConsumeVAA. That design does not work, because Canton LF 2.3 contract keys are weaker than the construction needs.Key lookups are resolved once, on the submitting participant, against the submission's readers (local contracts, then disclosed ones, then the readers' view), and the result is a pinned input to validation. Confirming participants re-check authorization, conformance, and the activeness of every contract a transaction uses — but they never re-run key lookups against their own stores: with non-unique keys and per-party visibility there is no canonical answer, and local re-checks would make validation nondeterministic and leak private state. So a positive resolution is safe (it names a cid that is consistency-checked like any input), while a negative one names nothing and fails open.
Replay protection is precisely a negative claim — "this digest was never consumed." Built on a key lookup, it was only sound when the consuming submission carried the consumer's read view, which we could document and pin in tests but not enforce: a submission carrying the consumer's inherited authority without its visibility would miss existing guards and silently double-consume. That is operational discipline standing in for a ledger guarantee, and for a bridge it is not good enough. This PR removes contract keys from replay protection entirely (first step of removing them from the repo).
The solution
Each consumer's consumed-digest set becomes a prefix trie of leaf contracts. A
ReplayNodecovers a hexprefixand stores the set of digest suffixes consumed under it; a trie starts as one root (empty prefix, empty set). Consuming checks the node covers the digest, rejects if the suffix is present, and recreates the node with it inserted. AtsplitThreshold(default 128) the consume instead splits the node into 16 children — one per nibble, empty ones included — bucketing the suffixes by their first nibble, which is dropped. Internal nodes are never stored.The invariant: a consumer's active nodes partition the digest space — every digest has exactly one covering node — so membership in that node is global consumption truth.
testIntegratorRedeem.ReplayRootRegistry— a positive, on-ledger one-root-per-consumer check, via a one-time propose-accept ceremony mirroring emitter registration.VerifyAndConsumeVAAadditionally binds the node to the (consumer, operator) pairing, rejecting tries co-signed by an accomplice "operator".splitThresholdconsumes.The disclosure service
Submitters locate the covering node off-ledger: a service indexes the consumer's active nodes by prefix (following the
CreatedEvents that consumes and splits produce) and serves the covering node's cid plus its explicit disclosure, alongside the CoreState disclosure it already serves. It is untrusted for safety — the worst it can do is serve a wrong or stale node, which fails the transaction — and trusted for liveness only, like any RPC endpoint. The Go implementation is future work; the Daml tests simulate it with a script helper that also asserts the partition invariant on every use.Caveat
Governance replay protection (
consumedGovernanceinCoreState) is unchanged — bounded by governance cadence and already churning the singleton. Removing keys from the remaining templates (CoreState,Emitter,EmitterRegistry) is follow-up work.🤖 Generated with Claude Code