Skip to content

Commit 77bc8ae

Browse files
committed
canton: migrate NTT Receive replay protection to ConsumedVAA guards
Receive now verifies and claims the VAA atomically via the core bridge's VerifyAndConsumeVAA instead of ParseAndVerifyVAA plus a manager-local consumed set. The guard is scoped to the deployment's admin party, so each deployment claims a VAA at most once and distinct VAAs never contend. With no manager state to update on the receive path, Receive becomes nonconsuming: the manager contract is no longer archived/recreated per inbound transfer, so concurrent receives of different VAAs do not race on the manager. The ConsumedVAA fail-open lookup caveat is closed structurally for NTT: operator, the controller and canonical submitter, co-signs every guard the deployment creates, so the submitting view contains all prior guards. Verified live via TestCantonNttReceiveMatchIntegration: matching-recipient Receive succeeds and a second relay of the same VAA is rejected. Claude-Session: https://claude.ai/code/session_01QJMuDHkjKHxZ3KvoS9Ryyz
1 parent 2f5a906 commit 77bc8ae

3 files changed

Lines changed: 64 additions & 44 deletions

File tree

canton/README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,8 @@ unlike requiring `admin` as an active co-controller would have been.
10711071

10721072
### Receive (`NttManager.Receive`, controller `operator` — guardian-relayed)
10731073

1074-
Verifies the VAA via the core bridge's `ParseAndVerifyVAA`, resolving the
1074+
Verifies the VAA **and claims it atomically** via the core bridge's
1075+
`VerifyAndConsumeVAA`, resolving the
10751076
`CoreState` **by key** against the manager's committed `guardianGovernance`
10761077
anchor — there is deliberately no caller-supplied core-state reference to
10771078
substitute. The key's maintainer is `guardianGovernance` itself, whose
@@ -1082,10 +1083,18 @@ compromised operator cannot recreate the manager under a forged anchor either.
10821083
This is the Daml analog of EVM NTT calling `parseAndVerifyVM` on an immutable
10831084
core address (and of Solana NTT's owner-checked posted-VAA account), and it
10841085
reuses the single audited verification path rather than duplicating
1085-
`verifyVAA` inline. `Receive` then enforces the configured peer
1086-
(`(chainId → managerAddress, transceiverAddress)` from `SetPeer`), **replay-
1087-
protects on the VAA hash** (integrator-owned — `CoreState.consumedGovernance`
1088-
stays reserved for `"Core"`), decodes, binds the caller-supplied `recipient` to
1086+
`verifyVAA` inline. **Replay protection is the per-VAA `ConsumedVAA` guard**
1087+
(§4.6), scoped to the deployment's `admin` party — not a consumed-set in
1088+
manager state (`CoreState.consumedGovernance` stays reserved for `"Core"`).
1089+
Because no manager state changes per inbound transfer, `Receive` is
1090+
`nonconsuming`: the manager contract is never archived/recreated on the
1091+
receive path, so concurrent receives of distinct VAAs do not contend. The
1092+
guard's fail-open lookup caveat (§4.6) is closed structurally here: `operator`
1093+
— the controller and canonical submitter — co-signs every guard the deployment
1094+
creates, so the submitting view provably contains all prior guards. `Receive`
1095+
then enforces the configured peer
1096+
(`(chainId → managerAddress, transceiverAddress)` from `SetPeer`), decodes,
1097+
binds the caller-supplied `recipient` to
10891098
the VAA's own `recipientAddress` (below), and drives `MintOrUnlock`. `pubKeys`
10901099
are untrusted hints today (hint-free is a follow-up). Pinning is
10911100
regression-tested by `testNttReceiveVerifiesAgainstPinnedGuardianSet`: a
@@ -1251,7 +1260,7 @@ and keyed by it (§4.1), so the operator can neither mutate the real `CoreState`
12511260
nor fabricate one that a consumer would find by the governance anchor. Every
12521261
inbound verification path really does resolve by that anchor: governance via
12531262
`SubmitGovernanceVAA` on the keyed singleton, and NTT `Receive` via
1254-
`ParseAndVerifyVAA` keyed on the manager's admin-co-signed `guardianGovernance`
1263+
`VerifyAndConsumeVAA` keyed on the manager's admin-co-signed `guardianGovernance`
12551264
field (§10, with no caller-supplied core-state reference). This closes
12561265
the one gap that observation-only hosting does *not* cover — inbound state
12571266
integrity — by moving it from "trust the single operator" to "trust the k-of-n

canton/ntt/daml/Wormhole/Ntt/Manager.daml

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,27 @@
2020
--
2121
-- Send rides the core bridge: 'Transfer' publishes via 'Emitter.PublishMessage',
2222
-- so the guardian watcher observes it as an ordinary core message (no NTT-specific
23-
-- watcher code). Receive is guardian-relayed: 'Receive' verifies the VAA via the
24-
-- core bridge's 'ParseAndVerifyVAA', resolving the 'CoreState' BY KEY against the
25-
-- deployment's committed @guardianGovernance@ anchor (admin-co-signed at approval,
26-
-- so a compromised operator cannot substitute a forged guardian set — the Daml
27-
-- analog of EVM NTT calling @parseAndVerifyVM@ on an immutable core address). It
28-
-- then enforces the configured peer, replay-protects on the VAA hash, decodes,
29-
-- binds the caller-supplied @recipient@ to the VAA's own @recipientAddress@ via
30-
-- 'recipientAddressFor', and drives the token mint/unlock.
23+
-- watcher code). Receive is guardian-relayed: 'Receive' verifies the VAA and
24+
-- claims it atomically via the core bridge's 'VerifyAndConsumeVAA' (per-VAA
25+
-- 'ConsumedVAA' replay guards scoped to the deployment's @admin@), resolving the
26+
-- 'CoreState' BY KEY against the deployment's committed @guardianGovernance@
27+
-- anchor (admin-co-signed at approval, so a compromised operator cannot
28+
-- substitute a forged guardian set — the Daml analog of EVM NTT calling
29+
-- @parseAndVerifyVM@ on an immutable core address). It then enforces the
30+
-- configured peer, decodes, binds the caller-supplied @recipient@ to the VAA's
31+
-- own @recipientAddress@ via 'recipientAddressFor', and drives the token
32+
-- mint/unlock.
3133
module Wormhole.Ntt.Manager where
3234

3335
import DA.Crypto.Text (keccak256, toHex)
3436
import DA.Map (Map)
3537
import DA.Map qualified as Map
3638
import DA.Optional (fromSomeNote)
37-
import DA.Set (Set)
38-
import DA.Set qualified as Set
3939

4040
import Splice.Api.Token.HoldingV1 (Holding)
4141
import Splice.Api.Token.MetadataV1 (ExtraArgs)
4242
import Wormhole.Core.Bytes
43-
import Wormhole.Core.State (Emitter, CoreState, ParseAndVerifyVAA (..), PublishMessage (..))
43+
import Wormhole.Core.State (Emitter, CoreState, VerifyAndConsumeVAA (..), PublishMessage (..))
4444
import Wormhole.Core.VAA () -- HasField instances for VAA record-dot access
4545
import Wormhole.Ntt.Payload
4646
import Wormhole.Ntt.Token
@@ -154,7 +154,6 @@ template NttDeploymentRequest
154154
tokenDecimals
155155
peers = Map.empty
156156
outboundSequence = 0
157-
consumed = Set.empty
158157

159158
template NttManager
160159
with
@@ -170,7 +169,8 @@ template NttManager
170169
tokenDecimals : Int
171170
peers : Map Int Peer -- recipient/ source chainId -> peer
172171
outboundSequence : Int -- next NttManagerMessage id
173-
consumed : Set Bytes32 -- inbound VAA hashes already executed
172+
-- (Inbound replay protection lives in per-VAA 'ConsumedVAA' guards keyed
173+
-- by (admin, vaaHash), not in manager state — see 'Receive'.)
174174
where
175175
-- Admin co-signs (mirroring 'Emitter'): the manager address includes the
176176
-- admin, so a compromised operator cannot mint a manager carrying an existing
@@ -268,21 +268,34 @@ template NttManager
268268
consistencyLevel
269269
create this with outboundSequence = outboundSequence + 1
270270

271-
-- | Inbound transfer, guardian-relayed. Verifies the VAA via the core
272-
-- bridge's 'ParseAndVerifyVAA', resolving the 'CoreState' BY KEY against
273-
-- the manager's committed @guardianGovernance@ anchor — the key's
274-
-- maintainer is @guardianGovernance@ itself, whose signature the operator
275-
-- lacks, so only the authentic 'CoreState' can ever satisfy the lookup
276-
-- (there is deliberately no caller-supplied core-state reference to
277-
-- substitute). The anchor was attested by @admin@ at deployment and is
278-
-- covered by the admin co-signature, so a compromised operator cannot
279-
-- recreate the manager under a forged anchor either. This mirrors EVM NTT
280-
-- (@parseAndVerifyVM@ on an immutable core address) and Solana NTT (the
281-
-- owner-checked posted-VAA account). It then enforces the configured
282-
-- peer, replay-protects on the VAA hash, decodes, binds the
283-
-- caller-supplied @recipient@ to the VAA's own @recipientAddress@ via
284-
-- 'recipientAddressFor', and mints/unlocks to it. @pubKeys@ are untrusted
285-
-- hints bound to guardian addresses inside 'verifyVAA'.
271+
-- | Inbound transfer, guardian-relayed. Verifies the VAA and records its
272+
-- consumption atomically via the core bridge's 'VerifyAndConsumeVAA',
273+
-- resolving the 'CoreState' BY KEY against the manager's committed
274+
-- @guardianGovernance@ anchor — the key's maintainer is
275+
-- @guardianGovernance@ itself, whose signature the operator lacks, so only
276+
-- the authentic 'CoreState' can ever satisfy the lookup (there is
277+
-- deliberately no caller-supplied core-state reference to substitute). The
278+
-- anchor was attested by @admin@ at deployment and is covered by the admin
279+
-- co-signature, so a compromised operator cannot recreate the manager
280+
-- under a forged anchor either. This mirrors EVM NTT (@parseAndVerifyVM@
281+
-- on an immutable core address) and Solana NTT (the owner-checked
282+
-- posted-VAA account). It then enforces the configured peer, decodes,
283+
-- binds the caller-supplied @recipient@ to the VAA's own
284+
-- @recipientAddress@ via 'recipientAddressFor', and mints/unlocks to it.
285+
-- @pubKeys@ are untrusted hints bound to guardian addresses inside
286+
-- 'verifyVAA'.
287+
--
288+
-- REPLAY PROTECTION is the per-VAA 'Wormhole.Core.Replay.ConsumedVAA'
289+
-- guard (the Solana claim-account shape), scoped to @admin@ — the
290+
-- deployment's integrator party — so each deployment claims a VAA at most
291+
-- once and distinct VAAs never contend. That is also why this choice is
292+
-- @nonconsuming@: with no consumed-set to update, the manager is not
293+
-- archived/recreated per inbound transfer, so concurrent 'Receive's of
294+
-- different VAAs do not race on the manager contract. The guard's
295+
-- fail-open lookup caveat (see 'Wormhole.Core.Replay') is closed here
296+
-- structurally: @operator@ — the controller and canonical submitter —
297+
-- co-signs every guard this deployment creates, so the submission's read
298+
-- view provably contains all prior guards.
286299
--
287300
-- @recipient@ is a choice argument, but the relayer (@operator@, the
288301
-- controller) cannot supply an arbitrary one: 'recipientAddressFor recipient'
@@ -304,7 +317,7 @@ template NttManager
304317
--
305318
-- NOTE (milestone gap, see README §10): hint-free verification via persisted
306319
-- guardian pubkeys is a tracked follow-up.
307-
choice Receive : NativeTokenTransfer
320+
nonconsuming choice Receive : NativeTokenTransfer
308321
with
309322
vaaBytes : Bytes
310323
pubKeys : [(Int, Bytes)]
@@ -313,15 +326,17 @@ template NttManager
313326
extraArgs : ExtraArgs -- CIP-56 registry choice context
314327
controller operator
315328
do
316-
vaa <- exerciseByKey @CoreState guardianGovernance ParseAndVerifyVAA with
317-
verifier = operator
318-
encodedVAA = vaaBytes
319-
pubKeys
329+
-- Verify + claim in one atomic step; a second relay of the same VAA
330+
-- fails inside VerifyAndConsumeVAA on the existing guard. @admin@'s
331+
-- controller authority is inherited from this contract's signatories.
332+
(vaa, _guardCid) <- exerciseByKey @CoreState guardianGovernance VerifyAndConsumeVAA with
333+
encodedVAA = vaaBytes
334+
pubKeys
335+
consumer = admin
320336
let peer = fromSomeNote ("ntt: no peer for source chain " <> show vaa.emitterChain)
321337
(Map.lookup vaa.emitterChain peers)
322338
assertMsg "ntt: VAA emitter is not the configured peer transceiver"
323339
(normalizeHex vaa.emitterAddress == peer.transceiverAddress)
324-
assertMsg "ntt: message already consumed" (not (Set.member vaa.hash consumed))
325340
let wm = decodeWormholeTransceiverMessage vaa.payload
326341
assertMsg "ntt: wrong recipient manager" (wm.recipientManager == managerAddress)
327342
assertMsg "ntt: source manager is not the peer manager"
@@ -335,5 +350,4 @@ template NttManager
335350
amount = TrimmedAmount with decimals = ntt.decimals, amount = ntt.amount
336351
inputHoldingCids
337352
extraArgs
338-
create this with consumed = Set.insert vaa.hash consumed
339353
pure ntt

canton/test/daml/Test/TestNtt.daml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,6 @@ testNttReceiveRecipientMismatchFails = do
276276
tokenDecimals = 8
277277
peers = Map.empty
278278
outboundSequence = 0
279-
consumed = Set.empty
280279
mgrId <- submit admin do
281280
exerciseCmd mgrId SetPeer with
282281
chainId = 2
@@ -334,7 +333,6 @@ testNttReceiveVerifiesAgainstPinnedGuardianSet = do
334333
tokenDecimals = 8
335334
peers = Map.empty
336335
outboundSequence = 0
337-
consumed = Set.empty
338336
mgrId <- submit admin do
339337
exerciseCmd mgrId SetPeer with
340338
chainId = 2
@@ -409,7 +407,6 @@ integrationNttReceiveMatchSetup = do
409407
tokenDecimals = 8
410408
peers = Map.empty
411409
outboundSequence = 0
412-
consumed = Set.empty
413410
mgrId <- submit admin do
414411
exerciseCmd mgrId SetPeer with
415412
chainId = 2

0 commit comments

Comments
 (0)