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/canton/core/daml/Wormhole/Core/Setup.daml b/canton/core/daml/Wormhole/Core/Setup.daml index 3ff654c9d5..da49096b77 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,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. -mkInitialEmitterRegistry : Party -> EmitterRegistry -mkInitialEmitterRegistry operator = EmitterRegistry with operator, nextId = 0 +-- 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 d90b96ceac..4ef0aeea8e 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 chainId : Int -- 72 (Canton) governanceChainId : Int -- 1 (Solana) governanceContract : Bytes32 -- governance emitter address @@ -106,6 +107,8 @@ template CoreState -- guardianGovernance's authority from the archived contract, so it signs only -- at genesis. signatory operator, guardianGovernance + -- Lets guardians watch guardian-set / governance transitions. + observer guardianObserver key guardianGovernance : Party maintainer key @@ -221,18 +224,20 @@ emitterAddressFor registrar owner emitterId = -- owner, whose signature it lacks (see 'Emitter'). template EmitterRegistry with - operator : Party - nextId : Int + operator : Party + guardianObserver : Party -- threaded into each new Emitter by ApproveEmitter + nextId : Int where signatory operator key operator : Party maintainer key - choice AllocateEmitterId : (ContractId EmitterRegistry, Int) + -- Returns guardianObserver for ApproveEmitter to stamp onto the new Emitter. + 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 +255,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 +270,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 + 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 +283,8 @@ 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 + -- 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. 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..c22e4fdfbf --- /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) + _ <- submit (actAs operator2 <> actAs 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) 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 diff --git a/node/cmd/guardiand/node.go b/node/cmd/guardiand/node.go index 54be1a6a89..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", "", "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 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 ef10f1c577..4280ef6123 100644 --- a/node/pkg/watchers/canton/config.go +++ b/node/pkg/watchers/canton/config.go @@ -19,9 +19,9 @@ 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. + // 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 }