diff --git a/canton/README.md b/canton/README.md index e504f8462b..5d0f585abf 100644 --- a/canton/README.md +++ b/canton/README.md @@ -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 diff --git a/canton/examples/token-bridge/.lib/THIRD_PARTY.md b/canton/examples/token-bridge/.lib/THIRD_PARTY.md new file mode 100644 index 0000000000..ef385fc99d --- /dev/null +++ b/canton/examples/token-bridge/.lib/THIRD_PARTY.md @@ -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 diff --git a/canton/examples/token-bridge/.lib/splice-api-token-holding-v1-1.0.0.dar b/canton/examples/token-bridge/.lib/splice-api-token-holding-v1-1.0.0.dar new file mode 100644 index 0000000000..cb12fcf29b Binary files /dev/null and b/canton/examples/token-bridge/.lib/splice-api-token-holding-v1-1.0.0.dar differ diff --git a/canton/examples/token-bridge/.lib/splice-api-token-metadata-v1-1.0.0.dar b/canton/examples/token-bridge/.lib/splice-api-token-metadata-v1-1.0.0.dar new file mode 100644 index 0000000000..535a7a0d57 Binary files /dev/null and b/canton/examples/token-bridge/.lib/splice-api-token-metadata-v1-1.0.0.dar differ diff --git a/canton/examples/token-bridge/.lib/splice-api-token-transfer-instruction-v1-1.0.0.dar b/canton/examples/token-bridge/.lib/splice-api-token-transfer-instruction-v1-1.0.0.dar new file mode 100644 index 0000000000..8d0cd9c0ab Binary files /dev/null and b/canton/examples/token-bridge/.lib/splice-api-token-transfer-instruction-v1-1.0.0.dar differ diff --git a/canton/examples/token-bridge/daml.yaml b/canton/examples/token-bridge/daml.yaml new file mode 100644 index 0000000000..bfec529fb3 --- /dev/null +++ b/canton/examples/token-bridge/daml.yaml @@ -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 diff --git a/canton/examples/token-bridge/daml/Wormhole/Example/Test/EvilRegistry.daml b/canton/examples/token-bridge/daml/Wormhole/Example/Test/EvilRegistry.daml new file mode 100644 index 0000000000..acf86dcf9b --- /dev/null +++ b/canton/examples/token-bridge/daml/Wormhole/Example/Test/EvilRegistry.daml @@ -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 diff --git a/canton/examples/token-bridge/daml/Wormhole/Example/Test/TestTokenBridge.daml b/canton/examples/token-bridge/daml/Wormhole/Example/Test/TestTokenBridge.daml new file mode 100644 index 0000000000..a685d471fd --- /dev/null +++ b/canton/examples/token-bridge/daml/Wormhole/Example/Test/TestTokenBridge.daml @@ -0,0 +1,702 @@ +-- | Daml Script tests and integration entrypoints for the Wormhole Token +-- Bridge worked example: the "lock-and-attest" send path and the +-- "verify-and-unlock" receive path. +module Wormhole.Example.Test.TestTokenBridge where + +import DA.Assert ((===)) +import DA.Map qualified as Map +import DA.Optional (fromSome) +import DA.Set qualified as Set +import Daml.Script + +import Splice.Api.Token.HoldingV1 (Holding, InstrumentId(..)) + +import Wormhole.Core.Bytes (Bytes, Bytes20, Bytes32) +import Wormhole.Core.Setup + ( cantonChainId + , defaultGovernanceContract + , mkInitialCoreState + , mkInitialEmitterRegistry + , solanaGovernanceChainId + ) +import Wormhole.Core.State + ( CoreState + , Emitter + , RegisterEmitter(..) + , WormholeMessage + ) +import Wormhole.Core.VAA (parseVAA) + +import Wormhole.Example.TokenBridge +import Wormhole.Example.TokenRegistry +import Wormhole.Example.Test.EvilRegistry (CounterfeitRegistry(..)) + +---------------------------------------------------------------------- +-- Fixed test data +---------------------------------------------------------------------- + +-- 32-byte addresses used inside outbound transfer payloads (opaque foreign- +-- chain bytes; unrelated to any derived address on Canton). +usdTokenAddr : Bytes32 +usdTokenAddr = "000000000000000000000000000000000000000000000000000000000000dead" + +recipientAddr : Bytes32 +recipientAddr = "000000000000000000000000000000000000000000000000000000000000aaaa" + +targetChainId : Int +targetChainId = 2 + +transferAmount : Decimal +transferAmount = 1000.0 + +-- A placeholder guardian for the CoreState's initial set. Also the address of +-- the well-known devnet guardian key used to sign the CompleteTransfer VAA +-- fixtures below (see node/pkg/cantonclient/vectorgen_test.go). +exampleGuardian : Bytes20 +exampleGuardian = "befa429d57cd18b7f8a4d91a2da9ab4af05d0fbe" + +fixtureGuardianPubKey : Bytes +fixtureGuardianPubKey = "04d4a4629979f0c9fa0f0bb54edf33f87c8c5a1f42c0350a30d68f7e967023e34e495a8ebf5101036d0fd66e3b0a8c7c61b65fceeaf487ab3cd1b5b7b50beb7970" + +-- The foreign chain + peer token-bridge address the CompleteTransfer fixtures +-- below are signed as coming from. +peerChainId : Int +peerChainId = 2 + +peerEmitterAddress : Bytes32 +peerEmitterAddress = "000000000000000000000000000000000000000000000000000000000000beef" + +-- Static VAA fixtures, generated offline by a throwaway Go program mirroring +-- node/pkg/cantonclient/vectorgen_test.go:TestGenerateCantonVector, signed by +-- the well-known devnet guardian key. Both encode a Transfer whose +-- tokenAddress/recipient are DELIBERATELY arbitrary, fixed values that can +-- never match a live Canton Party's derived hash -- the same static-fixture +-- limitation Wormhole.Ntt.Manager's Receive tests hit (a live Party's +-- fingerprint is nondeterministic and can't be baked into an already-signed +-- fixture). See CompleteTransfer's own comment: closing the true positive +-- path (a matching recipient AND tokenAddress) needs a live two-step +-- sign-then-relay harness, not a Daml Script fixture. +fixtureVaaToChain72 : Bytes +fixtureVaaToChain72 = "01000000000100422c30413ca2972ee7423a73ebcff4a29a21ce5485ad7cb8773bd3854823c04d6768c8a5e4bff86e9963368be3635ee6be971c82529c69a1be89cdadd91d450c006553f100000000000002000000000000000000000000000000000000000000000000000000000000beef00000000000000010101000000000000000000000000000000000000000000000000000000012a05f200000000000000000000000000000000000000000000000000000000000000abcd0048000000000000000000000000000000000000000000000000000000000001234f00480000000000000000000000000000000000000000000000000000000000000000" + +-- Same as above but with toChain = 999 (not Canton). +fixtureVaaWrongToChain : Bytes +fixtureVaaWrongToChain = "01000000000100d7f817dc6e9c1b80879150d9a029f168fc1d6160c4d52e55ecccaadd6a7e5c92335a012dc0775537a264ffd716408e4f179f1e832517424497021c2e0fcc04e2006553f100000000000002000000000000000000000000000000000000000000000000000000000000beef00000000000000020101000000000000000000000000000000000000000000000000000000012a05f200000000000000000000000000000000000000000000000000000000000000abcd0048000000000000000000000000000000000000000000000000000000000001234f03e70000000000000000000000000000000000000000000000000000000000000000" + +---------------------------------------------------------------------- +-- Shared setup +---------------------------------------------------------------------- + +data BaseSetup = BaseSetup with + operator : Party + guardianGovernance : Party + guardianObserver : Party + bridge : Party + admin : Party + coreStateCid : ContractId CoreState + emitterCid : ContractId Emitter + emitterId : Int + regId : ContractId ExampleRegistry + tbId : ContractId TokenBridge + +-- | Allocates parties, installs a minimal CoreState + EmitterRegistry +-- (guardian set 0 = the well-known devnet guardian, so CompleteTransfer's +-- fixtures verify against a real signature), registers the bridge's Emitter, +-- and deploys the TokenBridge orchestrator. +baseSetup : Script BaseSetup +baseSetup = do + operator <- allocateParty "Operator" + guardianGovernance <- allocateParty "GuardianGovernance" + guardianObserver <- allocateParty "GuardianObserver" + bridge <- allocateParty "Bridge" + admin <- allocateParty "Registry" + + -- Fees are 0 throughout this example, so the fee instrument is a never-validated + -- placeholder and the recipient is guardianGovernance (production custody). + let feeInstrument = InstrumentId with admin = guardianGovernance, id = "UNUSED" + coreStateCid <- submit (actAs operator <> actAs guardianGovernance) do + createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId defaultGovernanceContract [exampleGuardian] guardianObserver feeInstrument guardianGovernance) + regCid <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianObserver 0) + + -- Permissionless registration (wormhole-core 0.2.0): the requester submits + -- alone against the disclosed 'EmitterRegistry'; the operator's co-signature on + -- the 'Emitter' is inherited from the registry. Zero-fee, so no CoreState/allocation. + regDisc <- fromSome <$> queryDisclosure operator regCid + (_, emitterCid) <- submit (actAs bridge <> disclose regDisc) do + exerciseCmd regCid RegisterEmitter with + requester = bridge + coreStateCid = None + feeAllocation = None + Some emitter <- queryContractId bridge emitterCid + + -- The bridge is onboarded with exactly one trusted registry; every lock and + -- unlock settles through it and no other (see 'TokenBridge.trustedFactory'). + regId <- submit admin (createCmd ExampleRegistry with admin) + tbId <- submit bridge do + createCmd TokenBridge with + bridge + guardianObserver + operator + emitterId = emitter.emitterId + trustedFactory = toInterfaceContractId regId + peers = Map.empty + consumed = Set.empty + + pure BaseSetup with emitterId = emitter.emitterId, .. + +---------------------------------------------------------------------- +-- Send path: LockAndPublish +---------------------------------------------------------------------- + +-- | The full send flow: mint a holding to @user@, then lock it into the +-- bridge's custody and publish the transfer message. Returns the setup, the +-- user, the published message, and the custody holding. +lockAndAttest : Script (BaseSetup, Party, WormholeMessage, ContractId Holding) +lockAndAttest = do + bs@BaseSetup{..} <- baseSetup + user <- allocateParty "User" + let instrumentId = InstrumentId with admin, id = "USD" + tokId <- submit admin do + exerciseCmd regId Mint with owner = user, instrumentId, amount = transferAmount + + -- The bridge-signed orchestrator, the registry factory, and the bridge's + -- Emitter are not stakeholders' contracts for the user, so they are shared + -- via explicit disclosure -- exactly how the token standard expects an app + -- to hand its factory to a user. + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId + Some dEmitter <- queryDisclosure bridge emitterCid + Some dCs <- queryDisclosure operator coreStateCid + (custodyCid, msg) <- submit (actAs user <> discloseMany [dReg, dTb, dEmitter, dCs]) do + exerciseCmd tbId LockAndPublish with + user + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tokId] + instrumentId + amount = transferAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 42 + emitterCid + coreStateCid + feeAllocation = None + pure (bs, user, msg, custodyCid) + +-- | Byte-exact vector for 'encodeTransfer' (133 bytes, payload ID 1). +testEncodeTransferVector : Script () +testEncodeTransferVector = do + encodeTransfer 10000000000 usdTokenAddr cantonChainId recipientAddr targetChainId 0 + === "0100000000000000000000000000000000000000000000000000000002540be400000000000000000000000000000000000000000000000000000000000000dead0048000000000000000000000000000000000000000000000000000000000000aaaa00020000000000000000000000000000000000000000000000000000000000000000" + +-- | Fixed-text vector for 'tokenAddressFor', pinning the domain-tagged +-- preimage: a live Party's fingerprint is nondeterministic, so the encoding +-- can only be pinned against fixed text (mirrors +-- 'Wormhole.Ntt.Manager.recipientAddressFromText's testRecipientAddressForVector). +testTokenAddressForVector : Script () +testTokenAddressForVector = do + tokenAddressForText "vector-admin::1220deadbeef" "USD" + === "a15a49569d35f67a24e69900e45970d2c0de464eae03daec4b0db7d4e0676ff8" + assert (tokenAddressForText "vector-admin::1220deadbeef" "USD" + /= tokenAddressForText "vector-admin::1220cafebabe" "USD") + +-- | Fixed-text vector for 'tokenBridgeRecipientAddressFor'. +testTokenBridgeRecipientAddressForVector : Script () +testTokenBridgeRecipientAddressForVector = do + tokenBridgeRecipientAddressFromText "vector-recipient::1220deadbeef" + === "8ef13a618f8108a5bac88866c5064c596ac5feb70cc7ccb0bac09919b386ce9f" + assert (tokenBridgeRecipientAddressFromText "vector-recipient::1220deadbeef" + /= tokenBridgeRecipientAddressFromText "vector-recipient::1220cafebabe") + +-- | 'decodeTransfer' inverts 'encodeTransfer'. +testDecodeTransferRoundTrip : Script () +testDecodeTransferRoundTrip = do + let payload = encodeTransfer 123456000000 usdTokenAddr cantonChainId recipientAddr targetChainId 0 + decoded = decodeTransfer payload + decoded.amount === 123456000000 + decoded.tokenAddress === usdTokenAddr + decoded.tokenChain === cantonChainId + decoded.to === recipientAddr + decoded.toChain === targetChainId + decoded.fee === 0 + +-- | In-memory unit test: asserts the published message and that custody moved +-- from the user to the bridge. +testLockAndPublish : Script () +testLockAndPublish = do + (BaseSetup{..}, user, msg, _custodyCid) <- lockAndAttest + let instrumentId = InstrumentId with admin, id = "USD" + + msg.registrar === operator + msg.owner === bridge + msg.emitterId === 0 + msg.sequence === 0 + msg.nonce === 42 + msg.payload === encodeTransfer 100000000000 (tokenAddressFor instrumentId) cantonChainId recipientAddr targetChainId 0 + + bridgeHoldings <- query @ExampleToken bridge + case bridgeHoldings of + [(_, h)] -> do + h.owner === bridge + h.amount === transferAmount + _ -> abort "expected exactly one bridge-owned custody holding" + userHoldings <- query @ExampleToken user + length userHoldings === 0 + +-- | Locking a sub-8dp-precision amount: the amount actually taken into +-- custody is truncated to 8dp BEFORE locking (not just at encoding time), so +-- locked == attested, and the truncated dust comes back to the user as +-- ordinary registry change -- it is never stranded on either side. (The +-- original example's 'encodeTransfer (truncate amount)' only truncated the +-- ENCODED value: it would have locked 100.123456789 in full while attesting +-- 100, silently keeping the dust in an untracked custody holding.) +testLockAndPublishFractionalAmount : Script () +testLockAndPublishFractionalAmount = do + BaseSetup{..} <- baseSetup + user <- allocateParty "FracUser" + let instrumentId = InstrumentId with admin, id = "USD" + fullAmount = 100.123456789 + lockedAmount = 100.12345678 + dust = 0.000000009 + tokId <- submit admin do + exerciseCmd regId Mint with owner = user, instrumentId, amount = fullAmount + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId + Some dEmitter <- queryDisclosure bridge emitterCid + Some dCs <- queryDisclosure operator coreStateCid + (_custodyCid, msg) <- submit (actAs user <> discloseMany [dReg, dTb, dEmitter, dCs]) do + exerciseCmd tbId LockAndPublish with + user + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tokId] + instrumentId + amount = fullAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 1 + emitterCid + coreStateCid + feeAllocation = None + + let decoded = decodeTransfer msg.payload + intToDecimal decoded.amount / 100000000.0 === lockedAmount + + bridgeHoldings <- query @ExampleToken bridge + case bridgeHoldings of + [(_, h)] -> h.amount === lockedAmount + _ -> abort "expected exactly one bridge-owned custody holding" + userHoldings <- query @ExampleToken user + case userHoldings of + [(_, h)] -> h.amount === dust + _ -> abort "expected the truncated dust to come back to the user as change" + +-- | Two distinct failure reasons for the SAME choice, per the discipline +-- Wormhole.Ntt.Manager's authority proofs use: (i) supplying @user@ without +-- their actual authority fails on AUTHORIZATION, no matter what is disclosed; +-- (ii) supplying one's own authority as @user@ but locking a holding one +-- doesn't own fails on the registry's BUSINESS-LOGIC ownership check. Neither +-- of these was previously exercised -- the original suite only had the happy +-- path. +testLockAndPublishRequiresUserAuthority : Script () +testLockAndPublishRequiresUserAuthority = do + BaseSetup{..} <- baseSetup + bob <- allocateParty "Bob" + mallory <- allocateParty "Mallory" + let instrumentId = InstrumentId with admin, id = "USD" + tokId <- submit admin do + exerciseCmd regId Mint with owner = bob, instrumentId, amount = transferAmount + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId + Some dEmitter <- queryDisclosure bridge emitterCid + Some dCs <- queryDisclosure operator coreStateCid + Some dTok <- queryDisclosure admin tokId + + r1 <- trySubmit (actAs bridge <> discloseMany [dReg, dTb, dEmitter, dCs, dTok]) do + exerciseCmd tbId LockAndPublish with + user = bob + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tokId] + instrumentId + amount = transferAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 1 + emitterCid + coreStateCid + feeAllocation = None + case r1 of + Left (AuthorizationError _) -> pure () + Left other -> abort ("expected an AuthorizationError specifically, got: " <> show other) + Right _ -> abort "expected LockAndPublish to fail without bob's authority" + + r2 <- trySubmit (actAs mallory <> discloseMany [dReg, dTb, dEmitter, dCs, dTok]) do + exerciseCmd tbId LockAndPublish with + user = mallory + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tokId] + instrumentId + amount = transferAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 1 + emitterCid + coreStateCid + feeAllocation = None + case r2 of + Left (FailureStatusError _) -> pure () + Left other -> abort ("expected a FailureStatusError (registry ownership check), got: " <> show other) + Right _ -> abort "expected LockAndPublish to fail locking a holding mallory doesn't own" + +-- | @user@ has full CONTROLLER authority, but with no disclosures at all +-- 'TokenBridge'/the factory/the Emitter are not their stakeholder contracts: +-- this fails on VISIBILITY, not authorization -- backing the module header's +-- claim that disclosure is a data need, not an approval. +testLockAndPublishWithoutDisclosureFails : Script () +testLockAndPublishWithoutDisclosureFails = do + BaseSetup{..} <- baseSetup + bob <- allocateParty "Bob2" + let instrumentId = InstrumentId with admin, id = "USD" + tokId <- submit admin do + exerciseCmd regId Mint with owner = bob, instrumentId, amount = transferAmount + submitMustFail (actAs bob) do + exerciseCmd tbId LockAndPublish with + user = bob + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tokId] + instrumentId + amount = transferAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 1 + emitterCid + coreStateCid + feeAllocation = None + +-- | Two consecutive locks succeed because the bridge's Emitter is now +-- resolved BY KEY inside the choice, not from a caller-supplied cid -- the +-- original example took @emitterCid@ as an argument, so 'PublishMessage' +-- being consuming meant a second call against the same disclosure failed +-- outright (the emitter cid had already been archived by the first publish). +testLockAndPublishTwice : Script () +testLockAndPublishTwice = do + BaseSetup{..} <- baseSetup + user <- allocateParty "TwiceUser" + let instrumentId = InstrumentId with admin, id = "USD" + tok1 <- submit admin do exerciseCmd regId Mint with owner = user, instrumentId, amount = 10.0 + tok2 <- submit admin do exerciseCmd regId Mint with owner = user, instrumentId, amount = 10.0 + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId + Some dEmt1 <- queryDisclosure bridge emitterCid + Some dCs <- queryDisclosure operator coreStateCid + (_, msg1) <- submit (actAs user <> discloseMany [dReg, dTb, dEmt1, dCs]) do + exerciseCmd tbId LockAndPublish with + user + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tok1] + instrumentId + amount = 10.0 + recipient = recipientAddr + toChain = targetChainId + nonce = 1 + emitterCid + coreStateCid + feeAllocation = None + + -- The bridge's Emitter is now a DIFFERENT contract (sequence bumped by the + -- first publish) -- whoever serves disclosures must fetch and disclose the + -- CURRENT one and pass its cid. With wormhole-core 0.2.0's keyless Emitter it + -- is re-resolved from the bridge's ACS (the owner is a signatory), the same + -- "disclosure freshness" burden the module header calls out. + [(emitterCid2, _emitter2)] <- query @Emitter bridge + Some dEmt2 <- queryDisclosure bridge emitterCid2 + (_, msg2) <- submit (actAs user <> discloseMany [dReg, dTb, dEmt2, dCs]) do + exerciseCmd tbId LockAndPublish with + user + factoryCid = toInterfaceContractId regId + holdingCids = [toInterfaceContractId tok2] + instrumentId + amount = 10.0 + recipient = recipientAddr + toChain = targetChainId + nonce = 2 + emitterCid = emitterCid2 + coreStateCid + feeAllocation = None + + msg1.sequence === 0 + msg2.sequence === 1 + +-- | SECURITY regression (finding: forgeable lock). @factoryCid@ is an OPEN +-- 'TransferFactory' interface value, so a caller can supply a hand-rolled +-- implementation that fabricates a @Completed@ result with a counterfeit +-- holding, locking nothing real. This exercises the attack end to end: Mallory +-- passes her OWN 'CounterfeitRegistry' (see 'Wormhole.Example.Test.EvilRegistry') +-- and attests the real registry admin's instrument -- a token she holds none of. +-- +-- The bridge now settles only through the 'trustedFactory' it was onboarded +-- with, so Mallory's counterfeit is rejected (@factoryCid == trustedFactory@ +-- fails) and no message is published: the @Left@ branch is taken. Before the +-- fix this hit the @Right@ branch and aborted -- the executable proof of the +-- finding, preserved here as the guard against regression. +testLockAndPublishRejectsForgedFactory : Script () +testLockAndPublishRejectsForgedFactory = do + BaseSetup{..} <- baseSetup + mallory <- allocateParty "MalloryForge" + -- The instrument Mallory attests belongs to the real registry admin; she + -- never holds any of it and needs no real tokens for the attack. + let instrumentId = InstrumentId with admin, id = "USD" + evilReg <- submit mallory do createCmd CounterfeitRegistry with attacker = mallory + + -- Mallory has the bridge orchestrator, its Emitter, and the CoreState the + -- same way any user does -- by explicit disclosure. Her factory is her own + -- contract, so it needs no disclosure. + Some dTb <- queryDisclosure bridge tbId + Some dEmitter <- queryDisclosure bridge emitterCid + Some dCs <- queryDisclosure operator coreStateCid + result <- trySubmit (actAs mallory <> discloseMany [dTb, dEmitter, dCs]) do + exerciseCmd tbId LockAndPublish with + user = mallory + factoryCid = toInterfaceContractId evilReg + holdingCids = [] + instrumentId + amount = transferAmount + recipient = recipientAddr + toChain = targetChainId + nonce = 7 + emitterCid + coreStateCid + feeAllocation = None + case result of + Left _ -> pure () -- fixed: the bridge refused to attest a forged lock + Right (_custodyCid, msg) -> + abort $ "SECURITY: LockAndPublish attested a forged lock -- no genuine " + <> "admin-issued custody exists, yet it published a guardian-signable " + <> "message (sequence " <> show msg.sequence <> ")" + +---------------------------------------------------------------------- +-- Receive path: CompleteTransfer +---------------------------------------------------------------------- + +-- | VAA emitter is not the configured peer -- the registered peer address +-- deliberately does not match the fixture's actual emitter, so the check +-- fires before the fixture's payload is even decoded. +testCompleteTransferWrongPeerFails : Script () +testCompleteTransferWrongPeerFails = do + BaseSetup{..} <- baseSetup + recipient <- allocateParty "Recipient1" + let instrumentId = InstrumentId with admin, id = "USD" + tbId2 <- submit bridge do + exerciseCmd tbId RegisterPeer with + chainId = peerChainId + peerAddress = "00000000000000000000000000000000000000000000000000000000000000ff" + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId2 + submitMustFail (actAs operator <> discloseMany [dReg, dTb]) do + exerciseCmd tbId2 CompleteTransfer with + coreStateCid + vaaBytes = fixtureVaaToChain72 + pubKeys = [(0, fixtureGuardianPubKey)] + recipient + instrumentId + factoryCid = toInterfaceContractId regId + custodyHoldingCids = [] + +-- | Replay protection: the fixture's VAA hash is already in @consumed@ +-- (constructed directly, since reaching this state via a real prior +-- completion is blocked by the same live-Party-fingerprint limitation +-- documented on the fixtures above). +testCompleteTransferReplayFails : Script () +testCompleteTransferReplayFails = do + BaseSetup{..} <- baseSetup + recipient <- allocateParty "Recipient2" + let instrumentId = InstrumentId with admin, id = "USD" + vaa = parseVAA fixtureVaaToChain72 + tbReplay <- submit bridge do + createCmd TokenBridge with + bridge + guardianObserver + operator + emitterId + trustedFactory = toInterfaceContractId regId + peers = Map.fromList [(peerChainId, peerEmitterAddress)] + consumed = Set.fromList [vaa.hash] + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbReplay + submitMustFail (actAs operator <> discloseMany [dReg, dTb]) do + exerciseCmd tbReplay CompleteTransfer with + coreStateCid + vaaBytes = fixtureVaaToChain72 + pubKeys = [(0, fixtureGuardianPubKey)] + recipient + instrumentId + factoryCid = toInterfaceContractId regId + custodyHoldingCids = [] + +-- | @toChain@ in the payload is not Canton (999, not 72). +testCompleteTransferWrongToChainFails : Script () +testCompleteTransferWrongToChainFails = do + BaseSetup{..} <- baseSetup + recipient <- allocateParty "Recipient3" + let instrumentId = InstrumentId with admin, id = "USD" + tbId2 <- submit bridge do + exerciseCmd tbId RegisterPeer with chainId = peerChainId, peerAddress = peerEmitterAddress + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId2 + submitMustFail (actAs operator <> discloseMany [dReg, dTb]) do + exerciseCmd tbId2 CompleteTransfer with + coreStateCid + vaaBytes = fixtureVaaWrongToChain + pubKeys = [(0, fixtureGuardianPubKey)] + recipient + instrumentId + factoryCid = toInterfaceContractId regId + custodyHoldingCids = [] + +-- | The fixture's attested recipient can never match a live-allocated Party's +-- derived hash (see the fixtures' own doc comment) -- proving the check +-- actually gates 'CompleteTransfer' against a real, validly-signed VAA. Uses +-- 'trySubmit' rather than 'submitMustFail' to also serve as this choice's +-- empirical authorization proof (see 'CompleteTransfer's own comment): with +-- every prior check satisfied (peer, replay, toChain), operator + disclosures +-- reach all the way to the business-logic recipient check -- a +-- FailureStatusError from 'assertMsg', NOT an AuthorizationError or a +-- visibility error (ContractNotFound/ContractKeyNotFound). If this ever +-- changes, something upstream of the recipient check has regressed. +testCompleteTransferRecipientMismatchFails : Script () +testCompleteTransferRecipientMismatchFails = do + BaseSetup{..} <- baseSetup + recipient <- allocateParty "Recipient4" + let instrumentId = InstrumentId with admin, id = "USD" + tbId2 <- submit bridge do + exerciseCmd tbId RegisterPeer with chainId = peerChainId, peerAddress = peerEmitterAddress + Some dReg <- queryDisclosure admin regId + Some dTb <- queryDisclosure bridge tbId2 + result <- trySubmit (actAs operator <> discloseMany [dReg, dTb]) do + exerciseCmd tbId2 CompleteTransfer with + coreStateCid + vaaBytes = fixtureVaaToChain72 + pubKeys = [(0, fixtureGuardianPubKey)] + recipient + instrumentId + factoryCid = toInterfaceContractId regId + custodyHoldingCids = [] + case result of + Left (FailureStatusError _) -> pure () + Left other -> abort ("expected a FailureStatusError (recipient-check assertion), got: " <> show other) + Right _ -> abort "expected CompleteTransfer to fail on the fixture's recipient mismatch" + +---------------------------------------------------------------------- +-- Integration entrypoints, invoked by Go via `dpm script` against a live +-- sandbox (see node/pkg/watchers/canton/token_bridge_integration_test.go). +---------------------------------------------------------------------- + +integrationLockAndPublish : Script Party +integrationLockAndPublish = do + (BaseSetup{bridge}, _user, _msg, _custodyCid) <- lockAndAttest + pure bridge + +---------------------------------------------------------------------- +-- Live two-step integration harness for CompleteTransfer's positive path. +-- +-- Mirrors Wormhole.Ntt.Manager's ntt_recipient_match_integration_test.go +-- pattern, closing the same "known test gap" class: CompleteTransfer's +-- recipient AND tokenAddress bindings both depend on live-allocated Party +-- text that can't be baked into an ahead-of-time-signed VAA fixture (see the +-- static fixtures' own doc comment above). Closed with a live two-step +-- harness instead: +-- 1. integrationCompleteTransferSetup allocates everything CompleteTransfer +-- needs -- including a bridge-owned custody holding standing in for a +-- prior LockAndPublish (that leg is already covered live by +-- integrationLockAndPublish) -- and returns the ACTUAL on-ledger +-- tokenAddressFor/tokenBridgeRecipientAddressFor hashes, computed from +-- the real allocated admin/recipient parties, not guessed. +-- 2. Go signs a FRESH Transfer VAA against those hashes, using the same +-- well-known devnet guardian key vectorgen_test.go signs every other +-- fixture with. +-- 3. integrationCompleteTransfer relays it against the SAME sandbox, +-- asserting the unlock lands on the matching recipient and that +-- replaying the same VAA is rejected. +---------------------------------------------------------------------- + +data CompleteTransferSetup = CompleteTransferSetup with + coreStateCid : ContractId CoreState + tbId : ContractId TokenBridge + regId : ContractId ExampleRegistry + custodyHoldingId : ContractId ExampleToken + operator : Party + bridge : Party + admin : Party + recipient : Party + tokenAddress : Bytes32 + recipientAddress : Bytes32 + deriving (Eq, Show) + +integrationCompleteTransferSetup : Script CompleteTransferSetup +integrationCompleteTransferSetup = do + BaseSetup{..} <- baseSetup + recipient <- allocateParty "MatchRecipient" + let instrumentId = InstrumentId with admin, id = "USD" + tbId2 <- submit bridge do + exerciseCmd tbId RegisterPeer with chainId = peerChainId, peerAddress = peerEmitterAddress + custodyHoldingId <- submit admin do + exerciseCmd regId Mint with owner = bridge, instrumentId, amount = 50.0 + pure CompleteTransferSetup with + coreStateCid + tbId = tbId2 + regId + custodyHoldingId + operator + bridge + admin + recipient + tokenAddress = tokenAddressFor instrumentId + recipientAddress = tokenBridgeRecipientAddressFor recipient + +data CompleteTransferInput = CompleteTransferInput with + coreStateCid : ContractId CoreState + tbId : ContractId TokenBridge + regId : ContractId ExampleRegistry + custodyHoldingId : ContractId ExampleToken + operator : Party + bridge : Party + admin : Party + recipient : Party + vaaBytes : Bytes + deriving (Eq, Show) + +-- | Step 2: relay the freshly signed VAA. Succeeds -- proving the recipient +-- AND tokenAddress bindings really do match when the sender computed them +-- correctly off the ACTUAL allocated parties -- and unlocks to @recipient@. A +-- second relay of the same VAA is rejected (replay). +integrationCompleteTransfer : CompleteTransferInput -> Script () +integrationCompleteTransfer input = do + let instrumentId = InstrumentId with admin = input.admin, id = "USD" + Some dReg <- queryDisclosure input.admin input.regId + Some dTb <- queryDisclosure input.bridge input.tbId + Some dTok <- queryDisclosure input.bridge input.custodyHoldingId + _recipientCid <- submit (actAs input.operator <> discloseMany [dReg, dTb, dTok]) do + exerciseCmd input.tbId CompleteTransfer with + coreStateCid = input.coreStateCid + vaaBytes = input.vaaBytes + pubKeys = [(0, fixtureGuardianPubKey)] + recipient = input.recipient + instrumentId + factoryCid = toInterfaceContractId input.regId + custodyHoldingCids = [toInterfaceContractId input.custodyHoldingId] + + holdings <- query @ExampleToken input.recipient + case holdings of + [(_, h)] -> h.owner === input.recipient + _ -> abort "expected exactly one holding unlocked to the matching recipient" + + [(tb2, _)] <- query @TokenBridge input.bridge + Some dReg2 <- queryDisclosure input.admin input.regId + Some dTb2 <- queryDisclosure input.bridge tb2 + submitMustFail (actAs input.operator <> discloseMany [dReg2, dTb2]) do + exerciseCmd tb2 CompleteTransfer with + coreStateCid = input.coreStateCid + vaaBytes = input.vaaBytes + pubKeys = [(0, fixtureGuardianPubKey)] + recipient = input.recipient + instrumentId + factoryCid = toInterfaceContractId input.regId + custodyHoldingCids = [toInterfaceContractId input.custodyHoldingId] diff --git a/canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml b/canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml new file mode 100644 index 0000000000..556c6de6ac --- /dev/null +++ b/canton/examples/token-bridge/daml/Wormhole/Example/TokenBridge.daml @@ -0,0 +1,373 @@ +-- | The Wormhole Token Bridge on Canton: the "lock-and-attest" send path and +-- the "verify-and-unlock" receive path (whitepaper 0003 @Transfer@, payload +-- ID 1). A worked example alongside NTT (@Wormhole.Ntt.Manager@ in the +-- sibling @ntt@ package) — a different wire protocol and trust shape for the +-- same underlying primitives (core 'Emitter'/'PublishMessage', CIP-56 +-- factories, domain-tagged derived addresses). Unlock-only: a foreign token +-- arriving on Canton (wrapped-asset mint) is out of scope for this example. +-- +-- Send: 'LockAndPublish' is a nonconsuming choice on a @bridge@-signed +-- contract, controlled by @user@, so its body holds @{bridge, user}@ +-- authority (Daml propagates a contract's signatories' authority into every +-- choice exercised on it, consuming or not). Two legs run in one transaction: +-- * the nested @TransferFactory_Transfer@ authorizes off the @sender = user@ +-- controller the interface itself checks — @bridge@'s authority is not +-- needed for this leg, since the receiver (bridge) is only an observer of +-- the resulting custody holding. +-- * @PublishMessage@ (controller @owner = bridge, payer = user@) runs its +-- @bridge@ leg under the @bridge@ authority this choice's body carries +-- because 'TokenBridge' is signed by @bridge@ — NOT because @user@ is +-- trusted with it. The @user@ authority (the choice controller) covers only +-- the message-fee draw @user@ funds as @payer@, mirroring EVM's @msg.value@. +-- No core operator authority is involved, matching EVM's permissionless +-- lock+publish. VISIBILITY, not authorization, is the one real requirement on +-- @user@: they are not a natural stakeholder of 'TokenBridge', the registry +-- factory, or the bridge's 'Emitter', so a submission needs those disclosed +-- (Daml Explicit Contract Disclosure — a data attachment, not a signature). +-- +-- Receive: 'CompleteTransfer' verifies an inbound VAA and unlocks the +-- corresponding custody holding. It is CONSUMING (it updates the replay set), +-- controller @operator@ — a permissioned, guardian-relayed choice, matching +-- 'Wormhole.Ntt.Manager.Receive' exactly. This was NOT the original design: a +-- permissionless @controller relayer@ (mirroring EVM's @completeTransfer@) +-- was attempted first and empirically disproven — see 'CompleteTransfer's own +-- comment for why fetching the disclosed 'CoreState' structurally requires a +-- guardian-side party's authority no matter who else co-signs. +module Wormhole.Example.TokenBridge where + +import DA.Crypto.Text (keccak256, toHex) +import DA.Map (Map) +import DA.Map qualified as Map +import DA.Optional (fromSomeNote) +import DA.Set (Set) +import DA.Set qualified as Set +import DA.Time (addRelTime, hours) + +import Wormhole.Core.Bytes +import Wormhole.Core.Setup (cantonChainId) +import Wormhole.Core.State (CoreState(..), Emitter, PublishMessage(..), WormholeMessage) +import Wormhole.Core.VAA (parseVAA, verifyVAA) + +import Splice.Api.Token.AllocationV1 (Allocation) +import Splice.Api.Token.HoldingV1 +import Splice.Api.Token.MetadataV1 (ExtraArgs(..), emptyChoiceContext, emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 + +---------------------------------------------------------------------- +-- Wire codec: Wormhole Token Bridge "Transfer", payload ID 1 (whitepaper 0003) +-- +-- payloadID(1) ++ amount(32) ++ tokenAddress(32) ++ tokenChain(2) ++ to(32) ++ +-- toChain(2) ++ fee(32) = 133 bytes. Amounts are 8dp-normalized integers (see +-- 'truncateTo8dp'), matching EVM's cross-chain amount normalization. +---------------------------------------------------------------------- + +data TransferPayload = TransferPayload with + amount : Int + tokenAddress : Bytes32 + tokenChain : Int + to : Bytes32 + toChain : Int + fee : Int + deriving (Eq, Show) + +encodeTransfer : Int -> Bytes32 -> Int -> Bytes32 -> Int -> Int -> Bytes +encodeTransfer amount tokenAddress tokenChain to toChain fee = + intToHexByte 1 + <> intToBytesN amount 32 + <> normalizeHex tokenAddress + <> intToBytesN tokenChain 2 + <> normalizeHex to + <> intToBytesN toChain 2 + <> intToBytesN fee 32 + +-- | Inverse of 'encodeTransfer'. Fails loudly on an unsupported payload id — +-- 'CompleteTransfer' should reject a malformed/foreign payload, not silently +-- misparse it. +decodeTransfer : Bytes -> TransferPayload +decodeTransfer payload + | payloadId /= 1 = error ("token-bridge: unsupported payload id: " <> show payloadId) + | otherwise = TransferPayload with + amount = bytesToInt (sliceBytes 1 32 payload) + tokenAddress = normalizeHex (sliceBytes 33 32 payload) + tokenChain = bytesToInt (sliceBytes 65 2 payload) + to = normalizeHex (sliceBytes 67 32 payload) + toChain = bytesToInt (sliceBytes 99 2 payload) + fee = bytesToInt (sliceBytes 101 32 payload) + where + payloadId = bytesToInt (sliceBytes 0 1 payload) + +---------------------------------------------------------------------- +-- Amount normalization +---------------------------------------------------------------------- + +decimalScale8dp : Decimal +decimalScale8dp = 100000000.0 + +-- | Truncate a Decimal amount to at most 8 decimal places. Returns the actual +-- Decimal amount to lock — with sub-8dp dust dropped BEFORE locking — paired +-- with its 8dp integer wire encoding, so the amount taken into custody and +-- the amount attested can never diverge. (The original example's +-- @encodeTransfer (truncate amount)@ only truncated the ENCODED value: lock +-- 100.75, attest 100, silently keeping the 0.75 dust in a custody holding +-- nothing ever attested to.) +truncateTo8dp : Decimal -> (Decimal, Int) +truncateTo8dp amount = + let scaled = truncate (amount * decimalScale8dp) + in (intToDecimal scaled / decimalScale8dp, scaled) + +---------------------------------------------------------------------- +-- Derived addresses (domain-tagged keccak256, mirroring Wormhole.Core.Bytes / +-- Wormhole.Ntt.Manager's style: a small preimage helper split out in Text +-- form so vector tests can pin the encoding against fixed strings — a live +-- Party's fingerprint is nondeterministic and can't be baked into a static, +-- pre-signed VAA fixture). +---------------------------------------------------------------------- + +tokenAddressTag : Text +tokenAddressTag = "wormhole:token-bridge-token:v1" + +tokenAddressForText : Text -> Text -> Bytes32 +tokenAddressForText adminText idText = + normalizeHex (keccak256 (toHex tokenAddressTag + <> unLenPrefixed (lenPrefixed (toHex adminText)) + <> unLenPrefixed (lenPrefixed (toHex idText)))) + +-- | The 32-byte token address attested for @instrumentId@. Binds the +-- attestation to the instrument ACTUALLY locked — 'LockAndPublish' used to +-- take a free-standing @tokenAddress@ argument with no connection to +-- @instrumentId@, so a user could lock instrument A while attesting an +-- unrelated address. This is a Canton-local commitment, not a reversible +-- address: a counterpart chain cannot recover @(admin, id)@ from it; +-- attestation-of-metadata (payload 2) is out of scope here. +tokenAddressFor : InstrumentId -> Bytes32 +tokenAddressFor instrumentId = tokenAddressForText (partyToText instrumentId.admin) instrumentId.id + +-- | Distinct from every other domain tag in this codebase (including NTT's +-- own 'Wormhole.Ntt.Manager.recipientAddressTag') so a token-bridge recipient +-- binding can never collide with an NTT one or a registry address. +tokenBridgeRecipientAddressTag : Text +tokenBridgeRecipientAddressTag = "wormhole:token-bridge-recipient:v1" + +tokenBridgeRecipientAddressFromText : Text -> Bytes32 +tokenBridgeRecipientAddressFromText recipientText = + normalizeHex (keccak256 (toHex tokenBridgeRecipientAddressTag + <> unLenPrefixed (lenPrefixed (toHex recipientText)))) + +-- | The 32-byte binding a sender must compute off-chain and put in an +-- outbound transfer's @to@ field to address @recipient@ on Canton. Mirrors +-- 'Wormhole.Ntt.Manager.recipientAddressFor' and its tradeoff: this +-- permanently binds 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. +tokenBridgeRecipientAddressFor : Party -> Bytes32 +tokenBridgeRecipientAddressFor recipient = tokenBridgeRecipientAddressFromText (partyToText recipient) + +---------------------------------------------------------------------- +-- TokenBridge +---------------------------------------------------------------------- + +template TokenBridge + with + bridge : Party -- custodian; also the owner of the registered Emitter + guardianObserver : Party -- read-only guardian observer (matches the core templates) + operator : Party -- the registrar of the bridge's Emitter; CompleteTransfer controller + emitterId : Int -- the bridge's registered Emitter id (informational; the cid is passed in) + trustedFactory : ContractId TransferFactory -- the ONLY registry this bridge settles locks/unlocks through; bound at onboarding + peers : Map Int Bytes32 -- chainId -> peer token-bridge emitter address + consumed : Set Bytes32 -- inbound VAA hashes already executed + where + signatory bridge + -- Inert for LockAndPublish today: it is nonconsuming, and informees of a + -- nonconsuming exercise do not include a contract's template-level + -- observers (only its controllers and any explicit choice observers) — + -- guardians already see the message surface via the nested Emitter's OWN + -- observer field. Kept here deliberately for CompleteTransfer below, a + -- CONSUMING choice, where it becomes load-bearing: guardians become + -- informees of every unlock, matching the attestation-surface pattern on + -- Emitter/CoreState (README §4.1/§4.2). + observer guardianObserver + key bridge : Party + maintainer key + + -- | Register (or replace) the peer token-bridge deployment on @chainId@, + -- whose attested transfers this bridge accepts inbound. Controller + -- @bridge@: a real deployment would gate this behind a governance VAA + -- (mirroring core @SubmitGovernanceVAA@ / EVM's @registerChain@) — a + -- direct controller choice is a stated simplification for this example. + choice RegisterPeer : ContractId TokenBridge + with + chainId : Int + peerAddress : Bytes32 + controller bridge + do create this with peers = Map.insert chainId (normalizeHex peerAddress) peers + + -- | Lock the user's holding(s) into bridge custody and publish a transfer + -- attestation, atomically. Returns the new custody holding and the + -- published 'WormholeMessage'. See the module header for the authority + -- breakdown. + -- + -- The bridge's 'Emitter' is passed as a disclosed @emitterCid@: wormhole-core + -- 0.2.0 removed contract keys (they are non-unique on Daml-LF 2.3 — see the + -- core README §4), so a contract is addressed by cid and shared by explicit + -- disclosure. 'PublishMessage' is consuming, so a disclosed cid works exactly + -- once — whoever serves disclosures must resolve and disclose the CURRENT + -- 'Emitter' (and 'CoreState') before EACH call. The caller cannot smuggle in a + -- foreign emitter: 'PublishMessage' is controlled by its @owner@, so only the + -- @bridge@'s own Emitter can be published under this choice's @bridge@ + -- authority. + -- + -- The core @messageFee@ is charged through the referenced @coreStateCid@ with + -- @payer = user@ (EVM's @msg.value@ analog); at fee 0 pass @feeAllocation = + -- None@, but the 'CoreState' reference is still required since the amount is + -- only knowable from it. + nonconsuming choice LockAndPublish : (ContractId Holding, WormholeMessage) + with + user : Party + factoryCid : ContractId TransferFactory + holdingCids : [ContractId Holding] + instrumentId : InstrumentId + amount : Decimal + recipient : Bytes32 + toChain : Int + nonce : Int + emitterCid : ContractId Emitter + coreStateCid : ContractId CoreState + feeAllocation : Optional (ContractId Allocation, ExtraArgs) + controller user + do + now <- getTime + let (lockedAmount, scaledAmount) = truncateTo8dp amount + assertMsg "TokenBridge: amount truncates to zero at 8 decimals" (scaledAmount > 0) + -- Authenticate the factory before trusting its settlement. @factoryCid@ + -- is a value of the OPEN 'TransferFactory' interface type, so on its own + -- it is only "some contract implementing the interface" -- a caller can + -- supply their own implementation that fabricates a @Completed@ result + -- with a counterfeit holding, locking nothing (see + -- 'Wormhole.Example.Test.EvilRegistry'). Interface views never expose + -- signatories, and a plain @fetch@ of the admin-signed registry would + -- require the admin's authority (breaking permissionless lock), so the + -- factory cannot be authenticated by inspection here. Instead it is + -- bound: the bridge only settles through the exact @trustedFactory@ it + -- was onboarded with (a governance concern in production, mirroring the + -- NTT sibling binding its own CIP-56 custody seam). Settlement then runs + -- the genuine registry's real ownership/balance checks and mints a fresh + -- admin-signed custody holding, which no counterfeit can forge. + assertMsg "TokenBridge: factory is not the bridge's onboarded registry" + (factoryCid == trustedFactory) + let transfer = Transfer with + sender = user + receiver = bridge + amount = lockedAmount + instrumentId + requestedAt = now + executeBefore = addRelTime now (hours 1) + inputHoldingCids = holdingCids + meta = emptyMetadata + result <- exercise factoryCid TransferFactory_Transfer with + expectedAdmin = instrumentId.admin + transfer + extraArgs = ExtraArgs with context = emptyChoiceContext, meta = emptyMetadata + custodyCid <- case result.output of + TransferInstructionResult_Completed cids -> case cids of + [c] -> pure c + _ -> abort "TokenBridge: expected exactly one custody holding" + _ -> abort "TokenBridge: registry did not settle the transfer atomically" + let payload = encodeTransfer scaledAmount (tokenAddressFor instrumentId) cantonChainId recipient toChain 0 + msg <- exercise emitterCid PublishMessage with + nonce + payload + consistencyLevel = 1 + coreStateCid + payer = user + feeAllocation + pure (custodyCid, msg) + + -- | Verify an inbound VAA attesting a transfer TO Canton and unlock the + -- corresponding custody holding to @recipient@. Unlock-only (see module + -- header). Consuming — it archives and recreates 'TokenBridge' with the + -- VAA's hash added to @consumed@ — so it is resolved BY KEY + -- (@exerciseByKeyCmd@), not a caller-supplied cid: the same staleness + -- class 'LockAndPublish' used to have on 'Emitter' would otherwise recur + -- here on every single completion (this is why O6 keys 'TokenBridge' now + -- rather than later — Daml contract keys cannot be added by upgrade). + -- + -- Authorization: controller @operator@ — NOT the permissionless + -- @controller relayer@ this choice was first written with. That design + -- was EMPIRICALLY DISPROVEN (per the "verify empirically, don't just + -- assert" discipline 'Wormhole.Ntt.Manager.Transfer' models): fetching + -- the disclosed 'CoreState' below (@cs <- fetch coreStateCid@) is a plain + -- Daml @Fetch@ action, and a @Fetch@ action's required authorizers are + -- (at least one of) the FETCHED CONTRACT's own stakeholders — this is 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 them, 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. Confirmed with @testCompleteTransferReachesRecipientCheck@ + -- in @TestTokenBridge.daml@, which failed with an authorization error + -- under @controller relayer@ before this fix, and now reaches the + -- business-logic recipient check as controller @operator@ instead. + -- + -- @recipient@ is a choice argument, but the caller cannot steer it + -- arbitrarily: 'tokenBridgeRecipientAddressFor' of it must equal the + -- VAA's own @to@ field, a value fixed by the sender and covered by the + -- guardian signature (mirrors NTT's @recipientAddressFor@ binding and its + -- tradeoff, restated on that function). + choice CompleteTransfer : ContractId Holding + with + coreStateCid : ContractId CoreState + vaaBytes : Bytes + pubKeys : [(Int, Bytes)] + recipient : Party + instrumentId : InstrumentId + factoryCid : ContractId TransferFactory + custodyHoldingCids : [ContractId Holding] + controller operator + do + now <- getTime + let vaa = parseVAA vaaBytes + cs <- fetch coreStateCid + let gs = fromSomeNote "token-bridge: unknown guardian set index" + (Map.lookup vaa.guardianSetIndex cs.guardianSets) + either assertFail pure (verifyVAA gs vaa pubKeys now) + let peerAddress = fromSomeNote ("token-bridge: no peer for chain " <> show vaa.emitterChain) + (Map.lookup vaa.emitterChain peers) + assertMsg "token-bridge: VAA emitter is not the configured peer" + (normalizeHex vaa.emitterAddress == peerAddress) + assertMsg "token-bridge: message already consumed" (not (Set.member vaa.hash consumed)) + let transfer = decodeTransfer vaa.payload + assertMsg "token-bridge: wrong target chain" (transfer.toChain == cantonChainId) + assertMsg "token-bridge: recipient does not match VAA's attested recipient" + (tokenBridgeRecipientAddressFor recipient == transfer.to) + assertMsg "token-bridge: tokenAddress/tokenChain do not match instrument" + (transfer.tokenAddress == tokenAddressFor instrumentId && transfer.tokenChain == cantonChainId) + assertMsg "token-bridge: nonzero fee is not supported" (transfer.fee == 0) + -- Authenticate the factory (see 'LockAndPublish'): the unlock settles + -- only through the bridge's onboarded 'trustedFactory', never a + -- caller-supplied counterfeit implementing the interface. + assertMsg "token-bridge: factory is not the bridge's onboarded registry" + (factoryCid == trustedFactory) + let unlockAmount = intToDecimal transfer.amount / decimalScale8dp + xfer = Transfer with + sender = bridge + receiver = recipient + amount = unlockAmount + instrumentId + requestedAt = now + executeBefore = addRelTime now (hours 1) + inputHoldingCids = custodyHoldingCids + meta = emptyMetadata + result <- exercise factoryCid TransferFactory_Transfer with + expectedAdmin = instrumentId.admin + transfer = xfer + extraArgs = ExtraArgs with context = emptyChoiceContext, meta = emptyMetadata + recipientCid <- case result.output of + TransferInstructionResult_Completed cids -> case cids of + [c] -> pure c + _ -> abort "token-bridge: expected exactly one recipient holding" + _ -> abort "token-bridge: registry did not settle the unlock atomically" + create this with consumed = Set.insert vaa.hash consumed + pure recipientCid diff --git a/canton/examples/token-bridge/daml/Wormhole/Example/TokenRegistry.daml b/canton/examples/token-bridge/daml/Wormhole/Example/TokenRegistry.daml new file mode 100644 index 0000000000..856f4dde50 --- /dev/null +++ b/canton/examples/token-bridge/daml/Wormhole/Example/TokenRegistry.daml @@ -0,0 +1,123 @@ +-- | A minimal in-repo token registry implementing the Canton Network Token +-- Standard (CIP-0056) interfaces needed by the token-bridge example: +-- +-- * 'ExampleToken' implements @Holding@ (a fungible holding). +-- * 'ExampleRegistry' implements @TransferFactory@ — an atomic, +-- permissionless transfer that settles in one step (returns +-- @TransferInstructionResult_Completed@), now accepting multiple input +-- holdings and returning change. +-- +-- This stands in for a real registry (e.g. Amulet) so the example is +-- self-issuing and runnable without the full Canton Network. It is NOT +-- production-grade: no fee handling, and change/splitting is the only +-- multi-holding behavior modeled. For a REAL registry integration, see +-- @ntt-cip56@'s @Cip56CustodyToken@ in the sibling NTT package — it drives +-- the exact same @TransferFactory@ interface against Amulet or any +-- conforming token; this file only fills in for that in a self-contained +-- example. +-- +-- The atomic transfer creates a receiver-owned holding that needs only the +-- @admin@'s authority (carried by the admin-signed factory) — NOT the +-- receiver's, because 'ExampleToken' is @signatory admin@ with the owner as a +-- mere observer. That admin-custodied model is exactly what lets the transfer +-- settle in one step (a co-signed owner+admin holding would instead require a +-- two-step accept). +module Wormhole.Example.TokenRegistry where + +import DA.Foldable (forA_) + +import Splice.Api.Token.HoldingV1 +import Splice.Api.Token.MetadataV1 (emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 + +-- | A fungible holding. Signed solely by the instrument @admin@ (the +-- registry), with @owner@ as an observer. This admin-custodied model — rather +-- than a co-signed owner+admin holding — is what lets a transfer settle +-- atomically in a single step: creating the receiver's holding needs only +-- @admin@ authority, which the registry factory already carries. (A +-- co-signed model would instead require a two-step transfer the receiver +-- accepts.) +template ExampleToken + with + admin : Party + owner : Party + instrumentId : InstrumentId + amount : Decimal + where + signatory admin + observer owner + ensure instrumentId.admin == admin && amount > 0.0 + + interface instance Holding for ExampleToken where + view = HoldingView with + owner + instrumentId + amount + lock = None + meta = emptyMetadata + +-- | The registry/admin contract that mints tokens and instructs transfers. +template ExampleRegistry + with + admin : Party + where + signatory admin + + -- | Mint a holding to @owner@. The admin is the sole signatory, so this + -- needs only the admin's authority. + nonconsuming choice Mint : ContractId ExampleToken + with + owner : Party + instrumentId : InstrumentId + amount : Decimal + controller admin + do create ExampleToken with admin, owner, instrumentId, amount + + interface instance TransferFactory for ExampleRegistry where + view = TransferFactoryView with admin, meta = emptyMetadata + + -- | Accepts one or more input holdings, requires their total to cover + -- the transfer amount, and returns any excess to the sender as change — + -- unlike the original single-exact-input version, a sender no longer + -- needs to hold a holding whose amount matches the transfer exactly. + transferFactory_transferImpl _self arg = do + let t = arg.transfer + assertMsg "ExampleRegistry: unexpected admin" (arg.expectedAdmin == admin) + assertMsg "ExampleRegistry: instrument not administered here" + (t.instrumentId.admin == admin) + assertMsg "ExampleRegistry: amount must be positive" (t.amount > 0.0) + assertMsg "ExampleRegistry: at least one input holding required" + (not (null t.inputHoldingCids)) + toks <- mapA (fetch . fromInterfaceContractId @ExampleToken) t.inputHoldingCids + assertMsg "ExampleRegistry: input not owned by sender" + (all (\tok -> tok.owner == t.sender) toks) + assertMsg "ExampleRegistry: inputs must match the transfer's instrument" + (all (\tok -> tok.instrumentId == t.instrumentId) toks) + let total = sum (map (.amount) toks) + assertMsg "ExampleRegistry: input amount insufficient" (total >= t.amount) + forA_ t.inputHoldingCids (archive . fromInterfaceContractId @ExampleToken) + receiverCid <- create ExampleToken with + admin + owner = t.receiver + instrumentId = t.instrumentId + amount = t.amount + let change = total - t.amount + changeCids <- + if change > 0.0 + then do + changeCid <- create ExampleToken with + admin + owner = t.sender + instrumentId = t.instrumentId + amount = change + pure [toInterfaceContractId @Holding changeCid] + else pure [] + pure TransferInstructionResult with + output = TransferInstructionResult_Completed with + receiverHoldingCids = [toInterfaceContractId receiverCid] + senderChangeCids = changeCids + meta = emptyMetadata + + transferFactory_publicFetchImpl _self arg = do + assertMsg "ExampleRegistry: unexpected admin" (arg.expectedAdmin == admin) + pure TransferFactoryView with admin, meta = emptyMetadata diff --git a/canton/multi-package.yaml b/canton/multi-package.yaml index 3cfa179d6b..6d8ae08eea 100644 --- a/canton/multi-package.yaml +++ b/canton/multi-package.yaml @@ -6,3 +6,6 @@ packages: - ./core - ./test + # Example: token-bridge lock-and-attest send path (CN Token Standard). + # Data-depends on core's DAR; not part of the production package. + - ./examples/token-bridge diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 9a75422f4a..7ceca2cf00 100644 --- a/cspell-custom-words.txt +++ b/cspell-custom-words.txt @@ -51,6 +51,7 @@ Cosmwasm counterparty cpus crosschain +custodied Cyfrin damlc dars @@ -252,6 +253,7 @@ unpauser unrepresentable Unrepresentable untampered +untruncated upserted usize utest diff --git a/node/pkg/cantonclient/vectorgen_test.go b/node/pkg/cantonclient/vectorgen_test.go index f607e789d1..2935dd3155 100644 --- a/node/pkg/cantonclient/vectorgen_test.go +++ b/node/pkg/cantonclient/vectorgen_test.go @@ -98,3 +98,31 @@ func TestGenerateAddressVectors(t *testing.T) { buf = append(buf, idb[:]...) t.Logf("emitter(%s, %s, %d) = %x", registrar, owner, id, crypto.Keccak256(buf)) } + +// TestGenerateTokenBridgeVectors prints the token-bridge address vectors +// shared with canton/examples/token-bridge/daml/Wormhole/Example/Test/TestTokenBridge.daml +// (testTokenAddressForVector, testTokenBridgeRecipientAddressForVector). The +// preimages are: +// +// tokenAddressFor: utf8(tag) ‖ lp(utf8(adminText)) ‖ lp(utf8(idText)) +// tokenBridgeRecipientAddressFor: utf8(tag) ‖ lp(utf8(recipientText)) +func TestGenerateTokenBridgeVectors(t *testing.T) { + if os.Getenv("GEN_CANTON_VECTORS") == "" { + t.Skip("set GEN_CANTON_VECTORS=1 to regenerate the Daml test vector") + } + lp := func(s string) []byte { + var l [4]byte + binary.BigEndian.PutUint32(l[:], uint32(len(s))) //nolint:gosec // fixture strings are short + return append(l[:], s...) + } + + const adminText = "vector-admin::1220deadbeef" + const idText = "USD" + tokenBuf := append([]byte("wormhole:token-bridge-token:v1"), lp(adminText)...) + tokenBuf = append(tokenBuf, lp(idText)...) + t.Logf("tokenAddressFor(%s, %s) = %x", adminText, idText, crypto.Keccak256(tokenBuf)) + + const recipientText = "vector-recipient::1220deadbeef" + recipientBuf := append([]byte("wormhole:token-bridge-recipient:v1"), lp(recipientText)...) + t.Logf("tokenBridgeRecipientAddressFor(%s) = %x", recipientText, crypto.Keccak256(recipientBuf)) +} diff --git a/node/pkg/watchers/canton/token_bridge_complete_integration_test.go b/node/pkg/watchers/canton/token_bridge_complete_integration_test.go new file mode 100644 index 0000000000..cf485877f2 --- /dev/null +++ b/node/pkg/watchers/canton/token_bridge_complete_integration_test.go @@ -0,0 +1,241 @@ +//go:build integration + +// Integration test closing the token-bridge recipient/tokenAddress-binding +// "known test gap" noted in TestTokenBridge.daml: no automated test exercises +// the MATCHING path through a real CompleteTransfer call, because the static +// VAA fixtures used elsewhere in that suite are pre-signed against fixed, +// arbitrary tokenAddress/recipient values, and a live Party's fingerprint +// (and a live InstrumentId's admin party) are freshly and unpredictably +// allocated every run -- so nothing allocated in a Daml Script test can ever +// be made to match a value baked into an already-signed VAA ahead of time. +// +// This closes it with the live two-step harness the gap note calls for, +// mirroring ntt_recipient_match_integration_test.go: +// +// 1. Run Wormhole.Example.Test.TestTokenBridge:integrationCompleteTransferSetup +// against a live sandbox. It allocates the recipient/admin/bridge/custody +// holding CompleteTransfer needs and returns tokenAddressFor(instrumentId) +// and tokenBridgeRecipientAddressFor(recipient) -- computed ON-LEDGER from +// the ACTUAL allocated parties, not guessed. +// +// 2. Sign a FRESH Token Bridge Transfer VAA against those hashes here, in +// Go, using the same well-known devnet guardian test key +// node/pkg/cantonclient/vectorgen_test.go signs every other fixture with +// -- exactly the off-chain step a real sender takes. +// +// 3. Run Wormhole.Example.Test.TestTokenBridge:integrationCompleteTransfer +// against the SAME sandbox (ledger state persists across both dpm-script +// calls), relaying the fresh VAA. Its internal assertions (the unlock +// lands on the matching recipient; a replay of the same VAA is rejected) +// make `dpm script` exit non-zero if either fails. +// +// go test -tags integration -run TestCantonTokenBridgeCompleteIntegration ./pkg/watchers/canton -v +// +// Requires `dpm` (PATH or ~/.dpm/bin) + a JDK; skipped otherwise. Slow (boots +// a JVM sandbox), so it is excluded from the default build. findDpm/runCmd +// are shared with watcher_integration_test.go. +package canton + +import ( + "context" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +// signTokenBridgeTransferVAA builds and signs a whitepaper-0003 Token Bridge +// Transfer VAA -- source chain 2, peer token-bridge emitter +// 0x00..00beef (matching the peerChainId/peerEmitterAddress test constants in +// TestTokenBridge.daml), amount 50.0 at 8dp (5_000_000_000, matching the +// custody holding integrationCompleteTransferSetup mints) -- with +// caller-supplied tokenAddress/recipientAddress, using the well-known +// Wormhole devnet guardian key (the same one vectorgen_test.go signs every +// other fixture in this package with). +func signTokenBridgeTransferVAA(tokenAddress, recipientAddress [32]byte, sequence uint64) ([]byte, error) { + priv, err := crypto.HexToECDSA("cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0") + if err != nil { + return nil, err + } + + be := func(n uint64, width int) []byte { + b := make([]byte, width) + full := make([]byte, 8) + binary.BigEndian.PutUint64(full, n) + if width >= 8 { + copy(b[width-8:], full) + } else { + copy(b, full[8-width:]) + } + return b + } + + // Transfer payload, payload ID 1: id(1) ++ amount(32) ++ tokenAddress(32) + // ++ tokenChain(2) ++ to(32) ++ toChain(2) ++ fee(32). + payload := []byte{0x01} + payload = append(payload, be(5_000_000_000, 32)...) // amount, 8dp + payload = append(payload, tokenAddress[:]...) + payload = append(payload, be(72, 2)...) // tokenChain (Canton) + payload = append(payload, recipientAddress[:]...) + payload = append(payload, be(72, 2)...) // toChain (Canton) + payload = append(payload, be(0, 32)...) // fee + + // VAA body: emitter chain 2 (peerChainId), emitter = peer token-bridge + // address 0x00..00beef (peerEmitterAddress). + peerAddr := make([]byte, 32) + peerAddr[31] = 0xef + peerAddr[30] = 0xbe + + body := make([]byte, 0) + ts := make([]byte, 4) + binary.BigEndian.PutUint32(ts, 1700000000) + body = append(body, ts...) // timestamp + body = append(body, 0, 0, 0, 0) // nonce 0 + body = append(body, 0x00, 0x02) // emitterChain 2 + body = append(body, peerAddr...) + seqBytes := make([]byte, 8) + binary.BigEndian.PutUint64(seqBytes, sequence) + body = append(body, seqBytes...) // sequence + body = append(body, 0x01) // consistencyLevel + body = append(body, payload...) + + digest := crypto.Keccak256(crypto.Keccak256(body)) + sig, err := crypto.Sign(digest, priv) + if err != nil { + return nil, err + } + + vaa := []byte{0x01} // version + vaa = append(vaa, 0, 0, 0, 0) // guardianSetIndex 0 + vaa = append(vaa, 0x01) // sig count + vaa = append(vaa, 0x00) // guardian index 0 + vaa = append(vaa, sig...) + vaa = append(vaa, body...) + return vaa, nil +} + +// completeTransferSetup mirrors Wormhole.Example.Test.TestTokenBridge's +// CompleteTransferSetup JSON shape. +type completeTransferSetup struct { + CoreStateCid string `json:"coreStateCid"` + TbId string `json:"tbId"` + RegId string `json:"regId"` + CustodyHoldingId string `json:"custodyHoldingId"` + Operator string `json:"operator"` + Bridge string `json:"bridge"` + Admin string `json:"admin"` + Recipient string `json:"recipient"` + TokenAddress string `json:"tokenAddress"` + RecipientAddress string `json:"recipientAddress"` +} + +func TestCantonTokenBridgeCompleteIntegration(t *testing.T) { + dpm := findDpm(t) + + cantonDir, err := filepath.Abs("../../../../canton") + require.NoError(t, err) + dar := filepath.Join(cantonDir, "examples", "token-bridge", ".daml", "dist", + "wormhole-token-bridge-example-0.1.0.dar") + + port := os.Getenv("CANTON_SANDBOX_PORT") + if port == "" { + port = "6865" + } + addr := "localhost:" + port + + runCmd(t, cantonDir, dpm, "build", "--all") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sandboxDir := t.TempDir() + logPath := filepath.Join(sandboxDir, "sandbox.log") + logFile, err := os.Create(logPath) + require.NoError(t, err) + defer logFile.Close() + + sandbox := exec.CommandContext(ctx, dpm, "sandbox", "--no-tty") + sandbox.Dir = sandboxDir + sandbox.Stdout = logFile + sandbox.Stderr = logFile + sandbox.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, sandbox.Start()) + t.Cleanup(func() { + if sandbox.Process != nil { + _ = syscall.Kill(-sandbox.Process.Pid, syscall.SIGKILL) + } + }) + + require.Eventually(t, func() bool { + b, _ := os.ReadFile(logPath) + return strings.Contains(string(b), "Canton sandbox is ready") + }, 180*time.Second, 2*time.Second, "sandbox never became ready") + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + require.NoError(t, err) + _ = conn.Close() + + // Step 1: allocate everything CompleteTransfer needs and get the ACTUAL, + // on-ledger-computed tokenAddress/recipientAddress hashes. + setupFile := filepath.Join(sandboxDir, "setup.json") + out, err := exec.Command(dpm, "script", "--dar", dar, "--upload-dar", "yes", + "--script-name", "Wormhole.Example.Test.TestTokenBridge:integrationCompleteTransferSetup", + "--ledger-host", "localhost", "--ledger-port", port, + "--output-file", setupFile).CombinedOutput() + require.NoErrorf(t, err, "setup dpm script failed: %s", out) + + rawSetup, err := os.ReadFile(setupFile) + require.NoError(t, err) + var setup completeTransferSetup + require.NoError(t, json.Unmarshal(rawSetup, &setup)) + require.NotEmpty(t, setup.Recipient) + require.Contains(t, setup.Recipient, "::", "recipient should be a full party id") + t.Logf("step 1: recipient=%s tokenAddress=%s recipientAddress=%s", + setup.Recipient, setup.TokenAddress, setup.RecipientAddress) + + tokenAddrBytes, err := hex.DecodeString(strings.TrimPrefix(setup.TokenAddress, "0x")) + require.NoError(t, err) + require.Len(t, tokenAddrBytes, 32, "tokenAddressFor must be 32 bytes") + var tokenAddr32 [32]byte + copy(tokenAddr32[:], tokenAddrBytes) + + recipientAddrBytes, err := hex.DecodeString(strings.TrimPrefix(setup.RecipientAddress, "0x")) + require.NoError(t, err) + require.Len(t, recipientAddrBytes, 32, "tokenBridgeRecipientAddressFor must be 32 bytes") + var recipientAddr32 [32]byte + copy(recipientAddr32[:], recipientAddrBytes) + + // Step 2: sign a FRESH VAA, off-chain, against those ACTUAL hashes -- + // exactly what a real sender does. + vaaBytes, err := signTokenBridgeTransferVAA(tokenAddr32, recipientAddr32, 1) + require.NoError(t, err) + t.Logf("step 2: signed a fresh VAA (%d bytes)", len(vaaBytes)) + + // Step 3: relay it against the SAME sandbox/bridge/recipient from step 1. + // All internal assertions (unlock lands on the matching recipient; replay + // is rejected) live inside the script; a non-zero exit means one failed. + inputPayload := fmt.Sprintf( + `{"coreStateCid":%q,"tbId":%q,"regId":%q,"custodyHoldingId":%q,"operator":%q,"bridge":%q,"admin":%q,"recipient":%q,"vaaBytes":%q}`, + setup.CoreStateCid, setup.TbId, setup.RegId, setup.CustodyHoldingId, + setup.Operator, setup.Bridge, setup.Admin, setup.Recipient, + hex.EncodeToString(vaaBytes), + ) + inputFile := filepath.Join(sandboxDir, "input.json") + require.NoError(t, os.WriteFile(inputFile, []byte(inputPayload), 0o600)) + + out, err = exec.Command(dpm, "script", "--dar", dar, + "--script-name", "Wormhole.Example.Test.TestTokenBridge:integrationCompleteTransfer", + "--ledger-host", "localhost", "--ledger-port", port, + "--input-file", inputFile).CombinedOutput() + require.NoErrorf(t, err, "complete-transfer dpm script failed: %s", out) + t.Logf("step 3: matching-recipient CompleteTransfer + replay rejection both verified live: %s", out) +} diff --git a/node/pkg/watchers/canton/token_bridge_integration_test.go b/node/pkg/watchers/canton/token_bridge_integration_test.go new file mode 100644 index 0000000000..c4f4dca8c7 --- /dev/null +++ b/node/pkg/watchers/canton/token_bridge_integration_test.go @@ -0,0 +1,166 @@ +//go:build integration + +// End-to-end integration test for the token-bridge lock-and-attest send path on +// the Canton Network Token Standard (CIP-0056). +// +// It boots a live Canton sandbox, uploads+vets the example DAR (which packs the +// vendored splice-api-token-* DALFs, wormhole-core, and the example), runs the +// lock-and-attest flow via `dpm script`, and observes the resulting Wormhole +// Transfer message through the real cantonclient gRPC client. This additionally +// proves what the in-memory Daml Script test cannot: that the SDK-3.3.x-built +// token-standard DARs vet on the 3.5.x sandbox, that explicit disclosure works +// over the real Ledger API, and that a token-custody lock produces a message the +// guardian watcher maps to a common.MessagePublication. +// +// go test -tags integration -run TestCantonTokenBridgeIntegration ./pkg/watchers/canton -v +// +// Requires `dpm` (PATH or ~/.dpm/bin) + a JDK; skipped otherwise. Slow (boots a +// JVM sandbox), so it is excluded from the default build. findDpm/runCmd are +// shared with watcher_integration_test.go. +package canton + +import ( + "context" + "encoding/binary" + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/certusone/wormhole/node/pkg/cantonclient" + "github.com/certusone/wormhole/node/pkg/common" + gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wormhole-foundation/wormhole/sdk/vaa" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestCantonTokenBridgeIntegration(t *testing.T) { + dpm := findDpm(t) + + cantonDir, err := filepath.Abs("../../../../canton") + require.NoError(t, err) + // The example DAR packs the splice-api-token-* DALFs, wormhole-core, and the + // example itself, so --upload-dar uploads+vets the whole set in one shot. + dar := filepath.Join(cantonDir, "examples", "token-bridge", ".daml", "dist", + "wormhole-token-bridge-example-0.1.0.dar") + + port := os.Getenv("CANTON_SANDBOX_PORT") + if port == "" { + port = "6865" + } + addr := "localhost:" + port + + // Build all packages (core, test, example). + runCmd(t, cantonDir, dpm, "build", "--all") + + // Start the sandbox in its own process group, from a clean directory (so + // `dpm sandbox` does not auto-load a bootstrap/init-script). Ledger API on 6865. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sandboxDir := t.TempDir() + logPath := filepath.Join(sandboxDir, "sandbox.log") + logFile, err := os.Create(logPath) + require.NoError(t, err) + defer logFile.Close() + + sandbox := exec.CommandContext(ctx, dpm, "sandbox", "--no-tty") + sandbox.Dir = sandboxDir + sandbox.Stdout = logFile + sandbox.Stderr = logFile + sandbox.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, sandbox.Start()) + t.Cleanup(func() { + if sandbox.Process != nil { + _ = syscall.Kill(-sandbox.Process.Pid, syscall.SIGKILL) + } + }) + + require.Eventually(t, func() bool { + b, _ := os.ReadFile(logPath) + return strings.Contains(string(b), "Canton sandbox is ready") + }, 180*time.Second, 2*time.Second, "sandbox never became ready") + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + require.NoError(t, err) + _ = conn.Close() + + // Upload+vet the example DAR and run the lock-and-attest flow. --upload-dar + // makes vetting synchronous, so no retry is needed. + partyFile := filepath.Join(sandboxDir, "party.json") + out, err := exec.Command(dpm, "script", "--dar", dar, "--upload-dar", "yes", + "--script-name", "Wormhole.Example.Test.TestTokenBridge:integrationLockAndPublish", + "--ledger-host", "localhost", "--ledger-port", port, + "--output-file", partyFile).CombinedOutput() + require.NoErrorf(t, err, "dpm script failed: %s", out) + + raw, err := os.ReadFile(partyFile) + require.NoError(t, err) + var bridge string + require.NoError(t, json.Unmarshal(raw, &bridge)) + require.NotEmpty(t, bridge) + t.Logf("locked + attested; bridge party: %s", bridge) + + // Observe through the REAL cantonclient gRPC client. Empty readAsParty => + // wildcard "any party" filter, so we observe without the party fingerprint. + client, err := cantonclient.NewCantonGrpcClient(addr, "", zap.NewNop(), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer client.Close() + + eventChan := make(chan cantonclient.CantonMessageEvent, 8) + tmpl := cantonclient.TemplateID{ModuleName: publishMessageModule, EntityName: publishMessageEntity} + sub, err := client.SubscribeUpdates(ctx, 0, tmpl, publishMessageChoice, eventChan) + require.NoError(t, err) + defer sub.Unsubscribe() + + select { + case ev := <-eventChan: + // Header: the emitter address is derived from the message's key + // components (registrar/owner/emitterId), assigned by the live ledger. + require.NotEmpty(t, ev.Message.Registrar) + require.NotEmpty(t, ev.Message.Owner) + assert.Equal(t, uint64(0), ev.Message.EmitterID) + assert.Equal(t, uint64(0), ev.Message.Sequence) + assert.Equal(t, uint32(42), ev.Message.Nonce) + + wantAddr := deriveEmitterAddress(ev.Message.Registrar, ev.Message.Owner, ev.Message.EmitterID) + assert.NotEqual(t, vaa.Address{}, wantAddr, "derived emitter address must be non-zero") + + // Payload: Wormhole TokenBridge Transfer, payloadID 1 (133 bytes): + // id(1) ++ amount(32) ++ token(32) ++ tokenChain(2) ++ to(32) ++ toChain(2) ++ fee(32). + // The amount is 8dp-normalized (1000.0 -> 1000*1e8): the original example + // encoded the bare truncated Decimal (1000), which understated the real + // Token Bridge wire format's cross-chain amount normalization. + p := ev.Message.Payload + require.Len(t, p, 133) + assert.Equal(t, byte(0x01), p[0], "payloadID") + assert.Equal(t, uint64(1000_00000000), binary.BigEndian.Uint64(p[25:33]), "amount (8dp)") + assert.Equal(t, uint16(72), binary.BigEndian.Uint16(p[65:67]), "tokenChain") + assert.Equal(t, byte(0xaa), p[97], "recipient") + assert.Equal(t, byte(0xaa), p[98], "recipient") + assert.Equal(t, uint16(2), binary.BigEndian.Uint16(p[99:101]), "toChain") + + // Feed through the watcher to confirm the MessagePublication mapping. + msgC := make(chan *common.MessagePublication, 1) + w := NewWatcher(addr, "", "", true, msgC, make(chan *gossipv1.ObservationRequest)) + w.processMessage(zap.NewNop(), ev, false) + mp := <-msgC + assert.Equal(t, vaa.ChainIDCanton, mp.EmitterChain) + assert.Equal(t, uint64(0), mp.Sequence) + assert.Equal(t, uint32(42), mp.Nonce) + assert.Equal(t, wantAddr, mp.EmitterAddress) + assert.Equal(t, cantonclient.OffsetToTxID(ev.Offset), mp.TxID) + case err := <-sub.Err(): + t.Fatalf("subscription error: %v", err) + case <-time.After(60 * time.Second): + t.Fatal("did not observe the published Wormhole message within 60s") + } +}