Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions canton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,9 @@ consumer's trust anchor, is stable across rotation). Two things follow:
the real governance party, so any `CoreState` whose `guardianGovernance` field
is the known anchor is **authentic by construction**, however its cid was
obtained — consumers authenticate the payload, never the resolution path
(§4.7). **Pinning `operator` is weaker than pinning `guardianGovernance`:** a
compromised operator (a single hot key) can co-sign a `CoreState` whose
`operator` is genuine but whose `guardianGovernance`/`feeRecipient` it
controls, so an `operator`-only pin accepts a shadow `CoreState`. For the fee
path — where `guardianGovernance` _is_ the fee-custody anchor — only the
`guardianGovernance` pin suffices (see below).
(§4.7). An operator puppet could create a `CoreState` under a _different_
governance party — but its payload names the wrong parties and no consumer
trusts it.
- **Governance stays low-friction.** `guardianGovernance` actively signs only at
**genesis**; each `SubmitGovernanceVAA` inherits its authority from the
archived contract (as the `Emitter` owner does through `PublishMessage`), so
Expand Down Expand Up @@ -401,19 +398,12 @@ with Canton Coin or any standard token, chosen at genesis
an app publishes on a user's behalf (NTT §10); `payer` co-controls the publish,
so it cannot be a third party who did not authorize spending its funds.
Registries charge their `registrationFee`/`claimFee` the same way, skipping the
`CoreState` entirely at fee 0. Before charging, every call site pins the
`CoreState` by exercising `GetGuardianGovernance` against the
`guardianGovernance` committed on the emitter/registry — **the fee-custody
anchor, not merely the `operator`** — so a forged `CoreState` cannot redirect
the fee (see the custody bullet).
`CoreState` entirely at fee 0.
- **Custody is governance-controlled**: `feeRecipient` is the
`guardianGovernance` threshold party, so accrued fees move only via the k-of-n
guardian ceremony — the operator never touches them (the Canton analog of "fees
sit in the bridge contract"). This holds **even against a compromised operator
hot key**: each charge pins `guardianGovernance` (unforgeable by a single
operator key) rather than `operator`, so an operator co-signing a shadow
`CoreState` under a throwaway `feeRecipient` is rejected before any funds move.
A `TransferFees` VAA mints an on-ledger `FeeWithdrawalAuthorization` (signed by
sit in the bridge contract"). A `TransferFees` VAA mints an on-ledger
`FeeWithdrawalAuthorization` (signed by
guardianGovernance) recording the amount, the VAA's 32-byte recipient, and the
VAA hash — the artifact the guardians' custody policy matches when
threshold-signing the outbound transfer. "Only per authorization" is enforced
Expand Down
5 changes: 1 addition & 4 deletions canton/core/daml/Wormhole/Core/Fees.daml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
-- (guardianGovernance). The receiver's authority is inherited from
-- 'CoreState' (a signatory), so every charge routes through
-- 'ChargeMessageFee'/'ChargeFee' there, and a foreign sender can never be a
-- controller — blocking theft structurally, PROVIDED 'CoreState' is
-- authentic. Callers pin 'GetGuardianGovernance' against their own committed
-- anchor before charging, so a compromised operator can't redirect fees via
-- a shadow CoreState under a gg it controls.
-- controller — blocking theft structurally.
--
-- Allocation views are forgeable; authenticity is the instrument admin's
-- signature on the allocation, checked in 'chargeFee'.
Expand Down
21 changes: 9 additions & 12 deletions canton/core/daml/Wormhole/Core/Setup.daml
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,15 @@ mkInitialCoreState operator guardianGovernance govChain govContract initialGuard
consumedGovernance = Set.empty

-- | The emitter-id registry the operator creates alongside the 'CoreState' at
-- setup (sequential ids). Carries @guardianGovernance@ (the fee-custody anchor,
-- pinned by 'RegisterEmitter') and @guardianObserver@, both threaded into each
-- 'Emitter'. Pass the same @guardianGovernance@ the 'CoreState' was created with.
mkInitialEmitterRegistry : Party -> Party -> Party -> Int -> EmitterRegistry
mkInitialEmitterRegistry operator guardianGovernance guardianObserver registrationFee =
EmitterRegistry with operator, guardianGovernance, guardianObserver, registrationFee, nextId = 0
-- setup (sequential ids). Carries @guardianObserver@, threaded into each
-- 'Emitter'.
mkInitialEmitterRegistry : Party -> Party -> Int -> EmitterRegistry
mkInitialEmitterRegistry operator guardianObserver registrationFee =
EmitterRegistry with operator, guardianObserver, registrationFee, nextId = 0

-- | The replay-root registry (the stateless claim anchor) the operator creates
-- alongside the 'CoreState' at setup; hands a 'ReplayNode' trie root to any
-- consumer that claims one (see 'Wormhole.Core.Replay'). Carries
-- @guardianGovernance@ (the fee-custody anchor, pinned by 'ClaimReplayRoot');
-- pass the same one the 'CoreState' was created with.
mkInitialReplayRootRegistry : Party -> Party -> Int -> ReplayRootRegistry
mkInitialReplayRootRegistry operator guardianGovernance claimFee =
ReplayRootRegistry with operator, guardianGovernance, claimFee
-- consumer that claims one (see 'Wormhole.Core.Replay').
mkInitialReplayRootRegistry : Party -> Int -> ReplayRootRegistry
mkInitialReplayRootRegistry operator claimFee =
ReplayRootRegistry with operator, claimFee
88 changes: 24 additions & 64 deletions canton/core/daml/Wormhole/Core/State.daml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@
-- (README §4.7). Authenticity is checked on the PAYLOAD, never the resolution
-- path: signatories cannot be forged, so a 'CoreState' whose @guardianGovernance@
-- is the known anchor is authentic by construction — a compromised @operator@
-- cannot fabricate one. The fee-custody anchor is pinned this way: 'PublishMessage',
-- 'RegisterEmitter', and 'ClaimReplayRoot' exercise 'GetGuardianGovernance' and
-- compare it against the @guardianGovernance@ committed on the emitter/registry
-- before charging. Pinning @guardianGovernance@ (not @operator@) is what holds
-- against a compromised operator, which alone does not determine @feeRecipient@.
-- cannot fabricate one.
--
-- Two invariants:
--
Expand Down Expand Up @@ -166,21 +162,6 @@ template CoreState
controller reader
do pure operator

-- | Read the @guardianGovernance@ anchor off an authentic 'CoreState' — a
-- STRONGER pin than 'GetOperator' for consumers that must resist a compromised
-- operator, not just third-party forgery. A 'CoreState' needs only some
-- (operator, gg) signature pair, so a compromised operator key can co-sign a
-- forged one under a throwaway gg it controls and pass an operator-pin;
-- pinning @guardianGovernance@ (the guardians' k-of-n external party,
-- unforgeable by a single operator key) closes that. Used by
-- 'Wormhole.Ntt.Manager.Receive' and the fee-charging pins ('PublishMessage',
-- 'RegisterEmitter', 'ClaimReplayRoot').
nonconsuming choice GetGuardianGovernance : Party
with
reader : Party
controller reader
do pure guardianGovernance

-- | Charge the governance-set per-publish fee ('messageFee'). Lives here,
-- not on 'Emitter': the fee amount is only knowable from this contract, and
-- 'Allocation_ExecuteTransfer' needs the fee recipient's authority (sender ∧
Expand Down Expand Up @@ -370,13 +351,11 @@ emitterAddressFor registrar owner emitterId =
-- the owner, whose signature it lacks (see 'Emitter').
template EmitterRegistry
with
operator : Party
guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a
-- signatory) in RegisterEmitter's fee charge
guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
registrationFee : Int -- onboarding fee, 10^-10 feeInstrument units;
-- operator-set at creation, 0 for free
nextId : Int
operator : Party
guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
registrationFee : Int -- onboarding fee, 10^-10 feeInstrument units;
-- operator-set at creation, 0 for free
nextId : Int
where
signatory operator

Expand All @@ -396,16 +375,12 @@ template EmitterRegistry
controller requester
do
-- Zero-fee registrations never touch the CoreState; fee'd ones charge
-- through it. The gg pin runs BEFORE the charge: pinning
-- @guardianGovernance@ (not just @operator@, which a forged CoreState
-- would match) rejects a shadow CoreState under a rogue gg, so the
-- registration fee cannot be redirected.
-- through it, pinning its operator so a fabricated CoreState cannot
-- waive or redirect the fee.
case (registrationFee > 0, coreStateCid) of
(False, _) -> pure ()
(True, None) -> abort "registration fee due: pass the CoreState"
(True, Some csCid) -> do
csGg <- exercise csCid GetGuardianGovernance with reader = requester
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
csOperator <- exercise csCid ChargeFee with
payer = requester
fee = registrationFee
Expand All @@ -414,7 +389,6 @@ template EmitterRegistry
reg' <- create this with nextId = nextId + 1
emitter <- create Emitter with
operator
guardianGovernance
owner = requester
guardianObserver
emitterId = nextId
Expand All @@ -441,11 +415,9 @@ template EmitterRegistry
-- grows.
template ReplayRootRegistry
with
operator : Party
guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a
-- signatory) in ClaimReplayRoot's fee charge
claimFee : Int -- onboarding fee, 10^-10 feeInstrument units;
-- operator-set at creation, 0 for free
operator : Party
claimFee : Int -- onboarding fee, 10^-10 feeInstrument units;
-- operator-set at creation, 0 for free
where
signatory operator

Expand All @@ -457,17 +429,13 @@ template ReplayRootRegistry
feeAllocation : Optional (ContractId Allocation, ExtraArgs)
controller consumer
do
-- Zero-fee claims never touch the CoreState; fee'd ones charge through
-- it. The gg pin runs BEFORE the charge: pinning @guardianGovernance@
-- (not just @operator@, which a forged CoreState would match) rejects a
-- shadow CoreState under a rogue gg, so the claim fee cannot be
-- redirected (see README §4.2).
-- Zero-fee claims never touch the CoreState; fee'd ones charge
-- through it, pinning its operator so a fabricated CoreState cannot
-- waive or redirect the fee (see README §4.2).
case (claimFee > 0, coreStateCid) of
(False, _) -> pure ()
(True, None) -> abort "claim fee due: pass the CoreState"
(True, Some csCid) -> do
csGg <- exercise csCid GetGuardianGovernance with reader = consumer
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
csOperator <- exercise csCid ChargeFee with
payer = consumer
fee = claimFee
Expand All @@ -486,14 +454,12 @@ template ReplayRootRegistry
-- authority). Mirrors a Sui @EmitterCap@.
template Emitter
with
operator : Party
guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a
-- signatory) in PublishMessage's fee charge
owner : Party
guardianObserver : Party -- read-only guardian observer
emitterId : Int -- registry-allocated
emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
sequence : Int -- next sequence to assign
operator : Party
owner : Party
guardianObserver : Party -- read-only guardian observer
emitterId : Int -- registry-allocated
emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
sequence : Int -- next sequence to assign
where
-- Owner co-signs: only a transaction with the owner's authority can create a
-- contract whose derived address is the owner's — the invariant guardians
Expand All @@ -511,10 +477,10 @@ template Emitter
-- directly. Mirrors @Implementation.publishMessage@.
--
-- The 'messageFee' is charged through the referenced 'CoreState' (EVM parity:
-- the current fee is read on every call), whose @guardianGovernance@ and
-- operator are pinned so a fake CoreState cannot redirect or waive it.
-- Publishers attach the CoreState disclosure to every publish (cacheable
-- between governance actions, which invalidate the cid transiently —
-- the current fee is read on every call), whose operator is pinned so a fake
-- CoreState cannot waive the fee. Publishers attach the CoreState disclosure
-- to every publish (cacheable between governance actions, which invalidate
-- the cid transiently —
-- re-resolve and retry); at fee 0 pass @feeAllocation = None@, but the
-- CoreState reference is still required since the fee amount is only knowable
-- from it.
Expand All @@ -535,12 +501,6 @@ template Emitter
feeAllocation : Optional (ContractId Allocation, ExtraArgs)
controller owner, payer
do
-- Pin the fee-custody anchor BEFORE charging: @guardianGovernance@ (not
-- just @operator@, which a forged CoreState would match) determines where
-- the fee lands, so this check rejects a shadow CoreState under a rogue
-- gg before the message fee can be redirected.
csGg <- exercise coreStateCid GetGuardianGovernance with reader = payer
assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
csOperator <- exercise coreStateCid ChargeMessageFee with
payer
feeAllocation
Expand Down
8 changes: 4 additions & 4 deletions canton/test/daml/Test/TestCore.daml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ setupWithInstrument mInstrument = do
createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId
defaultGovernanceContract [devnetGuardian] guardianObserver
feeInstrument guardianGovernance)
_ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianGovernance guardianObserver 0)
_ <- submit operator do createCmd (mkInitialReplayRootRegistry operator guardianGovernance 0)
_ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianObserver 0)
_ <- submit operator do createCmd (mkInitialReplayRootRegistry operator 0)
pure (operator, guardianObserver, csId)

-- | Register an emitter for @owner@, the production way: the requester submits
Expand Down Expand Up @@ -168,14 +168,14 @@ testImpersonationBlocked = do
-- (a) Operator-alone forgery of Alice's address is impossible.
submitMustFail operator do
createCmd Emitter with
operator, guardianGovernance = a.guardianGovernance, owner = alice
operator, owner = alice
guardianObserver, emitterId = 0
emitterAddress = a.emitterAddress, sequence = 0

-- (b) Operator + Mallory can create a same-id emitter, but its address differs.
malloryEm <- submit (actAs operator <> actAs mallory) do
createCmd Emitter with
operator, guardianGovernance = a.guardianGovernance, owner = mallory
operator, owner = mallory
guardianObserver, emitterId = 0
emitterAddress = emitterAddressFor operator mallory 0, sequence = 0
Some m <- queryContractId mallory malloryEm
Expand Down
Loading
Loading