@@ -66,7 +66,7 @@ The most consequential differences:
6666 contract (§4.2), analogous to Sui's ` EmitterCap ` . The emitting party (` owner ` )
6767 co-signs the ` Emitter ` and is part of the address derivation, so it is the
6868 faithful Canton analog of ` msg.sender ` : only that party's key can emit under
69- its address (§4.2, §10).
69+ its address (§4.2, §10.1 ).
7070- ** No ` ecrecover ` .** Daml's ` secp256k1 ` builtin _ verifies_ a signature against
7171 a _ public key_ ; it does not _ recover_ the signer from the signature like
7272 Ethereum does. Guardian-set state on Canton therefore stores guardian
@@ -289,7 +289,7 @@ emit under an existing emitter's address: an operator-alone forge is rejected in
289289guardian replay (missing the owner's signature), and a colluding duplicate under
290290a different owner derives a ** different** , untrusted address. This is the
291291invariant guardians verify — "the transaction is Daml-valid under the vetted
292- package" — with no bespoke watcher logic (see [ §10] ( #10 -trust-model--deployment-topology ) ).
292+ package" — with no bespoke watcher logic (see [ §10.1 ] ( #101 -trust-model--deployment-topology ) ).
293293Registry-allocated ids give per-owner uniqueness and let one party hold ** many**
294294emitters, matching Sui's multiple ` EmitterCap ` s; id-sequence discipline is
295295operator-behavioral, but address integrity is not.
@@ -454,16 +454,17 @@ We use **`secp256k1WithEcdsaOnly`** because Ethereum signs the 32-byte
454454### Making verification hint-free for integrators (recommended)
455455
456456Supplying ` pubKeys ` on every call is acceptable for governance (rare,
457- operator-submitted) but is real friction for integrators (e.g. a token bridge),
458- whose relayers would otherwise have to look up the signing guardians' keys on
459- every inbound message. The recommended evolution: ** persist the guardian public
460- keys in the guardian set** — i.e. ` GuardianSet ` stores ` pubKeys ` alongside ` keys ` ,
457+ operator-submitted) but is real friction for integrators like NTT, whose
458+ relayers would otherwise have to look up the signing guardians' keys on every
459+ inbound message. The recommended evolution: ** persist the guardian public keys
460+ in the guardian set** — i.e. ` GuardianSet ` stores ` pubKeys ` alongside ` keys ` ,
461461bound to the canonical addresses _ once_ at install time (the
462462GuardianSetUpgrade / setup choices already receive pubkey hints and verify
463463` keccak256(pubKey)[12..32] == addr ` , so the binding is free). After that,
464464` verifyVAA ` reads the keys from the set and callers pass ** only the VAA bytes** ,
465- making the integrator API equivalent to EVM's ` parseAndVerifyVM(bytes) ` . This is
466- a planned change to the current code, which still takes per-call hints.
465+ making the integrator API equivalent to EVM's ` parseAndVerifyVM(bytes) ` . See
466+ [ §10] ( #10-native-token-transfers-ntt ) . This is a
467+ planned change to the current code, which still takes per-call hints.
467468
468469### Validation status
469470
@@ -626,9 +627,12 @@ canton/
626627 daml/Wormhole/Core/State.daml
627628 daml/Wormhole/Core/Governance.daml
628629 daml/Wormhole/Core/Setup.daml
630+ ntt-token/ ntt/ ntt-cip56/ ← NTT packages (see §10)
631+ vendor/token-standard/ ← vendored CIP-56 interface DARs
629632 test/ ← `wormhole-core-test` package (Daml Scripts)
630- daml.yaml ← data-dependency on core's DAR
633+ daml.yaml ← data-dependencies on the built DARs
631634 daml/Test/TestCore.daml ← unit tests + devnet `setup` / `integrationPublish`
635+ daml/Test/TestNtt.daml ← NTT codec / deployment / send / receive tests
632636 devnet/
633637 start_sandbox.sh ← starts the Ledger API v2 sandbox
634638 bootstrap.sh ← allocates Operator + creates CoreState
@@ -663,17 +667,19 @@ curl https://get.digitalasset.com/install/install.sh | sh
663667
664668cd canton
665669dpm install 3.5.1 # fetch the pinned SDK (Daml-LF 2.3)
666- dpm build --all # builds core, then test → *.daml/dist/*.dar
670+ dpm build --all # builds all packages (core, ntt-token, ntt,
671+ # ntt-cip56, test) → *.daml/dist/*.dar
667672cd test && dpm test --all --show-coverage # run the Scripts + coverage
668673```
669674
670675` dpm test ` runs every ` Script ` in
671- [ ` TestCore.daml ` ] ( test/daml/Test/TestCore.daml ) on an in-memory ledger — no
672- running sandbox required. The suite passes, including governance and VAA
673- verification with a ** real guardian-signed VAA** test vector (§5) and the
674- owner-bound emitter address derivation pinned against the shared cross-language
675- vector (` testAddressVector ` ). Note Daml coverage is ** template/choice coverage
676- only** ; the pure parser / DER / governance
676+ [ ` TestCore.daml ` ] ( test/daml/Test/TestCore.daml ) and
677+ [ ` TestNtt.daml ` ] ( test/daml/Test/TestNtt.daml ) on an in-memory ledger — no running
678+ sandbox required. The suite passes, including governance and VAA verification
679+ with a ** real guardian-signed VAA** test vector (§5) and the owner-bound emitter
680+ address derivation pinned against the shared cross-language vector
681+ (` testAddressVector ` , and the manager vector in ` testNttDeployment ` ). Note Daml
682+ coverage is ** template/choice coverage only** ; the pure parser / DER / governance
677683helpers and the address encoding are additionally validated by Script
678684_ assertions_ and the signed-VAA / address vectors, not by the coverage metric.
679685
@@ -739,7 +745,111 @@ covered by the integration test in §9.
739745
740746---
741747
742- ## 10. Trust model & deployment topology
748+ ## 10. Native Token Transfers (NTT)
749+
750+ [ NTT] ( https://github.com/wormhole-foundation/native-token-transfers ) is
751+ implemented as a separate, uploadable package set on top of the core bridge —
752+ the core bridge is untouched and used as two primitives, ** publish** and
753+ ** verify** . NTT's on-chain shape is a ** Manager** plus a ** Wormhole
754+ transceiver** ; on Canton the transceiver * is* a core ` Emitter ` .
755+
756+ Packages (all data-depend on the built ` wormhole-core ` DAR, as ` test ` does):
757+
758+ | Package | Contents |
759+ | --- | --- |
760+ | ` ntt-token ` (` wormhole-ntt-token ` ) | The ` NttToken ` interface (token effect seam) + ` TokenMode ` , ` TrimmedAmount ` . Interface-only, per Daml's upgradeability rule. Depends on the CIP-56 ` holding ` /` metadata ` interfaces (the seam carries ` [ContractId Holding] ` + ` ExtraArgs ` ). |
761+ | ` ntt ` (` wormhole-ntt ` ) | ` Payload ` (wire codec), ` Manager ` (` NttManagerRegistry ` , ` NttManager ` , deployment/send/receive/peers). |
762+ | ` ntt-cip56 ` (` wormhole-ntt-cip56 ` ) | Real CIP-56 implementations of ` NttToken ` — ` Cip56CustodyToken ` (lock/unlock via ` TransferFactory ` ) and ` Cip56BurnMintToken ` (burn/mint via ` BurnMintFactory ` ). Works for Canton Coin (Amulet) or any conforming token. See [ ` ntt-cip56/README.md ` ] ( ntt-cip56/README.md ) . |
763+ | ` vendor/token-standard/ ` | Vendored CIP-56 interface DARs (` splice-api-token-*-1.0.0 ` ), from cn-quickstart — see [ ` vendor/token-standard/README.md ` ] ( vendor/token-standard/README.md ) . |
764+
765+ ### Deployment — permissionless, guardian-hosted
766+
767+ Anyone can stand up a deployment (bring your own token, pick a mode). The
768+ ` operator ` and the deployer (` admin ` ) ** co-sign** the ` NttManager ` (mirroring
769+ ` Emitter ` ); neither has power over transfer contents. Flow: the deployer registers
770+ a core ` Emitter ` (the transceiver) via the existing ` EmitterRequest ` →` ApproveEmitter `
771+ flow, then submits an ` NttDeploymentRequest ` (the transceiver's key components
772+ ` transceiverRegistrar ` + ` transceiverEmitterId ` , token hook, mode, decimals); the
773+ operator's ` ApproveDeployment ` allocates a ` managerId ` from the ` NttManagerRegistry `
774+ and creates the keyed ` NttManager ` .
775+
776+ ** Two key-derived identities** , both owner-bound per
777+ [ §4.2] ( #42-message-publishing ) :
778+ - ** transceiver address** = ` keccak256("wormhole:emitter:v1" ‖ operator ‖ admin ‖ emitterId) `
779+ — the VAA emitter other chains register; derived by the watcher, not stored.
780+ - ** manager address** = ` keccak256("wormhole:ntt-manager:v1" ‖ operator ‖ admin ‖ managerId) ` .
781+ The manager needs its own address * on-ledger* to build/validate NTT payloads,
782+ so it is computed ** on-ledger at approval** (via ` DA.Crypto.Text ` ) and stored in
783+ ` NttManager.managerAddress ` — ** no longer operator-supplied** . The distinct
784+ domain tag keeps an emitter and a manager with the same ` (operator, admin, id) `
785+ ordinals from colliding; ` admin ` co-signs so a compromised operator cannot forge
786+ an existing manager's address (which would let it consume that peer's inbound
787+ VAAs). The former strict-binding follow-up is thus resolved by construction.
788+
789+ ### Wire codec (` Wormhole.Ntt.Payload ` )
790+
791+ Implements the three nested structures with big-endian encoders built on
792+ ` Bytes.daml ` (` NativeTokenTransfer ` prefix ` 0x994E5454 ` ; ` NttManagerMessage ` ;
793+ ` WormholeTransceiverMessage ` prefix ` 0x9945FF10 ` ). Amounts use the NTT
794+ ** TrimmedAmount** (≤ 8 decimals + a scale byte). Round-trips are tested in
795+ ` test/daml/Test/TestNtt.daml:testNttCodec ` .
796+
797+ ### Send (` NttManager.Transfer ` , controller ` admin ` )
798+
799+ Trims the amount, drives the token seam's ` LockOrBurn ` , assembles the nested
800+ message, and publishes it via ` Emitter.PublishMessage ` — so the guardian watcher
801+ observes an ** ordinary core message** (no NTT-specific watcher code). Both the
802+ manager and its transceiver are resolved by their stable keys (` Transfer ` is
803+ exercised via ` ExerciseByKeyCommand ` ; the transceiver is ` fetchByKey ` -ed inside
804+ the choice), so there is no caller-supplied cid to track — and because the
805+ transceiver key's owner slot is the ` admin ` , ownership is enforced by the key
806+ itself rather than a separate assertion.
807+
808+ ### Receive (` NttManager.Receive ` , controller ` operator ` — guardian-relayed)
809+
810+ Reuses ` Wormhole.Core.VAA.parseVAA ` /` verifyVAA ` against the guardian set read
811+ from a disclosed ` CoreState ` , enforces the configured peer
812+ (` (chainId → managerAddress, transceiverAddress) ` from ` SetPeer ` ), ** replay-
813+ protects on the VAA hash** (integrator-owned — ` CoreState.consumedGovernance `
814+ stays reserved for ` "Core" ` ), decodes, and drives ` MintOrUnlock ` . ` pubKeys ` are
815+ untrusted hints today (hint-free is a follow-up). Tested end-to-end against a
816+ real devnet-guardian-signed NTT VAA in ` testNttReceive ` (incl. replay rejection).
817+
818+ ### Token seam (` NttToken ` ) and modes
819+
820+ ` NttManager ` never references a concrete token; it holds a ` ContractId NttToken `
821+ and calls ` LockOrBurn ` / ` MintOrUnlock ` . This isolates the token detail the way
822+ ` VAA.daml ` isolates crypto. ` TokenMode ` selects ** lock/unlock** or ** burn/mint** .
823+ The seam choices carry the CIP-56 runtime handles a real registry needs —
824+ ` inputHoldingCids : [ContractId Holding] ` (the holdings to lock/burn; empty for a
825+ mint) and ` extraArgs : ExtraArgs ` (the registry's choice context) — which the
826+ manager threads from ` Transfer ` /` Receive ` .
827+
828+ Real implementations live in ` ntt-cip56 ` : ` Cip56CustodyToken ` drives
829+ ` TransferFactory_Transfer ` (lock = sender→custody, unlock = custody→recipient)
830+ and ` Cip56BurnMintToken ` drives ` BurnMintFactory_BurnMint ` (burn = inputs, mint =
831+ outputs). Both work for Canton Coin (Amulet) or any conforming token. A stdlib
832+ ` MockToken ` in the test package (which ignores the CIP-56 handles) exercises the
833+ whole protocol end-to-end in ` dpm test ` .
834+
835+ ** Submission caveat:** the factory calls are real token-standard choices, so a
836+ production send/receive is app-orchestrated — the caller submits with the token
837+ holder's authority and the registry's ** disclosed contracts** (` extraArgs ` ),
838+ via ` submitWithDisclosures ` , exactly as cn-quickstart's
839+ ` RegistryApi.getTransferFactory ` + ` submitWithDisclosures ` do. The on-ledger Daml
840+ is complete; that submission wiring is off-ledger (relayer/app).
841+
842+ ### Follow-ups (tracked in [ Open Questions] ( #11-open-questions--validation-items ) )
843+
844+ - ** ` recipientAddress ` → ` Party ` ** : inbound mints to a relayer-supplied ` Party ` ,
845+ not yet bound to the 32-byte NTT address — needs a Canton address registry.
846+ - ** Hint-free verification** (persist guardian pubkeys) + a ** readable
847+ guardian-set contract** for permissionless (non-operator) receive via
848+ disclosure, so ` Receive ` need not be operator-controlled.
849+ - Deferred NTT features: rate limiting, queuing, multi-transceiver threshold
850+ (currently fixed at 1).
851+
852+ ### 10.1 Trust model & deployment topology
743853
744854How does a set of ` n ` guardians (` k ` -of-` n ` threshold) run Wormhole on Canton
745855faithfully — see and attest only what actually happened under rules they accept —
@@ -817,14 +927,15 @@ implemented here.
817927 (Emitter/manager ** address** integrity is separately owner-bound and
818928 owner-co-signed, §4.2.)
8199293 . ** Guardian observer topology.** Confirm the observer-party mechanics from
820- §10 (dedicated observer party as template ` observer ` vs. replica-hosted
930+ §10.1 (dedicated observer party as template ` observer ` vs. replica-hosted
821931 ` operator ` ; external-party namespace-key custody; how ` --cantonReadAsParty `
822932 maps to the chosen party). Reconcile with ` feat/canton-public-observer ` . Note
823933 this observer party is distinct from the ` guardianGovernance ` signatory party
824934 (item 2): the observer is read-only, the governance party has signatory power.
8259354 . ** Integrator VAA verification.** Implement the hint-free guardian set (persist
826936 pubkeys, §5) and confirm the guardian-set distribution mechanism for
827- integrators (explicit disclosure vs public-observer snapshot).
937+ integrators (explicit disclosure vs public-observer snapshot). See
938+ [ §10] ( #10-native-token-transfers-ntt ) .
8289395 . ** Native fees.** Whether/when to wire real Canton Coin (Splice ` amulet ` )
829940 payments into ` PublishMessage ` /` TransferFees ` , or whether Canton runs fee-less
830941 initially (` messageFee = 0 ` ).
0 commit comments