Skip to content

canton: token-standard fee payments#40

Merged
bengtlofgren merged 4 commits into
integration/cantonfrom
cs/canton-fees
Jul 15, 2026
Merged

canton: token-standard fee payments#40
bengtlofgren merged 4 commits into
integration/cantonfrom
cs/canton-fees

Conversation

@kcsongor

Copy link
Copy Markdown

Stacked on #39 (cs/remove-keys); rebase onto integration/canton after that merges. The diff shown here is the fee work only.

The problem

The fee model has been deferred since the initial implementation: messageFee existed and was governance-settable, but nothing enforced it (the README even described a feePayment argument that never existed in the code), and TransferFees was 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_ExecuteTransfer in 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_ExecuteTransfer requires the joint authority of sender ∧ receiver ∧ executor. With the bridge convention executor = sender = payer, the missing authority is the receiver's — and since fee custody should be governance-controlled, the receiver is the guardianGovernance threshold party, whose authority exists in exactly one inheritable place: inside a CoreState choice. All charging therefore routes through CoreState (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. PublishMessage consequently references the CoreState on 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).
  • Allocation views are template-computed and forgeable — a hostile template can claim any instrument while moving nothing. The authenticity pin is the instrument admin's signature on the allocation contract (amulet allocations are DSO-co-signed), the repo's payloads-not-resolution-paths doctrine applied to tokens. The test matrix covers both the honest-template-wrong-signer and lying-view attacks.

Governance-controlled custody, making TransferFees real: fees settle directly into guardianGovernance'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. A TransferFees VAA now mints an on-ledger FeeWithdrawalAuthorization (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.registrationFee and ReplayRootRegistry.claimFee (operator-set, default 0) charge through the same choice; zero-fee flows never touch the CoreState at all. Fee amounts are Ints in 10^-10 token units — exactly Decimal's least significant digit, so every fee converts exactly; the frozen SetMessageFee wire 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 FakeAllocation to 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-core bumps 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. The WormholeMessage wire contract is unchanged.
  • The positive TransferFeesFeeWithdrawalAuthorization path needs a signed test vector from vectorgen (Go side) — the suite currently pins the negative side (no spurious authorizations).
  • Token Standard V2 migration lands, when it lands, entirely inside Wormhole.Core.Fees.chargeFee.

🤖 Generated with Claude Code

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Feels like we should include the guardianGovernance party instead/in-addition-to?

Base automatically changed from cs/remove-keys to integration/canton July 15, 2026 11:47

@bengtlofgren bengtlofgren left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I still think this gates on operator only but we can fix that later?

@bengtlofgren
bengtlofgren merged commit a4e6406 into integration/canton Jul 15, 2026
30 of 34 checks passed
@bengtlofgren
bengtlofgren deleted the cs/canton-fees branch July 15, 2026 13:30
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants