canton: token-standard fee payments#40
Merged
Merged
Conversation
Comment on lines
+45
to
+51
| -- includes the operator so callers can pin authenticity (GetOperator-style). | ||
| data FeeConfig = FeeConfig with | ||
| operator : Party -- authenticity pin for the caller | ||
| messageFee : Int -- 10^-10 instrument units | ||
| feeInstrument : InstrumentId | ||
| feeRecipient : Party | ||
| deriving (Eq, Show) |
There was a problem hiding this comment.
Feels like we should include the guardianGovernance party instead/in-addition-to?
bengtlofgren
approved these changes
Jul 15, 2026
bengtlofgren
approved these changes
Jul 15, 2026
bengtlofgren
left a comment
There was a problem hiding this comment.
I still think this gates on operator only but we can fix that later?
bengtlofgren
force-pushed
the
cs/canton-fees
branch
from
July 15, 2026 12:56
57c85e4 to
dd1dc9f
Compare
2 tasks
bengtlofgren
added a commit
that referenced
this pull request
Jul 16, 2026
The build stage still copied only core/ and test/ and referenced wormhole-core-0.1.0.dar, but core is now versioned 0.2.0 and canton/test's data-dependencies moved to the vendored ../dars/ directory (fee payments, #40) -- the image could not have built as written. Copy dars/ before the build and reference the correct 0.2.0 DAR filename. Verified core + test build via dpm and produce exactly the filenames this Dockerfile now references; the Docker build itself isn't runnable in this devcontainer (no Docker), so CI/a real build host should confirm the image build end to end. Claude-Session: https://claude.ai/code/session_01VFr5RtBKysvjdRN2nKmhMV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The fee model has been deferred since the initial implementation:
messageFeeexisted and was governance-settable, but nothing enforced it (the README even described afeePaymentargument that never existed in the code), andTransferFeeswas a recorded no-op — a design gap, since there was nothing on-ledger that fee custody or its governance could attach to. Onboarding (emitter registration, replay-root claims) was similarly free, leaving state allocation priced only by synchronizer traffic.The solution
Fees are paid via the Canton Network Token Standard (CIP-0056, V1) — the current standard for app payments on Canton; the older splice-wallet payment requests are deprecated, and V2 (CIP-0112) is approved but DevNet-only. The bridge depends only on the three small interface DARs (vendored verbatim from a Splice release bundle with pinned provenance — package-ids must match what the network has vetted), so it works with Canton Coin or any standard token, chosen at genesis.
The purpose-built pattern is the allocation flow, and one property of our flows makes it especially clean: the payer is always the submitter, so the bridge only consumes allocations — it validates the transfer leg and exercises
Allocation_ExecuteTransferin the same transaction as the publish, registration, or claim. Payment and effect are atomic; executing consumes the allocation, so double-spends die on activeness with no bookkeeping.Two mechanics shaped the design:
Allocation_ExecuteTransferrequires the joint authority of sender ∧ receiver ∧ executor. With the bridge conventionexecutor = sender = payer, the missing authority is the receiver's — and since fee custody should be governance-controlled, the receiver is theguardianGovernancethreshold party, whose authority exists in exactly one inheritable place: inside aCoreStatechoice. All charging therefore routes throughCoreState(ChargeMessageFee/ChargeFee, flexible-controller, returning the operator so callers pin authenticity). This also blocks theft structurally: a foreign allocation's sender is a required controller the transaction never has.PublishMessageconsequently references theCoreStateon every publish — exact EVM parity (the current fee is read each call), at the cost of the CoreState disclosure per publish and its signatories joining the publish informee set (recorded as a sizing item).Governance-controlled custody, making
TransferFeesreal: fees settle directly intoguardianGovernance's holdings — the Canton analog of "fees sit in the bridge contract, movable only by governance", since moving them requires the k-of-n guardian ceremony and the operator never touches them. ATransferFeesVAA now mints an on-ledgerFeeWithdrawalAuthorization(signed by guardianGovernance via inheritance; amount, 32-byte recipient, VAA hash) — the artifact the custody ceremony matches against. The residual is stated honestly: "only per authorization" is enforced at gg key custody, the same trust plane as the guardian keys themselves.Onboarding fees answer the earlier state-allocation concern the same way:
EmitterRegistry.registrationFeeandReplayRootRegistry.claimFee(operator-set, default 0) charge through the same choice; zero-fee flows never touch theCoreStateat all. Fee amounts areInts in 10^-10 token units — exactly Decimal's least significant digit, so every fee converts exactly; the frozenSetMessageFeewire format is untouched, and the fee instrument/recipient are genesis-fixed (no governance action covers them — flagged as a pre-mainnet decision).Tests run against a minimal in-house mock token implementing the standard interfaces (plus a hostile
FakeAllocationto prove the pin); fidelity against real amulet — choice contexts, deadlines, locking, and featured-app rewards, which the executor earns as the amulet "provider" — is a recorded DevNet validation item.Caveats
wormhole-corebumps to 0.2.0 and is SCU-incompatible with 0.1.0 (new non-optional fields/args) — fine pre-mainnet; devnet re-bootstraps and the Go watcher's configured packageID changes with the new build. TheWormholeMessagewire contract is unchanged.TransferFees→FeeWithdrawalAuthorizationpath needs a signed test vector from vectorgen (Go side) — the suite currently pins the negative side (no spurious authorizations).Wormhole.Core.Fees.chargeFee.🤖 Generated with Claude Code