Skip to content

canton: add token-bridge lock-and-attest example (CN Token Standard)#28

Closed
bengtlofgren wants to merge 8 commits into
integration/cantonfrom
feat/canton-token-bridge-example
Closed

canton: add token-bridge lock-and-attest example (CN Token Standard)#28
bengtlofgren wants to merge 8 commits into
integration/cantonfrom
feat/canton-token-bridge-example

Conversation

@bengtlofgren

@bengtlofgren bengtlofgren commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Adds a self-contained worked example of the Wormhole lock-and-attest send path on the Canton Network Token Standard (CIP-0056). A TokenBridge.LockAndPublish choice atomically transfers a user's holding into bridge custody and publishes a Wormhole Transfer message in a single user-submitted transaction. Stacks on canton-guardian-observer (#32) — rebased off feat/canton-public-observer, which is closed in favor of #32.

Changes

  • canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.damlTokenBridge orchestrator with LockAndPublish choice; composes transfer authority (from the token registry factory) with publish authority (from the bridge's Emitter) in one atomic transaction. Its visibility field is guardianObserver (was public), matching canton: read-only guardianObserver party for guardian read visibility #32.
  • canton/examples/token-bridge/daml/Wormhole/Example/TokenRegistry.daml — minimal admin-custodied token registry implementing the CN Token Standard Holding and TransferInstruction interfaces; self-issuing so the example runs without Amulet.
  • canton/examples/token-bridge/daml/Wormhole/Example/Test/TestTokenBridge.daml — Daml Script test covering the full flow: allocate parties, register emitter via the registry-keyed EmitterRegistry/ApproveEmitter, mint holding, lock and attest. Assertions moved from the old EmitterIdentity contract-id scheme to the current WormholeMessage.{registrar,owner,emitterId} fields.
  • canton/examples/token-bridge/.lib/ — vendored CN Token Standard interface DARs (splice-api-token-{metadata,holding,transfer-instruction}-v1-1.0.0.dar), pinned to Splice v0.5.18 with sha256 verification. See .lib/THIRD_PARTY.md.
  • node/pkg/watchers/canton/token_bridge_integration_test.go — integration test (//go:build integration) that runs the full flow against a live Canton sandbox and verifies the published transfer message through the real watcher, deriving the expected address via deriveEmitterAddress (matching watcher_integration_test.go) instead of hashing an identity contract-id.
  • canton/README.md — worked-example section added documenting the lock-and-attest pattern, atomicity via authority composition, and explicit disclosure.
  • canton/multi-package.yaml — token-bridge example added to the multi-package build.
  • canton/examples/token-bridge/daml.yaml — bumped to sdk-version: 3.5.1 / --target=2.3 to match wormhole-core.

Rebase notes (off feat/canton-public-observer onto canton-guardian-observer)

The example predated both #29 (registry-keyed Emitter/EmitterRegistry, no separate EmitterIdentity template) and #32 (guardianObserver replacing the generic public party), so this was an adaptation, not a mechanical replay:

  • mkInitialCoreState now takes guardianGovernance + guardianObserver; setup also creates an EmitterRegistry via mkInitialEmitterRegistry.
  • ApproveEmitter returns ContractId Emitter directly (no coreStateCid arg, no separate EmitterIdentity contract).
  • WormholeMessage carries registrar/owner/emitterId directly instead of an identity contract-id.

Verified: dpm build --all and dpm test --all pass (all 3 scripts); go vet -tags integration ./pkg/watchers/canton/... is clean.

Notes

  • Custody is implemented as a transfer to the bridge party via TransferFactory_Transfer, not the allocation API (allocation requires a counter-leg).
  • The bridge, factory, and emitter contracts are shared to the user via explicit disclosure — the standard pattern for handing a factory to a non-stakeholder.
  • Third-party DARs are committed (not fetched at build time) with pinned sha256s; re-fetch instructions are in .lib/THIRD_PARTY.md.
  • Build and test: dpm build --all then cd canton/examples/token-bridge && dpm test --all.

Hardening pass + CompleteTransfer receive leg. This example predated the NTT layer (#30); this commit applies lessons learned building and hardening NTT to the classic Token Bridge, and adds the receive leg that was previously missing entirely (the example only ever demonstrated the send path).

Send-leg fixes:

  • LockAndPublish now resolves the bridge's Emitter by its stable key instead of a caller-supplied emitterCid argument — the old cid arg was stale after one use (PublishMessage is consuming) and unchecked (a caller could pass any emitter satisfying controller owner with their own authority).
  • tokenAddress is now derived on-ledger from the instrument actually locked (tokenAddressFor instrumentId, a domain-tagged keccak256, mirroring Wormhole.Core.Bytes.derivedAddressFromText's style) instead of being a free-standing caller-supplied argument with no connection to what was locked.
  • Amounts are normalized to 8 decimals and truncated before locking, not just at encoding time — the original encodeTransfer (truncate amount) truncated only the encoded integer while locking the full untruncated amount, silently stranding the difference in an unaccounted-for custody holding. Locked and attested amounts can no longer diverge; any sub-8dp dust now comes back to the sender as ordinary registry change.
  • TokenRegistry's ExampleRegistry now accepts multiple input holdings and returns change, instead of requiring a single holding whose amount exactly matches the transfer.

New: TokenBridge.CompleteTransfer (receive leg) — verifies an inbound VAA against the guardian set, enforces a registered peer (RegisterPeer), replay-protects on the VAA hash (TokenBridge is now keyed, added now since Daml keys can't be added later by upgrade), decodes the payload, and unlocks the corresponding custody holding to the attested recipient. Recipient binding mirrors NTT's recipientAddressFor pattern (tokenBridgeRecipientAddressFor, a distinct domain tag) with the same tradeoff: the binding is permanent once the sender computes it, since a Canton Party id never changes.

Worth flagging explicitly: CompleteTransfer was first written controller relayer (an arbitrary, permissionless relay, matching EVM's completeTransfer) and that design was empirically disproven, not just reconsidered — fetching the disclosed CoreState is a plain Daml Fetch action, and a Fetch action's required authorizers must include one of the fetched contract's own stakeholders (operator/guardianGovernance/guardianObserver), which no relayer/bridge combination can ever satisfy. This is a hard Daml/Canton ledger-model constraint, not a design preference — it's exactly why NTT's own Receive is controller operator too. Fell back to controller operator, confirmed against a real AuthorizationError before the fix.

Verified:

  • dpm build --all clean; dpm test --all 17/17 (was 1) — new coverage includes derivation/codec vectors, authority-vs-visibility negative pairs, the fractional-amount fix, and the full CompleteTransfer check sequence against static VAA fixtures.
  • go build/gofmt/go vet (including -tags integration) clean.
  • Both integration tests pass live against a real Canton sandbox: TestCantonTokenBridgeIntegration (send, pre-existing) and the new TestCantonTokenBridgeCompleteIntegration — a live two-step sign-then-relay harness (mirroring NTT's own ntt_recipient_match_integration_test.go) that allocates real parties, reads their actual on-ledger derived hashes, signs a fresh VAA against them in Go, and relays it, asserting the unlock lands on the matching recipient and that replay is rejected.

canton/README.md rewritten for this example to match the design-rationale depth of the NTT section (§10), including an explicit "what this example deliberately does not show" note (wrapped-asset mint, fee handling, governance-gated peer registration).

@bengtlofgren
bengtlofgren marked this pull request as draft July 1, 2026 19:57

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Comment thread canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml
Comment thread canton/examples/token-bridge/daml/Wormhole/Example/TokenRegistry.daml Outdated
@bengtlofgren
bengtlofgren marked this pull request as ready for review July 1, 2026 22:45
@bengtlofgren
bengtlofgren force-pushed the feat/canton-token-bridge-example branch from 201add0 to 94ca728 Compare July 7, 2026 02:13
@bengtlofgren
bengtlofgren changed the base branch from feat/canton-public-observer to canton-guardian-observer July 7, 2026 02:14
Base automatically changed from canton-guardian-observer to integration/canton July 8, 2026 10:57
@bengtlofgren
bengtlofgren force-pushed the feat/canton-token-bridge-example branch from 777ba8a to 71e7a96 Compare July 8, 2026 11:16
Comment thread canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml
…erver core

Rebases the token-bridge example onto canton-guardian-observer (stacked, since
feat/canton-public-observer is closed). The example predates both #29 (registry-
keyed Emitter/EmitterRegistry, no more EmitterIdentity) and #32 (guardianObserver
replacing the generic public party), so this adapts rather than just replays:

- mkInitialCoreState now takes guardianGovernance + guardianObserver; setup also
  creates an EmitterRegistry via mkInitialEmitterRegistry.
- ApproveEmitter returns ContractId Emitter directly (no coreStateCid arg, no
  separate EmitterIdentity contract).
- WormholeMessage carries registrar/owner/emitterId directly instead of an
  identity contract-id; TestTokenBridge and the Go integration test assert on
  those fields and derive the address via deriveEmitterAddress, matching the
  pattern in watcher_integration_test.go.
- TokenBridge's public field/observer renamed to guardianObserver.
- daml.yaml bumped to sdk-version 3.5.1 / --target=2.3 to match core.

Verified: dpm build --all and dpm test --all pass (all 3 scripts); go vet -tags
integration ./pkg/watchers/canton/... is clean.
…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.
…h, keyless emitter)

Rebased onto current integration/canton. Adapts the example to core changes
merged since PR #34:

- Repin core data-dependency 0.1.0 -> 0.2.0; add the token-allocation DAR
  (referenced from shared dars/ to preserve package-id identity).
- PublishMessage now charges the message fee (token-standard fee payments):
  thread coreStateCid, payer = user, and feeAllocation through LockAndPublish.
- Emitter lost its contract key (core key removal): pass the disclosed
  emitterCid as a choice argument instead of fetchByKey/queryByKey.
- Tests: replace the removed EmitterRequest/ApproveEmitter flow with the
  permissionless EmitterRegistry.RegisterEmitter path; supply the new
  CoreState feeInstrument/feeRecipient and registry registrationFee.

dpm build --all green; 17/17 example scripts pass.
@bengtlofgren
bengtlofgren force-pushed the feat/canton-token-bridge-example branch from 71e7a96 to f812cdd Compare July 16, 2026 13:26
…locks

Fixes the forgeable-lock finding: LockAndPublish/CompleteTransfer settled
through a caller-supplied TransferFactory, an open-interface cid that a caller
can implement to fabricate a Completed result with a counterfeit holding,
yielding a guardian-signable attestation with no real escrow. The bridge now
binds one trustedFactory at onboarding and settles only through it (mirrors the
NTT manager binding its own CIP-56 custody seam); a counterfeit factory is
rejected before any message is published. Authenticating a caller-supplied
factory by fetch is impossible without the admin's authority, which would break
permissionless lock -- hence binding rather than inspection.

testLockAndPublishRejectsForgedFactory now passes (18/18 example scripts).
@bengtlofgren

Copy link
Copy Markdown
Author

I'm going to close this because token bridge isnt even a requirement. Reduce the surface area for review

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