Skip to content

canton: replay protection as a per-consumer digest trie#38

Merged
kcsongor merged 3 commits into
integration/cantonfrom
cs/canton-replay-trie
Jul 10, 2026
Merged

canton: replay protection as a per-consumer digest trie#38
kcsongor merged 3 commits into
integration/cantonfrom
cs/canton-replay-trie

Conversation

@kcsongor

@kcsongor kcsongor commented Jul 9, 2026

Copy link
Copy Markdown

The problem

The previous replay protection (#35) recorded each consumed VAA as a ConsumedVAA contract keyed (consumer, digest), with a lookupByKey-then-create inside VerifyAndConsumeVAA. 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 ReplayNode covers a hex prefix and 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. At splitThreshold (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.

  • Sound for any submitter. "Not consumed" is now a positive statement about one named contract: fetch the covering node, check absence, consume it. Every confirmer validates the node's activeness — a stale cid conflicts and aborts; the current covering node provably contains or excludes the digest. A wrong or stale node can only fail the transaction, never replay. Where the key design failed open, the trie fails closed: an archived subspace has no covering node and nothing verifies. The reference integrator is accordingly back to a direct, user-submitted redeem — the user attaches two disclosures (CoreState + covering node) and nothing else; no read grants, no relayer queue. The old footgun test is inverted: arbitrary-submitter replay attempts now fail on activeness or membership, pinned in testIntegratorRedeem.
  • No archive-and-replay, no shadow roots. Archival alone only bricks; the replay vector is node re-creation. Nodes are co-signed by the operator, so neither party can archive or create one alone (splits inherit both signatures from the archived parent — the operator is not involved per consume). Roots are granted exactly once per consumer through the operator's ReplayRootRegistry — a positive, on-ledger one-root-per-consumer check, via a one-time propose-accept ceremony mirroring emitter registration. VerifyAndConsumeVAA additionally binds the node to the (consumer, operator) pairing, rejecting tries co-signed by an accomplice "operator".
  • Cost and contention. Suffixes are ≤64-char hex (~66 B), so nodes top out ~8.5 KB at the default threshold, an average consume rewrites ~4 KB, and a split redistributes the same bytes across 16 creates (~15 KB). Total ACS bytes stay ~66 B per digest. Consumes racing on the same node conflict and exactly one commits — validator-checked, regardless of submitter; the loser re-resolves the covering node and retries, and a blind retry of a consume that actually committed fails cleanly on membership. Until the first split an app's consumes serialize through its root; 16-way fanout begins after splitThreshold consumes.

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 (consumedGovernance in CoreState) 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

@kcsongor
kcsongor requested a review from evan-gray as a code owner July 9, 2026 21:49

@bengtlofgren bengtlofgren 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.

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

@kcsongor
kcsongor merged commit ba90531 into integration/canton Jul 10, 2026
30 of 34 checks passed
@kcsongor
kcsongor deleted the cs/canton-replay-trie branch July 10, 2026 12:15
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