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.
3133module Wormhole.Ntt.Manager where
3234
3335import DA.Crypto.Text (keccak256, toHex)
3436import DA.Map (Map)
3537import DA.Map qualified as Map
3638import DA.Optional (fromSomeNote)
37- import DA.Set (Set)
38- import DA.Set qualified as Set
3939
4040import Splice.Api.Token.HoldingV1 (Holding)
4141import Splice.Api.Token.MetadataV1 (ExtraArgs)
4242import Wormhole.Core.Bytes
43- import Wormhole.Core.State (Emitter, CoreState, ParseAndVerifyVAA (..), PublishMessage (..))
43+ import Wormhole.Core.State (Emitter, CoreState, VerifyAndConsumeVAA (..), PublishMessage (..))
4444import Wormhole.Core.VAA () -- HasField instances for VAA record-dot access
4545import Wormhole.Ntt.Payload
4646import Wormhole.Ntt.Token
@@ -154,7 +154,6 @@ template NttDeploymentRequest
154154 tokenDecimals
155155 peers = Map.empty
156156 outboundSequence = 0
157- consumed = Set.empty
158157
159158template 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
0 commit comments