Skip to content

canton: NTT layer on the keyless core bridge#30

Closed
bengtlofgren wants to merge 15 commits into
integration/cantonfrom
canton-keyed-ntt
Closed

canton: NTT layer on the keyless core bridge#30
bengtlofgren wants to merge 15 commits into
integration/cantonfrom
canton-keyed-ntt

Conversation

@bengtlofgren

@bengtlofgren bengtlofgren commented Jul 3, 2026

Copy link
Copy Markdown

Reworks the NTT (Native Token Transfers) layer to sit on the current Canton core bridge. It was originally built on the registry-keyed core, before three later changes reworked every core primitive it used: a per-consumer replay trie (#38, merged), removal of all contract keys (#39), and CIP-0056 fee payments (#40). It no longer built, and its inbound trust argument no longer held. Stacked on cs/canton-fees (#40, itself on cs/remove-keys #39).

Why the old NTT layer doesn't work

NTT's shape is a Manager plus a Wormhole transceiver — on Canton the transceiver is a core Emitter, so a send is an ordinary core publish and the guardian watcher needs no NTT-specific code. The old layer resolved everything through Daml contract keys: NttManagerRegistry keyed by operator, NttManager keyed by (operator, admin, id), and — critically — Receive resolved the CoreState by a key whose maintainer was guardianGovernance, arguing that a compromised operator could not substitute a forged guardian set because it could not produce that key.

That argument was already unsound under Daml-LF 2.3, where key lookups resolve against the submitter's own view with no maintainer attestation — which is exactly why #39 removed contract keys from the core. With keys gone, replay protection now a per-consumer trie (#38), and publishing now charging a fee (#40), none of the old flows compile or verify.

The rework

Keyless, direct-cid, disclosure-served. Every contract is resolved by an explicit contract-id served by the deployment's disclosure service (untrusted for safety, liveness-only — the same model the core uses). The manager and its transceiver churn on their consuming choices, so a sender re-resolves fresh cids per send, failing closed on staleness.

Crankless, permissionless deployment. A deployer exercises RegisterManager directly on the disclosed NttManagerRegistry; the operator's co-signature on the manager is inherited from the registry signatory, so the guardian operator runs no approval crank (mirroring the core's direct RegisterEmitter). Manager and transceiver addresses stay owner-bound — admin is in the preimage and co-signs — so a compromised operator cannot forge an existing deployment's address.

Sends are permissionless and the sender self-pays. Transfer's sole controller is the sending user; the operator/admin authority the nested lock-or-burn and publish need is inherited from consuming the manager. Every Wormhole message pays a fee, no exceptions — and the sender pays its own message fee, from its own wallet. This needed a small core-fee change (in #40): PublishMessage now charges its payer, and payer co-controls the publish, so the user's authority reaches the fee's allocation transfer even though the shared transceiver Emitter is owned by admin. The transceiver has to be shared — its derived address is the peer identity other chains register — so per-user emitters, which would otherwise make the user the publisher, are not an option; the payer seam is what lets the user foot the fee anyway.

Receive is permissionless — any third-party executor. Anyone relays an inbound VAA on the recipient's behalf. Receive is nonconsuming, so the operator/admin authority every nested action needs is inherited from the manager's signatories regardless of who submits; the executor's identity carries no authority, only the disclosures it attaches. Replay protection is the core per-consumer trie, scoped to the deployment's admin.

The guardian trust root is pinned to guardianGovernance, not the operator. The old key-maintainer guarantee is replaced by an explicit pin: the disclosed CoreState's guardianGovernance must equal the anchor the admin committed at deployment (via a new GetGuardianGovernance read choice). This is deliberately stronger than pinning the operator: a CoreState requires only some (operator, gg) signature pair, so an operator whose hot key is compromised could co-sign a forged CoreState under a throwaway gg party and an attacker guardian set that would pass an operator-pin. guardianGovernance is the guardians' k-of-n external threshold party, which a single operator key cannot forge — pinning it closes that hole and preserves the compromised-operator defense the original design targeted. (The core reference integrator still pins only the operator; worth revisiting separately.)

Recipient binding. A Canton Party has no fixed-size address, so Receive binds the caller-supplied recipient to the VAA's own recipientAddress as keccak256 over the Party-id string — a value the sender fixes off-chain and the guardian signature covers, so no relayer can steer the mint. This permanently binds a signed VAA to the recipient party as it existed when the sender hashed it; a party that must change requires a re-send (chosen deliberately over a repointable-handle registry).

Token-standard DARs are shared with the core fee path — one vendored interface set under dars/, with burn-mint and transfer-instruction added for the CIP-0056 token hooks.

See canton/README.md §11 for the full design.

🤖 Generated with Claude Code

Base automatically changed from canton-keyed-identity to integration/canton July 6, 2026 13:06
@bengtlofgren

Copy link
Copy Markdown
Author

@cursor review?

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Skipping Bugbot: Bugbot is disabled for this repository. Visit the Bugbot dashboard to update your settings.

@bengtlofgren
bengtlofgren changed the base branch from integration/canton to canton-guardian-observer July 7, 2026 02:07
@bengtlofgren bengtlofgren changed the title canton: NTT layer on registry-keyed core (stacked on #29) canton: NTT layer on registry-keyed core (stacked on #32) Jul 7, 2026
@bengtlofgren
bengtlofgren force-pushed the canton-keyed-ntt branch 2 times, most recently from 067d93d to a156d08 Compare July 7, 2026 15:11
bengtlofgren added a commit that referenced this pull request Jul 8, 2026
…ive leg

Applies lessons learned building the NTT layer (#30) to the classic Token
Bridge example: resolve the bridge's Emitter by key instead of a
caller-supplied cid (was stale after one use and unchecked), derive
tokenAddress from the locked instrument instead of trusting a free-standing
argument, and normalize amounts to 8dp before locking so locked and attested
amounts can never diverge. Adds the previously-missing CompleteTransfer
receive leg (VAA verification, peer registration, replay protection,
recipient binding), a real permissioned-controller design (an initial
permissionless attempt was empirically disproven by a Daml fetch-authorization
constraint, mirroring why NTT's own Receive is controller-operator), and a
live two-step integration harness alongside the existing send-path test.

TokenRegistry now accepts multi-input holdings with change instead of a
single exact-amount holding.
Base automatically changed from canton-guardian-observer to integration/canton July 8, 2026 10:57
bengtlofgren added a commit that referenced this pull request Jul 8, 2026
…34)

* canton: make ParseAndVerifyVAA's controller the caller, not operator

ParseAndVerifyVAA is documented as being "for the token bridge / external
verifiers" -- but was controller operator, meaning every external protocol
needed operator to actively co-sign its own read-only verification call.

This is nonconsuming and does no create/archive at all -- unlike
SubmitGovernanceVAA (which IS consuming and creates a successor CoreState
genuinely needing guardianGovernance's co-signature), nothing in this choice's
body needs operator's authority for any structural reason. It's pure
computation: parse, look up the guardian set, verify, return.

Added `verifier : Party` as a choice argument and made it the sole controller.
Any external party can now verify a VAA as itself, with operator never an
active party. The one real requirement is visibility, not authorization: a
non-stakeholder needs CoreState explicitly disclosed to reference it -- a data
attachment servable by any party with read access, not a discretionary
approval. Same pattern as Wormhole.Ntt.Manager.Transfer's controller-user fix
on the canton-keyed-ntt branch (PR #30), which is where this was first
verified empirically; this is the same fix applied to already-merged core
code, hence its own standalone change against integration/canton rather than
being bundled into that NTT-specific PR.

testParseAndVerifyVAAByExternalVerifier proves it end-to-end: an external
party with no relationship to CoreState (not operator, guardianGovernance, or
guardianObserver) verifies a real guardian-signed VAA as itself, given only
CoreState disclosed to it.

Verified: dpm build --all clean; dpm test 13/13 (1 new).

* canton: add a live integration test for the external-verifier fix

Proves ParseAndVerifyVAA is callable by an external "verifier" party that is
NOT a stakeholder of CoreState (CoreState's only stakeholders are operator and
guardianGovernance) against a real Canton sandbox/participant, not just the
Script interpreter.

Test.TestCore:integrationParseAndVerifyVAAByExternalVerifier allocates a fresh
external party, has CoreState explicitly disclosed to it, and verifies a real
guardian-signed VAA as that party alone -- operator is never active in the
submission. node/pkg/watchers/canton/core_verify_vaa_integration_test.go boots
a live sandbox, runs it via `dpm script`, and confirms the returned party is a
real, live-hosted party id.

Also fixed a copy-paste inaccuracy in testParseAndVerifyVAAByExternalVerifier's
docstring from the previous commit: it named guardianObserver as one of
CoreState's stakeholders, but that party doesn't exist on this branch
(integration/canton) -- CoreState's only stakeholders here are operator and
guardianGovernance.

Verified live:
  go test -tags integration -run TestCantonParseAndVerifyVAAExternalVerifierIntegration ./pkg/watchers/canton -v
PASS (23.4s), clean teardown, no stray processes. dpm build --all / dpm test
also clean (14 scripts, 1 new).

* canton: use submitWithDisclosures for readability, add docs pointer

submit (actAs tokenBridge <> discloseMany [dCs]) is the general
SubmitOptions-builder form, needed when multiple actAs parties are required
alongside disclosure. Neither call site here needs more than one actAs party,
so this was just habit -- submitWithDisclosures is strictly equivalent (it's
literally defined as `submitWithDisclosures p ds cmds = submit (actAs p <>
discloseMany ds) cmds` in Daml.Script.Internal.Questions.Submit) and reads
more directly: the function name says "this uses disclosure" instead of
requiring the reader to notice `<> discloseMany` tacked onto a builder.

Also added a direct link to Daml's Explicit Contract Disclosure docs at the
point ParseAndVerifyVAA's comment first introduces the mechanism, and named
submitWithDisclosures as the Script-side idiom, so a future reader can look up
the primary source instead of just trusting the comment.

Verified: dpm build --all clean; dpm test passes (3/3 ParseAndVerifyVAA
scripts); live integration test re-run and still passes
(TestCantonParseAndVerifyVAAExternalVerifierIntegration, PASS, 24.3s).
@bengtlofgren bengtlofgren changed the title canton: NTT layer on registry-keyed core (stacked on #32) canton: NTT layer on the registry-keyed core bridge Jul 8, 2026
bengtlofgren added a commit that referenced this pull request Jul 8, 2026
…ive leg

Applies lessons learned building the NTT layer (#30) to the classic Token
Bridge example: resolve the bridge's Emitter by key instead of a
caller-supplied cid (was stale after one use and unchecked), derive
tokenAddress from the locked instrument instead of trusting a free-standing
argument, and normalize amounts to 8dp before locking so locked and attested
amounts can never diverge. Adds the previously-missing CompleteTransfer
receive leg (VAA verification, peer registration, replay protection,
recipient binding), a real permissioned-controller design (an initial
permissionless attempt was empirically disproven by a Daml fetch-authorization
constraint, mirroring why NTT's own Receive is controller-operator), and a
live two-step integration harness alongside the existing send-path test.

TokenRegistry now accepts multi-input holdings with change instead of a
single exact-amount holding.
@bengtlofgren
bengtlofgren force-pushed the canton-keyed-ntt branch 2 times, most recently from df2b35d to 77bc8ae Compare July 9, 2026 11:18
@kcsongor kcsongor changed the title canton: NTT layer on the registry-keyed core bridge canton: NTT layer on the keyless core bridge Jul 13, 2026
@kcsongor
kcsongor changed the base branch from integration/canton to cs/canton-fees July 13, 2026 20:32
Base automatically changed from cs/canton-fees to integration/canton July 15, 2026 13:30
@bengtlofgren

Copy link
Copy Markdown
Author

Closing this in favor of wormholelabs-xyz/native-token-transfers#36, which ports this branch's NTT layer to the native-token-transfers repo (Canton's canonical home for NTT, matching the EVM/Solana/Sui convention). All the design work here — GetGuardianGovernance, the crankless registration redesign, the replay-trie migration — carried over; the GetGuardianGovernance core choice itself already landed separately via #41.

#36 is currently open pending a required code-owner review on that repo, not yet merged. Once it lands, this branch's content will live there instead of here.

Thanks for the work on this — closing as superseded, not as a rejection of the design.

bengtlofgren added a commit that referenced this pull request Jul 16, 2026
…ive leg

Applies lessons learned building the NTT layer (#30) to the classic Token
Bridge example: resolve the bridge's Emitter by key instead of a
caller-supplied cid (was stale after one use and unchecked), derive
tokenAddress from the locked instrument instead of trusting a free-standing
argument, and normalize amounts to 8dp before locking so locked and attested
amounts can never diverge. Adds the previously-missing CompleteTransfer
receive leg (VAA verification, peer registration, replay protection,
recipient binding), a real permissioned-controller design (an initial
permissionless attempt was empirically disproven by a Daml fetch-authorization
constraint, mirroring why NTT's own Receive is controller-operator), and a
live two-step integration harness alongside the existing send-path test.

TokenRegistry now accepts multi-input holdings with change instead of a
single exact-amount holding.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants