Skip to content

Commit c2335c1

Browse files
committed
canton: document the NTT layer
1 parent f514116 commit c2335c1

2 files changed

Lines changed: 134 additions & 19 deletions

File tree

canton/README.md

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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
289289
guardian replay (missing the owner's signature), and a colluding duplicate under
290290
a different owner derives a **different**, untrusted address. This is the
291291
invariant 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)).
293293
Registry-allocated ids give per-owner uniqueness and let one party hold **many**
294294
emitters, matching Sui's multiple `EmitterCap`s; id-sequence discipline is
295295
operator-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

456456
Supplying `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`,
461461
bound to the canonical addresses _once_ at install time (the
462462
GuardianSetUpgrade / 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

664668
cd canton
665669
dpm 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
667672
cd 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
677683
helpers 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

744854
How does a set of `n` guardians (`k`-of-`n` threshold) run Wormhole on Canton
745855
faithfully — 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.)
819929
3. **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.
825935
4. **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).
828939
5. **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`).

cspell-custom-words.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,7 @@ pekko
302302
googleapis
303303
preimage
304304
builtins
305+
quickstart
306+
quickstart's
307+
cids
308+
dars

0 commit comments

Comments
 (0)