diff --git a/canton/README.md b/canton/README.md index 0c9324e9f3..55f78b98a6 100644 --- a/canton/README.md +++ b/canton/README.md @@ -182,18 +182,16 @@ template CoreState ``` **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. +(hence a stakeholder/informee, never a signatory or controller) on the +attestation surface — `CoreState` here, `Emitter` in §4.2. The ledger-model rule +that makes this sufficient: **informees of a _consuming_ exercise include the +contract's stakeholders**. `SubmitGovernanceVAA` and `PublishMessage` are both +consuming, so the observer sees every governance transition and the full message +surface; nonconsuming `ParseAndVerifyVAA` (§4.3) does not reach observers, which +is fine — it is a pure read. Adding an observer needs no authority and changes +nothing about who can create/exercise these contracts, so guardians watch what +they attest without entering Canton's confirmation path (§10). Scope is 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. @@ -202,41 +200,34 @@ replay-protected transitions. **Singleton co-signed by a governance party; authenticity is in the payload.** The `CoreState` is co-signed by the `operator` and a **`guardianGovernance`** -party. In production that is a **decentralized-namespace external party** -controlled by a k-of-n threshold of guardian keys — the same construction as -the Canton Network's DSO party — rotated at the topology layer without touching -this template (the party id, and thus every consumer's trust anchor, is stable -across guardian rotation). Two things follow: +party — in production a **decentralized-namespace external party** under a k-of-n +threshold of guardian keys (the Canton Network DSO construction), rotated at the +topology layer without touching this template (the party id, and thus every +consumer's trust anchor, is stable across rotation). Two things follow: - **The operator cannot forge the guardian set.** Because `guardianGovernance` is a signatory, a compromised operator cannot `create` a `CoreState` bearing - the real governance party — so any fetched or disclosed `CoreState` whose - `guardianGovernance` field is the known anchor party is **authentic by - construction**, however its cid was obtained. Consumers authenticate the - payload, never the resolution path (§4.7). An operator puppet could create a - `CoreState` under a *different* governance party — but its payload names the - wrong parties and no consumer trusts it. **Pinning `operator` is weaker than - pinning `guardianGovernance`, and the two are not interchangeable:** a - compromised operator (a single hot key) *can* co-sign a `CoreState` whose - `operator` field is genuine but whose `guardianGovernance`/`feeRecipient` it + the real governance party, so any `CoreState` whose `guardianGovernance` field + is the known anchor is **authentic by construction**, however its cid was + obtained — consumers authenticate the payload, never the resolution path + (§4.7). **Pinning `operator` is weaker than pinning `guardianGovernance`:** a + compromised operator (a single hot key) can co-sign a `CoreState` whose + `operator` is genuine but whose `guardianGovernance`/`feeRecipient` it controls, so an `operator`-only pin accepts a shadow `CoreState`. For the fee - path — where `guardianGovernance` *is* the fee-custody anchor — only the - `guardianGovernance` pin is sufficient (see below). + path — where `guardianGovernance` _is_ the fee-custody anchor — only the + `guardianGovernance` pin suffices (see below). - **Governance stays low-friction.** `guardianGovernance` actively signs only at - **genesis**; each `SubmitGovernanceVAA` transition inherits its authority from - the archived contract (the same authority-propagation as the `Emitter` owner - through `PublishMessage`), so the operator submits governance alone. "Exactly - one live `CoreState`" is still behavioral (operator + governance create one at - setup), but the set's *contents* are now anchored. - -Per-emitter **sequence numbers are stored in each `Emitter`** (§4.2), not in -`CoreState`. This is deliberate: in Canton, a contract's choice can only be -exercised by a stakeholder of that contract, and the emitter owner is _not_ a + **genesis**; each `SubmitGovernanceVAA` inherits its authority from the + archived contract (as the `Emitter` owner does through `PublishMessage`), so + the operator submits governance alone. "Exactly one live `CoreState`" is still + behavioral, but the set's _contents_ are now anchored. + +Per-emitter **sequence numbers live in each `Emitter`** (§4.2), not `CoreState`: +a choice can only be exercised by a stakeholder, and the emitter owner is not a stakeholder of the operator-owned `CoreState`. Keeping the sequence in the -`Emitter` (where the owner _is_ a stakeholder) lets the owner publish — and -advance its own sequence — **without the operator's authority**, preserving -EVM's permissionless `publishMessage`. `CoreState` therefore holds only shared -config and governance state. +`Emitter` lets the owner publish — and advance its own sequence — **without the +operator's authority**, preserving EVM's permissionless `publishMessage`. +`CoreState` holds only shared config and governance state. > **Why an `operator` party?** Canton requires every contract to have a > signatory. The `operator` is a designated, well-known party (configured at @@ -256,7 +247,7 @@ An integrator first registers an emitter — **directly, with no operator involvement**. The operator runs an `EmitterRegistry` (created at setup); the requester exercises `RegisterEmitter` on it, holding only the registry's disclosure (§4.7). The flexible controller brings the requester's signature; -the operator's co-signature on the created `Emitter` is *inherited* from the +the operator's co-signature on the created `Emitter` is _inherited_ from the registry's signatory — the same authority-inheritance that powers `VerifyAndConsumeVAA`. Registration is permissionless (EVM/Sui parity: anyone may emit); the choice is consuming, so racing registrations contend on the @@ -381,64 +372,52 @@ so the watcher reads it from the transaction's **ledger effective time** **Fees.** Whitepaper 0004 requires a fee in the chain's native token. Fees are paid via the **Canton Network Token Standard** (CIP-0056, V1 — the -`splice-api-token-*-v1` interface DARs vendored under the `canton/dars/` directory; the old -splice-wallet payment requests are deprecated), so the bridge works with -Canton Coin or any standard token, chosen at genesis -(`CoreState.feeInstrument` — no governance action covers it; migrating -instruments is a genesis-class ceremony): +`splice-api-token-*-v1` interface DARs under `canton/dars/`), so the bridge works +with Canton Coin or any standard token, chosen at genesis +(`CoreState.feeInstrument`, no governance action covers it): -- **The payer is the submitter**, so the bridge only *consumes* allocations, +- **The payer is the submitter**, so the bridge only _consumes_ allocations, never issues wallet-facing `AllocationRequest`s (future UX work). The payer - locks funds against the bridge's settlement via the token registry's - `AllocationFactory` (its wallet/CLI plus the registry's CIP-0056 OpenAPI, - which supplies the `ExtraArgs` choice context and any registry disclosures - — the same untrusted-for-safety, liveness-only role as our own disclosure - service, §4.7), with the bridge convention `executor = sender = payer`, + locks funds via the token registry's `AllocationFactory` (its wallet/CLI plus + the registry's CIP-0056 OpenAPI, which supplies the `ExtraArgs` choice context + and registry disclosures — untrusted-for-safety, liveness-only, like our own + disclosure service, §4.7), with the convention `executor = sender = payer`, `receiver = feeRecipient`. - The charging choice validates the allocation — receiver, instrument, amount - (`≥` fee: the **entire leg amount transfers**, allocations are - all-or-nothing, so overpayment is deliberate padding against fee-raise - races), sender, and the **instrument admin's signature** (views are - template-computed and forgeable; a signature is not — authenticate - payloads, never resolution paths) — then exercises - `Allocation_ExecuteTransfer` **in the same transaction** as the + (`≥` fee: the **entire leg amount transfers**, allocations are all-or-nothing, + so overpayment is deliberate padding against fee-raise races), sender, and the + **instrument admin's signature** (views are forgeable, a signature is not) — + then exercises `Allocation_ExecuteTransfer` **in the same transaction** as the publish/registration/claim. Executing consumes the allocation: one-shot by - activeness, no replay bookkeeping. Fees are `Int`s in **10^-10 token - units** (exactly Decimal's last digit — every fee is exactly - representable). + activeness, no replay bookkeeping. Fees are `Int`s in **10^-10 token units** + (exactly Decimal's last digit — every fee exactly representable). - `Allocation_ExecuteTransfer` needs sender ∧ receiver ∧ executor authority jointly, and the receiver is the fee recipient — so all charging routes through `CoreState` choices (`ChargeMessageFee` / `ChargeFee`), where the - recipient's authority is inherited. `PublishMessage` therefore references - the `CoreState` on every publish (EVM parity: the current `messageFee` is - read each call; the cid churns only on governance actions — a stale cid - fails closed and is re-resolved). The fee is charged to `payer`, a choice - argument that equals `owner` for a direct publish but is named explicitly - when an app publishes on a user's behalf through an `owner`-owned emitter - (NTT §10): `payer` co-controls the publish, so it cannot be a third party - who did not authorize spending its own funds. Registries charge their own - operator-set `registrationFee`/`claimFee` through the same choice; at fee 0 - they skip the `CoreState` entirely. Before charging, every call site pins the - `CoreState`'s authenticity by exercising `GetGuardianGovernance` and checking - it against the `guardianGovernance` committed on the emitter/registry — - **pinning the fee-custody anchor, not merely the `operator`** — so a forged - `CoreState` cannot redirect the fee (see the custody bullet). + recipient's authority is inherited. `PublishMessage` therefore references the + `CoreState` on every publish (EVM parity: the current `messageFee` is read each + call; a stale cid fails closed and is re-resolved). The fee is charged to + `payer`, which equals `owner` for a direct publish but is named explicitly when + an app publishes on a user's behalf (NTT §10); `payer` co-controls the publish, + so it cannot be a third party who did not authorize spending its funds. + Registries charge their `registrationFee`/`claimFee` the same way, skipping the + `CoreState` entirely at fee 0. Before charging, every call site pins the + `CoreState` by exercising `GetGuardianGovernance` against the + `guardianGovernance` committed on the emitter/registry — **the fee-custody + anchor, not merely the `operator`** — so a forged `CoreState` cannot redirect + the fee (see the custody bullet). - **Custody is governance-controlled**: `feeRecipient` is the - `guardianGovernance` threshold party, so accrued fees move only via the - k-of-n guardian ceremony — the operator never touches them (the Canton - analog of "fees sit in the bridge contract"). This holds **even against a - compromised operator hot key**: because each charge pins `guardianGovernance` - (which a single operator key cannot forge — it is the guardians' k-of-n - external party) rather than `operator`, an operator that co-signs a shadow - `CoreState` under a throwaway `guardianGovernance`/`feeRecipient` it controls - is rejected at the pin before any funds move. A `TransferFees` governance - VAA mints an on-ledger `FeeWithdrawalAuthorization` (signed by - guardianGovernance, authority inherited from the consumed `CoreState`) - recording the amount, the VAA's 32-byte recipient, and the VAA hash — the - artifact the guardians' custody policy matches when threshold-signing the - actual outbound token transfer. The enforcement of "only per authorization" - lives in gg key custody, the same trust plane as the guardian keys - themselves. + `guardianGovernance` threshold party, so accrued fees move only via the k-of-n + guardian ceremony — the operator never touches them (the Canton analog of "fees + sit in the bridge contract"). This holds **even against a compromised operator + hot key**: each charge pins `guardianGovernance` (unforgeable by a single + operator key) rather than `operator`, so an operator co-signing a shadow + `CoreState` under a throwaway `feeRecipient` is rejected before any funds move. + A `TransferFees` VAA mints an on-ledger `FeeWithdrawalAuthorization` (signed by + guardianGovernance) recording the amount, the VAA's 32-byte recipient, and the + VAA hash — the artifact the guardians' custody policy matches when + threshold-signing the outbound transfer. "Only per authorization" is enforced + in gg key custody, the same trust plane as the guardian keys. - Token Standard **V2** (CIP-0112) is approved but not yet on mainnet; it arrives as a parallel `-v2` interface family, and `Wormhole.Core.Fees.chargeFee` is the single seam where support would be @@ -468,7 +447,7 @@ provided key hashes to the guardian address stored in the set. is the sole controller — `operator` never needs to be an active party. The choice is `nonconsuming` and does no create/archive at all, so nothing in its body needs `operator`'s authority for any structural reason (contrast -`SubmitGovernanceVAA` below, which *is* consuming and creates a successor +`SubmitGovernanceVAA` below, which _is_ consuming and creates a successor `CoreState` genuinely needing `guardianGovernance`'s co-signature). Any external protocol — the token bridge, NTT, anything else — can verify a VAA as itself. The one real requirement is visibility, not authorization: a non-stakeholder @@ -554,7 +533,7 @@ built on key lookups — this module uses no contract keys at all. Instead, each consumer's consumed-digest set is a **prefix trie of leaf contracts** (`Replay.daml`). A node covers a hex `prefix` and stores the set -of digest *suffixes* consumed under it: +of digest _suffixes_ consumed under it: ```haskell template ReplayNode @@ -579,14 +558,14 @@ stored. **The invariant: a consumer's active nodes partition the digest space global consumption truth. **Why this is sound where keys were not.** "Not consumed" became a positive -statement about one *named* contract: fetch the covering node, check absence, +statement about one _named_ contract: fetch the covering node, check absence, consume it. Every confirming participant validates the node's activeness — a stale cid conflicts and aborts; the current covering node provably contains or excludes the digest. Soundness is **submitter-independent**: any submitter with a disclosure of the current covering node gets a correct check, and a wrong or stale node can only fail the transaction, never replay. Where the key design -failed *open* (a lookup that misses waves a replay through), the trie fails -*closed* (an archived subspace has no covering node — nothing verifies). +failed _open_ (a lookup that misses waves a replay through), the trie fails +_closed_ (an archived subspace has no covering node — nothing verifies). `VerifyAndConsumeVAA` (nonconsuming, on `CoreState`, flexible controller `consumer`) takes the covering node's cid, verifies the VAA (§5), binds the @@ -597,15 +576,15 @@ Properties: - **Nobody can touch someone else's scope; your own scope is your own problem.** Archival alone only bricks (fail-closed); the replay vector is - node *re-creation*. Nodes are co-signed, so no third party can archive or + node _re-creation_. Nodes are co-signed, so no third party can archive or re-create them (splits inherit both signatures from the archived parent — the operator is not involved per consume), and roots come only from the operator's `ReplayRootRegistry` — a stateless, never-churning anchor whose - nonconsuming `ClaimReplayRoot` lends the operator's *inherited* signature + nonconsuming `ClaimReplayRoot` lends the operator's _inherited_ signature to a consumer-submitted claim (the `RegisterEmitter` pattern: no crank; the operator multisig signs nothing after genesis). Claiming a root for a consumer requires **that consumer's authority**, so nobody can fork another - app's replay scope. There is deliberately *no* one-root-per-consumer + app's replay scope. There is deliberately _no_ one-root-per-consumer enforcement: maintaining a single trie is the consumer's own responsibility (its disclosure service defines which trie its consumes resolve into, and detects forks — more than one covering node — for free). This is EVM @@ -619,7 +598,7 @@ Properties: and the operator ~300 B of storage per worthless root; no shared contract grows, nothing bricks. - **Scoping and contention.** Tries are per consumer party: different apps - consume the same VAA independently. Consumes racing on the *same node* + consume the same VAA independently. Consumes racing on the _same node_ conflict — exactly one commits (validator-checked, regardless of submitter); the loser re-resolves the covering node and retries. A blind retry of a consume that actually committed fails on membership ("digest already @@ -643,7 +622,7 @@ service indexes the consumer's active nodes by prefix (following the the covering node's cid plus its explicit disclosure — alongside the `CoreState` disclosure it already serves (§4.3). It is **untrusted for safety**: the worst it can do is serve a wrong or stale node, which fails the -transaction. It is trusted for *liveness* only, like any RPC endpoint. §4.7 +transaction. It is trusted for _liveness_ only, like any RPC endpoint. §4.7 aggregates every resolution the service must provide. (Go implementation is future work; the Daml tests simulate it with a script helper, `Test.TestReplay.coveringNode`, which also asserts the partition invariant on @@ -654,8 +633,8 @@ every use.) (test package only): the end user herself exercises `Redeem` on the app contract with two disclosures attached (CoreState + covering node) and nothing else — no read grants, no `readAs`, no relayer queue. The manager's -*authority* for the consume is inherited from the app contract's signatory; -its *visibility* is simply not needed. `testIntegratorRedeem` pins the +_authority_ for the consume is inherited from the app contract's signatory; +its _visibility_ is simply not needed. `testIntegratorRedeem` pins the inversion of the key-based design's failure mode: replay attempts by arbitrary submitters fail on activeness (stale node) or membership (fresh node) — never succeed. @@ -666,7 +645,7 @@ With contract keys gone (§1), every contract reference is a **contract-id**. Two distinct needs hide behind "how do I find the contract", and they have different answers: -- **Cid resolution** — *what is the current cid of X?* Stakeholders get this +- **Cid resolution** — _what is the current cid of X?_ Stakeholders get this for free: a consuming exercise delivers the successor's `CreatedEvent` to every stakeholder's ACS, so the operator tracks its registries and the `CoreState`, an emitter owner tracks its `Emitter`, a consumer tracks its @@ -675,21 +654,20 @@ different answers: attach the explicit-disclosure blob of every contract its transaction references, or its participant cannot construct the transaction at all. -The **disclosure service** is the single off-ledger component that provides -both to parties that need them: an index over the relevant contracts — fed by -the Ledger API update stream / ACS of a party that sees them — answering -"current cid (+ disclosure blob) for X". The complete inventory of resolutions -it must serve: - -| Contract | Index | Needed by | Disclosure attachment? | Churn | -| ----------------------- | ---------------------------------- | ------------------------------------------------------------- | ----------------------------------------------- | ---------------------------- | -| `CoreState` | the one true instance | §4.3 verify, §4.6 consume, and EVERY publish / fee'd onboarding (§4.2) | yes — every non-stakeholder submitter (incl. all publishers) | per governance action (rare) | -| `ReplayNode` | (consumer, prefix covering digest) | every `VerifyAndConsumeVAA` / integrator redeem | yes for non-stakeholders (e.g. end users); the consumer itself only needs the cid | every consume under that node | -| `EmitterRegistry` | operator | `RegisterEmitter` | yes — the requester submits | per emitter registration; recreated on operator fee change | -| `ReplayRootRegistry` | operator | `ClaimReplayRoot` | yes — the consumer submits (cacheable blob) | only on operator fee change | -| `Emitter` | (operator, owner, emitterId) | `PublishMessage` | no — owner submits, own ACS (but see `CoreState` row) | per publish | -| token-registry context | the fee instrument's registry | any fee'd choice (§4.2): `ExtraArgs` + registry disclosures | yes — from the REGISTRY'S own CIP-0056 OpenAPI, not this service | registry-defined | -| app contracts (e.g. `ExampleIntegrator`) | app-defined | the app's own choices | app's concern (its users are observers) | app-defined | +The **disclosure service** is the single off-ledger component that provides both +to the parties that need them: an index over the relevant contracts — fed by the +update stream / ACS of a party that sees them — answering "current cid (+ +disclosure blob) for X". The complete inventory it must serve: + +| Contract | Index | Needed by | Disclosure attachment? | Churn | +| ---------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `CoreState` | the one true instance | §4.3 verify, §4.6 consume, and EVERY publish / fee'd onboarding (§4.2) | yes — every non-stakeholder submitter (incl. all publishers) | per governance action (rare) | +| `ReplayNode` | (consumer, prefix covering digest) | every `VerifyAndConsumeVAA` / integrator redeem | yes for non-stakeholders (e.g. end users); the consumer itself only needs the cid | every consume under that node | +| `EmitterRegistry` | operator | `RegisterEmitter` | yes — the requester submits | per emitter registration; recreated on operator fee change | +| `ReplayRootRegistry` | operator | `ClaimReplayRoot` | yes — the consumer submits (cacheable blob) | only on operator fee change | +| `Emitter` | (operator, owner, emitterId) | `PublishMessage` | no — owner submits, own ACS (but see `CoreState` row) | per publish | +| token-registry context | the fee instrument's registry | any fee'd choice (§4.2): `ExtraArgs` + registry disclosures | yes — from the REGISTRY'S own CIP-0056 OpenAPI, not this service | registry-defined | +| app contracts (e.g. `ExampleIntegrator`) | app-defined | the app's own choices | app's concern (its users are observers) | app-defined | Two properties make this safe to outsource: @@ -699,7 +677,7 @@ Two properties make this safe to outsource: cid was found: `CoreState` by its signatory anchor fields (§4.1), `ReplayNode` by the (consumer, operator) binding inside `VerifyAndConsumeVAA` plus the app-side operator pin (see `ExampleIntegrator.Redeem`). The service - is trusted for *liveness* only, like any RPC endpoint. + is trusted for _liveness_ only, like any RPC endpoint. - **One reader suffices.** A service reading as the operator (a stakeholder of `CoreState`, both registries, and — as co-signatory — every consumer's trie) can serve the whole table; integrators can equally run their own for their @@ -1090,7 +1068,7 @@ locally. That step is theoretical here by construction, not by omission: the decentralized namespace is guardian-owned, so SVs never approve it — CN "registration" is just (a) onboarding a participant to the CN global synchronizer (one-time, SV-sponsored validator onboarding) and (b) submitting -the *same* topology transactions this script builds to that synchronizer instead +the _same_ topology transactions this script builds to that synchronizer instead of the sandbox's, where they are sequenced and seen by all participants. The script is synchronizer-target-parameterized (`GG_SYNCHRONIZER_ALIAS`, `GG_CANTON_CONSOLE_CONFIG`) for exactly that reason, and the local run proves @@ -1155,7 +1133,7 @@ template `observer` on `Emitter`/`CoreState`, it is an informee of every 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 +surface only** (§4.1, §4.2): the observer is _not_ a stakeholder of `EmitterRegistry` allocations, replay tries, 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 @@ -1169,7 +1147,7 @@ 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* +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). @@ -1205,9 +1183,9 @@ Crucially, it also **cannot forge the guardian set** used for inbound verification: `CoreState` is co-signed by the `guardianGovernance` threshold party (§4.1), so the operator can neither mutate the real `CoreState` nor fabricate one whose payload names the governance anchor consumers trust. This closes -the one gap that observation-only hosting does *not* cover — inbound state +the one gap that observation-only hosting does _not_ cover — inbound state integrity — by moving it from "trust the single operator" to "trust the k-of-n -governance party." Compromising *that* is a threshold compromise, i.e. genuinely +governance party." Compromising _that_ is a threshold compromise, i.e. genuinely takeover-class, rather than a single-key bar. Residual inbound corruption (bugs in a verification path, not forged state) can still only damage Canton-local state — the same position Wormhole holds on every chain, where a compromised chain wrecks @@ -1248,14 +1226,14 @@ human decision; see Open Questions. id. Note the `CoreState` singleton count ("exactly 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.** *Mechanism resolved:* the dedicated read-only +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 + 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 @@ -1263,7 +1241,7 @@ human decision; see Open Questions. 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). -5. **Native fees.** *Mechanism resolved:* CIP-0056 (Token Standard V1) +5. **Native fees.** _Mechanism resolved:_ CIP-0056 (Token Standard V1) allocations, consumed atomically inside `PublishMessage` / `RegisterEmitter` / `ClaimReplayRoot`, with fee custody under the `guardianGovernance` threshold party and `TransferFees` minting on-ledger withdrawal diff --git a/canton/core/daml/Wormhole/Core/Bytes.daml b/canton/core/daml/Wormhole/Core/Bytes.daml index 56724b4457..cbf7c329cb 100644 --- a/canton/core/daml/Wormhole/Core/Bytes.daml +++ b/canton/core/daml/Wormhole/Core/Bytes.daml @@ -39,9 +39,8 @@ sliceFrom : Int -> Bytes -> Bytes sliceFrom start b = T.substring (start * 2) (T.length b - start * 2) b -- | Value of a single lowercase hex digit, given as a Unicode code point. --- Code-point arithmetic rather than an assoc-list lookup: under Daml's strict --- evaluation a @where@-bound 16-entry table would be rebuilt — and scanned --- linearly — once per nibble. +-- Code-point arithmetic, not an assoc-list lookup: under Daml's strict +-- evaluation a @where@-bound table would be rebuilt and scanned once per nibble. hexDigitValue : Int -> Int hexDigitValue cp | cp >= cp0 && cp <= cp9 = cp - cp0 @@ -109,38 +108,34 @@ leftPadTo numBytes b = else mconcat (replicate (numBytes - bl) "00") <> normalizeHex b -- | A length-prefixed byte segment: a 4-byte big-endian length followed by the --- bytes themselves. A distinct type from raw 'Bytes' (Daml has no @newtype@, so --- it is a single-constructor @data@) so a self-delimiting segment can't be --- mistaken for, or concatenated as, unprefixed bytes. Concatenating two --- variable-length fields bare is ambiguous ("ab"‖"c" vs "a"‖"bc"); the length --- prefix makes the address preimage injective. Use 'unLenPrefixed' to get the --- raw bytes back for hashing. +-- bytes. A distinct type from raw 'Bytes' (a single-constructor @data@, Daml +-- having no @newtype@) so it can't be concatenated as unprefixed bytes: +-- concatenating variable-length fields bare is ambiguous ("ab"‖"c" vs "a"‖"bc"), +-- and the length prefix makes the address preimage injective. 'unLenPrefixed' +-- recovers the raw bytes for hashing. data LenPrefixed = LenPrefixed { unLenPrefixed : Bytes } deriving (Eq, Show) lenPrefixed : Bytes -> LenPrefixed lenPrefixed b = LenPrefixed (intToBytesN (byteLength b) 4 <> b) --- | Typed inputs to the address preimage. Daml has no @newtype@, so these are --- single-constructor @data@ types. Distinct types for the three text fields (and --- the id) mean 'derivedAddressFromText' can't have its arguments transposed — --- swapping @registrar@ and @owner@ would otherwise silently derive a different --- but perfectly valid-looking address, a security-relevant mistake. +-- | Typed inputs to the address preimage (single-constructor @data@, Daml having +-- no @newtype@). Distinct types stop 'derivedAddressFromText' from having its +-- arguments transposed — swapping @registrar@ and @owner@ would otherwise +-- silently derive a different but valid-looking address, a security bug. data DomainTag = DomainTag Text deriving (Eq, Show) data RegistrarText = RegistrarText Text deriving (Eq, Show) data OwnerText = OwnerText Text deriving (Eq, Show) data RegistryId = RegistryId Int deriving (Eq, Show) --- | Canonical 32-byte registry address, derived from the components of a --- registry-allocated identity: +-- | Canonical 32-byte registry address of a registry-allocated identity: -- -- > keccak256( utf8(tag) ‖ lp(utf8(registrar)) ‖ lp(utf8(owner)) ‖ uint64be(id) ) -- --- where @lp@ is the 4-byte big-endian length prefix ('lenPrefixed'). The Text --- form is split out from 'derivedAddress' so tests can pin the encoding against --- fixed party strings (allocated 'Party' ids carry nondeterministic --- fingerprints). The Go watcher recomputes the same value from the --- 'WormholeMessage' fields; the encoding is pinned across both languages by a +-- where @lp@ is the 4-byte big-endian length prefix ('lenPrefixed'). Split out +-- from 'derivedAddress' so tests can pin the encoding against fixed party strings +-- (allocated 'Party' ids carry nondeterministic fingerprints). The Go watcher +-- recomputes the same value; the encoding is pinned across both languages by a -- shared test vector. derivedAddressFromText : DomainTag -> RegistrarText -> OwnerText -> RegistryId -> Bytes32 derivedAddressFromText (DomainTag domainTag) (RegistrarText registrarText) (OwnerText ownerText) (RegistryId id) = @@ -150,11 +145,9 @@ derivedAddressFromText (DomainTag domainTag) (RegistrarText registrarText) (Owne <> intToBytesN id 8)) -- | Address of the registry-allocated identity @(registrar, owner, id)@ under --- @domainTag@. The @owner@ is part of the preimage: reproducing an existing --- address requires the owner's signature at create time (the owner is a --- signatory of the identity contract), so a compromised registrar cannot --- impersonate an existing identity — it can only mint a duplicate under a --- different owner, which derives a different, untrusted address. +-- @domainTag@. The @owner@ is in the preimage and co-signs the identity contract, +-- so a compromised registrar cannot impersonate an existing identity — only mint +-- a duplicate under a different owner, deriving a different, untrusted address. derivedAddress : DomainTag -> Party -> Party -> RegistryId -> Bytes32 derivedAddress domainTag registrar owner id = derivedAddressFromText domainTag (RegistrarText (partyToText registrar)) (OwnerText (partyToText owner)) id diff --git a/canton/core/daml/Wormhole/Core/Fees.daml b/canton/core/daml/Wormhole/Core/Fees.daml index 1f5be036f1..3b3b0748b6 100644 --- a/canton/core/daml/Wormhole/Core/Fees.daml +++ b/canton/core/daml/Wormhole/Core/Fees.daml @@ -1,50 +1,26 @@ --- | Fee collection via the Canton Network Token Standard (CIP-0056, V1). +-- | Fee collection via the Canton Network Token Standard (CIP-0056 V1). -- --- The bridge charges fees by CONSUMING token-standard 'Allocation's: the payer --- locks funds against our settlement via the token registry's --- @AllocationFactory@ (off-ledger, its wallet/CLI plus the registry's CIP-0056 --- OpenAPI, which supplies the 'ExtraArgs' choice context and any registry --- disclosures), and the charging choice exercises --- 'Allocation_ExecuteTransfer' — payment and effect in ONE transaction. In --- every bridge flow the payer IS the submitter, so the bridge never implements --- the wallet-facing @AllocationRequest@ interface (future UX work); it only --- validates and executes allocations handed to it. +-- Fees are charged by consuming a payer-supplied 'Allocation' via +-- 'Allocation_ExecuteTransfer' (payment + effect in one transaction); the +-- bridge only validates/executes allocations, never issuing the +-- wallet-facing @AllocationRequest@ side. -- --- AUTHORITY: 'Allocation_ExecuteTransfer' requires the JOINT authority of the --- leg's sender, the leg's receiver, and the settlement executor. Bridge --- convention: @executor = sender = payer@, @receiver = feeRecipient@ (the --- guardianGovernance party — see 'Wormhole.Core.State.CoreState'). The --- receiver's authority exists in exactly one place: inherited inside a --- 'CoreState' choice (it is a signatory), which is why ALL charging routes --- through 'ChargeMessageFee' / 'ChargeFee' there. This also blocks theft --- structurally: a foreign allocation's sender is a required controller the --- transaction never has. +-- 'Allocation_ExecuteTransfer' needs the sender/receiver/executor's joint +-- authority: @executor = sender = payer@, @receiver = feeRecipient@ +-- (guardianGovernance). The receiver's authority is inherited from +-- 'CoreState' (a signatory), so every charge routes through +-- 'ChargeMessageFee'/'ChargeFee' there, and a foreign sender can never be a +-- controller — blocking theft structurally, PROVIDED 'CoreState' is +-- authentic. Callers pin 'GetGuardianGovernance' against their own committed +-- anchor before charging, so a compromised operator can't redirect fees via +-- a shadow CoreState under a gg it controls. -- --- The "blocks theft structurally" guarantee presupposes an AUTHENTIC --- 'CoreState' — a rogue one routes @receiver = feeRecipient@ to an --- attacker-controlled party. Authenticity is now pinned by the caller before --- every charge: each call site ('PublishMessage', 'RegisterEmitter', --- 'ClaimReplayRoot') exercises 'Wormhole.Core.State.GetGuardianGovernance' and --- checks it against the @guardianGovernance@ committed on the --- emitter/registry. Pinning @guardianGovernance@ (not just the operator) --- resists a compromised operator: the operator alone does not determine --- @feeRecipient@, so a shadow 'CoreState' the operator co-signs under a --- throwaway gg is rejected before any funds move. +-- Allocation views are forgeable; authenticity is the instrument admin's +-- signature on the allocation, checked in 'chargeFee'. -- --- AUTHENTICITY: allocation views are template-computed and forgeable — a --- hostile template can claim any instrument while moving nothing. The pin is --- the instrument admin's SIGNATURE on the allocation contract (amulet --- allocations are DSO-co-signed), checked in 'chargeFee': payloads are --- authenticated, never resolution paths. --- --- DENOMINATION: fees are 'Int's in units of 10^-10 tokens — exactly the last --- digit of Daml's @Decimal@ (Numeric 10), so every fee is exactly --- representable and 'feeToDecimal' is exact for all inputs. The --- governance-set @messageFee@ (frozen 32-byte wire format) shares this unit. --- --- Token Standard V2 (CIP-0112) is approved but not yet on mainnet; it ships --- as a parallel @-v2@ interface family, and this module is the single seam --- where support would be added. +-- Fees are 'Int's in 10^-10 token units (Decimal's last digit — exact by +-- construction); @messageFee@ shares this unit. Token Standard V2 (CIP-0112) +-- isn't on mainnet yet; this module is the seam for it. module Wormhole.Core.Fees where import DA.Optional (isNone) diff --git a/canton/core/daml/Wormhole/Core/Replay.daml b/canton/core/daml/Wormhole/Core/Replay.daml index 285cf7611d..5d6d22ce4e 100644 --- a/canton/core/daml/Wormhole/Core/Replay.daml +++ b/canton/core/daml/Wormhole/Core/Replay.daml @@ -1,62 +1,45 @@ --- | Per-consumer replay protection for integrators (token bridge / NTT), --- stored as a PREFIX TRIE of consumed VAA digests — leaf contracts only. +-- | Per-consumer replay protection for integrators (token bridge / NTT): a +-- PREFIX TRIE of consumed VAA digests, leaf contracts only. -- -- Governance replay protection lives in 'Wormhole.Core.State.CoreState' as a --- @Set@ of consumed digests — fine there, because governance actions are rare --- and already churn the singleton. App VAAs need a structure that scales and, --- crucially, one whose absence check does NOT rely on contract keys: on --- Daml-LF 2.3 key lookups resolve once, on the submitting participant, against --- the submission's readers, and a negative result is a pinned validation input --- no confirmer re-checks — negative lookups fail OPEN. This module uses no --- keys at all. +-- @Set@ — fine there, since governance actions are rare. App VAAs need a +-- structure that scales and whose absence check does NOT rely on contract keys: +-- on Daml-LF 2.3 key lookups resolve on the submitting participant against the +-- submission's readers, and a negative result no confirmer re-checks fails OPEN. +-- This module uses no keys at all. -- --- THE TRIE: a 'ReplayNode' holds a hex @prefix@ and the set of @consumed@ --- digest SUFFIXES under that prefix (digests are 64 lowercase hex nibbles; a --- node at depth d stores 64−d-nibble suffixes). A consumer's trie starts as a --- single root (empty prefix, empty set). When a node reaches @splitThreshold@ --- entries, the next consume splits it into 16 children — one per nibble, --- including empty ones — bucketing the suffixes by first nibble (which is --- dropped). Internal nodes are never stored. +-- THE TRIE: a 'ReplayNode' holds a hex @prefix@ and the set of consumed digest +-- SUFFIXES under it (digests are 64 lowercase hex nibbles; a node at depth d +-- stores 64−d-nibble suffixes). A trie starts as a single root (empty prefix, +-- empty set). When a node reaches @splitThreshold@ entries, the next consume +-- splits it into 16 children — one per nibble, empty ones included — bucketing +-- the suffixes by their (dropped) first nibble. Internal nodes are never stored. +-- INVARIANT: a consumer's active nodes PARTITION the digest space — every digest +-- has exactly one covering node. The root covers everything, and a split +-- replaces one node by 16 children that exactly tile its subspace, atomically. -- --- INVARIANT: the active nodes of one consumer's trie PARTITION the digest --- space — every digest has exactly one covering node (a node whose prefix is a --- prefix of the digest). The vetted operations preserve it: the root covers --- everything, and a split replaces one node by 16 children that exactly tile --- its subspace, atomically. --- --- WHY THIS IS SOUND WHERE KEYS WERE NOT: "digest not consumed" becomes a --- positive statement about one NAMED contract — fetch the covering node and --- check the suffix is absent. 'ConsumeDigest' is consuming, so every confirmer --- validates the node's activeness: a stale cid conflicts and aborts, and the --- current covering node provably contains or excludes the digest. Soundness is --- SUBMITTER-INDEPENDENT: any submitter with a disclosure of the current --- covering node gets a correct check; a wrong or stale node can only fail the --- transaction, never replay. Archiving a node (both signatories) merely BRICKS --- its subspace — no covering node, nothing verifies — fail-closed, the --- opposite of the key model. +-- WHY THIS IS SOUND WHERE KEYS WERE NOT: "digest not consumed" is a positive +-- statement about one NAMED contract — fetch the covering node, check the suffix +-- is absent. 'ConsumeDigest' is consuming, so every confirmer validates the +-- node's activeness: a stale cid conflicts and aborts. Soundness is +-- submitter-independent, and archiving a node merely BRICKS its subspace +-- (fail-closed) — the opposite of the key model. -- -- The replay vector is therefore node RE-CREATION. Against THIRD PARTIES the --- signatory design closes it: the @operator@ co-signs every node (inherited --- from the 'ReplayRootRegistry' anchor at the root, from the archived parent --- at splits — the operator never signs per consume), and claiming a root for --- a consumer requires that consumer's authority, so nobody can archive, --- re-create, or fork someone ELSE'S replay scope. Against the consumer --- ITSELF there is deliberately no on-ledger defense: a consumer can mint --- parallel roots for its own scope (see 'ReplayRootRegistry'), which is --- self-harm — replay protection is the consumer's own safety property, and a --- compromised consumer key defeats the app more directly anyway. Same class --- as the irreducible signatory remainder: signatories can always unmake (or --- here, duplicate) their own state. +-- signatory design closes it: the @operator@ co-signs every node (inherited from +-- the 'ReplayRootRegistry' anchor at the root, from the archived parent at splits +-- — never per consume), and claiming a root requires the consumer's authority, +-- so nobody can archive, re-create, or fork someone ELSE'S scope. Against the +-- consumer ITSELF there is deliberately no defense: minting parallel roots is +-- self-harm (see 'ReplayRootRegistry'), the same class as any signatory being +-- able to unmake its own state. -- --- OFF-LEDGER: consumers (and their users) locate the covering node for a --- digest via a disclosure service — an ACS index over (consumer, prefix) that --- serves the node's contract-id + explicit disclosure. It is untrusted for --- safety: the worst it can do is serve a wrong or stale node, which fails. --- RETRY PROTOCOL: racing consumes on the same node conflict and exactly one --- commits; on rejection, re-resolve the covering node (the cid churns on every --- consume) and resubmit. A blind retry of a consume that actually committed --- hits the membership check and fails "digest already consumed" — a clean --- idempotency signal. +-- OFF-LEDGER: consumers locate the covering node via a disclosure service — an +-- ACS index over (consumer, prefix), untrusted for safety (a wrong or stale node +-- only fails). RETRY: racing consumes on one node conflict and exactly one +-- commits; on rejection, re-resolve the covering node (its cid churns per +-- consume) and resubmit. A blind retry of a committed consume hits the +-- membership check — a clean idempotency signal. module Wormhole.Core.Replay where import DA.Map qualified as Map @@ -66,19 +49,18 @@ import DA.Set qualified as Set import DA.Text qualified as T import Wormhole.Core.Bytes --- | Default node capacity. Suffixes are ≤64 hex chars (~66 B serialized), and --- every consume rewrites the full node, so this bounds both the node payload --- (~8.5 KB) and the per-consume write cost (~4 KB on average). The split --- transaction redistributes the same bytes across 16 creates (~15 KB) — all --- comfortably below Canton's request caps. Larger values thin the ACS but --- amplify every write; the 'ensure' clause caps it at 4096 (~270 KB nodes). +-- | Default node capacity. Suffixes are ≤64 hex chars (~66 B serialized) and +-- every consume rewrites the full node, bounding the node payload (~8.5 KB), the +-- per-consume write (~4 KB), and the split's 16 creates (~15 KB) — all below +-- Canton's request caps. Larger values thin the ACS but amplify every write; +-- 'ensure' caps it at 4096 (~270 KB nodes). defaultSplitThreshold : Int defaultSplitThreshold = 128 --- | One trie node: the consumed digest suffixes under @prefix@. See the module --- header for the partition invariant and the trust model. NOT observed by the --- guardian observer party: replay state is integrator-private safety state, --- not attestation surface — guardians never re-check target-chain consumption. +-- | One trie node: the consumed digest suffixes under @prefix@ (see the module +-- header for the partition invariant and trust model). NOT observed by the +-- guardian observer: replay state is integrator-private safety state, not +-- attestation surface — guardians never re-check target-chain consumption. template ReplayNode with consumer : Party -- integrator scope party; bears the replay risk @@ -89,23 +71,22 @@ template ReplayNode where signatory consumer, operator -- Shape invariants only — 'ensure' is a bug-catcher, not a trust boundary - -- (the signatories can always sign a well-formed shadow node). Depth-64 - -- nodes are unrepresentable; suffix lengths pin the arithmetic that - -- membership depends on. Deliberately NO @size consumed <= splitThreshold@: - -- a split can legitimately create an over-full child (all parent entries - -- in one bucket); its next consume splits it further. + -- (signatories can always sign a well-formed shadow node). Depth-64 nodes are + -- unrepresentable; suffix lengths pin the membership arithmetic. Deliberately + -- NO @size consumed <= splitThreshold@: a split can legitimately create an + -- over-full child (all parent entries in one bucket), split further next + -- consume. ensure splitThreshold >= 1 && splitThreshold <= 4096 && T.length prefix <= 63 && isLowerHex prefix && all (\s -> T.length s == 64 - T.length prefix && isLowerHex s) (Set.toList consumed) - -- | Record @digest@ as consumed, or fail if it already was. Returns the - -- node that now contains the digest — the recreated node, or, on a split, - -- the child covering it (the other 15 children surface to the disclosure - -- service as CreatedEvents). Consuming: racing consumes conflict on this - -- contract and exactly one commits (see the retry protocol in the module - -- header). To consume two digests under the same node in one transaction, - -- chain the second exercise on the returned cid. + -- | Record @digest@ as consumed, or fail if it already was. Returns the node + -- now containing the digest — the recreated node, or on a split the child + -- covering it (the other 15 surface as CreatedEvents). Consuming: racing + -- consumes conflict and exactly one commits (module-header retry protocol). + -- To consume two digests under one node in a single transaction, chain the + -- second exercise on the returned cid. choice ConsumeDigest : ContractId ReplayNode with digest : Bytes32 diff --git a/canton/core/daml/Wormhole/Core/Setup.daml b/canton/core/daml/Wormhole/Core/Setup.daml index ddb3c6d2a7..74eb5b7cda 100644 --- a/canton/core/daml/Wormhole/Core/Setup.daml +++ b/canton/core/daml/Wormhole/Core/Setup.daml @@ -23,15 +23,14 @@ defaultGovernanceContract : Bytes32 defaultGovernanceContract = "0000000000000000000000000000000000000000000000000000000000000004" -- | Build the initial 'CoreState' value with guardian set 0 (never-expiring) --- and the supplied governance configuration. Genesis must be created with BOTH --- the @operator@ and the @guardianGovernance@ party's authority (the CoreState --- is co-signed); thereafter governance transitions inherit --- guardianGovernance's authority and the operator submits them alone. +-- and the supplied governance configuration. Genesis needs both the @operator@ +-- and @guardianGovernance@ signatures (the CoreState is co-signed); later +-- transitions inherit guardianGovernance's authority, so the operator submits +-- alone. -- --- @feeInstrument@ and @feeRecipient@ are GENESIS-FIXED (no governance action --- covers them — changing either is a genesis-class ceremony). In production --- the recipient is @guardianGovernance@, putting fee custody under the k-of-n --- guardian threshold; the instrument is Canton Coin +-- @feeInstrument@ and @feeRecipient@ are genesis-fixed (no governance action +-- covers them). In production the recipient is @guardianGovernance@, putting fee +-- custody under the k-of-n guardian threshold; the instrument is Canton Coin -- (@InstrumentId dsoParty "Amulet"@). mkInitialCoreState : Party -- ^ operator @@ -59,21 +58,18 @@ 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; it also carries --- @guardianGovernance@ (the CoreState fee-custody anchor, pinned by --- 'RegisterEmitter' and threaded into each 'Emitter') and @guardianObserver@ --- (threaded into each 'Emitter'). Pass the same @guardianGovernance@ the --- 'CoreState' was created with. +-- setup (sequential ids). Carries @guardianGovernance@ (the fee-custody anchor, +-- pinned by 'RegisterEmitter') and @guardianObserver@, both threaded into each +-- 'Emitter'. Pass the same @guardianGovernance@ the 'CoreState' was created with. mkInitialEmitterRegistry : Party -> Party -> Party -> Int -> EmitterRegistry mkInitialEmitterRegistry operator guardianGovernance guardianObserver registrationFee = EmitterRegistry with operator, guardianGovernance, guardianObserver, registrationFee, nextId = 0 --- | The replay-root registry (the stateless claim anchor) the operator --- creates alongside the 'CoreState' at setup. Hands a 'ReplayNode' trie root --- to any consumer that claims one (see 'Wormhole.Core.Replay'). Carries --- @guardianGovernance@ (the CoreState fee-custody anchor, pinned by --- 'ClaimReplayRoot'); pass the same @guardianGovernance@ the 'CoreState' was --- created with. +-- | The replay-root registry (the stateless claim anchor) the operator creates +-- alongside the 'CoreState' at setup; hands a 'ReplayNode' trie root to any +-- consumer that claims one (see 'Wormhole.Core.Replay'). Carries +-- @guardianGovernance@ (the fee-custody anchor, pinned by 'ClaimReplayRoot'); +-- pass the same one the 'CoreState' was created with. mkInitialReplayRootRegistry : Party -> Party -> Int -> ReplayRootRegistry mkInitialReplayRootRegistry operator guardianGovernance claimFee = ReplayRootRegistry with operator, guardianGovernance, claimFee diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index d2b3c99644..dcbdff1392 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -1,56 +1,47 @@ -- | The core-bridge state singleton, emitter registration, and message --- publishing. Mirrors @ethereum/contracts/Implementation.sol@ (publishMessage), --- @State.sol@ (storage), and @Governance.sol@ (governance choices). +-- publishing. Mirrors @Implementation.sol@ (publishMessage), @State.sol@ +-- (storage), and @Governance.sol@ (governance choices). -- --- State model: Daml state is the active-contract set, so the "core contract" is --- a single long-lived 'CoreState' that is archived and recreated on every --- governance transition. +-- State is the active-contract set, so the "core contract" is a single +-- long-lived 'CoreState', archived and recreated on every governance +-- transition. -- --- CONTRACT ADDRESSING / UNIQUENESS: this package uses NO contract keys. On --- Daml-LF 2.3 keys are non-unique and resolve against the submission's readers --- (negative lookups fail open — see 'Wormhole.Core.Replay'), so they can carry --- neither uniqueness nor authenticity; even as a pure index they are a --- convenience the ledger does not underwrite. Contracts are addressed by --- CONTRACT-ID: stakeholders resolve current cids from their own ACS (the --- successor's cid arrives as a CreatedEvent of the consuming exercise), and --- non-stakeholders receive cids WITH explicit disclosures from the off-ledger --- disclosure service (README §4.7). Authenticity is checked on the PAYLOAD, --- never on how the cid was resolved: signatories cannot be forged, so a --- fetched or disclosed 'CoreState' whose @guardianGovernance@ field is the --- known governance party is authentic by construction — a compromised --- @operator@ cannot fabricate one (it lacks that signature). The fee-custody --- anchor is pinned exactly this way: the fee-charging call sites --- ('PublishMessage', 'RegisterEmitter', 'ClaimReplayRoot') exercise --- 'GetGuardianGovernance' and compare it against the @guardianGovernance@ --- committed on the emitter/registry BEFORE charging. Pinning @guardianGovernance@ --- rather than @operator@ is what makes this hold against a compromised operator: --- @operator@ alone does not determine @feeRecipient@, so an operator co-signing a --- shadow 'CoreState' under a throwaway gg would pass an operator-pin but cannot --- forge the real gg (see 'GetGuardianGovernance'). Two invariants: --- * 'CoreState' singleton: co-signed by the @operator@ and a @guardianGovernance@ --- party. The @guardianGovernance@ party is the guardians' governance anchor — --- in production a decentralized-namespace external party controlled by a --- k-of-n threshold of guardian keys (the same pattern as the Canton Network --- DSO party), rotated at the topology layer without touching this template. --- It signs only at genesis; governance transitions ('SubmitGovernanceVAA') --- inherit its authority from the archived contract, so the operator submits --- them alone. "Exactly one" remains behavioral (operator + governance create --- one at setup), but the guardian-set contents are cryptographically --- anchored by the co-signature. --- * Emitter identity: cryptographic. The 32-byte Wormhole address is --- @keccak256(tag ‖ registrar ‖ owner ‖ id)@ (see 'emitterAddressFor'), and --- the emitting @owner@ is a co-signatory. A compromised operator therefore --- cannot mint a contract carrying an existing emitter's address (that needs --- the owner's signature) — it can only produce a duplicate under a DIFFERENT --- owner, which derives a different, untrusted address. This is the invariant --- guardians verify by replay: a valid transaction under the vetted package --- can never forge an existing address. +-- No contract keys: on Daml-LF 2.3 keys are non-unique and resolve against the +-- submission's readers (negative lookups fail open — see 'Wormhole.Core.Replay'), +-- so they carry neither uniqueness nor authenticity. Contracts are addressed by +-- contract-id — stakeholders resolve current cids from their own ACS (the +-- successor arrives as a CreatedEvent of the consuming exercise); non-stakeholders +-- receive cids with explicit disclosures from the off-ledger disclosure service +-- (README §4.7). Authenticity is checked on the PAYLOAD, never the resolution +-- path: signatories cannot be forged, so a 'CoreState' whose @guardianGovernance@ +-- is the known anchor is authentic by construction — a compromised @operator@ +-- cannot fabricate one. The fee-custody anchor is pinned this way: 'PublishMessage', +-- 'RegisterEmitter', and 'ClaimReplayRoot' exercise 'GetGuardianGovernance' and +-- compare it against the @guardianGovernance@ committed on the emitter/registry +-- before charging. Pinning @guardianGovernance@ (not @operator@) is what holds +-- against a compromised operator, which alone does not determine @feeRecipient@. -- --- Per-emitter sequence numbers live in each 'Emitter' (not in 'CoreState'), so --- that publishing is permissionless: the emitter owner can advance its own --- sequence without the operator's authority, matching EVM where anyone may call --- @publishMessage@. 'CoreState' holds only the shared config and governance --- state. +-- Two invariants: +-- +-- * 'CoreState' singleton co-signed by @operator@ and @guardianGovernance@. +-- The latter is the guardians' governance anchor — in production a +-- decentralized-namespace external party under a k-of-n threshold of guardian +-- keys (the Canton Network DSO pattern), rotated at the topology layer without +-- touching this template. It signs only at genesis; transitions +-- ('SubmitGovernanceVAA') inherit its authority from the archived contract. +-- "Exactly one" is behavioral, but the guardian-set contents are +-- cryptographically anchored by its co-signature. +-- * Emitter identity is cryptographic: the 32-byte address is +-- @keccak256(tag ‖ registrar ‖ owner ‖ id)@ ('emitterAddressFor') and the +-- @owner@ co-signs, so a compromised operator cannot mint a contract carrying +-- an existing emitter's address (that needs the owner's signature) — only a +-- duplicate under a DIFFERENT owner, which derives a different, untrusted +-- address. This is the invariant guardians verify by replay. +-- +-- Per-emitter sequence numbers live in each 'Emitter', not 'CoreState', so +-- publishing is permissionless: the owner advances its own sequence without the +-- operator, matching EVM's @publishMessage@. 'CoreState' holds only shared config +-- and governance state. module Wormhole.Core.State where import DA.Map (Map) @@ -69,22 +60,17 @@ import Wormhole.Core.Governance import Wormhole.Core.Replay import Wormhole.Core.VAA --- | The message record produced by 'PublishMessage'. This is the wire contract --- between Daml and the guardian watcher: the watcher reads it from the --- @ExercisedEvent.exercise_result@ on the Ledger-API stream. Equivalent to the --- EVM @LogMessagePublished@ event. --- --- Instead of a 32-byte @sender@, the record carries the emitter's identity --- components: @registrar@, @owner@, and @emitterId@. The 32-byte Wormhole emitter --- address is @keccak256(tag ‖ registrar ‖ owner ‖ emitterId)@ ('emitterAddressFor'), --- derived by the watcher — the guardian is the sole source of truth for the --- address, and it is never trusted from the ledger. The @owner@ is part of the --- preimage so a compromised operator cannot emit under an existing emitter's --- address (see the module header). +-- | The message record produced by 'PublishMessage' — the wire contract with +-- the guardian watcher, read from @ExercisedEvent.exercise_result@ on the +-- Ledger-API stream. Equivalent to the EVM @LogMessagePublished@ event. -- --- The block-derived 'timestamp' (whitepaper 0001/0004) is supplied by the --- watcher from the transaction's ledger effective time, NOT from this record, so --- it is intentionally absent here. +-- Instead of a 32-byte @sender@ it carries the emitter identity components +-- (@registrar@, @owner@, @emitterId@); the watcher derives the address as +-- @keccak256(tag ‖ registrar ‖ owner ‖ emitterId)@ ('emitterAddressFor') and is +-- its sole source of truth, never trusting the on-ledger value (the @owner@ in +-- the preimage blocks operator impersonation — see the module header). The +-- 'timestamp' (whitepaper 0001/0004) is likewise the watcher's, from the ledger +-- effective time, so it is absent here. data WormholeMessage = WormholeMessage with registrar : Party -- the operator; address input owner : Party -- the emitting party; address input @@ -105,14 +91,11 @@ maxPayloadBytes = 750 -- | Shared verification core of 'ParseAndVerifyVAA' and 'VerifyAndConsumeVAA': -- parse, resolve the guardian set the VAA names, and verify signatures against --- it. A VAA may be signed by a non-current-but-unexpired set; 'verifyVAA' --- requires the named set to still be active. @pubKeys@ are untrusted hints --- bound to guardian addresses inside 'verifyVAA'. NOT used by --- 'SubmitGovernanceVAA', which deliberately accepts the CURRENT set only --- (EVM Governance.sol parity). --- --- Pure: takes @now@ rather than calling 'getTime' itself, so callers do the --- one monadic step (fetching the time) and lift the 'Either' failure. +-- it (a non-current-but-unexpired set is accepted; 'verifyVAA' checks it is +-- still active). @pubKeys@ are untrusted hints bound to guardian addresses in +-- 'verifyVAA'. NOT used by 'SubmitGovernanceVAA', which accepts the CURRENT set +-- only (EVM Governance.sol parity). Pure — takes @now@ rather than calling +-- 'getTime', leaving the caller the one monadic step. verifyEncoded : CoreState -> Bytes -> [(Int, Bytes)] -> Time -> Either Text VAA verifyEncoded st encodedVAA pubKeys now = let vaa = parseVAA encodedVAA @@ -129,49 +112,37 @@ template CoreState governanceContract : Bytes32 -- governance emitter address guardianSetIndex : Int -- index of the current set guardianSets : Map Int GuardianSet - messageFee : Int -- per-publish fee, 10^-10 feeInstrument units; - -- governance-set via SetMessageFee + messageFee : Int -- per-publish fee, 10^-10 feeInstrument + -- units; governance-set via SetMessageFee feeInstrument : InstrumentId -- CIP-0056 instrument fees are paid in (CC on - -- mainnet). GENESIS-FIXED: no governance action - -- covers it — migrating instruments is a - -- genesis-class ceremony. - feeRecipient : Party -- fee custody: guardianGovernance in production, + -- mainnet). Genesis-fixed: no governance action + -- covers it. + feeRecipient : Party -- fee custody; guardianGovernance in production, -- so fees move only via the k-of-n guardian - -- ceremony and the operator never touches them. - -- Genesis-fixed, like feeInstrument. - consumedGovernance : Set Bytes32 -- GOVERNANCE-ONLY replay protection, keyed by - -- digest; bounded by governance cadence. App VAAs - -- use the per-consumer 'ReplayNode' trie instead. + -- ceremony. Genesis-fixed. + consumedGovernance : Set Bytes32 -- governance-only replay protection, keyed by + -- digest. App VAAs use the 'ReplayNode' trie. where - -- Co-signed: the operator advances the singleton, but the guardianGovernance - -- party co-signs so the operator cannot forge the guardian set (see module - -- header) — a contract bearing the known governance party's signature is - -- authentic by construction. Governance transitions inherit - -- guardianGovernance's authority from the archived contract, so it signs only - -- at genesis. + -- Co-signed: guardianGovernance's signature is what stops the operator + -- forging the guardian set (module header). Transitions inherit its + -- authority from the archived contract, so it signs only at genesis. signatory operator, guardianGovernance -- Lets guardians watch guardian-set / governance transitions. observer guardianObserver - -- | Read-only VAA verification, for the token bridge / external verifiers. - -- Verifies against the CURRENT guardian set. @pubKeys@ are untrusted hints - -- bound to guardian addresses inside 'verifyVAA'. + -- | Read-only VAA verification against the CURRENT guardian set, for the + -- token bridge / external verifiers. @pubKeys@ are untrusted hints bound to + -- guardian addresses in 'verifyVAA'. -- - -- @verifier@ (any party, supplied by the caller) is the sole controller -- - -- @operator@ does not need to be an active party. This choice is - -- @nonconsuming@ and does no create/archive at all, so nothing in its body - -- needs @operator@'s authority for any structural reason (contrast - -- 'SubmitGovernanceVAA', which IS consuming and creates a successor - -- 'CoreState' genuinely needing @guardianGovernance@'s co-signature). Any - -- external protocol can verify a VAA as itself. The one real requirement is - -- visibility, not authorization: a non-stakeholder needs @CoreState@ - -- explicitly disclosed to reference it at all, via Daml's Explicit Contract - -- Disclosure feature -- a data attachment, not a signature, servable by any - -- party with read access: + -- @verifier@ (any party) is the sole controller: this choice is + -- @nonconsuming@ and creates/archives nothing, so nothing needs @operator@'s + -- authority (contrast 'SubmitGovernanceVAA', which is consuming and needs + -- @guardianGovernance@'s co-signature). The one requirement is visibility: a + -- non-stakeholder needs 'CoreState' explicitly disclosed to it — a data + -- attachment, not a signature — -- . - -- In Daml Script, @'submitWithDisclosures'@ is the idiom (see - -- @testParseAndVerifyVAAByExternalVerifier@); same pattern as - -- 'Wormhole.Ntt.Manager.Transfer'. + -- In Daml Script that is @'submitWithDisclosures'@ (see + -- @testParseAndVerifyVAAByExternalVerifier@). nonconsuming choice ParseAndVerifyVAA : VAA with verifier : Party @@ -183,14 +154,12 @@ template CoreState either assertFail pure (verifyEncoded this encodedVAA pubKeys now) -- | Read the operator off an authentic 'CoreState' — the caller-side - -- authenticity pin for anyone addressing this contract by cid. Templates - -- must opt in to third-party reads via a choice: a plain @fetch@ requires - -- a stakeholder of the fetched contract among the authorizers, and - -- explicit disclosure grants visibility, not fetch authority. This is - -- that opt-in — nonconsuming, flexible controller, touches nothing. The - -- result is bound by validation to the exercised contract, so a - -- fabricated "CoreState" returns its forged operator and fails the - -- caller's pin (see Test.ExampleIntegrator.Redeem). + -- authenticity pin for anyone addressing this contract by cid. A plain + -- @fetch@ needs a stakeholder among the authorizers and disclosure grants + -- only visibility, so third-party reads must go through a choice; this is + -- it — nonconsuming, flexible controller. The result is bound to the + -- exercised contract, so a fabricated "CoreState" returns its forged + -- operator and fails the caller's pin (see Test.ExampleIntegrator.Redeem). nonconsuming choice GetOperator : Party with reader : Party @@ -198,35 +167,28 @@ template CoreState do pure operator -- | Read the @guardianGovernance@ anchor off an authentic 'CoreState' — a - -- STRONGER caller-side pin than 'GetOperator' for consumers that must resist - -- a compromised operator, not just a third-party forgery. A 'CoreState' - -- requires only SOME (operator, gg) signature pair, so an operator whose key - -- is compromised can co-sign a forged 'CoreState' (with a throwaway gg party - -- it controls and an attacker guardian set) that still passes an - -- operator-pin. Pinning @guardianGovernance@ closes that: gg is the - -- guardians' k-of-n external threshold party, whose signature a compromised - -- operator (a single hot participant key) cannot produce — so no forged - -- 'CoreState' can carry the real gg. Used by 'Wormhole.Ntt.Manager.Receive' - -- and the 'CoreState' fee-charging pins ('PublishMessage', 'RegisterEmitter', - -- 'ClaimReplayRoot'). + -- STRONGER pin than 'GetOperator' for consumers that must resist a compromised + -- operator, not just third-party forgery. A 'CoreState' needs only some + -- (operator, gg) signature pair, so a compromised operator key can co-sign a + -- forged one under a throwaway gg it controls and pass an operator-pin; + -- pinning @guardianGovernance@ (the guardians' k-of-n external party, + -- unforgeable by a single operator key) closes that. Used by + -- 'Wormhole.Ntt.Manager.Receive' and the fee-charging pins ('PublishMessage', + -- 'RegisterEmitter', 'ClaimReplayRoot'). nonconsuming choice GetGuardianGovernance : Party with reader : Party controller reader do pure guardianGovernance - -- | Charge the governance-set per-publish fee ('messageFee'). Lives here — - -- not on 'Emitter' — for two reasons: the fee amount is only knowable from - -- this contract, and executing the allocation needs the FEE RECIPIENT'S - -- authority ('Allocation_ExecuteTransfer' requires sender ∧ receiver ∧ - -- executor jointly), which is inherited from this contract's signatories - -- when @feeRecipient = guardianGovernance@. Returns the operator so the - -- caller can pin authenticity (a self-signed fee-0 fake CoreState must not - -- be able to waive the fee). - -- - -- Nonconsuming: publishes never contend with each other; a governance - -- transition archiving this contract makes racing publishes fail - -- activeness transiently — re-resolve the fresh cid and retry. + -- | Charge the governance-set per-publish fee ('messageFee'). Lives here, + -- not on 'Emitter': the fee amount is only knowable from this contract, and + -- 'Allocation_ExecuteTransfer' needs the fee recipient's authority (sender ∧ + -- receiver ∧ executor), inherited from this contract's signatories when + -- @feeRecipient = guardianGovernance@. Returns the operator so the caller can + -- pin authenticity (a self-signed fee-0 fake must not waive the fee). + -- Nonconsuming: a governance transition archiving this contract only makes + -- racing publishes fail activeness transiently — re-resolve and retry. nonconsuming choice ChargeMessageFee : Party with payer : Party @@ -250,19 +212,16 @@ template CoreState pure operator -- | Verify a VAA and record its consumption in @consumer@'s replay trie, - -- atomically. Integrators (token bridge / NTT) call this in the same - -- transaction as the action the VAA authorizes; a replay fails on the - -- covering node's membership check. Scoped per consumer: the same VAA can - -- be consumed by different integrator parties independently. + -- atomically — integrators (token bridge / NTT) call this in the same + -- transaction as the action the VAA authorizes. Scoped per consumer: the + -- same VAA can be consumed by different parties independently. -- - -- @replayNodeCid@ is the trie node covering @vaa.hash@ — resolved - -- off-ledger by the consumer's disclosure service (see - -- 'Wormhole.Core.Replay'). SOUNDNESS IS SUBMITTER-INDEPENDENT: the node is - -- fetched and consumed positively, so every confirmer validates its - -- activeness — a stale or wrong node can only fail the transaction, never - -- replay. Any submitter with disclosures of this contract and the covering - -- node (the controller is flexible) gets a correct check; no read-view - -- discipline is required, unlike the retired key-based guards. + -- @replayNodeCid@ is the covering trie node, resolved off-ledger by the + -- consumer's disclosure service ('Wormhole.Core.Replay'). Soundness is + -- submitter-independent: the node is fetched and consumed positively, so + -- every confirmer validates its activeness — a stale or wrong node can only + -- fail the transaction, never replay. Any submitter with disclosures of this + -- contract and the covering node gets a correct check. nonconsuming choice VerifyAndConsumeVAA : (VAA, ContractId ReplayNode) with encodedVAA : Bytes @@ -305,10 +264,9 @@ template CoreState (not (Set.member vaa.hash consumedGovernance)) let action = parseGovernance vaa.payload -- TransferFees additionally mints an on-ledger withdrawal - -- authorization ('applyGovernance' itself stays pure). Fees accrue in - -- feeRecipient's (guardianGovernance's) holdings; this contract is - -- the artifact the guardians' custody policy honors when - -- threshold-signing the actual outbound token transfer. + -- authorization ('applyGovernance' stays pure); it is the artifact the + -- guardians' custody policy honors when threshold-signing the outbound + -- transfer of the fees accrued in feeRecipient's holdings. case action of TransferFees _ amount recipient -> do _ <- create FeeWithdrawalAuthorization with @@ -336,9 +294,8 @@ applyGovernance st now action = case action of assertMsg "guardianSetUpgrade: index must be current + 1" (newIndex == st.guardianSetIndex + 1) assertMsg "guardianSetUpgrade: empty set" (not (null newKeys)) -- Expire the current set (now + 1 day) in place, then install the new one. - -- Map.alter names guardianSetIndex once (look up, modify, reinsert at the same - -- key); the None case is an invariant violation — a live CoreState always has - -- its current set. + -- The None case is an invariant violation — a live CoreState always has its + -- current set. let expiry = Some (addRelTime now (seconds guardianSetExpirySeconds)) expireCurrent mOld = case mOld of Some gs -> Some gs with expirationTime = expiry @@ -354,17 +311,16 @@ applyGovernance st now action = case action of TransferFees chain _amount _recipient -> do assertMsg "transferFees: wrong target chain" (chain == st.chainId || chain == 0) - -- No CoreState change: fees settle directly into feeRecipient's holdings - -- at charge time, so there is no pooled balance here to move. The caller - -- ('SubmitGovernanceVAA') mints the 'FeeWithdrawalAuthorization' and - -- records the action as consumed. + -- No CoreState change: fees settle into feeRecipient's holdings at charge + -- time, so there is no pooled balance here to move. The caller mints the + -- 'FeeWithdrawalAuthorization' and records the action consumed. pure st ContractUpgrade chain _newContract -> do assertMsg "contractUpgrade: wrong target chain" (chain == st.chainId) -- On Canton an "upgrade" is an operator-driven Daml package upgrade gated by - -- the recorded target package-id; the actual package migration is performed - -- out of band by the operator. The caller records the action as consumed. + -- the recorded target package-id, performed out of band. The caller records + -- the action consumed. pure st -- | Helper to read the current set off a 'CoreState' value (not a contract). @@ -372,15 +328,15 @@ currentSetForUpgrade : CoreState -> GuardianSet currentSetForUpgrade st = fromSomeNote "no current guardian set" (Map.lookup st.guardianSetIndex st.guardianSets) --- | On-ledger record of a TransferFees governance action: the guardians' --- authorization to move @amount@ of accrued fees to @recipient@. Fees live in --- feeRecipient's (guardianGovernance's) token holdings — Canton tokens move --- only by their owner's signature, so the actual outbound transfer is a --- k-of-n guardian threshold ceremony; this contract, signed by --- guardianGovernance (authority inherited from the consumed 'CoreState'), is --- the artifact that ceremony's custody policy matches against. @recipient@ is --- the VAA's 32-byte Wormhole address; mapping it to a Canton party/receiver --- is the custody layer's job. Archived by guardianGovernance when fulfilled. +-- | On-ledger record of a TransferFees action: the guardians' authorization to +-- move @amount@ of accrued fees to @recipient@. Fees live in feeRecipient's +-- (guardianGovernance's) holdings, and Canton tokens move only by their owner's +-- signature, so the outbound transfer is a k-of-n guardian ceremony; this +-- contract (signed by guardianGovernance, authority inherited from the consumed +-- 'CoreState') is the artifact that ceremony's custody policy matches against. +-- @recipient@ is the VAA's 32-byte Wormhole address; mapping it to a Canton +-- receiver is the custody layer's job. Archived by guardianGovernance when +-- fulfilled. template FeeWithdrawalAuthorization with guardianGovernance : Party @@ -409,33 +365,29 @@ emitterAddressFor registrar owner emitterId = derivedAddress (DomainTag emitterAddressTag) registrar owner (RegistryId emitterId) -- | Allocates monotonically increasing emitter ids, one registry per operator. --- Sequential-id discipline is operator-behavioral (nothing on-ledger enforces a --- single registry); what a rogue operator can NOT do is reuse an id to --- impersonate an existing emitter — that address includes the owner, whose --- signature it lacks (see 'Emitter'). +-- Sequential-id discipline is operator-behavioral; what a rogue operator cannot +-- do is reuse an id to impersonate an existing emitter — that address includes +-- the owner, whose signature it lacks (see 'Emitter'). template EmitterRegistry with operator : Party - guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT - -- a signatory) in RegisterEmitter's fee charge so - -- a forged CoreState under a rogue gg is rejected + guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a + -- signatory) in RegisterEmitter's fee charge guardianObserver : Party -- threaded into each new Emitter by RegisterEmitter - registrationFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument - -- units; operator-set at creation (archive + - -- recreate to change), 0 for free registration + registrationFee : Int -- onboarding fee, 10^-10 feeInstrument units; + -- operator-set at creation, 0 for free nextId : Int where signatory operator -- | Direct, requester-submitted registration: allocates the next id and - -- creates the co-signed 'Emitter', one transaction, no operator crank. The - -- flexible controller brings the @requester@'s signature; the operator's - -- is INHERITED from this contract's signatory. Permissionless by design - -- (EVM/Sui parity: anyone may emit) — the requester only needs this - -- registry disclosed (README §4.7), plus, when @registrationFee > 0@, the - -- 'CoreState' disclosure and a fee allocation (README §4.2). Consuming, so - -- racing registrations contend here and exactly one commits; the loser - -- re-resolves the fresh registry cid and retries. + -- creates the co-signed 'Emitter' in one transaction, no operator crank — + -- the flexible controller brings the @requester@'s signature, the operator's + -- is inherited from this contract. Permissionless (EVM/Sui parity); the + -- requester needs this registry disclosed (README §4.7), plus the 'CoreState' + -- disclosure and a fee allocation when @registrationFee > 0@ (README §4.2). + -- Consuming, so racing registrations contend here — the loser re-resolves + -- and retries. choice RegisterEmitter : (ContractId EmitterRegistry, ContractId Emitter) with requester : Party @@ -443,12 +395,11 @@ template EmitterRegistry feeAllocation : Optional (ContractId Allocation, ExtraArgs) controller requester do - -- Zero-fee registrations never touch the CoreState (single-disclosure - -- onboarding); fee'd ones charge through it. The gg pin runs BEFORE the - -- charge: pinning @guardianGovernance@ (not just the operator) rejects a - -- CoreState a compromised operator co-signed under a throwaway gg it - -- controls — the operator field would match, but the fee-custody anchor - -- would not, so a forged CoreState cannot redirect the registration fee. + -- Zero-fee registrations never touch the CoreState; fee'd ones charge + -- through it. The gg pin runs BEFORE the charge: pinning + -- @guardianGovernance@ (not just @operator@, which a forged CoreState + -- would match) rejects a shadow CoreState under a rogue gg, so the + -- registration fee cannot be redirected. case (registrationFee > 0, coreStateCid) of (False, _) -> pure () (True, None) -> abort "registration fee due: pass the CoreState" @@ -471,35 +422,30 @@ template EmitterRegistry sequence = 0 pure (reg', emitter) --- | Hands out replay-trie roots to any consumer that asks — a stateless --- anchor whose one nonconsuming choice lends the operator's INHERITED --- co-signature to consumer-submitted claims (the 'RegisterEmitter' pattern): --- no operator crank, no contention, and no churn from claims — the registry's --- disclosure blob is cacheable until the operator archives/recreates it to --- change 'claimFee'. One per operator, created at setup +-- | Hands out replay-trie roots to any consumer that asks — a stateless anchor +-- whose one nonconsuming choice lends the operator's INHERITED co-signature to +-- consumer-submitted claims (the 'RegisterEmitter' pattern): no operator crank, +-- no contention, and a disclosure blob cacheable until the operator +-- archives/recreates it to change 'claimFee'. One per operator, created at setup -- ('mkInitialReplayRootRegistry'). Lives here rather than in --- 'Wormhole.Core.Replay' because the claim fee is charged through the --- 'CoreState' (import cycle otherwise). +-- 'Wormhole.Core.Replay' because its claim fee is charged through the 'CoreState' +-- (which references 'ReplayNode' — an import cycle otherwise). -- -- There is deliberately NO one-root-per-consumer enforcement: maintaining a --- single trie is the CONSUMER'S OWN responsibility (its disclosure service --- defines which trie its consumes resolve into). The controller is what keeps --- that sound against everyone else — a third party cannot fork someone else's --- replay scope, because claiming a root for @consumer@ requires @consumer@'s --- authority. Parallel roots can only be minted by the consumer itself (or its --- stolen key), which is self-harm (see 'Wormhole.Core.Replay'). Spam claims --- cost the spammer synchronizer traffic per transaction, the 'claimFee' when --- nonzero, and the operator ~300 B of storage per worthless root; no shared --- contract grows. +-- single trie is the CONSUMER'S responsibility. The controller keeps that sound +-- against everyone else — claiming a root for @consumer@ needs @consumer@'s +-- authority, so nobody can fork another's replay scope. Parallel roots can only +-- be minted by the consumer itself, which is self-harm (see +-- 'Wormhole.Core.Replay'). Spam claims cost the spammer per-transaction traffic, +-- the 'claimFee', and the operator ~300 B per worthless root; no shared contract +-- grows. template ReplayRootRegistry with operator : Party - guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT - -- a signatory) in ClaimReplayRoot's fee charge so - -- a forged CoreState under a rogue gg is rejected - claimFee : Int -- onboarding fee, 10^-10 CoreState.feeInstrument - -- units; operator-set at creation (archive + - -- recreate to change), 0 for free claims + guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a + -- signatory) in ClaimReplayRoot's fee charge + claimFee : Int -- onboarding fee, 10^-10 feeInstrument units; + -- operator-set at creation, 0 for free where signatory operator @@ -513,10 +459,9 @@ template ReplayRootRegistry do -- Zero-fee claims never touch the CoreState; fee'd ones charge through -- it. The gg pin runs BEFORE the charge: pinning @guardianGovernance@ - -- (not just the operator) rejects a CoreState a compromised operator - -- co-signed under a throwaway gg it controls — the operator field would - -- match, but the fee-custody anchor would not, so a forged CoreState - -- cannot redirect the claim fee (see README §4.2). + -- (not just @operator@, which a forged CoreState would match) rejects a + -- shadow CoreState under a rogue gg, so the claim fee cannot be + -- redirected (see README §4.2). case (claimFee > 0, coreStateCid) of (False, _) -> pure () (True, None) -> abort "claim fee due: pass the CoreState" @@ -542,50 +487,44 @@ template ReplayRootRegistry template Emitter with operator : Party - guardianGovernance : Party -- CoreState fee-custody trust anchor; pinned (NOT - -- a signatory) in PublishMessage's fee charge so - -- a forged CoreState under a rogue gg is rejected + guardianGovernance : Party -- CoreState fee-custody anchor; pinned (not a + -- signatory) in PublishMessage's fee charge owner : Party guardianObserver : Party -- read-only guardian observer emitterId : Int -- registry-allocated emitterAddress : Bytes32 -- emitterAddressFor operator owner emitterId, set at creation sequence : Int -- next sequence to assign 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 - -- guardians verify by replay — a compromised operator cannot emit under an - -- existing address (Canton's msg.sender analog). It also means the operator + -- Owner co-signs: only a transaction with the owner's authority can create a + -- contract whose derived address is the owner's — the invariant guardians + -- verify by replay, and Canton's msg.sender analog. 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 -- | Publish a Wormhole message. The choice result is the 'WormholeMessage' - -- the watcher observes (decoded directly from @exercise_result@ — the - -- result type is a wire contract with the Go watcher; do not change it). - -- The sequence-bumped 'Emitter' is recreated with both signatures inherited - -- from the consumed contract; the owner is a signatory, so the successor's - -- cid arrives on its own ACS as the transaction's CreatedEvent and the next - -- publish addresses that cid directly. Mirrors - -- @Implementation.publishMessage@. + -- the watcher observes (decoded from @exercise_result@ — a wire contract + -- with the Go watcher; do not change it). The sequence-bumped 'Emitter' is + -- recreated with both signatures inherited; the owner is a signatory, so the + -- successor's cid arrives on its own ACS and the next publish addresses it + -- directly. Mirrors @Implementation.publishMessage@. -- - -- The governance-set 'messageFee' is charged through the referenced - -- 'CoreState' (EVM parity: publishMessage reads the current fee on every - -- call), whose operator is pinned so a fabricated CoreState cannot waive - -- the fee. Publishers therefore attach the CoreState disclosure to every - -- publish (cacheable between governance actions; a governance transition - -- invalidates the cid transiently — re-resolve and retry). At fee 0 pass - -- @feeAllocation = None@; the CoreState reference is still required, since - -- the fee amount is only knowable from it. + -- The 'messageFee' is charged through the referenced 'CoreState' (EVM parity: + -- the current fee is read on every call), whose @guardianGovernance@ and + -- operator are pinned so a fake CoreState cannot redirect or waive it. + -- Publishers attach the CoreState disclosure to every publish (cacheable + -- between governance actions, which invalidate the cid transiently — + -- re-resolve and retry); at fee 0 pass @feeAllocation = None@, but the + -- CoreState reference is still required since the fee amount is only knowable + -- from it. -- -- @payer@ funds the fee and co-controls the publish. A direct publisher is - -- its own payer (@payer = owner@; the controller set collapses to @owner@, - -- so it still submits alone). When the publisher is not the payer — an app - -- publishing on a user's behalf through an @owner@-owned emitter, e.g. NTT - -- 'Wormhole.Ntt.Manager.Transfer' — the payer is named explicitly, and its - -- authority is required precisely because it foots the bill: - -- 'Allocation_ExecuteTransfer' inside 'ChargeMessageFee' draws the payer's - -- funds, so the payer cannot be some third party who did not authorize. + -- its own payer (@payer = owner@, so the controller set collapses to @owner@). + -- When they differ — an app publishing on a user's behalf, e.g. NTT + -- 'Wormhole.Ntt.Manager.Transfer' — @payer@'s authority is required because + -- 'ChargeMessageFee' draws its funds, so it cannot be a non-consenting third + -- party. choice PublishMessage : WormholeMessage with nonce : Int @@ -596,11 +535,10 @@ template Emitter feeAllocation : Optional (ContractId Allocation, ExtraArgs) controller owner, payer do - -- Pin the fee-custody anchor BEFORE charging: 'guardianGovernance' (not - -- just the operator) is what determines where the fee lands. A - -- compromised operator can co-sign a forged CoreState under a throwaway - -- gg it controls — the operator field would match, but this check - -- rejects it, so the message fee cannot be redirected. + -- Pin the fee-custody anchor BEFORE charging: @guardianGovernance@ (not + -- just @operator@, which a forged CoreState would match) determines where + -- the fee lands, so this check rejects a shadow CoreState under a rogue + -- gg before the message fee can be redirected. csGg <- exercise coreStateCid GetGuardianGovernance with reader = payer assertMsg "core state guardianGovernance mismatch" (csGg == guardianGovernance) csOperator <- exercise coreStateCid ChargeMessageFee with diff --git a/canton/test/daml/Test/ExampleIntegrator.daml b/canton/test/daml/Test/ExampleIntegrator.daml index bd11d8f6f4..2213261f2d 100644 --- a/canton/test/daml/Test/ExampleIntegrator.daml +++ b/canton/test/daml/Test/ExampleIntegrator.daml @@ -1,24 +1,19 @@ --- | A reference integrator (a mock NTT-manager shape) demonstrating how an app +-- | A reference integrator (a mock NTT-manager shape) showing how an app -- consumes a VAA atomically via 'VerifyAndConsumeVAA' (README §4.6). -- --- The flow is DIRECT AND USER-SUBMITTED: the end user exercises 'Redeem' --- herself, attaching two explicit disclosures — the 'CoreState' and the --- current covering 'ReplayNode' of the manager's replay trie (both served by --- the app's disclosure service). This is sound for ANY submitter: the replay --- check is a positive fetch-and-consume of the named node, validated for --- activeness by every confirmer — a wrong or stale node can only fail the --- transaction, never replay. No read grants, no @readAs@, no propose-accept --- queue; the manager's AUTHORITY for the consume is inherited from this --- contract's signatory, and its VISIBILITY is simply not needed (contrast the --- retired key-based guards, whose negative lookups failed open unless the --- consumer submitted). +-- The flow is DIRECT AND USER-SUBMITTED: the end user exercises 'Redeem' herself +-- with two explicit disclosures — the 'CoreState' and the covering 'ReplayNode' +-- of the manager's trie. Sound for ANY submitter: the replay check is a positive +-- fetch-and-consume of the named node, so a wrong or stale node only fails the +-- transaction, never replays. No read grants, no @readAs@; the manager's +-- authority for the consume is inherited from this contract's signatory, and its +-- visibility is not needed. -- -- Guardian signatures prove the VAA is real, NOT that it is addressed to this --- app: 'Redeem' checks the emitter against the registered peer before touching --- the payload. +-- app: 'Redeem' checks the emitter against the registered peer before acting. -- --- This module ships in the TEST package only; it is a template for integrator --- packages, not part of the core DAR. +-- Test package only; a template for integrator packages, not part of the core +-- DAR. module Test.ExampleIntegrator where import Daml.Script @@ -56,13 +51,12 @@ template ExampleIntegrator signatory manager observer users - -- | Redeem a VAA: verify + consume + peer-check + mint, one atomic - -- transaction, submitted by the redeemer. @consumer = manager@ is - -- authorized by inheritance from this contract's signatory; the submitter - -- brings disclosures of the 'CoreState' and of the covering 'ReplayNode'. - -- All-or-nothing: an already-consumed digest, a stale node, a bad - -- signature, or a wrong peer aborts everything — there is no window where - -- a VAA is consumed but not acted on, or vice versa. + -- | Redeem a VAA: verify + consume + peer-check + mint in one atomic + -- transaction, submitted by the redeemer. @consumer = manager@ is authorized + -- by inheritance from this contract's signatory; the submitter brings the + -- 'CoreState' and covering 'ReplayNode' disclosures. All-or-nothing: an + -- already-consumed digest, stale node, bad signature, or wrong peer aborts + -- everything. nonconsuming choice Redeem : ContractId RedeemReceipt with redeemer : Party @@ -72,11 +66,10 @@ template ExampleIntegrator pubKeys : [(Int, Bytes)] controller redeemer do - -- Cids arrive from an untrusted submitter, so authenticate PAYLOADS: - -- a redeemer must not be able to point at a fabricated CoreState with - -- an attacker guardian set. A direct fetch is not authorized here (no - -- CoreState stakeholder among the authorizers — disclosure grants - -- visibility, not fetch authority), so the pin goes through the + -- Cids arrive from an untrusted submitter, so authenticate the PAYLOAD: + -- a redeemer must not point at a fabricated CoreState with an attacker + -- guardian set. A direct fetch is not authorized here (no CoreState + -- stakeholder among the authorizers), so the pin goes through the -- 'GetOperator' read choice, which needs only its controller. csOperator <- exercise coreStateCid GetOperator with reader = redeemer assertMsg "unexpected core operator" (csOperator == operator) @@ -126,12 +119,10 @@ devnetPubKeys : [(Int, Bytes)] devnetPubKeys = [(0, devnetGuardianPubKey)] -- | End-to-end user-submitted redemption: alice redeems with her own authority --- and two disclosures — nothing else. Replay attempts then fail for EVERY --- submitter and every node freshness: the stale node disclosure dies on --- activeness, the fresh one on membership. This inverts the old key-based --- footgun (where a submitter without the consumer's read view could --- double-consume): submitter identity and visibility are no longer --- load-bearing. +-- and two disclosures — nothing else. Replay attempts then fail for every +-- submitter regardless of node freshness (stale node dies on activeness, fresh +-- on membership), inverting the old key-based footgun where a submitter without +-- the consumer's read view could double-consume. testIntegratorRedeem : Script () testIntegratorRedeem = do IntegratorFixture{operator, csId, manager, users = [alice, bob], intCid, csDisc} <- setupIntegrator diff --git a/canton/test/daml/Test/MockToken.daml b/canton/test/daml/Test/MockToken.daml index 824f3b9662..9c57b14f25 100644 --- a/canton/test/daml/Test/MockToken.daml +++ b/canton/test/daml/Test/MockToken.daml @@ -1,16 +1,13 @@ --- | A minimal CIP-0056 token for tests: just enough of the standard's --- 'Holding' and 'Allocation' interfaces for 'Wormhole.Core.Fees.chargeFee' to --- validate and execute against, without vendoring the splice-amulet --- dependency closure. Deliberate divergences from a real registry: no --- deadline enforcement (registry-side by design — the bridge never checks --- them) and no locking/change semantics (each allocation is minted whole). --- Fidelity against real amulet belongs to devnet validation (README Open --- Questions). +-- | A minimal CIP-0056 token for tests: just enough of the 'Holding' and +-- 'Allocation' interfaces for 'Wormhole.Core.Fees.chargeFee' to validate and +-- execute against, without the splice-amulet dependency closure. Deliberate +-- divergences from a real registry: no deadline enforcement (registry-side by +-- design) and no locking/change (each allocation is minted whole). Fidelity +-- against real amulet belongs to devnet validation (README Open Questions). -- --- 'FakeAllocation' exists to prove the admin-signature pin: its view CLAIMS --- an arbitrary instrument, but it carries only the sender's signature — a --- genuine registry allocation is co-signed by the instrument admin, which --- cannot be forged. +-- 'FakeAllocation' proves the admin-signature pin: its view CLAIMS an arbitrary +-- instrument but carries only the sender's signature — a genuine allocation is +-- co-signed by the instrument admin, which cannot be forged. module Test.MockToken where import Daml.Script diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index 56963d73f7..6c7700b37c 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -30,15 +30,13 @@ devnetGuardianPubKey = "04d4a4629979f0c9fa0f0bb54edf33f87c8c5a1f42c0350a30d68f7e govSetFeeVAA : Bytes govSetFeeVAA = "01000000000100710ab5dcf6885f62016990540e090400f26f41286382dab479a78ed5f85017ff4a562a46379753e94abba1e69b9fec5d1994ac7d9fb145820727df0759bf4f80016553f100000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000010000000000000000000000000000000000000000000000000000000000436f726503004800000000000000000000000000000000000000000000000000000000000003e8" --- | 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. It also --- allocates the read-only @guardianObserver@ party and threads it into the --- 'CoreState' and 'EmitterRegistry' (from which 'RegisterEmitter' carries it into --- each 'Emitter'). Also creates the 'ReplayRootRegistry' (replay-trie root --- grants, see Test.TestReplay). The result keeps @operator@ FIRST so --- bootstrap.sh's first-party extraction still yields the operator; --- @guardianObserver@ follows. +-- | Bootstrap a devnet 'CoreState', 'EmitterRegistry', and 'ReplayRootRegistry'. +-- The 'CoreState' is co-signed by @operator@ and @guardianGovernance@ (a single +-- party here; a k-of-n threshold party in production), so genesis needs both. It +-- allocates the read-only @guardianObserver@ and threads it into the 'CoreState' +-- and 'EmitterRegistry' (from which 'RegisterEmitter' carries it into each +-- 'Emitter'). Keeps @operator@ FIRST so bootstrap.sh's first-party extraction +-- still yields it. setup : Script (Party, Party, ContractId CoreState) setup = setupWithInstrument None @@ -233,11 +231,9 @@ testParseAndVerifyVAA = do vaa.sequence === 1 -- | ParseAndVerifyVAA is genuinely permissionless: an external party with no --- relationship to CoreState at all (not operator or guardianGovernance -- --- CoreState's only two stakeholders) can verify a VAA as itself, with no --- involvement from operator, given only CoreState disclosed to it -- a data --- attachment, not an approval. This is what the "for the token bridge / --- external verifiers" doc comment on the choice promises. +-- relationship to CoreState (not operator or guardianGovernance, its only two +-- stakeholders) verifies a VAA as itself, given only CoreState disclosed to it +-- -- a data attachment, not an approval. testParseAndVerifyVAAByExternalVerifier : Script () testParseAndVerifyVAAByExternalVerifier = do (operator, _, csId) <- setup @@ -252,13 +248,10 @@ testParseAndVerifyVAAByExternalVerifier = do vaa.emitterChain === 1 vaa.sequence === 1 --- | Integration entrypoint, invoked by the Go integration test via `dpm --- script` against a live sandbox/participant -- not just the Script --- interpreter. Same scenario as 'testParseAndVerifyVAAByExternalVerifier': --- an external party with no relationship to CoreState verifies a real --- guardian-signed VAA as itself, given only CoreState disclosed to it. --- Returns the external verifier's party id so the Go test can sanity-check --- it (a real, hosted party, not something forged). +-- | Integration entrypoint, invoked by the Go integration test via `dpm script` +-- against a live sandbox/participant. Same scenario as +-- 'testParseAndVerifyVAAByExternalVerifier'; returns the external verifier's +-- party id so the Go test can sanity-check it (a real, hosted party). integrationParseAndVerifyVAAByExternalVerifier : Script Party integrationParseAndVerifyVAAByExternalVerifier = do (operator, _, csId) <- setup diff --git a/canton/test/daml/Test/TestFees.daml b/canton/test/daml/Test/TestFees.daml index 672e587168..3e64fd62a0 100644 --- a/canton/test/daml/Test/TestFees.daml +++ b/canton/test/daml/Test/TestFees.daml @@ -250,19 +250,16 @@ testNoSpuriousWithdrawalAuthorization = do -- guardianGovernance pin: the shadow-CoreState fee-redirect regression ---------------------------------------------------------------------- -- --- The three fee-charging call sites pinned only the @operator@ field returned --- by 'ChargeFee' / 'ChargeMessageFee'. But @operator@ does NOT determine --- @feeRecipient@ (the fee-custody anchor is @guardianGovernance@). A genuine or --- compromised operator can co-sign a SECOND, ledger-valid 'CoreState' carrying --- an attacker-controlled @guardianGovernance@ (= @feeRecipient@) — the operator --- field still matches, so an operator-only pin accepts it and real fees flow to --- the attacker. Each call site now exercises 'GetGuardianGovernance' and pins it --- against the @guardianGovernance@ committed on the emitter/registry BEFORE --- charging, so the forged 'CoreState' dies on the NEW check before any funds --- move. Each test builds exactly that forgery (real operator + rogue gg) and --- asserts the failure carries the new "guardianGovernance mismatch" message — --- the operator pin would pass, so a match on this substring proves the fix, not --- an incidental failure. +-- The call sites once pinned only the @operator@ returned by 'ChargeFee' / +-- 'ChargeMessageFee', but @operator@ does NOT determine @feeRecipient@ (that is +-- @guardianGovernance@). A compromised operator can co-sign a second, +-- ledger-valid 'CoreState' under an attacker-controlled gg (= @feeRecipient@) — +-- the operator field still matches, so an operator-only pin lets real fees flow +-- to the attacker. Each call site now pins 'GetGuardianGovernance' against the +-- gg committed on the emitter/registry before charging. Each test builds that +-- forgery (real operator + rogue gg) and asserts the failure carries +-- "guardianGovernance mismatch" — the operator pin would pass, so the substring +-- match proves the fix rather than an incidental failure. -- | 'PublishMessage' pins the Emitter's guardianGovernance anchor. The forged -- CoreState even has its (rogue) messageFee raised and a matching allocation diff --git a/canton/test/daml/Test/TestGuardianObserver.daml b/canton/test/daml/Test/TestGuardianObserver.daml index 9afc1adbc1..a5fa48c7dc 100644 --- a/canton/test/daml/Test/TestGuardianObserver.daml +++ b/canton/test/daml/Test/TestGuardianObserver.daml @@ -1,18 +1,16 @@ -- | 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. +-- @guardianObserver@ is a template @observer@ on the core bridge's attestation +-- surface ('Emitter' and 'CoreState'), never a signatory or controller — full +-- read visibility of what guardians 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. +-- Daml Script cannot read the update stream directly, so event-level visibility +-- is asserted via its Script-level proxy: stakeholder query visibility +-- ('queryContractId' / 'query') as @guardianObserver@. A template observer is a +-- stakeholder, hence an informee of every consuming exercise (e.g. +-- 'PublishMessage', 'SubmitGovernanceVAA'). The true stream-level check is the +-- Go integration test. module Test.TestGuardianObserver where import Daml.Script @@ -100,12 +98,10 @@ testObserverHasNoAuthority = do cs.feeInstrument cs.feeRecipient) -- | Adding the observer introduces no new AUTHORITY requirement on publishing: --- the @owner@ alone still authorizes a publish. (The informee set of a publish --- is wider since fee wiring: the referenced 'CoreState''s signatories — --- operator and guardianGovernance — are informees of the fee-read exercise, --- and the observer remains a plain informee; none of them gains authority.) --- Genesis still requires exactly the @operator@ and @guardianGovernance@ --- signatures — the observer is not one of them. +-- the @owner@ alone still authorizes it. (Fee wiring widened the informee set — +-- the CoreState's operator and guardianGovernance are informees of the fee-read +-- — but none of them gains authority.) Genesis still requires exactly the +-- @operator@ and @guardianGovernance@ signatures; the observer is not one. testPublishRequiresOnlyOwner : Script () testPublishRequiresOnlyOwner = do (operator, _guardianObserver, csId) <- setup diff --git a/canton/test/daml/Test/TestReplay.daml b/canton/test/daml/Test/TestReplay.daml index 3f7961042c..4a9a941868 100644 --- a/canton/test/daml/Test/TestReplay.daml +++ b/canton/test/daml/Test/TestReplay.daml @@ -1,13 +1,12 @@ -- | Tests for the replay-protection trie ('Wormhole.Core.Replay'), plus the -- script-side simulation of the DISCLOSURE SERVICE: 'coveringNode' resolves the -- unique active node whose prefix covers a digest (asserting the partition --- invariant on every use — exactly one covering node), and --- 'coveringDisclosure' packages it for a non-stakeholder submitter. Production --- runs the same logic off-ledger over an ACS index by (consumer, prefix). +-- invariant on every use) and 'coveringDisclosure' packages it for a +-- non-stakeholder submitter. Production runs the same logic off-ledger over an +-- ACS index by (consumer, prefix). -- --- Trie tests use tiny thresholds (the threshold is a root-creation parameter --- precisely so splits are cheap to reach in tests; production uses --- 'defaultSplitThreshold'). +-- Trie tests use tiny thresholds so splits are cheap to reach; production uses +-- 'defaultSplitThreshold'. module Test.TestReplay where import Daml.Script @@ -46,11 +45,10 @@ registerReplayRoot operator consumer threshold = do feeAllocation = None -- | The disclosure-service lookup: the unique active node of @consumer@'s trie --- covering @digest@, queried as @consumer@. The filter matches the trie owner --- too, because a reader can see nodes of several tries (the operator co-signs --- all of them). Aborts if coverage is not exactly one — every call doubles as --- a check of the partition invariant, and this abort is also how the service --- detects a forked trie (a consumer that minted parallel roots). +-- covering @digest@, queried as @consumer@. The filter also matches the trie +-- owner, since a reader can see several tries' nodes (the operator co-signs all). +-- Aborts unless coverage is exactly one — doubling as a partition-invariant +-- check, and how the service detects a forked trie (parallel roots). coveringNode : Party -> Text -> Script (ContractId ReplayNode, ReplayNode) coveringNode consumer digest = do nodes <- query @ReplayNode consumer @@ -266,14 +264,13 @@ testNodeArchiveNeedsBoth = do [(regCid, _)] <- query @ReplayRootRegistry operator submit operator do archiveCmd regCid --- | Root claims are free and self-serve; what the registry pins down is WHOSE --- scope a claim can touch. Claiming a root for a consumer requires that --- consumer's authority — the load-bearing property: nobody can fork someone --- ELSE'S replay scope. A consumer duplicating its own root is deliberately --- possible (pinned below): parallel roots are self-harm, detected off-ledger --- by the disclosure service ('coveringNode' aborts on more than one covering --- node). Roots cannot be minted outside the registry — the consumer alone --- lacks the operator's signature. +-- | Root claims are free and self-serve; the registry pins down WHOSE scope a +-- claim can touch. Claiming a root for a consumer requires that consumer's +-- authority — nobody can fork someone ELSE'S scope. A consumer duplicating its +-- own root is deliberately possible: parallel roots are self-harm, detected +-- off-ledger ('coveringNode' aborts on more than one covering node). Roots +-- cannot be minted outside the registry — the consumer alone lacks the +-- operator's signature. testRootClaims : Script () testRootClaims = do (operator, consumer, rootCid) <- setupTrie 4