From fcb7bbcec18a2ecbcc45d44345ea6dfa68585da5 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:00:02 -0700 Subject: [PATCH 1/5] canton: add read-only guardianObserver party on the attestation surface Introduce a dedicated read-only guardianObserver party as a template observer on exactly Emitter and CoreState (the attestation surface), so guardians get full read visibility of the contracts and events they attest without being in Canton's confirmation path. The party is carried as a template field, never a signatory or controller. - Emitter/CoreState: guardianObserver field + observer clause. Because PublishMessage and SubmitGovernanceVAA are consuming, observers are informees of the exercise and its result, delivering the message surface and governance transitions over the update stream. - EmitterRegistry: field only (not observer); AllocateEmitterId's result is extended to carry the party so ApproveEmitter threads it into each Emitter from a single key resolution. - mkInitialCoreState / mkInitialEmitterRegistry gain a guardianObserver parameter; Test.TestCore:setup allocates the party and returns it (operator kept first for bootstrap.sh's party extraction). - bootstrap.sh logs the guardianObserver party id. - New tests (Test/TestGuardianObserver.daml) cover visibility of the message surface and CoreState, absence of authority, unchanged publish authority, and out-of-scope invisibility. --- canton/core/daml/Wormhole/Core/Setup.daml | 13 +- canton/core/daml/Wormhole/Core/State.daml | 43 +++-- canton/devnet/bootstrap.sh | 14 +- canton/test/daml/Test/TestCore.daml | 38 +++-- .../test/daml/Test/TestGuardianObserver.daml | 147 ++++++++++++++++++ 5 files changed, 220 insertions(+), 35 deletions(-) create mode 100644 canton/test/daml/Test/TestGuardianObserver.daml diff --git a/canton/core/daml/Wormhole/Core/Setup.daml b/canton/core/daml/Wormhole/Core/Setup.daml index 3ff654c9d5..f33d2f160f 100644 --- a/canton/core/daml/Wormhole/Core/Setup.daml +++ b/canton/core/daml/Wormhole/Core/Setup.daml @@ -32,11 +32,13 @@ mkInitialCoreState : -> Int -- ^ governance chain id -> Bytes32 -- ^ governance contract -> [Bytes20] -- ^ initial guardian addresses (set 0) + -> Party -- ^ guardianObserver (read-only guardian observer party) -> CoreState -mkInitialCoreState operator guardianGovernance govChain govContract initialGuardians = +mkInitialCoreState operator guardianGovernance govChain govContract initialGuardians guardianObserver = CoreState with operator guardianGovernance + guardianObserver chainId = cantonChainId governanceChainId = govChain governanceContract = normalizeHex govContract @@ -46,6 +48,9 @@ mkInitialCoreState operator guardianGovernance govChain govContract initialGuard consumedGovernance = Set.empty -- | The emitter-id registry the operator creates alongside the 'CoreState' at --- setup. Emitter ids are allocated sequentially from this contract. -mkInitialEmitterRegistry : Party -> EmitterRegistry -mkInitialEmitterRegistry operator = EmitterRegistry with operator, nextId = 0 +-- setup. Emitter ids are allocated sequentially from this contract. It carries +-- @guardianObserver@ as a field only (not a template observer) so 'ApproveEmitter' +-- can thread it into each new 'Emitter'. +mkInitialEmitterRegistry : Party -> Party -> EmitterRegistry +mkInitialEmitterRegistry operator guardianObserver = + EmitterRegistry with operator, guardianObserver, nextId = 0 diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index d90b96ceac..b245279462 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -90,6 +90,7 @@ template CoreState with operator : Party -- advances the singleton; no power over outcomes guardianGovernance : Party -- guardians' governance anchor (threshold party) + guardianObserver : Party -- read-only guardian observer (stakeholder, never signs) chainId : Int -- 72 (Canton) governanceChainId : Int -- 1 (Solana) governanceContract : Bytes32 -- governance emitter address @@ -106,6 +107,12 @@ template CoreState -- guardianGovernance's authority from the archived contract, so it signs only -- at genesis. signatory operator, guardianGovernance + -- Read-only guardian observer: an informee of every consuming exercise on + -- this contract (SubmitGovernanceVAA) and of each successor create, so + -- guardians watch guardian-set/fee/governance transitions without any + -- authority. Never a signatory or controller. The field propagates across + -- every `create st with ...` governance recreation automatically. + observer guardianObserver key guardianGovernance : Party maintainer key @@ -221,18 +228,25 @@ emitterAddressFor registrar owner emitterId = -- owner, whose signature it lacks (see 'Emitter'). template EmitterRegistry with - operator : Party - nextId : Int + operator : Party + guardianObserver : Party -- carried (field only) so ApproveEmitter can thread it into new Emitters + nextId : Int where signatory operator + -- The registry carries guardianObserver as a FIELD only, NOT as a template + -- observer: guardians watch the attestation surface (Emitter/CoreState), not + -- id allocation (least privilege). The field lets ApproveEmitter thread the + -- party into each new Emitter without a second key resolution. key operator : Party maintainer key - choice AllocateEmitterId : (ContractId EmitterRegistry, Int) + -- Result carries guardianObserver so ApproveEmitter threads it into the new + -- Emitter from this single key resolution (no separate fetchByKey). + choice AllocateEmitterId : (ContractId EmitterRegistry, Int, Party) controller operator do reg' <- create this with nextId = nextId + 1 - pure (reg', nextId) + pure (reg', nextId, guardianObserver) -- | A request by @requester@ to be granted an emitter. The operator approves it, -- allocating a stable @emitterId@ from the 'EmitterRegistry' and creating the @@ -250,10 +264,11 @@ template EmitterRequest choice ApproveEmitter : ContractId Emitter controller operator do - (_, emitterId) <- exerciseByKey @EmitterRegistry operator AllocateEmitterId + (_, emitterId, guardianObserver) <- exerciseByKey @EmitterRegistry operator AllocateEmitterId create Emitter with operator owner = requester + guardianObserver emitterId emitterAddress = emitterAddressFor operator requester emitterId sequence = 0 @@ -264,11 +279,12 @@ template EmitterRequest -- operator authority). Mirrors a Sui @EmitterCap@. template Emitter with - operator : Party - owner : Party - emitterId : Int -- registry-allocated; key._3 - 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 (stakeholder, never signs) + emitterId : Int -- registry-allocated; key._3 + emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation + sequence : Int -- next sequence to assign where -- Owner co-signs: only a transaction carrying the owner's authority can create -- a contract whose derived address is the owner's. This is the invariant @@ -276,6 +292,13 @@ template Emitter -- existing address (Canton's msg.sender analog). It also means the operator -- alone cannot archive an emitter (Archive needs all signatories). signatory operator, owner + -- Read-only guardian observer: because PublishMessage is a CONSUMING choice, + -- observers (stakeholders) are informees of the exercise, so guardianObserver + -- sees every PublishMessage exercise (and its result) plus the successor + -- Emitter create — the full message surface — over the update stream. It + -- never signs, so it confers no authority; the sequence-bump recreation + -- (`create this with ...`) preserves the field automatically. + observer guardianObserver -- Non-unique key: stable across the sequence-bump churn so the owner can -- publish again by key (ExerciseByKeyCommand) without tracking contract-ids. key (operator, owner, emitterId) : (Party, Party, Int) diff --git a/canton/devnet/bootstrap.sh b/canton/devnet/bootstrap.sh index 8d85b36011..de38883645 100755 --- a/canton/devnet/bootstrap.sh +++ b/canton/devnet/bootstrap.sh @@ -35,11 +35,17 @@ dpm script \ --ledger-port "${PORT}" \ --output-file "${RESULT}" -# The setup script returns a tuple {"_1": , "_2": } as JSON. -# The party id is the quoted value containing "::" (the namespace separator); the -# contract id has none. Informational only (the watcher needs no party id). -OPERATOR_PARTY="$(grep -oE '"[^"]*::[^"]*"' "${RESULT}" | head -1 | tr -d '"')" +# The setup script returns a tuple {"_1": , "_2": , +# "_3": } as JSON. Party ids are the quoted values containing "::" +# (the namespace separator); the contract id has none. The parties appear in +# result order, so _1 is the operator and _2 is the read-only guardianObserver +# (the party a production guardian reads as via --cantonReadAsParty). The devnet +# sandbox is a single participant, so no topology work is needed; both ids are +# surfaced for information / wiring only. +OPERATOR_PARTY="$(grep -oE '"[^"]*::[^"]*"' "${RESULT}" | sed -n '1p' | tr -d '"')" +OBSERVER_PARTY="$(grep -oE '"[^"]*::[^"]*"' "${RESULT}" | sed -n '2p' | tr -d '"')" echo "[canton] setup complete; Operator party: ${OPERATOR_PARTY}" +echo "[canton] guardianObserver party (read-only; --cantonReadAsParty): ${OBSERVER_PARTY}" touch /canton/success echo "[canton] bootstrap done; sleeping" diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index d963b5f1eb..a7cc41718a 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -31,16 +31,20 @@ govSetFeeVAA = "01000000000100710ab5dcf6885f62016990540e090400f26f41286382dab479 -- | Bootstrap a devnet 'CoreState' and the 'EmitterRegistry'. The 'CoreState' is -- co-signed by the @operator@ and a @guardianGovernance@ party (a single party --- here; in production a k-of-n threshold party), so genesis needs both. The --- guardianGovernance party is readable off the created 'CoreState'. -setup : Script (Party, ContractId CoreState) +-- here; in production a k-of-n threshold party), so genesis needs both. It also +-- allocates the read-only @guardianObserver@ party and threads it into the +-- 'CoreState' and 'EmitterRegistry' (from which 'ApproveEmitter' carries it into +-- each 'Emitter'). The result keeps @operator@ FIRST so bootstrap.sh's first-party +-- extraction still yields the operator; @guardianObserver@ follows. +setup : Script (Party, Party, ContractId CoreState) setup = do operator <- allocateParty "Operator" guardianGovernance <- allocateParty "GuardianGovernance" + guardianObserver <- allocateParty "GuardianObserver" csId <- submit (actAs operator <> actAs guardianGovernance) do - createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId defaultGovernanceContract [devnetGuardian]) - _ <- submit operator do createCmd (mkInitialEmitterRegistry operator) - pure (operator, csId) + createCmd (mkInitialCoreState operator guardianGovernance solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver) + _ <- submit operator do createCmd (mkInitialEmitterRegistry operator guardianObserver) + pure (operator, guardianObserver, csId) -- | Register an emitter for @owner@: allocates a stable @emitterId@ from the -- 'EmitterRegistry' and creates the keyed 'Emitter', returning its cid. @@ -55,7 +59,7 @@ registerEmitter operator owner = do -- the second publish exercises the stable key directly, with no cid tracking. testPublishMessage : Script () testPublishMessage = do - (operator, _) <- setup + (operator, _, _) <- setup alice <- allocateParty "Alice" emitterId <- registerEmitter operator alice Some em <- queryContractId alice emitterId @@ -86,7 +90,7 @@ testPublishMessage = do -- addresses; the registry's counter advances. testRegistryAllocation : Script () testRegistryAllocation = do - (operator, _) <- setup + (operator, _, _) <- setup alice <- allocateParty "AllocAlice" bob <- allocateParty "AllocBob" aliceEm <- registerEmitter operator alice @@ -122,7 +126,7 @@ testAddressVector = -- so registry-id discipline is behavioral — but address integrity is not. testImpersonationBlocked : Script () testImpersonationBlocked = do - (operator, _) <- setup + (operator, guardianObserver, _) <- setup alice <- allocateParty "VictimAlice" mallory <- allocateParty "Mallory" aliceEm <- registerEmitter operator alice @@ -131,13 +135,13 @@ testImpersonationBlocked = do -- (a) Operator-alone forgery of Alice's address is impossible. submitMustFail operator do createCmd Emitter with - operator, owner = alice, emitterId = 0 + 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, owner = mallory, emitterId = 0 + operator, owner = mallory, guardianObserver, emitterId = 0 emitterAddress = emitterAddressFor operator mallory 0, sequence = 0 Some m <- queryContractId mallory malloryEm assert (m.emitterAddress /= a.emitterAddress) @@ -145,7 +149,7 @@ testImpersonationBlocked = do -- | Payloads larger than 750 bytes are rejected. testPayloadTooLarge : Script () testPayloadTooLarge = do - (operator, _) <- setup + (operator, _, _) <- setup bob <- allocateParty "Bob" emitterId <- registerEmitter operator bob -- 751 bytes = 1502 hex chars. @@ -166,7 +170,7 @@ testQuorum = do -- | The current guardian set is installed at index 0 and never expires. testInitialGuardianSet : Script () testInitialGuardianSet = do - (operator, csId) <- setup + (operator, _, csId) <- setup Some cs <- queryContractId operator csId cs.guardianSetIndex === 0 let gs = fromSome (Map.lookup 0 cs.guardianSets) @@ -178,7 +182,7 @@ testInitialGuardianSet = do -- exercises the full keccak256 + DER + secp256k1WithEcdsaOnly path. testParseAndVerifyVAA : Script () testParseAndVerifyVAA = do - (operator, csId) <- setup + (operator, _, csId) <- setup vaa <- submit operator do exerciseCmd csId ParseAndVerifyVAA with encodedVAA = govSetFeeVAA @@ -193,7 +197,7 @@ testParseAndVerifyVAA = do -- operator submits alone (guardianGovernance's authority is inherited). testGovernanceSetMessageFee : Script () testGovernanceSetMessageFee = do - (operator, csId) <- setup + (operator, _, csId) <- setup Some cs0 <- queryContractId operator csId newCsId <- submit operator do exerciseByKeyCmd @CoreState cs0.guardianGovernance SubmitGovernanceVAA with @@ -211,7 +215,7 @@ testGovernanceSetMessageFee = do -- from the message's key components, which the Go test recomputes. integrationPublish : Script Party integrationPublish = do - (operator, _) <- setup + (operator, _, _) <- setup owner <- allocateParty "IntegrationEmitter" _ <- registerEmitter operator owner _ <- submit owner do @@ -222,7 +226,7 @@ integrationPublish = do -- | Exercise the auto-generated Archive choice on each template (for coverage). testArchives : Script () testArchives = do - (operator, csId) <- setup + (operator, _, csId) <- setup Some cs <- queryContractId operator csId -- CoreState is co-signed, so archiving needs operator + guardianGovernance. submit (actAs operator <> actAs cs.guardianGovernance) do archiveCmd csId diff --git a/canton/test/daml/Test/TestGuardianObserver.daml b/canton/test/daml/Test/TestGuardianObserver.daml new file mode 100644 index 0000000000..d045be3a94 --- /dev/null +++ b/canton/test/daml/Test/TestGuardianObserver.daml @@ -0,0 +1,147 @@ +-- | Daml Script tests for the read-only @guardianObserver@ party. +-- +-- The @guardianObserver@ is a template @observer@ on exactly the attestation +-- surface of the core bridge ('Emitter' and 'CoreState'). It is never a +-- signatory or controller of anything, so it gives guardians full read +-- visibility of the contracts and events they attest without placing them in +-- Canton's confirmation path. +-- +-- Daml Script cannot read the Ledger-API update stream directly, so event-level +-- visibility is asserted via its exact Script-level proxy: stakeholder query +-- visibility ('queryContractId' / 'query') as @guardianObserver@. Because a +-- template observer is a stakeholder, it is an informee of every consuming +-- exercise on the contract (e.g. 'PublishMessage', 'SubmitGovernanceVAA') — the +-- ledger-model rule the target design relies on. The true stream-level check is +-- the Go integration test. +module Test.TestGuardianObserver where + +import Daml.Script +import DA.Assert ((===)) +import DA.Map qualified as Map +import DA.Optional (fromSome) +import Wormhole.Core.State +import Wormhole.Core.Setup +import Test.TestCore (setup, registerEmitter, devnetGuardian, govSetFeeVAA, devnetGuardianPubKey) + +-- | The observer is a stakeholder of the message surface, so after a publish it +-- can query the sequence-bumped 'Emitter' by key and via 'query @Emitter'. This +-- is the Script-level proxy for "informee of the consuming 'PublishMessage' +-- exercise" — i.e. it observes the message on the update stream. +testObserverSeesPublishedMessage : Script () +testObserverSeesPublishedMessage = do + (operator, guardianObserver, _) <- setup + alice <- allocateParty "Alice" + emitterId <- registerEmitter operator alice + _ <- submit alice do + exerciseCmd emitterId PublishMessage with nonce = 7, payload = "deadbeef", consistencyLevel = 0 + + -- The observer sees the post-publish Emitter (recreated under the same key + -- with the sequence bumped) both by key and via an ACS query. + Some (_, em) <- queryByKey @Emitter guardianObserver (operator, alice, 0) + em.sequence === 1 + em.owner === alice + em.guardianObserver === guardianObserver + emitters <- query @Emitter guardianObserver + assertMsg "observer should see at least one Emitter" (not (null emitters)) + +-- | The observer is a stakeholder of 'CoreState', so it reads the current +-- guardian set and — because 'SubmitGovernanceVAA' is consuming — the successor +-- 'CoreState' after a governance transition. +testObserverSeesCoreState : Script () +testObserverSeesCoreState = do + (operator, guardianObserver, csId) <- setup + Some cs <- queryContractId guardianObserver csId + cs.guardianSetIndex === 0 + let gs = fromSome (Map.lookup 0 cs.guardianSets) + gs.keys === [devnetGuardian] + + newCsId <- submit operator do + exerciseByKeyCmd @CoreState cs.guardianGovernance SubmitGovernanceVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + Some cs' <- queryContractId guardianObserver newCsId + cs'.messageFee === 1000 + cs'.guardianObserver === guardianObserver + +-- | The observer has no authority: it cannot publish, submit governance, archive +-- the attestation contracts, or create contracts bearing the real parties. +-- Read-only in the strictest sense — adding an observer confers no power. +testObserverHasNoAuthority : Script () +testObserverHasNoAuthority = do + (operator, guardianObserver, csId) <- setup + alice <- allocateParty "Alice" + emitterId <- registerEmitter operator alice + Some cs <- queryContractId operator csId + + -- Cannot publish on another owner's emitter (not the controller). + submitMustFail guardianObserver do + exerciseCmd emitterId PublishMessage with nonce = 1, payload = "00", consistencyLevel = 0 + + -- Cannot drive governance. + submitMustFail guardianObserver do + exerciseByKeyCmd @CoreState cs.guardianGovernance SubmitGovernanceVAA with + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + + -- Cannot archive the attestation contracts (Archive needs all signatories). + submitMustFail guardianObserver do archiveCmd emitterId + submitMustFail guardianObserver do archiveCmd csId + + -- Cannot create contracts bearing the real signatory parties (no authority). + submitMustFail guardianObserver do + createCmd Emitter with + operator, owner = alice, emitterId = 0 + emitterAddress = emitterAddressFor operator alice 0, sequence = 0 + guardianObserver + submitMustFail guardianObserver do + createCmd (mkInitialCoreState operator cs.guardianGovernance + solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver) + +-- | Adding the observer introduces no new authority requirement on publishing: +-- the @owner@ alone still publishes. And genesis still requires exactly the +-- @operator@ and @guardianGovernance@ signatures — the observer is not one of +-- them. This is the Script-level proxy for "confirmation semantics unchanged". +testPublishRequiresOnlyOwner : Script () +testPublishRequiresOnlyOwner = do + (operator, _guardianObserver, _) <- setup + alice <- allocateParty "Alice" + emitterId <- registerEmitter operator alice + msg <- submit alice do + exerciseCmd emitterId PublishMessage with nonce = 7, payload = "dead", consistencyLevel = 0 + msg.sequence === 0 + + -- Genesis authority is unchanged: operator alone cannot create a CoreState + -- (guardianGovernance must co-sign); the pair together can. + operator2 <- allocateParty "Operator2" + guardianGovernance2 <- allocateParty "GuardianGovernance2" + guardianObserver2 <- allocateParty "GuardianObserver2" + submitMustFail operator2 do + createCmd (mkInitialCoreState operator2 guardianGovernance2 + solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver2) + _ <- submitMulti [operator2, guardianGovernance2] [] do + createCmd (mkInitialCoreState operator2 guardianGovernance2 + solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver2) + pure () + +-- | The observer is scoped to the attestation surface only. It is NOT a +-- stakeholder of pre-approval intent ('EmitterRequest') or id allocation +-- ('EmitterRegistry'), so neither is visible to it — least privilege. +testObserverDoesNotSeeOutOfScope : Script () +testObserverDoesNotSeeOutOfScope = do + (operator, guardianObserver, _) <- setup + requester <- allocateParty "Requester" + reqId <- submit requester do createCmd EmitterRequest with requester, operator + + -- EmitterRequest is out of scope. + reqView <- queryContractId guardianObserver reqId + reqView === None + reqs <- query @EmitterRequest guardianObserver + assertMsg "observer must not see EmitterRequests" (null reqs) + + -- EmitterRegistry is out of scope (it carries the observer as a field for + -- threading, but does not name it as a template observer). + Some (regId, _) <- queryByKey @EmitterRegistry operator operator + regView <- queryContractId guardianObserver regId + regView === None + regs <- query @EmitterRegistry guardianObserver + assertMsg "observer must not see the EmitterRegistry" (null regs) From 92e69018524f3bd139da62339caab1c3f713d15b Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:02:28 -0700 Subject: [PATCH 2/5] canton: document the guardianObserver party and observer topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update canton/README.md for the read-only guardianObserver: template listings (§4.1 CoreState, §4.2 Emitter/EmitterRegistry) with the informee rule that makes a template observer see consuming exercises; production --cantonReadAsParty guidance (§7.1, §7.4); the deployment topology (§10) hosting the party at Observation permission with the two-layer non-blocking argument, operator replica-hosting kept as a fallback, and the least-privilege scoping rule; resolve Open Question 3 to the residual runbook/namespace-key-custody items; add the new test module to the file map. Also update the flag help (node.go) and WatcherConfig.ReadAsParty comment (config.go) to recommend the guardianObserver party for production guardians. Comments/help text only; no Go logic change. --- canton/README.md | 138 ++++++++++++++++++++++------- node/cmd/guardiand/node.go | 2 +- node/pkg/watchers/canton/config.go | 12 ++- 3 files changed, 115 insertions(+), 37 deletions(-) diff --git a/canton/README.md b/canton/README.md index a1896f9ab9..b756268484 100644 --- a/canton/README.md +++ b/canton/README.md @@ -163,6 +163,7 @@ template CoreState with operator : Party -- advances the singleton; the "deployer" guardianGovernance : Party -- guardians' governance anchor (threshold party) + guardianObserver : Party -- read-only guardian observer (stakeholder, never signs) chainId : Int -- 72 governanceChainId : Int -- 1 (Solana) governanceContract : Bytes32 -- 0x..0004 (the governance emitter) @@ -172,10 +173,25 @@ template CoreState consumedGovernance : Set Bytes32 -- replay protection (keyed by digest) where signatory operator, guardianGovernance + observer guardianObserver -- read-only; sees governance transitions key guardianGovernance : Party -- lookup by the governance anchor is authentic maintainer key ``` +**Read-only guardian observer.** `guardianObserver` is a template `observer` +(hence a **stakeholder/informee**, never a signatory or controller) on the +attestation surface — `CoreState` here and `Emitter` in §4.2. The ledger-model +rule that makes this sufficient: **informees of a *consuming* exercise include +the contract's stakeholders**. `SubmitGovernanceVAA` is consuming, so the +observer sees every guardian-set/fee/governance transition and each successor +`CoreState` create; `PublishMessage` is consuming, so it sees the full message +surface (§4.2). Nonconsuming exercises (`ParseAndVerifyVAA`, §4.3) do NOT reach +observers, which is acceptable — that path is a pure read. Adding an observer +requires no authority from that party and changes nothing about who can +create/exercise these contracts, so guardians watch what they attest without +ever entering Canton's confirmation path (§10). The scope is deliberately the +attestation surface only, **never** holdings, balances, or transfer legs. + Every state-mutating choice is `controller`-checked (controller `operator`), archives the current `CoreState`, and `create`s the next one with updated fields. This is the Daml idiom for mutable state and gives us deterministic, @@ -244,27 +260,41 @@ template EmitterRequest with -- One per operator; allocates monotonically increasing emitter ids. template EmitterRegistry with - operator : Party - nextId : Int + operator : Party + guardianObserver : Party -- carried (field only) to thread into new Emitters + nextId : Int where signatory operator key operator : Party maintainer key - -- choice AllocateEmitterId : (ContractId EmitterRegistry, Int) + -- guardianObserver is a FIELD only here, NOT a template observer: guardians + -- watch the attestation surface, not id allocation (least privilege). + -- choice AllocateEmitterId : (ContractId EmitterRegistry, Int, Party) + -- -- result carries guardianObserver so ApproveEmitter threads it into the + -- -- new Emitter from this single key resolution. template Emitter with - operator : Party - owner : Party - emitterId : Int -- registry-allocated; key._3 - emitterAddress : Bytes32 -- keccak256(tag ‖ operator ‖ owner ‖ emitterId), set at creation - sequence : Int -- next sequence to assign (per-emitter) + operator : Party + owner : Party + guardianObserver : Party -- read-only guardian observer (stakeholder, never signs) + emitterId : Int -- registry-allocated; key._3 + emitterAddress : Bytes32 -- keccak256(tag ‖ operator ‖ owner ‖ emitterId), set at creation + sequence : Int -- next sequence to assign (per-emitter) where signatory operator, owner -- owner co-signs: the msg.sender analog + observer guardianObserver -- read-only; sees PublishMessage (consuming) key (operator, owner, emitterId) : (Party, Party, Int) maintainer key._1 ``` +The `guardianObserver` is threaded in at registration: `ApproveEmitter` +resolves the `EmitterRegistry` once (`AllocateEmitterId`, whose result now +carries the party) and sets the field on the created `Emitter`. Because +`PublishMessage` is consuming, the observer is an informee of every publish +exercise (and its `WormholeMessage` result) plus the sequence-bumped successor +`Emitter` create — the full message surface — with no authority (§4.1, §10). + **Emitter address derivation.** The 32-byte Wormhole emitter address is ``` @@ -554,9 +584,15 @@ against the Daml side by a shared cross-language test vector. **Party filter.** The `GetUpdates` request uses an `UpdateFormat` with a wildcard template filter under **`filters_for_any_party`** by default — the watcher observes `PublishMessage` from _every_ emitter on the participant and needs no -operator party id. (An optional `--cantonReadAsParty` narrows the stream to a -single party via `filters_by_party` instead.) `TRANSACTION_SHAPE_LEDGER_EFFECTS` -is required so `ExercisedEvent`s — which carry the choice result — are included. +operator party id. For **production guardians**, set `--cantonReadAsParty` to the +read-only **`guardianObserver`** party (§4.1): it is a stakeholder of the +`Emitter`/`CoreState` attestation surface, so the narrowed `filters_by_party` +stream still carries every `PublishMessage`, and it makes the trust boundary +explicit. If the guardian participant hosts only `guardianObserver` the wildcard +default is equivalent, so the flag is precision / defense-in-depth. The **devnet** +sandbox is a single participant, so it keeps the wildcard default. +`TRANSACTION_SHAPE_LEDGER_EFFECTS` is required so `ExercisedEvent`s — which carry +the choice result — are included. ### 7.2 `TxID` and offsets @@ -598,16 +634,20 @@ Each observed event becomes a `common.MessagePublication` ```go type WatcherConfig struct { - NetworkID watchers.NetworkID - ChainID vaa.ChainID // ChainIDCanton - Rpc string // host:port of the Ledger API - PackageID string // package-id of wormhole-core (template filter) + NetworkID watchers.NetworkID + ChainID vaa.ChainID // ChainIDCanton + Rpc string // host:port of the Ledger API + PackageID string // package-id of wormhole-core (template filter) + ReadAsParty string // optional; production guardians set the guardianObserver party } ``` Wired into the guardian via [`node/pkg/node/options.go`](../node/pkg/node/options.go) and a `--cantonRPC` / `--cantonContract` flag pair in `node/cmd/guardiand`, following the Sui pattern. +`--cantonReadAsParty` populates `ReadAsParty`; production guardians set it to the +read-only `guardianObserver` party (§4.1, §7.1), and devnet leaves it empty +(wildcard). --- @@ -629,6 +669,7 @@ canton/ test/ ← `wormhole-core-test` package (Daml Scripts) daml.yaml ← data-dependency on core's DAR daml/Test/TestCore.daml ← unit tests + devnet `setup` / `integrationPublish` + daml/Test/TestGuardianObserver.daml ← read-only guardianObserver visibility/authority tests devnet/ start_sandbox.sh ← starts the Ledger API v2 sandbox bootstrap.sh ← allocates Operator + creates CoreState @@ -758,17 +799,39 @@ by two mechanisms: choice from the vetted DAR and confirming the result; a transaction commits only if the confirmation policy over those participants is met. -**Topology.** Each guardian runs a **participant node**, and the `operator` party -is **multi-hosted on the guardian participants with observation-only permission**. -That is sufficient and deliberately minimal: the operator is a signatory on every -core contract, so its projection is the full app trace; each guardian's -participant independently receives and validates every such transaction, and an -invalid one never appears on that participant's Ledger API — so a watcher cannot -sign it. Guardians are **not** given confirmation rights: the VAA quorum is +**Topology.** Each guardian runs a **participant node**, and the read-only +**`guardianObserver`** party (§4.1) is **hosted on every guardian participant at +`Observation` permission** in the `PartyToParticipant` topology mapping. Being a +template `observer` on `Emitter`/`CoreState`, it is an informee of every +`PublishMessage` and `SubmitGovernanceVAA` exercise, so each guardian's +participant independently receives and validates every attestation-bearing +transaction, and an invalid one never appears on that participant's Ledger API — +so a watcher cannot sign it. This is deliberately scoped to the **attestation +surface only** (§4.1, §4.2): the observer is *not* a stakeholder of +`EmitterRequest`s, `EmitterRegistry` allocations, or any holding/balance/transfer +state of other packages — least privilege, in contrast to replica-hosting the +`operator` (whose projection is its entire signatory footprint; kept as a +fallback below). A single shared `guardianObserver` covers all guardians (one +mapping listing every guardian participant), not one party per guardian. + +Guardians are **not** given confirmation rights, by two independent properties: +(1) under Canton's confirmation policy the confirming parties of a node are its +signatories/actors — a plain observer party is an informee, never a confirming +party; and (2) even for a confirming party, a participant hosting it at +`Observation` permission does not submit confirmations. So guardian participant +downtime never blocks a Canton transaction; it only delays that guardian's own +observation (fail-safe: it stops signing). This is the point — the VAA quorum is already the attestation layer, and Wormhole's job is to *refuse to attest* forgeries, not to halt Canton (giving guardians confirmation duty would duplicate the quorum and turn guardian downtime into a chain-halt). +> **Fallback (interim, no template change):** where a participant cannot yet host +> the dedicated party, replica-host the `operator` at `Observation` instead. It +> works with zero Daml change but gives a coarser scope (the operator's full +> projection, including `EmitterRequest`s) and couples guardian read access to the +> operator party and its namespace key. Prefer the dedicated `guardianObserver`; +> use operator replica-hosting only as a migration stopgap. + **What a compromised operator can and cannot do.** It can degrade **liveness** (stop approving registrations, stop relaying, evict observers — all fail-safe: guardians then simply stop seeing and stop signing). It **cannot** produce @@ -791,11 +854,14 @@ its own state but cannot forge messages other chains accept. The guardians' faithfulness invariant thus reduces to **"the transaction is Daml-valid under the vetted package."** -The exact observer-party mechanics — a dedicated guardian-observer party named as -a template `observer` vs. replica-hosting the `operator`; external-party namespace -key custody controlling the hosting topology — are the subject of the -`feat/canton-public-observer` work and are tracked as an Open Question, not -implemented here. +The observer-party mechanism is now implemented: a dedicated read-only +`guardianObserver` named as a template `observer` on `Emitter`/`CoreState` +(§4.1, §4.2). The residual operational item is **namespace-key custody** — which +key owns the `guardianObserver` party (operator-ops vs a guardian-ops key), since +that key controls the hosting mapping and can therefore evict guardians from +observation. Eviction is fail-safe (guardians stop seeing → stop signing, the same +liveness posture as a compromised operator above), but the custody choice needs a +human decision; see Open Questions. --- @@ -816,12 +882,18 @@ implemented here. one") is still behavioral — confirm the genesis automation creates exactly one. (Emitter/manager **address** integrity is separately owner-bound and owner-co-signed, §4.2.) -3. **Guardian observer topology.** Confirm the observer-party mechanics from - §10 (dedicated observer party as template `observer` vs. replica-hosted - `operator`; external-party namespace-key custody; how `--cantonReadAsParty` - maps to the chosen party). Reconcile with `feat/canton-public-observer`. Note - this observer party is distinct from the `guardianGovernance` signatory party - (item 2): the observer is read-only, the governance party has signatory power. +3. **Guardian observer topology.** *Mechanism resolved:* the dedicated read-only + `guardianObserver` party is implemented as a template `observer` on + `Emitter`/`CoreState` (§4.1, §4.2, §10), and `--cantonReadAsParty` maps to it + for production guardians (§7.1). This observer party is distinct from the + `guardianGovernance` signatory party (item 2): the observer is read-only, the + governance party has signatory power. **Residual (needs a human decision):** + the production `PartyToParticipant` runbook (host at `Observation` permission; + host the party *before* genesis, or run Canton party replication / ACS import + so a late-joining participant receives prior contracts), and + **namespace-key custody** for the party (operator-ops vs guardian-ops key, + which controls the hosting mapping and thus observer eviction — fail-safe + either way). 4. **Integrator VAA verification.** Implement the hint-free guardian set (persist pubkeys, §5) and confirm the guardian-set distribution mechanism for integrators (explicit disclosure vs public-observer snapshot). diff --git a/node/cmd/guardiand/node.go b/node/cmd/guardiand/node.go index 54be1a6a89..38e8c95011 100644 --- a/node/cmd/guardiand/node.go +++ b/node/cmd/guardiand/node.go @@ -427,7 +427,7 @@ func init() { cantonRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "cantonRPC", "Canton Ledger API v2 gRPC endpoint", "canton:5011", []string{""}) cantonPackageID = NodeCmd.Flags().String("cantonPackageID", "", "Canton wormhole-core Daml package id (empty matches any package version)") - cantonReadAsParty = NodeCmd.Flags().String("cantonReadAsParty", "", "Optional Canton party to narrow the watcher's update stream to; empty observes all parties on the participant") + cantonReadAsParty = NodeCmd.Flags().String("cantonReadAsParty", "", "Canton party to narrow the watcher's update stream to; production guardians set this to the read-only guardianObserver party. Empty observes all parties on the participant (devnet default)") solanaRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "solanaRPC", "Solana RPC URL (required)", "http://solana-devnet:8899", []string{"http", "https"}) fogoRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "fogoRPC", "Fogo RPC URL (required)", "http://solana-devnet:8899", []string{"http", "https"}) diff --git a/node/pkg/watchers/canton/config.go b/node/pkg/watchers/canton/config.go index ef10f1c577..75d5bef757 100644 --- a/node/pkg/watchers/canton/config.go +++ b/node/pkg/watchers/canton/config.go @@ -19,9 +19,15 @@ type WatcherConfig struct { // while preserving module/entity names). PackageID string // ReadAsParty optionally narrows the update stream to a single Canton party. - // Leave empty (the default) to observe every party hosted on the participant - // — the right default for the core bridge, which should see PublishMessage - // from every emitter, and which avoids depending on the operator party id. + // For a production guardian this should be the read-only guardianObserver + // party (a template observer on the Emitter/CoreState attestation surface): + // it is a stakeholder of every PublishMessage exercise and CoreState + // transition, so reading as it observes the full message surface without any + // authority and without depending on the operator party id. Leaving it empty + // observes every party hosted on the participant; if the participant hosts + // only guardianObserver the two are equivalent, but setting the flag makes + // the intended trust boundary explicit (defense in depth). In devnet the + // sandbox is a single participant, so the wildcard default is used. ReadAsParty string } From a88b62f2df1251da961047d4a8000cbc2bc0af8f Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 18:21:20 -0700 Subject: [PATCH 3/5] canton: replace deprecated submitMulti with submit + actAs --- canton/test/daml/Test/TestGuardianObserver.daml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canton/test/daml/Test/TestGuardianObserver.daml b/canton/test/daml/Test/TestGuardianObserver.daml index d045be3a94..c22e4fdfbf 100644 --- a/canton/test/daml/Test/TestGuardianObserver.daml +++ b/canton/test/daml/Test/TestGuardianObserver.daml @@ -118,7 +118,7 @@ testPublishRequiresOnlyOwner = do submitMustFail operator2 do createCmd (mkInitialCoreState operator2 guardianGovernance2 solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver2) - _ <- submitMulti [operator2, guardianGovernance2] [] do + _ <- submit (actAs operator2 <> actAs guardianGovernance2) do createCmd (mkInitialCoreState operator2 guardianGovernance2 solanaGovernanceChainId defaultGovernanceContract [devnetGuardian] guardianObserver2) pure () From 870a717334ab1ac954f79fcedd9ff81fb45cc0d6 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 18:41:43 -0700 Subject: [PATCH 4/5] canton: trim guardianObserver comments --- canton/core/daml/Wormhole/Core/Setup.daml | 5 ++--- canton/core/daml/Wormhole/Core/State.daml | 26 ++++++----------------- node/cmd/guardiand/node.go | 2 +- node/pkg/watchers/canton/config.go | 12 +++-------- 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/canton/core/daml/Wormhole/Core/Setup.daml b/canton/core/daml/Wormhole/Core/Setup.daml index f33d2f160f..da49096b77 100644 --- a/canton/core/daml/Wormhole/Core/Setup.daml +++ b/canton/core/daml/Wormhole/Core/Setup.daml @@ -48,9 +48,8 @@ mkInitialCoreState operator guardianGovernance govChain govContract initialGuard consumedGovernance = Set.empty -- | The emitter-id registry the operator creates alongside the 'CoreState' at --- setup. Emitter ids are allocated sequentially from this contract. It carries --- @guardianObserver@ as a field only (not a template observer) so 'ApproveEmitter' --- can thread it into each new 'Emitter'. +-- setup. Emitter ids are allocated sequentially; it also carries +-- @guardianObserver@ for 'ApproveEmitter' to thread into each 'Emitter'. mkInitialEmitterRegistry : Party -> Party -> EmitterRegistry mkInitialEmitterRegistry operator guardianObserver = EmitterRegistry with operator, guardianObserver, nextId = 0 diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index b245279462..4ef0aeea8e 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -90,7 +90,7 @@ template CoreState with operator : Party -- advances the singleton; no power over outcomes guardianGovernance : Party -- guardians' governance anchor (threshold party) - guardianObserver : Party -- read-only guardian observer (stakeholder, never signs) + guardianObserver : Party -- read-only guardian observer chainId : Int -- 72 (Canton) governanceChainId : Int -- 1 (Solana) governanceContract : Bytes32 -- governance emitter address @@ -107,11 +107,7 @@ template CoreState -- guardianGovernance's authority from the archived contract, so it signs only -- at genesis. signatory operator, guardianGovernance - -- Read-only guardian observer: an informee of every consuming exercise on - -- this contract (SubmitGovernanceVAA) and of each successor create, so - -- guardians watch guardian-set/fee/governance transitions without any - -- authority. Never a signatory or controller. The field propagates across - -- every `create st with ...` governance recreation automatically. + -- Lets guardians watch guardian-set / governance transitions. observer guardianObserver key guardianGovernance : Party maintainer key @@ -229,19 +225,14 @@ emitterAddressFor registrar owner emitterId = template EmitterRegistry with operator : Party - guardianObserver : Party -- carried (field only) so ApproveEmitter can thread it into new Emitters + guardianObserver : Party -- threaded into each new Emitter by ApproveEmitter nextId : Int where signatory operator - -- The registry carries guardianObserver as a FIELD only, NOT as a template - -- observer: guardians watch the attestation surface (Emitter/CoreState), not - -- id allocation (least privilege). The field lets ApproveEmitter thread the - -- party into each new Emitter without a second key resolution. key operator : Party maintainer key - -- Result carries guardianObserver so ApproveEmitter threads it into the new - -- Emitter from this single key resolution (no separate fetchByKey). + -- Returns guardianObserver for ApproveEmitter to stamp onto the new Emitter. choice AllocateEmitterId : (ContractId EmitterRegistry, Int, Party) controller operator do @@ -281,7 +272,7 @@ template Emitter with operator : Party owner : Party - guardianObserver : Party -- read-only guardian observer (stakeholder, never signs) + guardianObserver : Party -- read-only guardian observer emitterId : Int -- registry-allocated; key._3 emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation sequence : Int -- next sequence to assign @@ -292,12 +283,7 @@ template Emitter -- existing address (Canton's msg.sender analog). It also means the operator -- alone cannot archive an emitter (Archive needs all signatories). signatory operator, owner - -- Read-only guardian observer: because PublishMessage is a CONSUMING choice, - -- observers (stakeholders) are informees of the exercise, so guardianObserver - -- sees every PublishMessage exercise (and its result) plus the successor - -- Emitter create — the full message surface — over the update stream. It - -- never signs, so it confers no authority; the sequence-bump recreation - -- (`create this with ...`) preserves the field automatically. + -- Lets guardians watch PublishMessage exercises and their results. observer guardianObserver -- Non-unique key: stable across the sequence-bump churn so the owner can -- publish again by key (ExerciseByKeyCommand) without tracking contract-ids. diff --git a/node/cmd/guardiand/node.go b/node/cmd/guardiand/node.go index 38e8c95011..bb325f2785 100644 --- a/node/cmd/guardiand/node.go +++ b/node/cmd/guardiand/node.go @@ -427,7 +427,7 @@ func init() { cantonRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "cantonRPC", "Canton Ledger API v2 gRPC endpoint", "canton:5011", []string{""}) cantonPackageID = NodeCmd.Flags().String("cantonPackageID", "", "Canton wormhole-core Daml package id (empty matches any package version)") - cantonReadAsParty = NodeCmd.Flags().String("cantonReadAsParty", "", "Canton party to narrow the watcher's update stream to; production guardians set this to the read-only guardianObserver party. Empty observes all parties on the participant (devnet default)") + cantonReadAsParty = NodeCmd.Flags().String("cantonReadAsParty", "", "Canton party to narrow the watcher's update stream to; production guardians set the guardianObserver party. Empty observes all parties (devnet default)") solanaRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "solanaRPC", "Solana RPC URL (required)", "http://solana-devnet:8899", []string{"http", "https"}) fogoRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "fogoRPC", "Fogo RPC URL (required)", "http://solana-devnet:8899", []string{"http", "https"}) diff --git a/node/pkg/watchers/canton/config.go b/node/pkg/watchers/canton/config.go index 75d5bef757..4280ef6123 100644 --- a/node/pkg/watchers/canton/config.go +++ b/node/pkg/watchers/canton/config.go @@ -19,15 +19,9 @@ type WatcherConfig struct { // while preserving module/entity names). PackageID string // ReadAsParty optionally narrows the update stream to a single Canton party. - // For a production guardian this should be the read-only guardianObserver - // party (a template observer on the Emitter/CoreState attestation surface): - // it is a stakeholder of every PublishMessage exercise and CoreState - // transition, so reading as it observes the full message surface without any - // authority and without depending on the operator party id. Leaving it empty - // observes every party hosted on the participant; if the participant hosts - // only guardianObserver the two are equivalent, but setting the flag makes - // the intended trust boundary explicit (defense in depth). In devnet the - // sandbox is a single participant, so the wildcard default is used. + // Production guardians set this to the read-only guardianObserver party (an + // observer on the Emitter/CoreState attestation surface). Empty observes + // every party on the participant (the devnet default). ReadAsParty string } From db50e5346013c38911e6b9cb11c8ec036a9756fd Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 19:03:39 -0700 Subject: [PATCH 5/5] canton: add cspell words for guardianObserver docs informee, informees, runbook --- cspell-custom-words.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 9ee4e38469..46f4e2791a 100644 --- a/cspell-custom-words.txt +++ b/cspell-custom-words.txt @@ -302,3 +302,6 @@ pekko googleapis preimage builtins +informee +informees +runbook