From 9f2fd94b2e0c2dd576c747feed80b8c839c7b718 Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 9 Jul 2026 23:05:01 +0200 Subject: [PATCH 1/3] canton: replay protection as a per-consumer digest trie; no contract keys --- canton/core/daml/Wormhole/Core/Bytes.daml | 9 + canton/core/daml/Wormhole/Core/Replay.daml | 225 +++++++++--- canton/core/daml/Wormhole/Core/Setup.daml | 8 + canton/core/daml/Wormhole/Core/State.daml | 69 ++-- canton/test/daml/Test/ExampleIntegrator.daml | 250 +++++--------- canton/test/daml/Test/TestCore.daml | 49 +-- canton/test/daml/Test/TestReplay.daml | 344 +++++++++++++++++++ 7 files changed, 672 insertions(+), 282 deletions(-) create mode 100644 canton/test/daml/Test/TestReplay.daml diff --git a/canton/core/daml/Wormhole/Core/Bytes.daml b/canton/core/daml/Wormhole/Core/Bytes.daml index 8afc787d65..6c9181ec54 100644 --- a/canton/core/daml/Wormhole/Core/Bytes.daml +++ b/canton/core/daml/Wormhole/Core/Bytes.daml @@ -50,6 +50,15 @@ hexDigitValue cp where [cp0, cp9, cpa, cpf] = T.toCodePoints "09af" +-- | True if every character is a lowercase hex digit (@0-9a-f@); true for the +-- empty string. The predicate counterpart of 'hexDigitValue' (which errors on +-- a bad digit and so cannot validate). +isLowerHex : Text -> Bool +isLowerHex t = all valid (T.toCodePoints t) + where + valid cp = (cp >= cp0 && cp <= cp9) || (cp >= cpa && cp <= cpf) + [cp0, cp9, cpa, cpf] = T.toCodePoints "09af" + -- | Parse a big-endian hex byte string into an 'Int'. Suitable for values that -- fit in 63 bits (chain ids, sequences, nonces, fees in practice). Callers that -- need uint256 semantics must bound-check upstream. diff --git a/canton/core/daml/Wormhole/Core/Replay.daml b/canton/core/daml/Wormhole/Core/Replay.daml index d7df00cb3d..695d57ad8e 100644 --- a/canton/core/daml/Wormhole/Core/Replay.daml +++ b/canton/core/daml/Wormhole/Core/Replay.daml @@ -1,55 +1,194 @@ --- | Per-VAA replay protection for integrators (token bridge / NTT). +-- | Per-consumer replay protection for integrators (token bridge / NTT), +-- stored as a PREFIX TRIE of consumed VAA digests — leaf contracts only. -- -- Governance replay protection lives in 'Wormhole.Core.State.CoreState' as a -- @Set@ of consumed digests — fine there, because governance actions are rare --- and already churn the singleton. App VAAs are different: recording them in a --- shared @Set@ would (a) reserialize an ever-growing structure into every --- create node and (b) serialize ALL integrators' consumes through one --- contract. Instead, each consumed VAA gets its own small contract — the --- Solana claim-account shape — so distinct VAAs never contend and the cost per --- consume is one indexed row, forever. +-- and already churn the singleton. App VAAs need a structure that scales and, +-- crucially, one whose absence check does NOT rely on contract keys: on +-- Daml-LF 2.3 key lookups resolve once, on the submitting participant, against +-- the submission's readers, and a negative result is a pinned validation input +-- no confirmer re-checks — negative lookups fail OPEN. This module uses no +-- keys at all. -- --- UNIQUENESS / TRUST MODEL: contract keys are non-unique on Canton (see --- 'Wormhole.Core.State'), and key lookups are NOT maintainer-attested — a --- lookup resolves against what the SUBMISSION'S READERS can see (local --- contracts, then disclosed ones, then the readers' view; pinned by --- Test.ExampleIntegrator). "At most one 'ConsumedVAA' per (consumer, hash)" --- therefore rests on three legs: --- * every create flows through the vetted --- 'Wormhole.Core.State.VerifyAndConsumeVAA' choice, which looks up before --- creating (behavioral, like the 'EmitterRegistry' id discipline); --- * every consuming submission carries the @consumer@'s read view — the --- only view guaranteed to contain all of its guards. Canonically the --- consumer's automation submits (propose-accept, see --- Test.ExampleIntegrator); @readAs consumer@ through its participant also --- works. A submission with only the consumer's inherited AUTHORITY but not --- its VISIBILITY misses existing guards and double-consumes; --- * the consumer is hosted on a single participant, which then serializes --- its consumes. --- The trust is aligned: guardians never re-check target-chain consumption, so --- replay protection is the consumer's own safety property, enforced on exactly --- the participant that bears the risk. The @operator@ co-signs (authority --- inherited from the 'CoreState' choice) so the consumer cannot unilaterally --- archive a guard and replay the VAA against its own users. +-- THE TRIE: a 'ReplayNode' holds a hex @prefix@ and the set of @consumed@ +-- digest SUFFIXES under that prefix (digests are 64 lowercase hex nibbles; a +-- node at depth d stores 64−d-nibble suffixes). A consumer's trie starts as a +-- single root (empty prefix, empty set). When a node reaches @splitThreshold@ +-- entries, the next consume splits it into 16 children — one per nibble, +-- including empty ones — bucketing the suffixes by first nibble (which is +-- dropped). Internal nodes are never stored. +-- +-- INVARIANT: the active nodes of one consumer's trie PARTITION the digest +-- space — every digest has exactly one covering node (a node whose prefix is a +-- prefix of the digest). The vetted operations preserve it: the root covers +-- everything, and a split replaces one node by 16 children that exactly tile +-- its subspace, atomically. +-- +-- WHY THIS IS SOUND WHERE KEYS WERE NOT: "digest not consumed" becomes a +-- positive statement about one NAMED contract — fetch the covering node and +-- check the suffix is absent. 'ConsumeDigest' is consuming, so every confirmer +-- validates the node's activeness: a stale cid conflicts and aborts, and the +-- current covering node provably contains or excludes the digest. Soundness is +-- SUBMITTER-INDEPENDENT: any submitter with a disclosure of the current +-- covering node gets a correct check; a wrong or stale node can only fail the +-- transaction, never replay. Archiving a node (both signatories) merely BRICKS +-- its subspace — no covering node, nothing verifies — fail-closed, the +-- opposite of the key model. +-- +-- The replay vector is therefore node RE-CREATION (archive-and-recreate, or a +-- parallel shadow root), which the signatory design closes: the @operator@ +-- co-signs every node, so the consumer alone can neither archive nor create +-- one, and roots are granted exactly once per consumer through the +-- 'ReplayRootRegistry' — a positive, on-ledger one-root-per-consumer check +-- linearized by consuming the registry (the 'EmitterRegistry' pattern, minus +-- the key). Splits inherit both signatures from the archived parent, so the +-- operator is not involved per consume. The irreducible remainder: consumer +-- and operator COLLUDING can archive and rebuild a trie — signatories can +-- always unmake their own contracts. +-- +-- OFF-LEDGER: consumers (and their users) locate the covering node for a +-- digest via a disclosure service — an ACS index over (consumer, prefix) that +-- serves the node's contract-id + explicit disclosure. It is untrusted for +-- safety: the worst it can do is serve a wrong or stale node, which fails. +-- RETRY PROTOCOL: racing consumes on the same node conflict and exactly one +-- commits; on rejection, re-resolve the covering node (the cid churns on every +-- consume) and resubmit. A blind retry of a consume that actually committed +-- hits the membership check and fails "VAA already consumed" — a clean +-- idempotency signal. module Wormhole.Core.Replay where +import DA.Map qualified as Map +import DA.Optional (fromOptional) +import DA.Set (Set) +import DA.Set qualified as Set +import DA.Text qualified as T import Wormhole.Core.Bytes --- | The replay guard: one active contract per consumed VAA, scoped to the --- consuming integrator party. Never archived — its permanence IS the replay --- protection. Keyed by @(consumer, vaaHash)@ where @vaaHash@ is the --- double-keccak digest ('Wormhole.Core.VAA.digestOf'), matching --- @consumedGovernance@ and the EVM @vm.hash@. -template ConsumedVAA +-- | Default node capacity. Suffixes are ≤64 hex chars (~66 B serialized), and +-- every consume rewrites the full node, so this bounds both the node payload +-- (~8.5 KB) and the per-consume write cost (~4 KB on average). The split +-- transaction redistributes the same bytes across 16 creates (~15 KB) — all +-- comfortably below Canton's request caps. Larger values thin the ACS but +-- amplify every write; the 'ensure' clause caps it at 4096 (~270 KB nodes). +defaultSplitThreshold : Int +defaultSplitThreshold = 128 + +-- | One trie node: the consumed digest suffixes under @prefix@. See the module +-- header for the partition invariant and the trust model. NOT observed by the +-- guardian observer party: replay state is integrator-private safety state, +-- not attestation surface — guardians never re-check target-chain consumption. +template ReplayNode with consumer : Party -- integrator scope party; bears the replay risk - operator : Party -- co-signs so the consumer cannot archive-and-replay - vaaHash : Bytes32 -- double-keccak digest of the VAA body - -- Emitter provenance, for ops/debugging only — never trusted, never keyed. - emitterChain : Int - emitterAddress : Bytes32 - sequence : Int + operator : Party -- co-signs: no unilateral archive or re-create + prefix : Bytes -- lowercase hex, 0..63 nibbles; "" at the root + splitThreshold : Int -- copied to children; see 'defaultSplitThreshold' + consumed : Set Bytes -- suffixes, each exactly (64 − |prefix|) nibbles where signatory consumer, operator - key (consumer, vaaHash) : (Party, Bytes32) - maintainer key._1 + -- Shape invariants only — 'ensure' is a bug-catcher, not a trust boundary + -- (the signatories can always sign a well-formed shadow node). Depth-64 + -- nodes are unrepresentable; suffix lengths pin the arithmetic that + -- membership depends on. Deliberately NO @size consumed <= splitThreshold@: + -- a split can legitimately create an over-full child (all parent entries + -- in one bucket); its next consume splits it further. + ensure splitThreshold >= 1 && splitThreshold <= 4096 + && T.length prefix <= 63 && isLowerHex prefix + && all (\s -> T.length s == 64 - T.length prefix && isLowerHex s) + (Set.toList consumed) + + -- | Record @digest@ as consumed, or fail if it already was. Returns the + -- node that now contains the digest — the recreated node, or, on a split, + -- the child covering it (the other 15 children surface to the disclosure + -- service as CreatedEvents). Consuming: racing consumes conflict on this + -- contract and exactly one commits (see the retry protocol in the module + -- header). To consume two digests under the same node in one transaction, + -- chain the second exercise on the returned cid. + choice ConsumeDigest : ContractId ReplayNode + with + digest : Bytes32 + controller consumer + do + -- Normalize before anything: membership is exact-text, so an + -- uppercase twin of a consumed digest must not slip past the set. + -- Validate independently of the caller — this choice is exercisable + -- outside 'VerifyAndConsumeVAA'. + let d = normalizeHex digest + assertMsg "digest must be 32 bytes of lowercase hex" + (T.length d == 64 && isLowerHex d) + -- Distinct message from the replay failure: callers re-resolve the + -- covering node on this one, drop the VAA on the other. + assertMsg "replay node does not cover digest" (prefix `T.isPrefixOf` d) + let suffix = T.drop (T.length prefix) d + -- The replay check MUST precede the split branch: a split inserting a + -- duplicate would be a silent Set.insert no-op — a replay that + -- "succeeds". + assertMsg "VAA already consumed" (not (Set.member suffix consumed)) + if Set.size consumed < splitThreshold || T.length prefix == 63 + -- Depth-63 nodes never split: suffixes are single nibbles (≤16 + -- distinct, so the set is bounded), and children would be degenerate + -- depth-64 nodes (unrepresentable, see 'ensure'). + then create this with consumed = Set.insert suffix consumed + else do + -- Split: bucket every suffix (plus the new one) by first nibble, + -- dropping it, in one pass; then create ALL 16 children — empty + -- ones included, or their subspaces would have no covering node. + let bucket m s = Map.insertWith Set.union (T.take 1 s) (Set.singleton (T.drop 1 s)) m + buckets = bucket (foldl bucket Map.empty (Set.toList consumed)) suffix + child nib = create this with + prefix = prefix <> nib + consumed = fromOptional Set.empty (Map.lookup nib buckets) + cids <- forA (T.explode "0123456789abcdef") (\nib -> (,) nib <$> child nib) + case lookup (T.take 1 suffix) cids of + Some cid -> pure cid + None -> abort "unreachable: split lost the covering child" + +-- | Grants each consumer AT MOST ONE trie root — the positive, on-ledger +-- uniqueness check that a contract key cannot provide. One registry per +-- operator, created at setup ('mkInitialReplayRootRegistry'); every grant +-- consumes it, so grants are linearized through the operator. The set grows by +-- one Party per integrator app — dozens, not millions. +template ReplayRootRegistry + with + operator : Party + granted : Set Party + where + signatory operator + + choice GrantReplayRoot : ContractId ReplayRootRegistry + with + consumer : Party + controller operator + do + assertMsg "consumer already has a replay root" (not (Set.member consumer granted)) + create this with granted = Set.insert consumer granted + +-- | The consumer's half of the root-creation ceremony: a one-time +-- propose-accept per app (mirroring 'EmitterRequest'). The consumer's +-- signature on this request is what authorizes its co-signature on the root; +-- the operator accepts against its registry, which enforces +-- one-root-per-consumer. +template ReplayRootRequest + with + consumer : Party + operator : Party + splitThreshold : Int -- normally 'defaultSplitThreshold'; tiny in tests + where + signatory consumer + observer operator + + choice ApproveReplayRoot : (ContractId ReplayRootRegistry, ContractId ReplayNode) + with + registryCid : ContractId ReplayRootRegistry -- passed by cid: no keys + controller operator + do + reg <- fetch registryCid + assertMsg "registry belongs to a different operator" (reg.operator == operator) + regCid' <- exercise registryCid GrantReplayRoot with consumer + rootCid <- create ReplayNode with + consumer + operator + prefix = "" + splitThreshold + consumed = Set.empty + pure (regCid', rootCid) diff --git a/canton/core/daml/Wormhole/Core/Setup.daml b/canton/core/daml/Wormhole/Core/Setup.daml index da49096b77..be6bf7689b 100644 --- a/canton/core/daml/Wormhole/Core/Setup.daml +++ b/canton/core/daml/Wormhole/Core/Setup.daml @@ -7,6 +7,7 @@ import DA.Map qualified as Map import DA.Set qualified as Set import Wormhole.Core.Bytes import Wormhole.Core.GuardianSet +import Wormhole.Core.Replay import Wormhole.Core.State -- | Wormhole ChainID assigned to Canton. @@ -53,3 +54,10 @@ mkInitialCoreState operator guardianGovernance govChain govContract initialGuard mkInitialEmitterRegistry : Party -> Party -> EmitterRegistry mkInitialEmitterRegistry operator guardianObserver = EmitterRegistry with operator, guardianObserver, nextId = 0 + +-- | The replay-root registry the operator creates alongside the 'CoreState' at +-- setup. Grants each integrator consumer at most one 'ReplayNode' trie root +-- (see 'Wormhole.Core.Replay'). +mkInitialReplayRootRegistry : Party -> ReplayRootRegistry +mkInitialReplayRootRegistry operator = + ReplayRootRegistry with operator, granted = Set.empty diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index 934b6a7590..db5d7b83ab 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -43,7 +43,7 @@ module Wormhole.Core.State where import DA.Map (Map) import DA.Map qualified as Map -import DA.Optional (fromSomeNote, isNone) +import DA.Optional (fromSomeNote) import DA.Set (Set) import DA.Set qualified as Set import DA.Time (addRelTime, seconds) @@ -116,7 +116,7 @@ template CoreState messageFee : Int -- required fee (native units); 0 in v1 consumedGovernance : Set Bytes32 -- GOVERNANCE-ONLY replay protection, keyed by -- digest; bounded by governance cadence. App VAAs - -- use per-VAA 'ConsumedVAA' guards instead. + -- use the per-consumer 'ReplayNode' trie instead. where -- Co-signed: the operator advances the singleton, but the guardianGovernance -- party co-signs so the operator cannot forge the guardian set (see module @@ -160,50 +160,39 @@ template CoreState now <- getTime either assertFail pure (verifyEncoded this encodedVAA pubKeys now) - -- | Verify a VAA and record its consumption for @consumer@, atomically. - -- Integrators (token bridge / NTT) call this in the same transaction as the - -- action the VAA authorizes; the second consume of the same digest under - -- the same @consumer@ fails on the key lookup. Scoped per consumer: the - -- same VAA can be consumed by different integrator parties independently, - -- and distinct digests never contend (this choice is nonconsuming). + -- | Verify a VAA and record its consumption in @consumer@'s replay trie, + -- atomically. Integrators (token bridge / NTT) call this in the same + -- transaction as the action the VAA authorizes; a replay fails on the + -- covering node's membership check. Scoped per consumer: the same VAA can + -- be consumed by different integrator parties independently. -- - -- The controller is flexible, so the submitter needs to SEE this contract - -- to exercise — via explicit disclosure or an observer party (README "Open - -- Questions"). Creating the guard inside this choice is what gives it the - -- operator's co-signature (inherited authority), so the consumer cannot - -- archive it and replay. - -- - -- SOUNDNESS: the guard lookup below resolves against the submission's - -- readers (key lookups are not maintainer-attested; a negative result is a - -- pinned validation input, so it fails OPEN), and it is complete only in - -- the consumer's read view — the consumer signs every one of its guards. - -- The consuming submission must therefore carry that view: canonically, - -- propose-accept, where the consumer's automation submits the accept (see - -- Test.ExampleIntegrator). The consumer's inherited authority alone does - -- not bring its visibility; without the view, the lookup misses existing - -- guards and double-consumes (pinned by - -- Test.ExampleIntegrator.testReplayNeedsConsumerView). - nonconsuming choice VerifyAndConsumeVAA : (VAA, ContractId ConsumedVAA) + -- @replayNodeCid@ is the trie node covering @vaa.hash@ — resolved + -- off-ledger by the consumer's disclosure service (see + -- 'Wormhole.Core.Replay'). SOUNDNESS IS SUBMITTER-INDEPENDENT: the node is + -- fetched and consumed positively, so every confirmer validates its + -- activeness — a stale or wrong node can only fail the transaction, never + -- replay. Any submitter with disclosures of this contract and the covering + -- node (the controller is flexible) gets a correct check; no read-view + -- discipline is required, unlike the retired key-based guards. + nonconsuming choice VerifyAndConsumeVAA : (VAA, ContractId ReplayNode) with - encodedVAA : Bytes - pubKeys : [(Int, Bytes)] - consumer : Party + encodedVAA : Bytes + pubKeys : [(Int, Bytes)] + consumer : Party + replayNodeCid : ContractId ReplayNode controller consumer do now <- getTime vaa <- either assertFail pure (verifyEncoded this encodedVAA pubKeys now) - -- The consumer is the key maintainer, so its authority (the controller) - -- covers the lookup, and its participant serializes racing consumes. - existing <- lookupByKey @ConsumedVAA (consumer, vaa.hash) - assertMsg "VAA already consumed" (isNone existing) - guardCid <- create ConsumedVAA with - consumer - operator - vaaHash = vaa.hash - emitterChain = vaa.emitterChain - emitterAddress = vaa.emitterAddress - sequence = vaa.sequence - pure (vaa, guardCid) + node <- fetch replayNodeCid + -- Exercising ConsumeDigest on a foreign node would fail authorization + -- anyway; the explicit check gives a clean error. The operator binding + -- rejects tries co-signed by an accomplice "operator" (a shadow trie + -- outside the root registry's one-root-per-consumer grant). + assertMsg "replay node consumer mismatch" (node.consumer == consumer) + assertMsg "replay node operator mismatch" (node.operator == operator) + newNodeCid <- exercise replayNodeCid ConsumeDigest with digest = vaa.hash + pure (vaa, newNodeCid) -- | Apply a governance VAA: verify it (against the CURRENT set, per EVM), -- check the governance emitter and replay protection, then dispatch. diff --git a/canton/test/daml/Test/ExampleIntegrator.daml b/canton/test/daml/Test/ExampleIntegrator.daml index b130453d1b..08f9aa1eda 100644 --- a/canton/test/daml/Test/ExampleIntegrator.daml +++ b/canton/test/daml/Test/ExampleIntegrator.daml @@ -1,31 +1,21 @@ -- | A reference integrator (a mock NTT-manager shape) demonstrating how an app -- consumes a VAA atomically via 'VerifyAndConsumeVAA' (README §4.6). -- --- The flow is PROPOSE-ACCEPT: the user creates a 'RedeemRequest' with their --- sole signature (no disclosures, no read grants), and the manager's --- automation exercises 'AcceptRedeem', which verifies the VAA, consumes it, --- checks the peer, and mints — one atomic transaction. The manager is the --- SUBMITTER of the consuming transaction, so the replay-guard lookup inside --- 'VerifyAndConsumeVAA' runs in the manager's read view, which provably --- contains every guard of this app (the manager signs them all) — the --- negative lookup is complete BY CONSTRUCTION, not by operational discipline. --- Authority flows the other way: the redeemer's signature on the receipt is --- inherited from the archived request. The 'CoreState' disclosure is needed --- only by the manager's backend, never by end users. --- --- Why the manager must submit (pinned by 'testReplayNeedsConsumerView' below): --- keys are non-unique and lookups are NOT maintainer-attested. Resolution --- happens 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 — no confirming participant --- re-runs it against its own store. A positive resolution names a cid that is --- checked for activeness like any input; a negative one names nothing and --- fails OPEN. A user-submitted consume would carry the manager's inherited --- AUTHORITY but not its VISIBILITY, miss existing guards, and double-consume. +-- The flow is DIRECT AND USER-SUBMITTED: the end user exercises 'Redeem' +-- herself, attaching two explicit disclosures — the 'CoreState' and the +-- current covering 'ReplayNode' of the manager's replay trie (both served by +-- the app's disclosure service). This is sound for ANY submitter: the replay +-- check is a positive fetch-and-consume of the named node, validated for +-- activeness by every confirmer — a wrong or stale node can only fail the +-- transaction, never replay. No read grants, no @readAs@, no propose-accept +-- queue; the manager's AUTHORITY for the consume is inherited from this +-- contract's signatory, and its VISIBILITY is simply not needed (contrast the +-- retired key-based guards, whose negative lookups failed open unless the +-- consumer submitted). -- -- Guardian signatures prove the VAA is real, NOT that it is addressed to this --- app: 'AcceptRedeem' checks the emitter against the registered peer before --- touching the payload. +-- app: 'Redeem' checks the emitter against the registered peer before touching +-- the payload. -- -- This module ships in the TEST package only; it is a template for integrator -- packages, not part of the core DAR. @@ -38,11 +28,13 @@ import Wormhole.Core.Bytes import Wormhole.Core.Replay import Wormhole.Core.Setup import Wormhole.Core.State +import Wormhole.Core.VAA (parseVAA) import Test.TestCore (setup, govSetFeeVAA, devnetGuardianPubKey) +import Test.TestReplay (registerReplayRoot, coveringDisclosure) -- | Stand-in for the integrator's real effect (a mint / unlock). Co-signed: --- the manager's authority comes from the accepting submission, the redeemer's --- is inherited from the archived 'RedeemRequest'. +-- the manager's authority is inherited from the app contract, the redeemer's +-- from being the choice controller. template RedeemReceipt with manager : Party @@ -52,85 +44,52 @@ template RedeemReceipt where signatory manager, redeemer --- | The app's configuration anchor: the replay scope and the registered peer. template ExampleIntegrator with manager : Party -- app party: signatory, and the replay scope + users : [Party] -- redeemers; observers so they can exercise Redeem peerChain : Int -- registered source-chain peer of this app peerAddress : Bytes32 where signatory manager - --- | The user's half of the propose-accept flow: created with the redeemer's --- sole signature and no special visibility. Withdrawable by the redeemer (the --- auto-generated Archive) until accepted. -template RedeemRequest - with - redeemer : Party - manager : Party - encodedVAA : Bytes - where - signatory redeemer - observer manager - - -- | The manager's half: verify + consume + peer-check + mint, one atomic - -- transaction, SUBMITTED BY THE MANAGER — its read view makes the guard - -- lookup complete, structurally. Consuming: the accepted request is - -- archived (redeemer's authority inherited from this contract), and that - -- same inherited authority co-signs the receipt. @pubKeys@ are the - -- submitter's untrusted hints, so they are supplied here, not by the user. - choice AcceptRedeem : ContractId RedeemReceipt + observer users + + -- | Redeem a VAA: verify + consume + peer-check + mint, one atomic + -- transaction, submitted by the redeemer. @consumer = manager@ is + -- authorized by inheritance from this contract's signatory; the submitter + -- brings disclosures of the 'CoreState' and of the covering 'ReplayNode'. + -- All-or-nothing: an already-consumed digest, a stale node, a bad + -- signature, or a wrong peer aborts everything — there is no window where + -- a VAA is consumed but not acted on, or vice versa. + nonconsuming choice Redeem : ContractId RedeemReceipt with - integratorCid : ContractId ExampleIntegrator - coreStateCid : ContractId CoreState -- disclosed to the manager's backend + redeemer : Party + coreStateCid : ContractId CoreState + replayNodeCid : ContractId ReplayNode + encodedVAA : Bytes pubKeys : [(Int, Bytes)] - controller manager + controller redeemer do - integ <- fetch integratorCid - assertMsg "request addressed to a different manager" (integ.manager == manager) - (vaa, _guard) <- exercise coreStateCid VerifyAndConsumeVAA with + (vaa, _node) <- exercise coreStateCid VerifyAndConsumeVAA with encodedVAA pubKeys consumer = manager + replayNodeCid + -- The integrator's own acceptance check — never skip this. assertMsg "not from the registered peer" - (vaa.emitterChain == integ.peerChain - && normalizeHex vaa.emitterAddress == normalizeHex integ.peerAddress) + (vaa.emitterChain == peerChain + && normalizeHex vaa.emitterAddress == normalizeHex peerAddress) create RedeemReceipt with manager, redeemer, sequence = vaa.sequence, payload = vaa.payload --- | NOT an integration pattern — a minimal semantics probe backing the module --- header and README §4.6. Its one choice hands @anyone@ the consumer's --- inherited authority for a consume, without the consumer's read view. See --- 'testReplayNeedsConsumerView'. -template LookupProbe - with - consumer : Party - anyone : Party - where - signatory consumer - observer anyone - - nonconsuming choice ProbeConsume : () - with - coreStateCid : ContractId CoreState - encodedVAA : Bytes - pubKeys : [(Int, Bytes)] - controller anyone - do - _ <- exercise coreStateCid VerifyAndConsumeVAA with - encodedVAA - pubKeys - consumer - pure () - --- | Shared fixture: a bootstrapped bridge, one integrator app, and the --- CoreState disclosure the manager's backend attaches to accepts. +-- | Shared fixture: a bootstrapped bridge, one integrator app with its replay +-- trie root, and the CoreState disclosure users attach to redemptions. data IntegratorFixture = IntegratorFixture with operator : Party csId : ContractId CoreState manager : Party users : [Party] intCid : ContractId ExampleIntegrator - disc : Disclosure + csDisc : Disclosure setupIntegrator : Script IntegratorFixture setupIntegrator = do @@ -141,102 +100,83 @@ setupIntegrator = do intCid <- submit manager do createCmd ExampleIntegrator with manager + users = [alice, bob] -- govSetFeeVAA is emitted by (chain 1, 0x..04); register that as the peer. peerChain = solanaGovernanceChainId peerAddress = defaultGovernanceContract - -- A CoreState stakeholder (here the operator) exports the disclosure for the - -- manager's backend. - disc <- fromSome <$> queryDisclosure operator csId - pure IntegratorFixture with operator, csId, manager, users = [alice, bob], intCid, disc + -- One-time ceremony: the manager's replay trie root. + _ <- registerReplayRoot operator manager defaultSplitThreshold + -- The app's disclosure service exports the CoreState disclosure (the + -- operator is a stakeholder) and, per redemption, the covering node's. + csDisc <- fromSome <$> queryDisclosure operator csId + pure IntegratorFixture with operator, csId, manager, users = [alice, bob], intCid, csDisc devnetPubKeys : [(Int, Bytes)] devnetPubKeys = [(0, devnetGuardianPubKey)] --- | End-to-end propose-accept redemption, the production shape: the user --- creates the request alone (no disclosures, no read grants); the manager's --- backend accepts with the CoreState disclosure attached, consuming the VAA in --- its own read view. Replays are then rejected at accept time for every --- requester of this app, while a second app remains its own replay scope. +-- | End-to-end user-submitted redemption: alice redeems with her own authority +-- and two disclosures — nothing else. Replay attempts then fail for EVERY +-- submitter and every node freshness: the stale node disclosure dies on +-- activeness, the fresh one on membership. This inverts the old key-based +-- footgun (where a submitter without the consumer's read view could +-- double-consume): submitter identity and visibility are no longer +-- load-bearing. testIntegratorRedeem : Script () testIntegratorRedeem = do - IntegratorFixture{csId, manager, users = [alice, bob], intCid, disc} <- setupIntegrator - - -- The user's half needs nothing but her own signature. - reqCid <- submit alice do - createCmd RedeemRequest with redeemer = alice, manager, encodedVAA = govSetFeeVAA - let acceptArgs = AcceptRedeem with - integratorCid = intCid - coreStateCid = csId - pubKeys = devnetPubKeys - - -- Even the manager cannot see the CoreState without the disclosure (it is - -- not a stakeholder): rejected. - submitMustFail (actAs manager) do exerciseCmd reqCid acceptArgs - - -- The manager's backend accepts with the disclosure: verify + consume + - -- peer-check + mint, one transaction. - receiptCid <- submit (actAs manager <> disclose disc) do - exerciseCmd reqCid acceptArgs + IntegratorFixture{operator, csId, manager, users = [alice, bob], intCid, csDisc} <- setupIntegrator + -- The disclosure service resolves the covering node for the VAA's digest; + -- 'parseVAA' is pure, so the script computes the digest as the service would. + let digest = (parseVAA govSetFeeVAA).hash + (nodeCid, nodeDisc) <- coveringDisclosure manager manager digest + + -- Alice redeems: her own authority plus the two disclosures — nothing else. + receiptCid <- submit (actAs alice <> disclose csDisc <> disclose nodeDisc) do + exerciseCmd intCid Redeem with + redeemer = alice + coreStateCid = csId + replayNodeCid = nodeCid + encodedVAA = govSetFeeVAA + pubKeys = devnetPubKeys Some receipt <- queryContractId alice receiptCid receipt.sequence === 1 receipt.redeemer === alice - -- The request is consumed by the accept. - archived <- queryContractId alice reqCid - archived === None - -- The guard is scoped to the manager (not to alice). - [(_, guard)] <- query @ConsumedVAA manager - guard.consumer === manager + -- Replay with the STALE node disclosure: rejected on activeness — the + -- consume archived that node. + submitMustFail (actAs bob <> disclose csDisc <> disclose nodeDisc) do + exerciseCmd intCid Redeem with + redeemer = bob + coreStateCid = csId + replayNodeCid = nodeCid + encodedVAA = govSetFeeVAA + pubKeys = devnetPubKeys - -- Replay: any further request for the same VAA is rejected at accept time — - -- the manager's view sees the guard, structurally. - reqCid2 <- submit bob do - createCmd RedeemRequest with redeemer = bob, manager, encodedVAA = govSetFeeVAA - submitMustFail (actAs manager <> disclose disc) do exerciseCmd reqCid2 acceptArgs + -- Replay with the FRESH covering node: rejected on membership. + (freshCid, freshDisc) <- coveringDisclosure manager manager digest + submitMustFail (actAs bob <> disclose csDisc <> disclose freshDisc) do + exerciseCmd intCid Redeem with + redeemer = bob + coreStateCid = csId + replayNodeCid = freshCid + encodedVAA = govSetFeeVAA + pubKeys = devnetPubKeys -- A second app is its own replay scope: the SAME VAA redeems there too. manager2 <- allocateParty "ExampleManager2" intCid2 <- submit manager2 do createCmd ExampleIntegrator with manager = manager2 + users = [alice] peerChain = solanaGovernanceChainId peerAddress = defaultGovernanceContract - reqCid3 <- submit alice do - createCmd RedeemRequest with redeemer = alice, manager = manager2, encodedVAA = govSetFeeVAA - receipt2Cid <- submit (actAs manager2 <> disclose disc) do - exerciseCmd reqCid3 AcceptRedeem with - integratorCid = intCid2 + _ <- registerReplayRoot operator manager2 defaultSplitThreshold + (node2Cid, node2Disc) <- coveringDisclosure manager2 manager2 digest + receipt2Cid <- submit (actAs alice <> disclose csDisc <> disclose node2Disc) do + exerciseCmd intCid2 Redeem with + redeemer = alice coreStateCid = csId + replayNodeCid = node2Cid + encodedVAA = govSetFeeVAA pubKeys = devnetPubKeys Some receipt2 <- queryContractId alice receipt2Cid receipt2.manager === manager2 - --- | THE FOOTGUN, pinned via 'LookupProbe': a consume submitted without the --- consumer's read view double-consumes. The guard lookup inside --- 'VerifyAndConsumeVAA' resolves against the submission's readers and its --- negative result is a pinned input to validation — no confirming participant --- re-checks it — so with only the submitter's view it misses the existing --- guard and a SECOND guard is created under the same key. This is why --- redemption flows must put the consumer in the submission (propose-accept). --- If this test ever starts failing, the ledger has tightened key semantics: --- revisit README §4.6. -testReplayNeedsConsumerView : Script () -testReplayNeedsConsumerView = do - IntegratorFixture{csId, manager, users = [alice, _], disc} <- setupIntegrator - -- A sound consume first: the manager submits it itself. - _ <- submit (actAs manager <> disclose disc) do - exerciseCmd csId VerifyAndConsumeVAA with - encodedVAA = govSetFeeVAA - pubKeys = devnetPubKeys - consumer = manager - -- Alice replays through the probe: the manager's AUTHORITY is inherited, - -- but the guard lookup runs in alice's view and misses. - probeCid <- submit manager do - createCmd LookupProbe with consumer = manager, anyone = alice - submit (actAs alice <> disclose disc) do - exerciseCmd probeCid ProbeConsume with - coreStateCid = csId - encodedVAA = govSetFeeVAA - pubKeys = devnetPubKeys - guards <- query @ConsumedVAA manager - length guards === 2 -- two guards, same key: the double-consume happened diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index 72aa5f262d..ed421cda2f 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -11,7 +11,6 @@ import DA.Map qualified as Map import DA.Optional (fromSome) import Wormhole.Core.Bytes import Wormhole.Core.GuardianSet -import Wormhole.Core.Replay () -- HasField instances for ConsumedVAA record-dot access import Wormhole.Core.State import Wormhole.Core.Setup import Wormhole.Core.VAA () -- HasField instances for VAA record-dot access @@ -35,8 +34,10 @@ govSetFeeVAA = "01000000000100710ab5dcf6885f62016990540e090400f26f41286382dab479 -- here; in production a k-of-n threshold party), so genesis needs both. It also -- allocates the read-only @guardianObserver@ party and threads it into the -- 'CoreState' and 'EmitterRegistry' (from which 'ApproveEmitter' carries it into --- each 'Emitter'). The result keeps @operator@ FIRST so bootstrap.sh's first-party --- extraction still yields the operator; @guardianObserver@ follows. +-- each 'Emitter'). Also creates the 'ReplayRootRegistry' (replay-trie root +-- grants, see Test.TestReplay). The result keeps @operator@ FIRST so +-- bootstrap.sh's first-party extraction still yields the operator; +-- @guardianObserver@ follows. setup : Script (Party, Party, ContractId CoreState) setup = do operator <- allocateParty "Operator" @@ -45,6 +46,7 @@ setup = do csId <- submit (actAs operator <> actAs guardianGovernance) do createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver) _ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianObserver) + _ <- submit operator do createCmd (mkInitialReplayRootRegistry operator) pure (operator, guardianObserver, csId) -- | Register an emitter for @owner@: allocates a stable @emitterId@ from the @@ -250,47 +252,6 @@ testGovernanceSetMessageFee = do Some cs <- queryContractId operator newCsId cs.messageFee === 1000 --- | Per-VAA replay protection: a consumer verifies-and-consumes a VAA once, --- creating the 'ConsumedVAA' guard; the second consume of the same digest is --- rejected on the key lookup. Scoping is per consumer (another party may --- consume the same VAA), and the guard is co-signed, so neither the consumer --- nor the operator can archive it alone. The consumer is not a 'CoreState' --- stakeholder — it exercises via explicit disclosure, which is also the --- production access path. -testVerifyAndConsumeVAA : Script () -testVerifyAndConsumeVAA = do - (operator, _, csId) <- setup - ntt <- allocateParty "NttManager" - disc <- fromSome <$> queryDisclosure operator csId - (vaa, guardCid) <- submit (actAs ntt <> disclose disc) do - exerciseCmd csId VerifyAndConsumeVAA with - encodedVAA = govSetFeeVAA - pubKeys = [(0, devnetGuardianPubKey)] - consumer = ntt - Some guard <- queryContractId ntt guardCid - guard.vaaHash === vaa.hash - guard.emitterChain === 1 - guard.sequence === 1 - - -- Replay under the same consumer is rejected. - submitMustFail (actAs ntt <> disclose disc) do - exerciseCmd csId VerifyAndConsumeVAA with - encodedVAA = govSetFeeVAA - pubKeys = [(0, devnetGuardianPubKey)] - consumer = ntt - - -- A different consumer party has its own scope. - other <- allocateParty "OtherApp" - _ <- submit (actAs other <> disclose disc) do - exerciseCmd csId VerifyAndConsumeVAA with - encodedVAA = govSetFeeVAA - pubKeys = [(0, devnetGuardianPubKey)] - consumer = other - - -- The guard is co-signed: neither party can archive-and-replay alone. - submitMustFail ntt do archiveCmd guardCid - submitMustFail operator do archiveCmd guardCid - -- | Governance replay protection (the 'consumedGovernance' set) still rejects -- a resubmitted governance VAA. testGovernanceReplayRejected : Script () diff --git a/canton/test/daml/Test/TestReplay.daml b/canton/test/daml/Test/TestReplay.daml new file mode 100644 index 0000000000..89c0c70912 --- /dev/null +++ b/canton/test/daml/Test/TestReplay.daml @@ -0,0 +1,344 @@ +-- | Tests for the replay-protection trie ('Wormhole.Core.Replay'), plus the +-- script-side simulation of the DISCLOSURE SERVICE: 'coveringNode' resolves the +-- unique active node whose prefix covers a digest (asserting the partition +-- invariant on every use — exactly one covering node), and +-- 'coveringDisclosure' packages it for a non-stakeholder submitter. Production +-- runs the same logic off-ledger over an ACS index by (consumer, prefix). +-- +-- Trie tests use tiny thresholds (the threshold is a root-creation parameter +-- precisely so splits are cheap to reach in tests; production uses +-- 'defaultSplitThreshold'). +module Test.TestReplay where + +import Daml.Script +import DA.Assert ((===)) +import DA.Foldable (forA_) +import DA.List (sort) +import DA.Optional (fromSome) +import DA.Set qualified as Set +import DA.Text qualified as T +import Wormhole.Core.Bytes +import Wormhole.Core.Replay +import Wormhole.Core.State +import Test.TestCore (setup, govSetFeeVAA, devnetGuardianPubKey) + +-- | Right-pad a hex fragment with zeros to a full 64-nibble digest. +pad64 : Text -> Text +pad64 t = t <> mconcat (replicate (64 - T.length t) "0") + +nibbles : [Text] +nibbles = T.explode "0123456789abcdef" + +-- | Propose-accept ceremony granting @consumer@ its trie root: the consumer +-- requests, the operator approves against its (queried, cid-passed — no keys) +-- 'ReplayRootRegistry'. +registerReplayRoot : Party -> Party -> Int -> Script (ContractId ReplayNode) +registerReplayRoot operator consumer threshold = do + reqCid <- submit consumer do + createCmd ReplayRootRequest with consumer, operator, splitThreshold = threshold + [(regCid, _)] <- query @ReplayRootRegistry operator + (_, rootCid) <- submit operator do + exerciseCmd reqCid ApproveReplayRoot with registryCid = regCid + pure rootCid + +-- | The disclosure-service lookup: the unique active node of @consumer@'s trie +-- covering @digest@. Aborts if coverage is not exactly one — every call doubles +-- as a check of the partition invariant. +coveringNode : Party -> Text -> Script (ContractId ReplayNode, ReplayNode) +coveringNode consumer digest = do + nodes <- query @ReplayNode consumer + let d = normalizeHex digest + case filter (\(_, n) -> n.prefix `T.isPrefixOf` d) nodes of + [one] -> pure one + covering -> abort ("partition invariant violated: " <> show (length covering) <> " covering nodes") + +-- | 'coveringNode' plus the explicit disclosure a non-stakeholder submitter +-- attaches. @reader@ is any party that sees the node (the consumer, or the +-- operator's service). +coveringDisclosure : Party -> Party -> Text -> Script (ContractId ReplayNode, Disclosure) +coveringDisclosure reader consumer digest = do + (cid, _) <- coveringNode consumer digest + disc <- fromSome <$> queryDisclosure reader cid + pure (cid, disc) + +-- | Consume @digest@ into @consumer@'s trie via the disclosure-service lookup. +consume : Party -> Text -> Script (ContractId ReplayNode) +consume consumer digest = do + (cid, _) <- coveringNode consumer digest + submit consumer do exerciseCmd cid ConsumeDigest with digest + +consumeMustFail : Party -> Text -> Script () +consumeMustFail consumer digest = do + (cid, _) <- coveringNode consumer digest + submitMustFail consumer do exerciseCmd cid ConsumeDigest with digest + +-- | Fixture: bridge + one consumer with a threshold-@t@ trie root. +setupTrie : Int -> Script (Party, Party, ContractId ReplayNode) +setupTrie t = do + (operator, _, _) <- setup + consumer <- allocateParty "TrieConsumer" + rootCid <- registerReplayRoot operator consumer t + pure (operator, consumer, rootCid) + +-- | Inserts land in the root and replays are rejected; the returned cid is the +-- node the disclosure service would resolve next. +testTrieInsertAndReplay : Script () +testTrieInsertAndReplay = do + (_, consumer, _) <- setupTrie 4 + let dA = pad64 "a1" + dB = pad64 "b2" + dC = pad64 "c3" + cid1 <- consume consumer dA + (cid1', _) <- coveringNode consumer dB + cid1' === cid1 + _ <- consume consumer dB + _ <- consume consumer dC + (_, root) <- coveringNode consumer dA + root.prefix === "" + Set.size root.consumed === 3 + consumeMustFail consumer dA + +-- | The consume that exceeds the threshold splits the node into exactly 16 +-- children — empty ones included — with the suffixes bucketed by (dropped) +-- first nibble; the parent is archived; the returned cid is the child covering +-- the digest. +testTrieSplit : Script () +testTrieSplit = do + (_, consumer, _) <- setupTrie 4 + let ds = map pad64 ["a1", "b2", "c3", "d4"] + forA_ ds (consume consumer) + -- 5th consume: 4 == threshold, so the root splits. + childCid <- consume consumer (pad64 "e5") + nodes <- query @ReplayNode consumer + length nodes === 16 + sort (map (\(_, n) -> n.prefix) nodes) === nibbles + -- Buckets: each consumed digest's suffix sits in its first-nibble child, + -- nibble dropped; unused children are empty. + forA_ nodes \(cid, n) -> case n.prefix of + "a" -> do Set.toList n.consumed === [T.drop 1 (pad64 "a1")] + "e" -> do + Set.toList n.consumed === [T.drop 1 (pad64 "e5")] + cid === childCid + "b" -> Set.size n.consumed === 1 + "c" -> Set.size n.consumed === 1 + "d" -> Set.size n.consumed === 1 + _ -> Set.size n.consumed === 0 + -- Post-split, both old and new digests remain replay-protected. + consumeMustFail consumer (pad64 "a1") + consumeMustFail consumer (pad64 "e5") + -- A fresh digest under a now-empty child inserts normally. + _ <- consume consumer (pad64 "f6") + pure () + +-- | Digests sharing a first nibble force an over-threshold child at the first +-- split (all parent entries in one bucket — legal and transient); its next +-- consume splits it again, one level deeper. +testMultiLevelSplit : Script () +testMultiLevelSplit = do + (_, consumer, _) <- setupTrie 2 + forA_ (map pad64 ["f0", "f1"]) (consume consumer) + -- Third consume splits the root; all three suffixes share the 'f' bucket. + _ <- consume consumer (pad64 "f2") + (_, childF) <- coveringNode consumer (pad64 "f0") + childF.prefix === "f" + Set.size childF.consumed === 3 -- over the threshold of 2, by design + -- Fourth consume under 'f' splits the over-full child into grandchildren. + _ <- consume consumer (pad64 "f3") + nodes <- query @ReplayNode consumer + length nodes === 31 -- 15 depth-1 siblings + 16 depth-2 children of "f" + (_, gf) <- coveringNode consumer (pad64 "f2") + gf.prefix === "f2" + Set.toList gf.consumed === [T.drop 2 (pad64 "f2")] + -- Replay still rejected across both levels. + consumeMustFail consumer (pad64 "f0") + consumeMustFail consumer (pad64 "f3") + +-- | Input hygiene. Uppercase input is normalized (its lowercase twin is a +-- replay); malformed digests are rejected outright. +testDigestValidation : Script () +testDigestValidation = do + (_, consumer, rootCid) <- setupTrie 4 + let upper = "AB" <> mconcat (replicate 62 "0") + _ <- consume consumer upper + consumeMustFail consumer (pad64 "ab") -- lowercase twin: same digest + forA_ [ mconcat (replicate 63 "0") -- too short + , mconcat (replicate 65 "0") -- too long + , "0x" <> mconcat (replicate 62 "0") -- 0x prefix + , "g" <> mconcat (replicate 63 "0") -- not hex + ] \bad -> do + (cid, _) <- coveringNode consumer (pad64 "ff") + submitMustFail consumer do exerciseCmd cid ConsumeDigest with digest = bad + -- rootCid is stale after the first consume: distinct failure from hygiene. + submitMustFail consumer do exerciseCmd rootCid ConsumeDigest with digest = pad64 "cd" + +-- | A node rejects digests outside its prefix ("does not cover") — the +-- disclosure-service-handed-me-the-wrong-node case; the digest stays +-- consumable via the right node. +testWrongNodeRejected : Script () +testWrongNodeRejected = do + (_, consumer, _) <- setupTrie 4 + forA_ (map pad64 ["a0", "b1", "c2", "d3", "e4"]) (consume consumer) -- forces the split + (wrongCid, wrong) <- coveringNode consumer (pad64 "a0") + wrong.prefix === "a" + submitMustFail consumer do exerciseCmd wrongCid ConsumeDigest with digest = pad64 "b9" + _ <- consume consumer (pad64 "b9") + pure () + +-- | The retry protocol: a consume against a stale cid is rejected by +-- activeness (someone advanced the node); the retry against the fresh covering +-- node succeeds; and a blind retry of a consume that actually committed fails +-- on membership — never a double consume. +testStaleCidAndRetry : Script () +testStaleCidAndRetry = do + (_, consumer, rootCid) <- setupTrie 4 + _ <- submit consumer do exerciseCmd rootCid ConsumeDigest with digest = pad64 "11" + -- rootCid is now archived: a consume of a DIFFERENT digest against it fails. + submitMustFail consumer do exerciseCmd rootCid ConsumeDigest with digest = pad64 "22" + -- Retry against the fresh covering node succeeds. + _ <- consume consumer (pad64 "22") + -- Ambiguous-timeout case: the original commit actually landed; the blind + -- retry hits the membership check, a clean idempotency signal. + consumeMustFail consumer (pad64 "11") + +-- | Depth-63 nodes only insert, never split (suffixes are single nibbles, +-- bounded at 16); and the 'ensure' shape invariants reject degenerate nodes. +testDepth63NeverSplits : Script () +testDepth63NeverSplits = do + (operator, consumer, _) <- setupTrie 4 + let deepPrefix = mconcat (replicate 63 "f") + -- Direct creation needs both signatories — fine for a test fixture. + deepCid <- submit (actAs consumer <> actAs operator) do + createCmd ReplayNode with + consumer, operator, prefix = deepPrefix, splitThreshold = 1, consumed = Set.empty + -- Two inserts exceed threshold 1, yet the node never splits. + deepCid' <- submit consumer do exerciseCmd deepCid ConsumeDigest with digest = deepPrefix <> "0" + deepCid'' <- submit consumer do exerciseCmd deepCid' ConsumeDigest with digest = deepPrefix <> "1" + Some deep <- queryContractId consumer deepCid'' + deep.prefix === deepPrefix + Set.size deep.consumed === 2 + submitMustFail consumer do exerciseCmd deepCid'' ConsumeDigest with digest = deepPrefix <> "0" + -- ensure: depth-64 prefixes, non-positive thresholds, and wrong-length + -- suffixes are unrepresentable. + submitMustFail (actAs consumer <> actAs operator) do + createCmd ReplayNode with + consumer, operator, prefix = pad64 "", splitThreshold = 1, consumed = Set.empty + submitMustFail (actAs consumer <> actAs operator) do + createCmd ReplayNode with + consumer, operator, prefix = "", splitThreshold = 0, consumed = Set.empty + submitMustFail (actAs consumer <> actAs operator) do + createCmd ReplayNode with + consumer, operator, prefix = "", splitThreshold = 4, consumed = Set.fromList ["00"] + +-- | Scopes are independent and authorization is per-consumer: a different +-- party can neither advance another's trie nor is it affected by it. +testCrossConsumerScopes : Script () +testCrossConsumerScopes = do + (operator, alice, _) <- setupTrie 4 + bob <- allocateParty "TrieBob" + _ <- registerReplayRoot operator bob 4 + _ <- consume alice (pad64 "aa") + -- Bob cannot exercise Alice's node, even seeing it via disclosure. + (aliceCid, _) <- coveringNode alice (pad64 "bb") + aliceDisc <- fromSome <$> queryDisclosure alice aliceCid + submitMustFail (actAs bob <> disclose aliceDisc) do + exerciseCmd aliceCid ConsumeDigest with digest = pad64 "bb" + -- The same digest consumes independently in Bob's own trie. + _ <- consume bob (pad64 "aa") + pure () + +-- | Nodes are co-signed: neither the consumer nor the operator can archive +-- (and so re-create-empty) a node alone. +testNodeArchiveNeedsBoth : Script () +testNodeArchiveNeedsBoth = do + (operator, consumer, rootCid) <- setupTrie 4 + submitMustFail consumer do archiveCmd rootCid + submitMustFail operator do archiveCmd rootCid + -- Both together can (the irreducible signatory remainder) — coverage for the + -- auto-generated Archive choices, including request and registry. + submit (actAs consumer <> actAs operator) do archiveCmd rootCid + reqCid <- submit consumer do + createCmd ReplayRootRequest with consumer, operator, splitThreshold = 4 + submit consumer do archiveCmd reqCid + [(regCid, _)] <- query @ReplayRootRegistry operator + submit operator do archiveCmd regCid + +-- | The root registry grants at most one root per consumer — the positive, +-- on-ledger check that replaces key uniqueness — and roots cannot be minted +-- outside it (the consumer alone lacks the operator's signature). +testRootRegistry : Script () +testRootRegistry = do + (operator, consumer, rootCid) <- setupTrie 4 + Some root <- queryContractId consumer rootCid + root.prefix === "" + root.splitThreshold === 4 + Set.size root.consumed === 0 + -- A second grant for the same consumer is rejected. + reqCid <- submit consumer do + createCmd ReplayRootRequest with consumer, operator, splitThreshold = 4 + [(regCid, _)] <- query @ReplayRootRegistry operator + submitMustFail operator do + exerciseCmd reqCid ApproveReplayRoot with registryCid = regCid + -- The consumer cannot create a co-signed node alone. + submitMustFail consumer do + createCmd ReplayNode with + consumer, operator, prefix = "", splitThreshold = 4, consumed = Set.empty + +-- | End-to-end: verify + consume through 'CoreState.VerifyAndConsumeVAA' with +-- the real guardian-signed VAA. The consumer submits with only the CoreState +-- disclosure (it is a stakeholder of its own nodes); replay against the fresh +-- covering node fails on membership; a second consumer has its own scope; and +-- nodes from outside the consumer/operator pairing are rejected. +testVerifyAndConsumeVAA : Script () +testVerifyAndConsumeVAA = do + (operator, _, csId) <- setup + ntt <- allocateParty "NttManager" + rootCid <- registerReplayRoot operator ntt defaultSplitThreshold + csDisc <- fromSome <$> queryDisclosure operator csId + (vaa, nodeCid) <- submit (actAs ntt <> disclose csDisc) do + exerciseCmd csId VerifyAndConsumeVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + consumer = ntt + replayNodeCid = rootCid + Some node <- queryContractId ntt nodeCid + Set.member vaa.hash node.consumed === True + -- Replay: resolve the FRESH covering node, still rejected (membership). + (freshCid, _) <- coveringNode ntt vaa.hash + freshCid === nodeCid + submitMustFail (actAs ntt <> disclose csDisc) do + exerciseCmd csId VerifyAndConsumeVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + consumer = ntt + replayNodeCid = freshCid + -- A different consumer party consumes the same VAA in its own trie. + other <- allocateParty "OtherApp" + otherRoot <- registerReplayRoot operator other defaultSplitThreshold + _ <- submit (actAs other <> disclose csDisc) do + exerciseCmd csId VerifyAndConsumeVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + consumer = other + replayNodeCid = otherRoot + -- Consumer mismatch: ntt cannot consume into other's trie. + (otherFresh, _) <- coveringNode other vaa.hash + otherDisc <- fromSome <$> queryDisclosure other otherFresh + submitMustFail (actAs ntt <> disclose csDisc <> disclose otherDisc) do + exerciseCmd csId VerifyAndConsumeVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + consumer = ntt + replayNodeCid = otherFresh + -- Operator mismatch: a shadow trie co-signed by an accomplice "operator" is + -- rejected even though the consumer authority matches. + accomplice <- allocateParty "AccompliceOperator" + shadowCid <- submit (actAs ntt <> actAs accomplice) do + createCmd ReplayNode with + consumer = ntt, operator = accomplice, prefix = "" + splitThreshold = 4, consumed = Set.empty + submitMustFail (actAs ntt <> disclose csDisc) do + exerciseCmd csId VerifyAndConsumeVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + consumer = ntt + replayNodeCid = shadowCid From e695bd0864c4dd430cf60c9c03f91014489392e8 Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 9 Jul 2026 23:05:01 +0200 Subject: [PATCH 2/3] canton: document the replay trie --- canton/README.md | 176 +++++++++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 76 deletions(-) diff --git a/canton/README.md b/canton/README.md index 0ff3a9a6ed..2c5750a271 100644 --- a/canton/README.md +++ b/canton/README.md @@ -133,10 +133,10 @@ digest = keccak256( keccak256( body ) ) // double keccak, Ethereum-styl ``` Guardians sign `digest` with secp256k1 (Ethereum ECDSA, 65-byte `r‖s‖v`). -Replay protection keys off `digest` (the double keccak — EVM's `vm.hash`), both -for governance (`consumedGovernance`, §4.4) and for integrator VAAs -(`ConsumedVAA`, §4.6). The on-chain Daml verifier reconstructs and re-hashes the -body and checks signatures against the stored guardian set (§5). +Replay protection records `digest` (the double keccak — EVM's `vm.hash`), both +for governance (`consumedGovernance`, §4.4) and for integrator VAAs (the +`ReplayNode` trie, §4.6). The on-chain Daml verifier reconstructs and re-hashes +the body and checks signatures against the stored guardian set (§5). `ChainID` for Canton is **72** (`ChainIDCanton`), the next free mainnet ID after Arc (71). It is registered in [`sdk/vaa/structs.go`](../sdk/vaa/structs.go). @@ -469,7 +469,7 @@ Matches EVM (`Setters.sol`): when a new set is installed, the previous set's non-current set only while `effectiveTime < expirationTime`. The current set never expires. -### 4.6 Per-VAA replay protection for integrators +### 4.6 Replay protection for integrators: a trie of consumed digests Governance replay protection is the `consumedGovernance` set inside `CoreState` (§4.4) — fine there, because governance actions are rare and already churn the @@ -479,89 +479,112 @@ set would be reserialized into every create node (payload size, participant DB write, and network cost all linear in history), and every integrator's consume would contend on the one singleton contract. -Instead, consuming a VAA creates a dedicated contract — the Solana -claim-account shape (`Replay.daml`): +They also must not be recorded as per-digest keyed contracts (the design this +replaces): Canton LF 2.3 key lookups resolve once, on the submitting +participant, against the submission's readers, 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 do **not** 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). A +_positive_ resolution names a cid that is consistency-checked like any input; +a _negative_ one names nothing and **fails open**. Replay protection is +precisely a negative claim ("this digest was never consumed"), so it cannot be +built on key lookups — this module uses no contract keys at all. + +Instead, each consumer's consumed-digest set is a **prefix trie of leaf +contracts** (`Replay.daml`). A node covers a hex `prefix` and stores the set +of digest *suffixes* consumed under it: ```haskell -template ConsumedVAA +template ReplayNode with - consumer : Party -- integrator scope party; bears the replay risk - operator : Party -- co-signs so the consumer cannot archive-and-replay - vaaHash : Bytes32 -- double-keccak digest, same keying as consumedGovernance - ... -- emitter provenance (untrusted, for ops/debugging) + consumer : Party -- integrator scope party; bears the replay risk + operator : Party -- co-signs: no unilateral archive or re-create + prefix : Bytes -- lowercase hex, 0..63 nibbles; "" at the root + splitThreshold : Int -- node capacity; children inherit it + consumed : Set Bytes -- suffixes, each (64 − |prefix|) nibbles where signatory consumer, operator - key (consumer, vaaHash) : (Party, Bytes32) - maintainer key._1 ``` +A trie starts as one root (empty prefix, empty set). `ConsumeDigest` +(consuming, controller `consumer`) checks the node covers the digest, rejects +if the suffix is already present, and recreates the node with it inserted. +When the node is at `splitThreshold`, the consume instead **splits** it: all +16 children are created (one per nibble, empty ones included), suffixes +bucketed 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. + +**Why this is sound where keys were not.** "Not consumed" became 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. Soundness is **submitter-independent**: any submitter with a +disclosure of the current covering node gets a correct check, and a wrong or +stale node can only fail the transaction, never replay. Where the key design +failed *open* (a lookup that misses waves a replay through), the trie fails +*closed* (an archived subspace has no covering node — nothing verifies). + `VerifyAndConsumeVAA` (nonconsuming, on `CoreState`, flexible controller -`consumer`) verifies the VAA (§5), fails if `(consumer, digest)` already has a -guard (`lookupByKey`), and creates one. Integrators exercise it in the same -transaction as the action the VAA authorizes, so verify + consume + act is -atomic. The consumer must be able to see the `CoreState` to exercise — explicit -disclosure or an observer party (Open Questions item 4). +`consumer`) takes the covering node's cid, verifies the VAA (§5), binds the +node to the (consumer, operator) pairing, and exercises `ConsumeDigest` with +the digest — verify + consume + act in one transaction. Properties: -- **Uniqueness.** Contract keys are non-unique on Canton (§4.1), and key - lookups are **not maintainer-attested**: a lookup resolves against what the - _submission's readers_ can see (local contracts first, then disclosed ones, - then the readers' view). "At most one guard per (consumer, digest)" therefore - rests on three legs: every create flows through the vetted choice, which - looks up before creating (behavioral, like the `EmitterRegistry` id - discipline); every consuming submission carries the consumer's read view - (see the gotcha below); and the consumer is hosted on a single participant, - which then serializes its consumes. The trust is aligned: guardians never - re-check target-chain consumption — replay protection is the consumer's own - safety property, enforced on exactly the participant that bears the risk. -- **No archive-and-replay.** The guard is created inside a `CoreState` choice - and inherits the `operator`'s signature, so it is co-signed: neither the - consumer nor the operator can archive it alone. Guards are never archived — - their permanence _is_ the replay protection. -- **Scoping and contention.** Guards are scoped per consumer party: different - apps consume the same VAA independently, and distinct digests never contend - at all (the choice is nonconsuming). Cost per consume is one small, key-indexed - contract, forever — ACS growth is linear in consumed VAAs by design, the same - trade Solana makes with claim accounts. - -**Integrating — propose-accept, and why.** The executable reference is +- **No archive-and-replay, no shadow roots.** Archival alone only bricks + (fail-closed); the replay vector is node *re-creation*. Nodes are co-signed, + so neither the consumer nor the operator 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 (the registry carries the granted set and every + grant consumes it), via a one-time propose-accept ceremony + (`ReplayRootRequest`), mirroring emitter registration. The irreducible + remainder: consumer and operator *colluding* can rebuild a trie — + signatories can always unmake their own contracts. +- **Scoping and contention.** Tries are per consumer party: different apps + consume the same VAA independently. Consumes racing on the *same node* + conflict — exactly one commits (validator-checked, regardless of submitter); + the loser re-resolves the covering node and retries. A blind retry of a + consume that actually committed fails on membership ("VAA already consumed") + — a clean idempotency signal. Until the first split, all of one app's + consumes serialize through its root; 16-way fanout begins at + `splitThreshold` consumes. +- **Cost.** `defaultSplitThreshold = 128`: suffixes are ≤64-char hex (~66 B + serialized), so nodes top out ~8.5 KB, the average consume rewrites ~4 KB, + and the split transaction redistributes the same bytes across 16 creates + (~15 KB) — all far below Canton's request caps. Total ACS bytes stay ~66 B + per digest; 10 M digests ≈ 150 k nodes at depth 3–4. Larger thresholds thin + the ACS but amplify every write. +- **Privacy.** Nodes are integrator-private safety state — deliberately NOT + observed by `guardianObserver` (§4.1): guardians never re-check target-chain + consumption, and the attestation surface does not include app redemptions. + +**The disclosure service.** Submitters locate the covering node off-ledger: a +service indexes the consumer's active nodes by prefix (following the +`CreatedEvent`s that consumes and splits produce) and serves, for a digest, +the covering node's cid plus its explicit disclosure — alongside the +`CoreState` disclosure it already serves (§4.3). It is **untrusted for +safety**: the worst it can do is serve a wrong or stale node, which fails the +transaction. It is trusted for *liveness* only, like any RPC endpoint. (Go +implementation is future work; the Daml tests simulate it with a script +helper, `Test.TestReplay.coveringNode`, which also asserts the partition +invariant on every use.) + +**Integrating — direct and user-submitted.** The executable reference is [`test/daml/Test/ExampleIntegrator.daml`](test/daml/Test/ExampleIntegrator.daml) -(test package only). The recommended shape is **propose-accept**: the user -creates a `RedeemRequest` with their sole signature (no disclosures, no read -grants), and the manager's automation exercises `AcceptRedeem` — verify + -consume + peer-check + mint, one atomic transaction, **submitted by the -manager**. The `CoreState` disclosure is held by the manager's backend only; -the redeemer's signature on the receipt is inherited from the archived -request. - -The reason the *manager must submit* is a property of this key model, worth -being explicit about. Key lookups are resolved once, on the submitting -participant, against the submission's readers (local contracts → disclosed → -reader-visible); the result is a pinned input to validation. Confirming -participants re-check authorization, conformance, and the activeness of every -contract the transaction _uses_ — but they do **not** 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**. A negative lookup is sound only when the -resolving view is complete by construction: the manager signs every guard of -its app, so manager-as-submitter sees them all. A redemption submitted with -only the user's view misses existing guards and double-consumes — -`testReplayNeedsConsumerView` pins exactly that (two guards under one key) via -a minimal `LookupProbe` template, deliberately not shaped like a flow. If that -test ever starts failing, the ledger has tightened key semantics and this -section should be revisited. - -User-*submitted* redemption (a flexible-controller choice exercised by the end -user) is deliberately absent from the example: it is sound only when the -submission carries the manager's read view — `readAs manager` through the -app's participant, a per-API-user IAM grant exposing the manager's entire ACS. -If user-submitted redemption is genuinely needed, scope the exposure instead: -add a well-known public party as observer on `ConsumedVAA` and have users -submit with `readAs public` (the Splice pattern), trading consumption metadata -privacy for it. +(test package only): the end user herself exercises `Redeem` on the app +contract with two disclosures attached (CoreState + covering node) and nothing +else — no read grants, no `readAs`, no relayer queue. The manager's +*authority* for the consume is inherited from the app contract's signatory; +its *visibility* is simply not needed. `testIntegratorRedeem` pins the +inversion of the old key-based footgun: replay attempts by arbitrary +submitters fail on activeness (stale node) or membership (fresh node) — never +succeed. --- @@ -791,6 +814,7 @@ canton/ daml.yaml ← data-dependency on core's DAR daml/Test/TestCore.daml ← unit tests + devnet `setup` / `integrationPublish` daml/Test/TestGuardianObserver.daml ← read-only guardianObserver visibility/authority tests + daml/Test/TestReplay.daml ← replay-trie tests + disclosure-service simulation (§4.6) daml/Test/ExampleIntegrator.daml ← reference integrator + disclosure/replay e2e (§4.6) devnet/ start_sandbox.sh ← starts the Ledger API v2 sandbox From dc18b73cf4be39a930cf1f33e1e5ae34dde72b91 Mon Sep 17 00:00:00 2001 From: csongor Date: Fri, 10 Jul 2026 14:11:55 +0200 Subject: [PATCH 3/3] canton: appease the spellchecker --- canton/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/canton/README.md b/canton/README.md index 2c5750a271..a3a377d37b 100644 --- a/canton/README.md +++ b/canton/README.md @@ -520,7 +520,7 @@ global consumption truth. **Why this is sound where keys were not.** "Not consumed" became 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 +consume it. Every confirming participant validates the node's activeness — a stale cid conflicts and aborts; the current covering node provably contains or excludes the digest. Soundness is **submitter-independent**: any submitter with a disclosure of the current covering node gets a correct check, and a wrong or @@ -552,7 +552,7 @@ Properties: the loser re-resolves the covering node and retries. A blind retry of a consume that actually committed fails on membership ("VAA already consumed") — a clean idempotency signal. Until the first split, all of one app's - consumes serialize through its root; 16-way fanout begins at + consumes serialize through its root; 16-way fan-out begins at `splitThreshold` consumes. - **Cost.** `defaultSplitThreshold = 128`: suffixes are ≤64-char hex (~66 B serialized), so nodes top out ~8.5 KB, the average consume rewrites ~4 KB, @@ -582,7 +582,7 @@ contract with two disclosures attached (CoreState + covering node) and nothing else — no read grants, no `readAs`, no relayer queue. The manager's *authority* for the consume is inherited from the app contract's signatory; its *visibility* is simply not needed. `testIntegratorRedeem` pins the -inversion of the old key-based footgun: replay attempts by arbitrary +inversion of the key-based design's failure mode: replay attempts by arbitrary submitters fail on activeness (stale node) or membership (fresh node) — never succeed.