canton: NTT layer on the keyless core bridge#30
Conversation
c2335c1 to
64660ce
Compare
|
@cursor review? |
|
Skipping Bugbot: Bugbot is disabled for this repository. Visit the Bugbot dashboard to update your settings. |
64660ce to
a4422e3
Compare
067d93d to
a156d08
Compare
…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.
…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).
073a046 to
4fe8d17
Compare
…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.
df2b35d to
77bc8ae
Compare
77bc8ae to
d5321d9
Compare
57c85e4 to
dd1dc9f
Compare
|
Closing this in favor of wormholelabs-xyz/native-token-transfers#36, which ports this branch's NTT layer to the #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. |
…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.
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 oncs/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:NttManagerRegistrykeyed by operator,NttManagerkeyed by(operator, admin, id), and — critically —Receiveresolved theCoreStateby a key whose maintainer wasguardianGovernance, 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
RegisterManagerdirectly on the disclosedNttManagerRegistry; 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 directRegisterEmitter). Manager and transceiver addresses stay owner-bound —adminis 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 sendinguser; 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):PublishMessagenow charges itspayer, andpayerco-controls the publish, so the user's authority reaches the fee's allocation transfer even though the shared transceiverEmitteris owned byadmin. 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; thepayerseam 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.
Receiveis 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'sadmin.The guardian trust root is pinned to
guardianGovernance, not the operator. The old key-maintainer guarantee is replaced by an explicit pin: the disclosedCoreState'sguardianGovernancemust equal the anchor the admin committed at deployment (via a newGetGuardianGovernanceread choice). This is deliberately stronger than pinning the operator: aCoreStaterequires only some(operator, gg)signature pair, so an operator whose hot key is compromised could co-sign a forgedCoreStateunder a throwawayggparty and an attacker guardian set that would pass an operator-pin.guardianGovernanceis 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
Partyhas no fixed-size address, soReceivebinds the caller-supplied recipient to the VAA's ownrecipientAddressaskeccak256over 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/, withburn-mintandtransfer-instructionadded for the CIP-0056 token hooks.See
canton/README.md§11 for the full design.🤖 Generated with Claude Code