Skip to content

Commit a156d08

Browse files
committed
canton: bind NTT Receive's recipient to the VAA's recipientAddress via a hash
Receive previously took `recipient : Party` as a plain, relayer-supplied choice argument with no binding to the VAA's `recipientAddress` at all -- the operator (the choice's controller) could steer an inbound mint to any Canton party. recipientAddress is a Bytes32, sized for chains whose native address IS a fixed-size identifier (EVM, Solana). A Canton Party id has no such fixed-size form, so there's nothing to compare it against directly. recipientAddressFor recipient = keccak256("wormhole:ntt-recipient:v1" ‖ lp(partyToText recipient)), mirroring nttManagerAddressFor's style. Receive keeps `recipient` as a choice argument, but asserts recipientAddressFor recipient == ntt.recipientAddress before minting: recipientAddress is fixed by the sender and covered by the guardian signature, so the operator still cannot steer an inbound mint anywhere the sender didn't address, without needing a registry template pair or an extra on-ledger lookup on every receive. Tradeoff, accepted deliberately: a Canton Party id never changes once allocated (it's the hash of a namespace root, or of a DecentralizedNamespaceDefinition's founding owners), so this permanently binds a signed VAA to the recipient party as it existed when the sender computed the hash. If that party ever needs to change (a lost key, a planned custodial migration -- not a rename of the same party, which has no meaning in Canton), a VAA already signed against the old hash cannot be redirected; the sender must re-send against the new party's hash. A self-service handle registry (resolving a stable, sender-chosen handle to a party that can be repointed later) avoids that, at the cost of an extra template pair and a fetchByKey on every receive; this is the simpler mechanism, accepting a manual re-send as the cost of a Party ever having to change. Tests: testRecipientAddressForVector pins the encoding against fixed text (a live Party's fingerprint is nondeterministic and can't be baked into a constant -- same reasoning as nttManagerAddressFor's own vector), cross-checked in node/pkg/cantonclient/vectorgen_test.go:TestGenerateAddressVectors (extended with the matching preimage shape). testNttReceiveRecipientMismatchFails proves the check actually gates Receive against the real signed fixture. Known test gap (documented in README.md and Manager.daml): no automated test exercises the MATCHING-recipient path through a real Receive call end-to-end. The fixture is a single, statically pre-signed VAA with a fixed recipientAddress, and a live Party's fingerprint is freshly and unpredictably allocated every run, so no allocated Party can ever be made to match it ahead of time. Closing this needs a live two-step sign-then-relay harness (allocate the recipient, compute its hash, sign a fresh VAA against that hash via the guardian test key, then relay it) -- not built here, since it would add back most of the complexity this change was meant to avoid. Verified: dpm build --all clean (5 packages); dpm test 20/20; gofmt/go vet/go test clean on node/pkg/cantonclient and pkg/watchers/canton.
1 parent 584340a commit a156d08

4 files changed

Lines changed: 154 additions & 45 deletions

File tree

canton/README.md

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,7 @@ canton/
673673
daml.yaml ← data-dependencies on the built DARs
674674
daml/Test/TestCore.daml ← unit tests + devnet `setup` / `integrationPublish`
675675
daml/Test/TestGuardianObserver.daml ← read-only guardianObserver visibility/authority tests
676-
daml/Test/TestNtt.daml ← NTT codec / deployment / send / receive tests
676+
daml/Test/TestNtt.daml ← NTT codec / deployment / send / receive / recipient-binding tests
677677
devnet/
678678
start_sandbox.sh ← starts the Ledger API v2 sandbox
679679
bootstrap.sh ← allocates Operator + creates CoreState
@@ -852,9 +852,61 @@ Reuses `Wormhole.Core.VAA.parseVAA`/`verifyVAA` against the guardian set read
852852
from a disclosed `CoreState`, enforces the configured peer
853853
(`(chainId → managerAddress, transceiverAddress)` from `SetPeer`), **replay-
854854
protects on the VAA hash** (integrator-owned — `CoreState.consumedGovernance`
855-
stays reserved for `"Core"`), decodes, and drives `MintOrUnlock`. `pubKeys` are
856-
untrusted hints today (hint-free is a follow-up). Tested end-to-end against a
857-
real devnet-guardian-signed NTT VAA in `testNttReceive` (incl. replay rejection).
855+
stays reserved for `"Core"`), decodes, binds the caller-supplied `recipient` to
856+
the VAA's own `recipientAddress` (below), and drives `MintOrUnlock`. `pubKeys`
857+
are untrusted hints today (hint-free is a follow-up).
858+
859+
### Recipient binding (`recipientAddressFor`)
860+
861+
The NTT wire payload's `recipientAddress` is a `Bytes32`, sized for chains whose
862+
native address *is* a fixed-size on-chain identifier (EVM, Solana). A Canton
863+
`Party` id is an unbounded-length, namespace-qualified string, so there is no
864+
such fixed-size value to bind it to directly. `Receive` used to take `recipient`
865+
as a plain, relayer-supplied choice argument with no binding to the VAA at all —
866+
the operator could steer an inbound mint to any party.
867+
868+
`Receive` now checks `recipientAddressFor recipient == ntt.recipientAddress`
869+
`recipientAddressFor` is `keccak256("wormhole:ntt-recipient:v1" ‖
870+
lp(partyToText recipient))` (`lp` the same 4-byte length-prefix
871+
`derivedAddressFromText` uses), mirroring `nttManagerAddressFor`'s style. A
872+
sender addresses Canton by computing this same hash off-chain, over the
873+
recipient's exact Party-id string, and putting the result in the outbound
874+
transfer's `recipientAddress` field. Since that field is inside the VAA
875+
payload, it's covered by the guardian signature — the operator (`Receive`'s
876+
controller) still supplies `recipient` as a choice argument, but the
877+
transaction only succeeds if it hashes to whatever the sender committed to, so
878+
the operator cannot steer the mint anywhere the sender didn't address.
879+
880+
**Tradeoff, chosen deliberately over a self-service handle-registry
881+
indirection:** a Canton Party id never changes once allocated (it's the hash of
882+
a namespace root, or of a `DecentralizedNamespaceDefinition`'s founding
883+
owners), so this binds a signed VAA to the recipient party *as it exists when
884+
the sender computes the hash* — permanently. If the recipient ever needs a
885+
*different* party (a lost key, or a planned custodial migration — not a rename
886+
of the same party, since that has no meaning in Canton), a VAA already signed
887+
against the old party's hash cannot be redirected to the new one; the sender
888+
would need to re-send using the new party's hash. A registry indirection (a
889+
recipient-chosen handle resolving to a party that can be updated later) avoids
890+
that at the cost of an extra template pair and an on-ledger lookup on every
891+
receive; this is the simpler mechanism, accepting a manual re-send as the cost
892+
of a Party ever having to change.
893+
894+
Tested in `test/daml/Test/TestNtt.daml`: `testRecipientAddressForVector` pins
895+
the encoding against fixed text (a live Party's fingerprint is nondeterministic
896+
and can't be baked into a constant — same reasoning as `nttManagerAddressFor`'s
897+
vector, and cross-checked in
898+
[`node/pkg/cantonclient/vectorgen_test.go`](../node/pkg/cantonclient/vectorgen_test.go):`TestGenerateAddressVectors`);
899+
`testNttReceiveRecipientMismatchFails` proves the check actually gates
900+
`Receive` against the real signed fixture. **Known test gap:** the fixture is a
901+
single, statically pre-signed VAA with a fixed `recipientAddress`, and a live
902+
Party's fingerprint is freshly (and unpredictably) allocated every test run, so
903+
no allocated Party can ever be made to match it — there is currently no
904+
automated test exercising the *matching*-recipient path through a real
905+
`Receive` call end-to-end (only the pure-function vector above and the
906+
negative/mismatch case are covered). Closing that gap needs either a
907+
live two-step flow (allocate the recipient, compute its hash, sign a fresh VAA
908+
against that hash via the guardian test key, *then* relay it) or accepting
909+
manual/code-review coverage for that one branch.
858910

859911
### Token seam (`NttToken`) and modes
860912

@@ -882,8 +934,13 @@ is complete; that submission wiring is off-ledger (relayer/app).
882934

883935
### Follow-ups (tracked in [Open Questions](#11-open-questions--validation-items))
884936

885-
- **`recipientAddress``Party`**: inbound mints to a relayer-supplied `Party`,
886-
not yet bound to the 32-byte NTT address — needs a Canton address registry.
937+
- **Recipient-address change**: `recipientAddressFor` permanently binds a signed
938+
VAA to the recipient party as it existed when the sender computed the hash —
939+
a party that needs to change (lost key, custodial migration) has no recovery
940+
for VAAs already signed against the old one; the sender must re-send.
941+
- **Matching-recipient test coverage**: no automated test exercises `Receive`'s
942+
success path end-to-end (see §10's "Known test gap"); a live two-step
943+
sign-then-relay harness would close it.
887944
- **Hint-free verification** (persist guardian pubkeys) + a **readable
888945
guardian-set contract** for permissionless (non-operator) receive via
889946
disclosure, so `Receive` need not be operator-controlled.

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

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
-- watcher code). Receive is guardian-relayed: 'Receive' reuses
2424
-- 'Wormhole.Core.VAA.parseVAA'/'verifyVAA' against the guardian set read from a
2525
-- disclosed 'CoreState', enforces the configured peer, replay-protects on the VAA
26-
-- hash, decodes, and drives the token mint/unlock.
26+
-- hash, decodes, binds the caller-supplied @recipient@ to the VAA's own
27+
-- @recipientAddress@ via 'recipientAddressFor', and drives the token mint/unlock.
2728
module Wormhole.Ntt.Manager where
2829

30+
import DA.Crypto.Text (keccak256, toHex)
2931
import DA.Map (Map)
3032
import DA.Map qualified as Map
3133
import DA.Optional (fromSomeNote)
@@ -65,6 +67,30 @@ nttManagerAddressFor : Party -> Party -> Int -> Bytes32
6567
nttManagerAddressFor registrar admin managerId =
6668
derivedAddress (DomainTag nttManagerAddressTag) registrar admin (RegistryId managerId)
6769

70+
-- | Domain-separation tag for NTT recipient-address bindings. Distinct from
71+
-- every other domain tag in this codebase so a recipient binding can never
72+
-- collide with an emitter, manager, or governance address.
73+
recipientAddressTag : Text
74+
recipientAddressTag = "wormhole:ntt-recipient:v1"
75+
76+
-- | @keccak256(tag ‖ lp(utf8(recipientText)))@, where @lp@ is the 4-byte
77+
-- big-endian length prefix ('lenPrefixed'). Text form split out from
78+
-- 'recipientAddressFor' so tests can pin the encoding against a fixed string —
79+
-- a live Party's fingerprint is nondeterministic, so it cannot be baked into a
80+
-- static, pre-signed VAA fixture. Mirrors 'derivedAddressFromText'.
81+
recipientAddressFromText : Text -> Bytes32
82+
recipientAddressFromText recipientText =
83+
normalizeHex (keccak256 (toHex recipientAddressTag
84+
<> unLenPrefixed (lenPrefixed (toHex recipientText))))
85+
86+
-- | The 32-byte binding a sender must compute off-chain and put in an outbound
87+
-- NTT transfer's @recipientAddress@ field to address @recipient@ on Canton.
88+
-- 'Receive' recomputes this from the caller-supplied @recipient@ and checks it
89+
-- against the VAA's own @recipientAddress@ -- see 'Receive' for the tradeoff
90+
-- this makes against a registry indirection.
91+
recipientAddressFor : Party -> Bytes32
92+
recipientAddressFor recipient = recipientAddressFromText (partyToText recipient)
93+
6894
-- | Allocates monotonically increasing manager ids, one registry per operator
6995
-- (keyed by the operator party). Same non-uniqueness caveat as
7096
-- 'Wormhole.Core.State.EmitterRegistry': the counter discipline is behavioral,
@@ -209,15 +235,32 @@ template NttManager
209235
create this with outboundSequence = outboundSequence + 1
210236

211237
-- | Inbound transfer, guardian-relayed. Verifies the VAA against the guardian
212-
-- set in a disclosed 'CoreState', enforces the configured peer, replay-protects
213-
-- on the VAA hash, decodes, and mints/unlocks to @recipient@. @pubKeys@ are
214-
-- untrusted hints bound to guardian addresses inside 'verifyVAA'.
238+
-- set in a disclosed 'CoreState', enforces the configured peer,
239+
-- replay-protects on the VAA hash, decodes, binds the caller-supplied
240+
-- @recipient@ to the VAA's own @recipientAddress@ via
241+
-- 'recipientAddressFor', and mints/unlocks to it. @pubKeys@ are untrusted
242+
-- hints bound to guardian addresses inside 'verifyVAA'.
243+
--
244+
-- @recipient@ is a choice argument, but the relayer (@operator@, the
245+
-- controller) cannot supply an arbitrary one: 'recipientAddressFor recipient'
246+
-- must equal the VAA's own @recipientAddress@, a value fixed by the sender
247+
-- and therefore covered by the guardian signature. A sender addresses
248+
-- Canton by computing this same keccak256 off-chain (mirroring
249+
-- 'nttManagerAddressFor') over the recipient's exact Party-id string and
250+
-- putting the result in the outbound transfer's @recipientAddress@ field.
251+
--
252+
-- Tradeoff (chosen over a registry indirection): this permanently binds a
253+
-- signed VAA to the recipient Party string as it existed when the sender
254+
-- computed the hash. A Canton Party id is fixed forever once allocated, but
255+
-- if the recipient ever needs a DIFFERENT party (lost key, planned
256+
-- custodial migration -- not a rename of the same party, which cannot
257+
-- happen), a VAA already signed against the old party's hash cannot be
258+
-- redirected to the new one. A self-service handle registry avoids that at
259+
-- the cost of an extra template pair and an on-ledger lookup; this is the
260+
-- simpler mechanism, accepting that cost.
215261
--
216-
-- NOTE (milestone gaps, see README §10): (a) the NTT @recipientAddress@ is a
217-
-- 32-byte address; mapping it to a Canton @Party@ needs an address registry,
218-
-- so @recipient@ is supplied by the relayer here and NOT yet bound to
219-
-- @recipientAddress@; (b) hint-free verification via persisted guardian
220-
-- pubkeys is a tracked follow-up.
262+
-- NOTE (milestone gap, see README §10): hint-free verification via persisted
263+
-- guardian pubkeys is a tracked follow-up.
221264
choice Receive : NativeTokenTransfer
222265
with
223266
coreStateCid : ContractId CoreState
@@ -245,6 +288,8 @@ template NttManager
245288
(wm.sourceManager == peer.managerAddress)
246289
let mm = decodeNttManagerMessage wm.managerPayload
247290
ntt = decodeNativeTokenTransfer mm.payload
291+
assertMsg "ntt: recipient does not match VAA recipientAddress"
292+
(recipientAddressFor recipient == normalizeHex ntt.recipientAddress)
248293
exercise tokenCid MintOrUnlock with
249294
recipient
250295
amount = TrimmedAmount with decimals = ntt.decimals, amount = ntt.amount

canton/test/daml/Test/TestNtt.daml

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
-- | Daml Script tests for the NTT package: the wire codec, permissionless
22
-- deployment, the outbound send path (publishes via the core Emitter), and the
33
-- inbound receive path (verifies a real guardian-signed VAA, enforces the peer,
4-
-- mints via the token seam, and rejects replays).
4+
-- binds the recipient to the VAA's own recipientAddress, mints via the token
5+
-- seam, and rejects replays).
56
--
67
-- 'MockToken' is a minimal CIP-56-shaped implementation of the 'NttToken' seam
78
-- (stdlib only) so the whole protocol is exercised without the real
@@ -218,11 +219,34 @@ testNttSend = do
218219
nttTransferVAA : Bytes
219220
nttTransferVAA = "010000000001009e180f3b0c9a9b4d77e9be4c25e67db813e222af53f3fff09219c2b1c43570ea003de6b1c3f91ad6a80a7c8c5b32ee349dd2e8778a77f1d2638414a18821de72016553f10000000000000200000000000000000000000000000000000000000000000000000000000000cc0000000000000001009945ff1000000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000aa0091000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000bb004f994e54540800000000000f424000000000000000000000000000000000000000000000000000000000000000dd00000000000000000000000000000000000000000000000000000000000000ee00480000"
220221

221-
testNttReceive : Script ()
222-
testNttReceive = do
222+
-- | Pins the recipient-binding formula against fixed, non-Party text -- a live
223+
-- Party's fingerprint is nondeterministic (freshly allocated each run) and
224+
-- cannot be pinned into a constant, unlike the string literals here. Same
225+
-- style as testNttDeployment's nttManagerAddressFor vector; Go side in
226+
-- node/pkg/cantonclient/vectorgen_test.go:TestGenerateAddressVectors.
227+
testRecipientAddressForVector : Script ()
228+
testRecipientAddressForVector = do
229+
recipientAddressFromText "vector-recipient::1220deadbeef"
230+
=== "dbd2d1b09ea5715be3e45d0e38e5c5c0f6fcd0f181d45a9620dfb20f8ae3a4d9"
231+
assert (recipientAddressFromText "vector-recipient::1220deadbeef"
232+
/= recipientAddressFromText "vector-recipient::1220cafebabe")
233+
234+
-- | 'Receive' verifies the VAA, checks the peer, and decodes the payload
235+
-- against the real signed fixture -- then rejects a recipient whose
236+
-- 'recipientAddressFor' doesn't match the fixture's baked-in recipientAddress
237+
-- (0x..ee). This is the only receive-level case a static fixture can exercise
238+
-- for the SUCCESS side of the check: a live Party's fingerprint is
239+
-- nondeterministic, so no Party allocated in this (or any) test run can ever
240+
-- hash to a value that was baked into an already-signed fixture ahead of time.
241+
-- The match case itself is pinned in testRecipientAddressForVector above; a
242+
-- real production Receive matches because the sender computed
243+
-- recipientAddressFor over the ACTUAL recipient's Party-id text before the VAA
244+
-- was ever signed, which this fixture-based test cannot reproduce.
245+
testNttReceiveRecipientMismatchFails : Script ()
246+
testNttReceiveRecipientMismatchFails = do
223247
(operator, csId) <- nttSetup
224248
admin <- allocateParty "RecvAdmin"
225-
recipient <- allocateParty "Recipient"
249+
wrongRecipient <- allocateParty "WrongRecipient"
226250
-- Our manager address must match the VAA's recipientManager (0x..aa), which a
227251
-- registry-derived address cannot reproduce (party fingerprints are
228252
-- nondeterministic). Create the manager directly — deliberately using the
@@ -231,7 +255,7 @@ testNttReceive = do
231255
-- against the static signed fixture. The transceiver key is unused by Receive.
232256
mockCid <- submit admin do createCmd MockToken with admin, manager = operator, mode = BurnMint
233257
let tokenCid = toInterfaceContractId @NttToken mockCid
234-
mgrId <- submitMulti [operator, admin] [] do
258+
mgrId <- submit (actAs operator <> actAs admin) do
235259
createCmd NttManager with
236260
operator
237261
admin
@@ -250,35 +274,12 @@ testNttReceive = do
250274
chainId = 2
251275
peer = Peer with managerAddress = b32 "bb", transceiverAddress = b32 "cc"
252276

253-
ntt <- submit operator do
254-
exerciseCmd mgrId Receive with
255-
coreStateCid = csId
256-
vaaBytes = nttTransferVAA
257-
pubKeys = [(0, devnetGuardianPubKey)]
258-
recipient
259-
inputHoldingCids = []
260-
extraArgs = emptyExtraArgs
261-
262-
-- The decoded transfer matches the fixture.
263-
ntt.decimals === 8
264-
ntt.amount === 1000000
265-
ntt.recipientChain === 72
266-
ntt.sourceToken === b32 "dd"
267-
ntt.recipientAddress === b32 "ee"
268-
269-
-- The token was minted to the recipient.
270-
[(_, h)] <- query @MockHolding admin
271-
h.owner === recipient
272-
h.amount === 1000000
273-
274-
-- Replaying the same VAA is rejected.
275-
[(mgr2, _)] <- query @NttManager admin
276277
submitMustFail operator do
277-
exerciseCmd mgr2 Receive with
278+
exerciseCmd mgrId Receive with
278279
coreStateCid = csId
279280
vaaBytes = nttTransferVAA
280281
pubKeys = [(0, devnetGuardianPubKey)]
281-
recipient
282+
recipient = wrongRecipient
282283
inputHoldingCids = []
283284
extraArgs = emptyExtraArgs
284285
pure ()

node/pkg/cantonclient/vectorgen_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,10 @@ func TestGenerateAddressVectors(t *testing.T) {
182182
buf = append(buf, idb[:]...)
183183
t.Logf("%s(%s, %s, %d) = %x", tag, registrar, owner, id, crypto.Keccak256(buf))
184184
}
185+
186+
// Recipient-address preimage shape differs (single string, no id):
187+
// utf8(tag) ‖ uint32be(len(recipientText)) ‖ utf8(recipientText)
188+
const recipientText = "vector-recipient::1220deadbeef"
189+
recipientBuf := append([]byte("wormhole:ntt-recipient:v1"), lp(recipientText)...)
190+
t.Logf("wormhole:ntt-recipient:v1(%s) = %x", recipientText, crypto.Keccak256(recipientBuf))
185191
}

0 commit comments

Comments
 (0)