canton: add token-bridge lock-and-attest example (CN Token Standard)#28
Closed
bengtlofgren wants to merge 8 commits into
Closed
canton: add token-bridge lock-and-attest example (CN Token Standard)#28bengtlofgren wants to merge 8 commits into
bengtlofgren wants to merge 8 commits into
Conversation
bengtlofgren
marked this pull request as draft
July 1, 2026 19:57
bengtlofgren
marked this pull request as ready for review
July 1, 2026 22:45
bengtlofgren
force-pushed
the
feat/canton-token-bridge-example
branch
from
July 7, 2026 02:13
201add0 to
94ca728
Compare
bengtlofgren
changed the base branch from
feat/canton-public-observer
to
canton-guardian-observer
July 7, 2026 02:14
bengtlofgren
force-pushed
the
feat/canton-token-bridge-example
branch
from
July 8, 2026 11:16
777ba8a to
71e7a96
Compare
connorstake
reviewed
Jul 8, 2026
…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
force-pushed
the
feat/canton-token-bridge-example
branch
from
July 16, 2026 13:26
71e7a96 to
f812cdd
Compare
…uthenticated factory)
…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).
Author
|
I'm going to close this because token bridge isnt even a requirement. Reduce the surface area for review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.LockAndPublishchoice atomically transfers a user's holding into bridge custody and publishes a Wormhole Transfer message in a single user-submitted transaction. Stacks oncanton-guardian-observer(#32) — rebased offfeat/canton-public-observer, which is closed in favor of #32.Changes
canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml—TokenBridgeorchestrator withLockAndPublishchoice; composes transfer authority (from the token registry factory) with publish authority (from the bridge'sEmitter) in one atomic transaction. Its visibility field isguardianObserver(waspublic), 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 StandardHoldingandTransferInstructioninterfaces; 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-keyedEmitterRegistry/ApproveEmitter, mint holding, lock and attest. Assertions moved from the oldEmitterIdentitycontract-id scheme to the currentWormholeMessage.{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 viaderiveEmitterAddress(matchingwatcher_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 tosdk-version: 3.5.1/--target=2.3to matchwormhole-core.Rebase notes (off
feat/canton-public-observerontocanton-guardian-observer)The example predated both
#29(registry-keyedEmitter/EmitterRegistry, no separateEmitterIdentitytemplate) and#32(guardianObserverreplacing the genericpublicparty), so this was an adaptation, not a mechanical replay:mkInitialCoreStatenow takesguardianGovernance+guardianObserver; setup also creates anEmitterRegistryviamkInitialEmitterRegistry.ApproveEmitterreturnsContractId Emitterdirectly (nocoreStateCidarg, no separateEmitterIdentitycontract).WormholeMessagecarriesregistrar/owner/emitterIddirectly instead of an identity contract-id.Verified:
dpm build --allanddpm test --allpass (all 3 scripts);go vet -tags integration ./pkg/watchers/canton/...is clean.Notes
TransferFactory_Transfer, not the allocation API (allocation requires a counter-leg)..lib/THIRD_PARTY.md.dpm build --allthencd canton/examples/token-bridge && dpm test --all.Hardening pass +
CompleteTransferreceive 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:
LockAndPublishnow resolves the bridge'sEmitterby its stable key instead of a caller-suppliedemitterCidargument — the old cid arg was stale after one use (PublishMessageis consuming) and unchecked (a caller could pass any emitter satisfyingcontroller ownerwith their own authority).tokenAddressis now derived on-ledger from the instrument actually locked (tokenAddressFor instrumentId, a domain-taggedkeccak256, mirroringWormhole.Core.Bytes.derivedAddressFromText's style) instead of being a free-standing caller-supplied argument with no connection to what was locked.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'sExampleRegistrynow 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 (TokenBridgeis 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'srecipientAddressForpattern (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:
CompleteTransferwas first writtencontroller relayer(an arbitrary, permissionless relay, matching EVM'scompleteTransfer) and that design was empirically disproven, not just reconsidered — fetching the disclosedCoreStateis a plain DamlFetchaction, and aFetchaction's required authorizers must include one of the fetched contract's own stakeholders (operator/guardianGovernance/guardianObserver), which norelayer/bridgecombination can ever satisfy. This is a hard Daml/Canton ledger-model constraint, not a design preference — it's exactly why NTT's ownReceiveiscontroller operatortoo. Fell back tocontroller operator, confirmed against a realAuthorizationErrorbefore the fix.Verified:
dpm build --allclean;dpm test --all17/17 (was 1) — new coverage includes derivation/codec vectors, authority-vs-visibility negative pairs, the fractional-amount fix, and the fullCompleteTransfercheck sequence against static VAA fixtures.go build/gofmt/go vet(including-tags integration) clean.TestCantonTokenBridgeIntegration(send, pre-existing) and the newTestCantonTokenBridgeCompleteIntegration— a live two-step sign-then-relay harness (mirroring NTT's ownntt_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.mdrewritten 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).