1818-- never on how the cid was resolved: signatories cannot be forged, so a
1919-- fetched or disclosed 'CoreState' whose @guardianGovernance@ field is the
2020-- known governance party is authentic by construction — a compromised
21- -- @operator@ cannot fabricate one (it lacks that signature). Two invariants:
21+ -- @operator@ cannot fabricate one (it lacks that signature). The fee-custody
22+ -- anchor is pinned exactly this way: the fee-charging call sites
23+ -- ('PublishMessage', 'RegisterEmitter', 'ClaimReplayRoot') exercise
24+ -- 'GetGuardianGovernance' and compare it against the @guardianGovernance@
25+ -- committed on the emitter/registry BEFORE charging. Pinning @guardianGovernance@
26+ -- rather than @operator@ is what makes this hold against a compromised operator:
27+ -- @operator@ alone does not determine @feeRecipient@, so an operator co-signing a
28+ -- shadow 'CoreState' under a throwaway gg would pass an operator-pin but cannot
29+ -- forge the real gg (see 'GetGuardianGovernance'). Two invariants:
2230-- * 'CoreState' singleton: co-signed by the @operator@ and a @guardianGovernance@
2331-- party. The @guardianGovernance@ party is the guardians' governance anchor —
2432-- in production a decentralized-namespace external party controlled by a
@@ -189,6 +197,24 @@ template CoreState
189197 controller reader
190198 do pure operator
191199
200+ -- | Read the @guardianGovernance@ anchor off an authentic 'CoreState' — a
201+ -- STRONGER caller-side pin than 'GetOperator' for consumers that must resist
202+ -- a compromised operator, not just a third-party forgery. A 'CoreState'
203+ -- requires only SOME (operator, gg) signature pair, so an operator whose key
204+ -- is compromised can co-sign a forged 'CoreState' (with a throwaway gg party
205+ -- it controls and an attacker guardian set) that still passes an
206+ -- operator-pin. Pinning @guardianGovernance@ closes that: gg is the
207+ -- guardians' k-of-n external threshold party, whose signature a compromised
208+ -- operator (a single hot participant key) cannot produce — so no forged
209+ -- 'CoreState' can carry the real gg. Used by 'Wormhole.Ntt.Manager.Receive'
210+ -- and the 'CoreState' fee-charging pins ('PublishMessage', 'RegisterEmitter',
211+ -- 'ClaimReplayRoot').
212+ nonconsuming choice GetGuardianGovernance : Party
213+ with
214+ reader : Party
215+ controller reader
216+ do pure guardianGovernance
217+
192218 -- | Charge the governance-set per-publish fee ('messageFee'). Lives here —
193219 -- not on 'Emitter' — for two reasons: the fee amount is only knowable from
194220 -- this contract, and executing the allocation needs the FEE RECIPIENT'S
@@ -389,12 +415,15 @@ emitterAddressFor registrar owner emitterId =
389415-- signature it lacks (see 'Emitter').
390416template EmitterRegistry
391417 with
392- operator : Party
393- guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
394- registrationFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
395- -- units; operator-set at creation (archive +
396- -- recreate to change), 0 for free registration
397- nextId : Int
418+ operator : Party
419+ guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
420+ -- a signatory) in RegisterEmitter's fee charge so
421+ -- a forged CoreState under a rogue gg is rejected
422+ guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter
423+ registrationFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
424+ -- units; operator-set at creation (archive +
425+ -- recreate to change), 0 for free registration
426+ nextId : Int
398427 where
399428 signatory operator
400429
@@ -415,12 +444,17 @@ template EmitterRegistry
415444 controller requester
416445 do
417446 -- Zero-fee registrations never touch the CoreState (single-disclosure
418- -- onboarding); fee'd ones charge through it, pinning its operator so a
419- -- fabricated CoreState cannot waive or redirect the fee.
447+ -- onboarding); fee'd ones charge through it. The gg pin runs BEFORE the
448+ -- charge: pinning @guardianGovernance@ (not just the operator) rejects a
449+ -- CoreState a compromised operator co-signed under a throwaway gg it
450+ -- controls — the operator field would match, but the fee-custody anchor
451+ -- would not, so a forged CoreState cannot redirect the registration fee.
420452 case (registrationFee > 0, coreStateCid) of
421453 (False, _) -> pure ()
422454 (True, None) -> abort "registration fee due: pass the CoreState"
423455 (True, Some csCid) -> do
456+ csGg <- exercise csCid GetGuardianGovernance with reader = requester
457+ assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
424458 csOperator <- exercise csCid ChargeFee with
425459 payer = requester
426460 fee = registrationFee
@@ -429,6 +463,7 @@ template EmitterRegistry
429463 reg' <- create this with nextId = nextId + 1
430464 emitter <- create Emitter with
431465 operator
466+ guardianGovernance
432467 owner = requester
433468 guardianObserver
434469 emitterId = nextId
@@ -458,10 +493,13 @@ template EmitterRegistry
458493-- contract grows.
459494template ReplayRootRegistry
460495 with
461- operator : Party
462- claimFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument units;
463- -- operator-set at creation (archive + recreate to
464- -- change), 0 for free claims
496+ operator : Party
497+ guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
498+ -- a signatory) in ClaimReplayRoot's fee charge so
499+ -- a forged CoreState under a rogue gg is rejected
500+ claimFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument
501+ -- units; operator-set at creation (archive +
502+ -- recreate to change), 0 for free claims
465503 where
466504 signatory operator
467505
@@ -473,13 +511,18 @@ template ReplayRootRegistry
473511 feeAllocation : Optional (ContractId Allocation, ExtraArgs)
474512 controller consumer
475513 do
476- -- Zero-fee claims never touch the CoreState; fee'd ones charge
477- -- through it, pinning its operator so a fabricated CoreState cannot
478- -- waive or redirect the fee (see README §4.2).
514+ -- Zero-fee claims never touch the CoreState; fee'd ones charge through
515+ -- it. The gg pin runs BEFORE the charge: pinning @guardianGovernance@
516+ -- (not just the operator) rejects a CoreState a compromised operator
517+ -- co-signed under a throwaway gg it controls — the operator field would
518+ -- match, but the fee-custody anchor would not, so a forged CoreState
519+ -- cannot redirect the claim fee (see README §4.2).
479520 case (claimFee > 0, coreStateCid) of
480521 (False, _) -> pure ()
481522 (True, None) -> abort "claim fee due: pass the CoreState"
482523 (True, Some csCid) -> do
524+ csGg <- exercise csCid GetGuardianGovernance with reader = consumer
525+ assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
483526 csOperator <- exercise csCid ChargeFee with
484527 payer = consumer
485528 fee = claimFee
@@ -498,12 +541,15 @@ template ReplayRootRegistry
498541-- authority). Mirrors a Sui @EmitterCap@.
499542template Emitter
500543 with
501- operator : Party
502- owner : Party
503- guardianObserver : Party -- read-only guardian observer
504- emitterId : Int -- registry-allocated
505- emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
506- sequence : Int -- next sequence to assign
544+ operator : Party
545+ guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT
546+ -- a signatory) in PublishMessage's fee charge so
547+ -- a forged CoreState under a rogue gg is rejected
548+ owner : Party
549+ guardianObserver : Party -- read-only guardian observer
550+ emitterId : Int -- registry-allocated
551+ emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation
552+ sequence : Int -- next sequence to assign
507553 where
508554 -- Owner co-signs: only a transaction carrying the owner's authority can create
509555 -- a contract whose derived address is the owner's. This is the invariant
@@ -550,6 +596,13 @@ template Emitter
550596 feeAllocation : Optional (ContractId Allocation, ExtraArgs)
551597 controller owner, payer
552598 do
599+ -- Pin the fee-custody anchor BEFORE charging: 'guardianGovernance' (not
600+ -- just the operator) is what determines where the fee lands. A
601+ -- compromised operator can co-sign a forged CoreState under a throwaway
602+ -- gg it controls — the operator field would match, but this check
603+ -- rejects it, so the message fee cannot be redirected.
604+ csGg <- exercise coreStateCid GetGuardianGovernance with reader = payer
605+ assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance)
553606 csOperator <- exercise coreStateCid ChargeMessageFee with
554607 payer
555608 feeAllocation
0 commit comments