Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions canton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,158 @@ observation. Eviction is fail-safe (guardians stop seeing → stop signing, the
liveness posture as a compromised operator above), but the custody choice needs a
human decision; see Open Questions.

### Worked example: the Wormhole Token Bridge

`examples/token-bridge` is a runnable example of the classic **Wormhole Token
Bridge** protocol (whitepaper 0003, payload ID 1 `Transfer`) on the Canton Network
Token Standard (CIP-0056) — both the send ("lock-and-attest") and receive
("verify-and-unlock") legs. It sits alongside NTT (§10) as a second, independent
worked example: NTT and the classic Token Bridge are genuinely different transfer
protocols with different wire formats and trust shapes, not two versions of the
same thing, even though both are built on the same underlying primitives (the core
`Emitter`/`PublishMessage`, CIP-56 transfer factories, domain-tagged derived
addresses). This repo carries both because integrators depend on both across the
existing Wormhole network.

#### Send: `TokenBridge.LockAndPublish`

A `TokenBridge.LockAndPublish` choice atomically (1) transfers a user's holding
into the bridge's custody via the token standard's `TransferFactory_Transfer`
(`receiver = bridge`) and (2) publishes a Wormhole `Transfer` message from the
bridge's `Emitter` — in a single transaction the user submits.

- **Custody = transfer to the bridge party**, not the allocation API (allocation is
DvP and needs a counter-leg, which a one-sided lock does not have).
- **Atomicity via authority composition**: `LockAndPublish` is a nonconsuming
choice on the @bridge@-signed `TokenBridge` contract, controlled by `user`, so
its body carries `{bridge, user}` authority (Daml propagates a contract's
signatories' authority into every choice exercised on it, whether or not that
choice is consuming). The nested `TransferFactory_Transfer` authorizes off the
`sender = user` controller the interface itself checks; the nested
`PublishMessage` (controller `owner = bridge`) runs under the inherited `bridge`
authority. No core operator authority is involved, matching EVM's permissionless
lock+publish.
- **The bridge's `Emitter` is resolved by its stable key**, not a caller-supplied
cid. An earlier version of this choice took `emitterCid` as an argument, which
was both stale after one use (`PublishMessage` is consuming, so a disclosed cid
works exactly once) and unchecked (nothing stopped a caller supplying a
DIFFERENT emitter satisfying `controller owner` with their own authority,
attesting from a non-bridge identity). `fetchByKey` removes the cid-tracking
burden; it does not remove the disclosure-freshness one — whoever serves
disclosures must still fetch and disclose the CURRENT `Emitter` before each call.
- **`tokenAddress` is derived, not caller-supplied.** `tokenAddressFor instrumentId`
— `keccak256("wormhole:token-bridge-token:v1" ‖ lp(admin) ‖ lp(id))`, mirroring
`Wormhole.Core.Bytes.derivedAddressFromText`'s style — binds the attestation to
the instrument actually locked. The earlier version took `tokenAddress` as a free
argument with no connection to `instrumentId`, so a user could lock instrument A
while attesting an unrelated address. Like `derivedAddressFromText`'s addresses,
this is a Canton-local commitment, not a reversible one: a counterpart chain
cannot recover `(admin, id)` from it.
- **Amounts are normalized to 8 decimals, and locked == attested.** The wire
amount is `truncate(amount * 1e8)`; the DECIMAL amount actually taken into
custody is truncated to the same precision BEFORE the lock, not just at encoding
time. The earlier version's `encodeTransfer (truncate amount)` truncated only the
encoded integer (dropping ALL decimals, not just sub-8dp ones) while locking the
full, untruncated amount — silently stranding the difference in an
unaccounted-for custody holding. Locking the truncated amount instead means any
dust comes back to the sender as ordinary registry change (§ toy registry, below)
rather than disappearing into custody.
- **Explicit disclosure, not authorization.** The user receives the bridge,
factory, and emitter contracts as disclosed contracts (they are not a
stakeholder of them) — the standard pattern for an app handing its factory to a
user. Disclosure is a data attachment servable by anyone with read access, not an
approval; `testLockAndPublishWithoutDisclosureFails` /
`testLockAndPublishRequiresUserAuthority` in `TestTokenBridge.daml` pin the two
failure modes (visibility vs. authorization) apart, mirroring the discipline
`Wormhole.Ntt.Manager.Transfer`'s own authority proof uses.

#### Receive: `TokenBridge.CompleteTransfer`

`CompleteTransfer` verifies an inbound VAA attesting a transfer TO Canton and
unlocks the corresponding custody holding to `recipient`. It is **unlock-only** —
a foreign token arriving on Canton (wrapped-asset mint) is out of scope for this
example. It reuses the core `parseVAA`/`verifyVAA` primitives against the guardian
set in a disclosed `CoreState`, enforces a registered peer
(`RegisterPeer`, `chainId -> peer token-bridge emitter address` — a direct
controller choice standing in for a real deployment's governance-VAA-gated
`registerChain`), replay-protects on the VAA hash (`TokenBridge.consumed`, keyed
`key bridge` so repeated completions resolve by key rather than a churning cid —
added now rather than later, since Daml keys cannot be added by upgrade), decodes
the payload, and checks `toChain`/`tokenAddress`/`fee` before unlocking.

**Recipient binding.** `recipient` is a choice argument, but the caller cannot
steer it arbitrarily: `tokenBridgeRecipientAddressFor recipient` must equal the
VAA's own `to` field — `keccak256("wormhole:token-bridge-recipient:v1" ‖
lp(partyToText recipient))`, a fresh domain tag distinct from NTT's own
`recipientAddressTag` so the two can never collide. This mirrors
`Wormhole.Ntt.Manager.recipientAddressFor` and its tradeoff exactly: the binding
permanently ties a signed VAA to the recipient Party string as it existed when the
sender computed the hash. If the recipient ever needs a DIFFERENT party (lost key,
planned custodial migration), a VAA already signed against the old hash cannot be
redirected; the sender must re-send.

**Controller — a correction, not just a decision.** `CompleteTransfer` was
initially written `controller relayer`, an arbitrary uninvolved party, attempting
the same permissionless property EVM's `completeTransfer` has and NTT's own
`Receive` did NOT attempt. This was **empirically disproven**, per the "verify
empirically, don't just assert" discipline `Wormhole.Ntt.Manager.Transfer`'s
authority proof models: `cs <- fetch coreStateCid` is a plain Daml `Fetch` action,
and a `Fetch` action's required authorizers must include at least one of the
fetched contract's own stakeholders — a hard Daml/Canton ledger-model rule, not a
design choice. `CoreState`'s stakeholders are
`operator`/`guardianGovernance`/`guardianObserver`; neither an arbitrary `relayer`
nor `bridge` (this contract's only signatory) is ever one of those, so NO choice of
controller other than one of those three can ever authorize this fetch. This is
exactly why `Wormhole.Ntt.Manager.Receive` is `controller operator` too — not a
stylistic preference for a "guardian-relayed" design, but the same structural
consequence, discovered independently here and confirmed against a real
`AuthorizationError` before the fix (see `CompleteTransfer`'s own comment and
`testCompleteTransferRecipientMismatchFails`, which now reaches the business-logic
recipient check as controller `operator` instead of failing on authorization).

#### The toy registry (`ExampleRegistry`/`ExampleToken`)

The example vendors the standard's interface DARs (pinned; see
`examples/token-bridge/.lib/THIRD_PARTY.md`) and ships a minimal admin-custodied
registry so it is self-issuing and runnable without Amulet. It now accepts
multiple input holdings and returns change (`total - amount` back to the sender)
rather than requiring a single holding whose amount matches the transfer exactly —
still no fee handling, and still explicitly **not production-grade**. For a REAL
`TransferFactory` integration, see NTT's `ntt-cip56` package: its
`Cip56CustodyToken` drives the exact same interface against Amulet or any
conforming token. `TokenBridge.LockAndPublish`/`CompleteTransfer` already ARE
`TransferFactory` clients in the same sense `Cip56CustodyToken` is one — the toy
registry here only fills in the OTHER side (the factory implementation) so the
example needs no external token deployment.

#### What this example deliberately does not show

Wrapped-asset mint/burn for a foreign token arriving on Canton (only unlock of a
Canton-native token is implemented); registry-based fee handling (fee is asserted
`== 0`); governance-VAA-gated peer registration (`RegisterPeer` is a direct
`controller bridge` choice instead); and the NTT wire protocol generally — see §10
for that.

Build and test with `dpm build --all` then `cd examples/token-bridge && dpm test
--all`. Two end-to-end integration tests
(`//go:build integration`) run against a live sandbox:

```
# Send path: observes the published Transfer message through the real watcher —
# additionally confirming the SDK-3.3.x token-standard DARs vet on the Canton
# 3.5.x participant and that explicit disclosure works over the Ledger API.
go test -tags integration -run TestCantonTokenBridgeIntegration ./pkg/watchers/canton -v

# Receive path: closes the same "known test gap" class NTT's own
# ntt_recipient_match_integration_test.go closes — a live Party's fingerprint
# (and here, also a live InstrumentId's admin party) can never be baked into an
# already-signed VAA fixture ahead of time, so the MATCHING-recipient/tokenAddress
# path can only be proven by a live two-step sign-then-relay harness: allocate on
# a live sandbox, read the ACTUAL on-ledger hashes, sign a fresh VAA against them
# in Go, then relay and assert the unlock lands (plus replay rejection).
go test -tags integration -run TestCantonTokenBridgeCompleteIntegration ./pkg/watchers/canton -v
```

---

## 11. Open Questions & Validation Items
Expand Down
34 changes: 34 additions & 0 deletions canton/examples/token-bridge/.lib/THIRD_PARTY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Vendored third-party DARs (Canton Network Token Standard)

These DARs implement the Canton Network Token Standard (CIP-0056) and are required
to build the token-bridge example. They are vendored (committed) here; do not
regenerate them by an unaudited fetch.

## Provenance

- Source: [`digital-asset/cn-quickstart`](https://github.com/digital-asset/cn-quickstart/tree/main/quickstart/daml/dars),
which mirrors the Splice release bundle v0.5.18 (Canton 3.4 / Daml SDK 3.4.11).
- sha256s verified against the upstream Splice release bundle:
`https://github.com/digital-asset/decentralized-canton-sync/releases/download/v0.5.18/0.5.18_splice-node.tar.gz`
(bundle sha256: `4639977f854f3c3c032d7dbe93a933f74644bd410a128f1e7d5371bc988653e1`)

## Pinned files (sha256)

| File | sha256 |
| --- | --- |
| splice-api-token-metadata-v1-1.0.0.dar | 455eb160cb5abd4ae9918a6fbb9dad471f721adda39f0e5c76feef08d05637fc |
| splice-api-token-holding-v1-1.0.0.dar | ef75f8eb41a65810221784fdb78bb9dfac7cb22245aba14fa7cb7f69c34e0175 |
| splice-api-token-transfer-instruction-v1-1.0.0.dar | e4c73aa7ae73fb2fc330b938ffb99f568792321640ba4b9472902aa8d742c994 |

Package versions are all `1.0.0`; built with Daml SDK 3.3.x (Daml-LF 2.x), which is
data-dependency compatible with this project's SDK 3.5.1 (Daml-LF 2.3).

## Re-fetch (only with authorization)

curl -fSL -o splice-api-token-metadata-v1-1.0.0.dar \
https://raw.githubusercontent.com/digital-asset/cn-quickstart/main/quickstart/daml/dars/splice-api-token-metadata-v1-1.0.0.dar
curl -fSL -o splice-api-token-holding-v1-1.0.0.dar \
https://raw.githubusercontent.com/digital-asset/cn-quickstart/main/quickstart/daml/dars/splice-api-token-holding-v1-1.0.0.dar
curl -fSL -o splice-api-token-transfer-instruction-v1-1.0.0.dar \
https://raw.githubusercontent.com/digital-asset/cn-quickstart/main/quickstart/daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar
# verify each DAR sha256 above
Binary file not shown.
Binary file not shown.
Binary file not shown.
37 changes: 37 additions & 0 deletions canton/examples/token-bridge/daml.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Example package: the Wormhole token-bridge "lock-and-attest" send path on the
# Canton Network Token Standard (CIP-0056). NOT part of the production
# wormhole-core DAR. Contains a minimal in-repo token registry (token +
# TransferFactory) implementing the standard's Holding/TransferInstruction
# interfaces, the TokenBridge orchestrator, and Daml Script tests.
#
# Vendored standard DARs live under .lib/ (see .lib/THIRD_PARTY.md for pinned
# versions and provenance). It data-depends on the built wormhole-core DAR for
# the Emitter / PublishMessage primitive.
sdk-version: 3.5.1
name: wormhole-token-bridge-example
version: 0.1.0
source: daml
dependencies:
- daml-prim
- daml-stdlib
- daml-script
data-dependencies:
- ../../core/.daml/dist/wormhole-core-0.2.0.dar
- .lib/splice-api-token-metadata-v1-1.0.0.dar
- .lib/splice-api-token-holding-v1-1.0.0.dar
- .lib/splice-api-token-transfer-instruction-v1-1.0.0.dar
# Allocation interface for the CN Token Standard message-fee payments that
# wormhole-core 0.2.0 threads through PublishMessage. Referenced from the
# shared ../../dars/ copy (not .lib/) so its package-id is identical to the one
# wormhole-core was built against — a different copy would produce a
# conflicting DALF at link time (see ../../dars/README.md).
- ../../dars/splice-api-token-allocation-v1-1.0.0.dar
build-options:
# Daml-LF 2.3 (Protocol Version 35), matching wormhole-core. LF 2.3 enables the
# contract key this example keeps on 'TokenBridge'; wormhole-core itself no
# longer uses keys (identity is registry-derived — see the core README §4).
- --target=2.3
# This is an example package that intentionally bundles its Daml Script tests
# with the templates; it is never uploaded to a participant, so the
# script-in-template-package warning does not apply.
- -Wno-template-interface-depends-on-daml-script
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- | Adversarial CIP-56 implementations used only by the token-bridge security
-- tests. A hostile 'TransferFactory' that reports a transfer @Completed@ with a
-- counterfeit holding while locking nothing real -- modelling an attacker who
-- passes their OWN contract as 'Wormhole.Example.TokenBridge.LockAndPublish's
-- @factoryCid@/@holdingCids@. See 'testLockAndPublishRejectsForgedFactory'.
--
-- Templates-only (no daml-script dependency): the point is that the
-- @TransferFactory@/@Holding@ interface types are open, so a caller can supply
-- an implementation the bridge never authored and never authenticated.
module Wormhole.Example.Test.EvilRegistry where

import Splice.Api.Token.HoldingV1
import Splice.Api.Token.MetadataV1 (emptyMetadata)
import Splice.Api.Token.TransferInstructionV1

-- | A counterfeit holding: signed only by the attacker, yet its 'HoldingView'
-- advertises an @instrumentId@ whose @admin@ is the real registry -- a claim
-- nothing on-ledger backs. The genuine 'Wormhole.Example.TokenRegistry.ExampleToken'
-- is @signatory admin@; this one cannot be, because the attacker lacks the
-- admin's authority. The finding is precisely that 'LockAndPublish' never
-- checks the difference: a 'HoldingView' exposes @owner@/@instrumentId@/@amount@
-- but never the signatories that would prove issuance.
template CounterfeitToken
with
attacker : Party
instrumentId : InstrumentId
amount : Decimal
where
signatory attacker
ensure amount > 0.0
interface instance Holding for CounterfeitToken where
view = HoldingView with
owner = attacker
instrumentId
amount
lock = None
meta = emptyMetadata

-- | A hostile factory. Its 'transferFactory_transferImpl' ignores
-- @expectedAdmin@, the input holdings, and ownership entirely: it mints a
-- 'CounterfeitToken' from thin air and reports the transfer @Completed@. A
-- genuine factory ('ExampleRegistry') is @signatory admin@ and can only create
-- admin-signed holdings; this one proves that the shared @TransferFactory@
-- interface type alone guarantees neither provenance nor a real lock.
template CounterfeitRegistry
with
attacker : Party
where
signatory attacker
interface instance TransferFactory for CounterfeitRegistry where
view = TransferFactoryView with admin = attacker, meta = emptyMetadata

transferFactory_transferImpl _self arg = do
let t = arg.transfer
counterfeit <- create CounterfeitToken with
attacker
instrumentId = t.instrumentId
amount = t.amount
pure TransferInstructionResult with
output = TransferInstructionResult_Completed with
receiverHoldingCids = [toInterfaceContractId @Holding counterfeit]
senderChangeCids = []
meta = emptyMetadata

transferFactory_publicFetchImpl _self _arg =
pure TransferFactoryView with admin = attacker, meta = emptyMetadata
Loading
Loading