diff --git a/.github/workflows/soroban-ci.yml b/.github/workflows/soroban-ci.yml index 07b9da4..31134b5 100644 --- a/.github/workflows/soroban-ci.yml +++ b/.github/workflows/soroban-ci.yml @@ -27,6 +27,10 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: soroban-contracts + # A run that fails on fmt/clippy has still built the whole + # dependency tree; caching it anyway keeps the retry from + # recompiling soroban-sdk from scratch. + cache-on-failure: true - name: Format check run: cargo fmt --check - name: Clippy (warnings are errors) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md new file mode 100644 index 0000000..a065242 --- /dev/null +++ b/soroban-contracts/CHANGELOG.md @@ -0,0 +1,93 @@ +# Changelog + +Notable changes to the Soroban contracts in this workspace. Entries are +newest first and reference the issue and PR they came from. + +This file starts at the emergency circuit breaker (#42). Earlier work is +summarised under [Before this changelog](#before-this-changelog) from the +commit history rather than reconstructed in detail, so nothing here claims +more precision than it has. + +The workspace is pre-release: nothing is deployed, every contract is on +storage version 1, and no migration has been needed yet. Versioned release +sections start once something ships. + +## [Unreleased] + +### Added + +- **Emergency circuit breaker across `escrow`, `reputation`, `loyalty-token` + and `loyalty-emissions`** ([#42](https://github.com/workman-labs/guildworkman-core/issues/42), + [PR #46](https://github.com/workman-labs/guildworkman-core/pull/46)). A shared pausability primitive in + `contracts/governance-guard`'s new `pausable` module, built so that a halt + can never become a fund trap: + - **Scoped guards** rather than one global flag — `SCOPE_INTAKE`, + `SCOPE_SETTLEMENT`, `SCOPE_ATTESTATION` — so paths that let value *in* + can be halted independently of paths that let it *out*. + - **Fund-recovery paths are never guarded**, enforced by omission at the + call sites. `escrow`'s `cancel_appointment` and every dispute + entrypoint, `loyalty-token`'s `transfer`/`transfer_from`/`approve`/ + `burn` of already-held balances, `loyalty-emissions`' `reclaim`, and all + read-only views carry no pause check. No scope value, `ALL_SCOPES` + included, can reach them. + - **Ledger-time auto-expiry** capped at `MAX_PAUSE_DURATION` (7 days), + evaluated on every guard consultation, so an unattended pause clears + itself with no transaction, no live admin and no working key. + - **Authorization by any single governance signer**, not the M-of-N + threshold and not each contract's `admin` — mirroring `cancel_upgrade`. + Unilateral in both directions, so a responder who pauses and then goes + offline cannot wedge it in place. + - New entrypoints on all four contracts: `pause`, `unpause` (masked, and + deliberately does not touch the deadline), `get_pause_state`, + `paused_scopes`, `is_paused`. + - New events `Paused { caller, scopes, expires_at, reason }` and + `Unpaused { caller, scopes, remaining_scopes }` for off-chain + monitoring. Auto-expiry emits nothing — it has no transaction behind it + — so `Paused.expires_at` is the authoritative end of a window unless an + `Unpaused` arrives sooner. + - A length-capped operator `reason` (≤ 64 UTF-8 **bytes**, not characters; + may be empty) stored with the pause and emitted with the event, so "why + is this halted?" is answerable from chain state. Client-side validation + must count bytes. + - Event wire format is pinned by assertion, not just documented: topics are + `[Symbol("gov_pause"), Symbol("paused"|"unpaused"), Address(caller)]` and + data is a `Map` keyed by field name. + - New errors per contract: `OperationPaused` (deliberately distinct from + any status error), `InvalidPauseScope`, `InvalidPauseDuration`, + `NotPaused`, `InvalidPauseReason` — appended so no existing code moved. + - New storage key `GovernanceDataKey::PauseState`, appended after + `PendingRotation` so deployed contracts' key encodings stay put. + - `scripts/broadcast-pause.sh` for sweeping a mask across all four + contracts during an incident. + +### Changed + +- CI (`.github/workflows/soroban-ci.yml`) now caches Cargo artifacts on + failed runs too (`cache-on-failure`), so a run that fails on `fmt` or + `clippy` doesn't make the retry rebuild `soroban-sdk` from scratch. + +### Notes + +- Measured guard overhead on the hot path: ~168 CPU instructions when no + pause has been set (against ~336k for a full `create_appointment`), rising + to ~25k only while a pause record actually exists. Full table in + [README.md](README.md#hot-path-cost); tests in + `contracts/escrow/src/test.rs` under "hot-path cost". Numbers are + SDK-test-metered and underestimate compiled Wasm — they are a relative + comparison, not a fee estimate. + +## Before this changelog + +Reconstructed from commit history; see each PR for detail. + +- Governance signer rotation for the upgrade guard — timelocked + propose/approve/execute, no unilateral veto (#27, PR #32). +- `dispute-resolution` v2 (PR #31), and the original decentralized dispute + resolution with staked jury commit-reveal voting and slashing (PR #28). +- Multi-sig governance guard for contract upgrades and storage migration + (#16, PR #26). +- `reputation`: sybil-resistant weighted scoring from signed attestations, + with time decay and rate limiting (PR #20). +- `loyalty-emissions`: streaming emission engine with per-account and global + rate limiting, plus admin reclaim (PR #19). +- Monorepo split into `backend-api/` and `soroban-contracts/` (c405b31). diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index 24da3a7..6acbd45 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -24,6 +24,7 @@ holding money, recording reviews, issuing rewards — on-chain. - [Project ecosystem](#project-ecosystem) - [Architecture](#architecture) - [Upgrade governance](#upgrade-governance) +- [Emergency circuit breaker](#emergency-circuit-breaker) - [Prerequisites](#prerequisites) - [Build](#build) - [Test](#test) @@ -33,6 +34,8 @@ holding money, recording reviews, issuing rewards — on-chain. - [Notes / follow-ups](#notes--follow-ups) - [License](#license) +Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md). + ## Project ecosystem GuildWorkman lives in two repositories: @@ -137,6 +140,292 @@ variants it inherited from `governance-guard`, at whatever numeric offset came next in that contract's existing `Error` enum — the variant names are identical across all four, only the numbers differ. +## Emergency circuit breaker + +Jump to: [Pause authorization](#pause-authorization) · +[Pause entrypoints](#pause-entrypoints) · [Events](#events) · +[Pause errors](#pause-errors) · [Pause storage layout](#pause-storage-layout) · +[Which clock](#which-clock) · [Hot-path cost](#hot-path-cost) · +[Pausing from the CLI](#pausing-from-the-cli) + +The same four contracts can be **paused** during an incident. The primitive +lives in `contracts/governance-guard`'s `pausable` module, next to the +upgrade guard and for the same reason: every contract that needs it already +depends on that crate. + +A plain `bool paused` flag would be a rug pull waiting to happen, so this one +is built around three properties instead. + +**1. Scoped, not global.** A pause names a bitmask of scopes rather than +freezing the contract: + +| Scope | Bit | Meaning | Guarded entrypoints | +|---|---|---|---| +| `SCOPE_INTAKE` | `1` | New value or new obligations entering the system | `escrow`: `create_appointment`, `create_milestone_escrow`, `add_milestone` · `loyalty-token`: `mint` · `loyalty-emissions`: `create_schedule` | +| `SCOPE_SETTLEMENT` | `2` | Discretionary happy-path payouts | `escrow`: `confirm_completion`, `approve_milestone`, `release_milestone_funds` · `loyalty-emissions`: `claim` | +| `SCOPE_ATTESTATION` | `4` | Reputation writes | `reputation`: `submit_attestation` | + +`ALL_SCOPES` (`7`) is all three. A contract with no entrypoint in some scope +ignores a pause naming it — pausing `SCOPE_ATTESTATION` on `escrow` is a +well-formed no-op, not an error, so an operator can broadcast one mask to +every contract without special-casing. + +**2. Fund-recovery paths are never guarded.** This is the constraint the +whole design exists to satisfy, and it is enforced by *omission* — the +following entrypoints carry no pause check at all, so no scope value, +`ALL_SCOPES` included, can reach them: + +| Contract | Always callable, even while paused | Why | +|---|---|---| +| `escrow` | `cancel_appointment`, `raise_dispute`, `resolve_dispute`, `raise_milestone_dispute`, `resolve_milestone_dispute` | Every route by which an escrowed balance reaches whoever is entitled to it. Pausing intake stops new money entering; pausing a refund would create a hostage situation. | +| `loyalty-token` | `transfer`, `transfer_from`, `approve`, `burn` | These move *already-held* balances. A holder's points are their property; only `mint` — the sole path that creates supply that doesn't yet exist — is guarded. | +| `loyalty-emissions` | `reclaim` | Mints and burns nothing; it only marks an allocation as never-to-be-minted, so it can't take a balance anyone holds. | +| all | every read-only view | A consumer must always be able to see current state, including the state that prompted the halt. | + +Pausing `SCOPE_SETTLEMENT` does withhold a payout, which deserves an explicit +argument rather than an assumption. It is a delay, not a seizure: it changes +no party's power relative to the others (a client could always cancel a +`Funded` appointment; either party could always force a dispute — both stay +open), and `release_milestone_funds` is *permissionless*, which is exactly +why it has to be haltable. On `loyalty-emissions`, vesting is a pure function +of the ledger clock and keeps accruing through a pause, so a halted `claim` +mints the identical amount afterwards, just later. + +**3. Time-bound, enforced on read.** A pause carries an `expires_at` ledger +timestamp, capped at `MAX_PAUSE_DURATION` (7 days). Expiry is evaluated every +time a guard is consulted, so a pause lapses with **no unpause transaction, +no live admin and no working key** — an abandoned pause is indistinguishable +from no pause at all. + +What this deliberately does *not* promise: an admin who is present and +hostile can re-pause each time the window lapses. That is not closable here — +the same signer set can already replace all of a contract's code through the +upgrade flow. The honest guarantee is narrower: *an unattended pause always +clears*, and *no pause of any duration can stop a user from recovering funds +they already own*. The second half is what keeps the residual risk a liveness +problem for new business rather than a custody problem for existing balances. + +#### Pause authorization + +`pause` and `unpause` require **any single governance +signer** — not the full M-of-N threshold, and deliberately not each +contract's own `admin`. Gathering a threshold takes time an incident doesn't +give you, and the action being authorized is bounded on every axis that +matters, so this mirrors `cancel_upgrade`, which is unilateral for the same +reason. Using the signer set rather than `admin` matters because the signer +set is M-of-N, is rotatable through the timelocked flow, and survives the +loss of any one key — an incident is exactly when a single non-rotatable key +is least trustworthy. `unpause` is unilateral in the same way, so a responder +who places a pause and then goes offline cannot wedge it in place. + +#### Pause entrypoints + +Added to all four contracts: + +- `pause(caller: Address, scopes: u32, duration_secs: u64, reason: String) -> PauseState` +- `unpause(caller: Address, scopes: u32) -> u32` — clears only the named + scopes and returns what's still halted, *without* touching the deadline. + Re-calling `pause` with a narrower mask would also lift scopes, but restarts + the clock on everything left; partial `unpause` is how you bring the system + back a piece at a time. +- `get_pause_state() -> Option` — the active pause as + `{ scopes, expires_at, paused_by, paused_at, reason }`, or `None` once + expired. This is the one call an operator or watcher needs: mask and + deadline in a single read. +- `paused_scopes() -> u32`, `is_paused(scope: u32) -> bool` — narrower + convenience views over the same record. + +`reason` is free-form operator context, may be empty, and is stored and +emitted verbatim — never parsed or compared — so "why is this halted?" is +answerable from chain state instead of from a chat log nobody can find at +3am. It is length-capped because it lives in instance storage that every +subsequent invocation pays to load: an incident ticket reference belongs +on-chain, the write-up does not. + +> **The cap is 64 UTF-8 _bytes_, not characters.** `MAX_PAUSE_REASON_LEN` +> bounds what `String::len()` reports, which is the byte length. For ASCII +> the two are identical, which is exactly why this is worth stating: a +> 33-character reason made of two-byte code points is **66 bytes and is +> rejected**, even though a character-count check would have passed it. +> Client-side validation must count bytes — `Buffer.byteLength(s, 'utf8')` +> in JS, `len(s.encode('utf-8'))` in Python — or restrict input to ASCII. +> Both boundaries are pinned by tests +> (`a_multibyte_reason_is_measured_in_bytes_and_accepted_at_exactly_the_cap` +> and `…_over_the_byte_cap_is_rejected_despite_a_short_char_count` in +> `contracts/governance-guard/src/pausable_test.rs`). + +#### Events + +Two events let off-chain monitoring react. Both shapes are pinned by +assertion in `contracts/escrow/src/test.rs` +(`paused_event_has_the_documented_topics_and_data_shape` and its `unpaused` +counterpart), so this table cannot drift from what is emitted without a test +failing. + +Topics are ordered: the two prefix symbols first, then each `#[topic]` field +in declaration order. Data is a **`Map` keyed by field name** +(`data_format` defaults to `"map"`), so field *names* are part of the +contract but their order is not — index by key, not by position. + +| Event | Topics (in order) | Data map | +|---|---|---| +| `Paused` | `Symbol("gov_pause")`, `Symbol("paused")`, `Address(caller)` | `expires_at: u64`, `reason: String`, `scopes: u32` | +| `Unpaused` | `Symbol("gov_pause")`, `Symbol("unpaused")`, `Address(caller)` | `scopes: u32`, `remaining_scopes: u32` | + +`scopes` on `Paused` is the full set halted *after* the call — the new state, +not a delta. On `Unpaused` it is the set that call *cleared*, with +`remaining_scopes` the set still halted afterwards (`0` when fully lifted). + +As an indexer would see them, after +`pause(signer, SCOPE_INTAKE, 7200, "INC-412")` at ledger timestamp `1000`, +then `unpause(signer, SCOPE_INTAKE)`: + +```jsonc +// Paused +{ + "contract": "C…ESCROW", + "topics": ["gov_pause", "paused", "G…SIGNER"], + "data": { "scopes": 1, "expires_at": 8200, "reason": "INC-412" } +} +// Unpaused +{ + "contract": "C…ESCROW", + "topics": ["gov_pause", "unpaused", "G…SIGNER"], + "data": { "scopes": 1, "remaining_scopes": 0 } +} +``` + +**Auto-expiry emits nothing.** It is a read-time evaluation with no +transaction behind it, so there is no execution context to emit from — which +is the same property that makes expiry trustworthy in the first place. +Monitors should treat the `expires_at` carried by `Paused` as the +authoritative end of a window unless an `Unpaused` arrives sooner, and must +not wait for an event that will never come. + +#### Pause errors + +Five variants per contract, at whatever offset came next in that +contract's existing `Error` enum — identical names, different numbers: + +| Variant | `escrow` | `reputation` | `loyalty-token` | `loyalty-emissions` | Meaning | +|---|---|---|---|---|---| +| `OperationPaused` | 37 | 29 | 24 | 30 | The entrypoint's scope is currently halted. A dedicated variant rather than a reused `InvalidStatus`: "the protocol is halted, retry later" and "this request was never valid" call for opposite reactions from a client. | +| `InvalidPauseScope` | 38 | 30 | 25 | 31 | The scope mask was empty or contained bits outside `ALL_SCOPES`. Empty is rejected rather than treated as a no-op — during an incident a mask that halts nothing is a mistake the operator wants to hear about. | +| `InvalidPauseDuration` | 39 | 31 | 26 | 32 | The duration was `0` or exceeded `MAX_PAUSE_DURATION`. | +| `NotPaused` | 40 | 32 | 27 | 33 | `unpause` with nothing in effect, including a record that already auto-expired. | +| `InvalidPauseReason` | 41 | 33 | 28 | 34 | The `reason` exceeded `MAX_PAUSE_REASON_LEN` (64 bytes). | + +A non-signer calling `pause`/`unpause` gets the existing `NotASigner`. + +#### Pause storage layout + +One instance-storage entry, `GovernanceDataKey::PauseState`, +holding `PauseState { scopes, expires_at, paused_by, paused_at, reason }`. +Appended after `PendingRotation` so already-deployed contracts' key encodings +stay put. Only one record exists at a time — a second `pause` replaces the +first outright rather than layering — so the halted scopes and the deadline +are always readable from one place. `paused_by` is recorded for attribution +and grants no rights: any signer may lift a pause, not just the one who +placed it. + +#### Which clock + +`expires_at` is compared against `env.ledger().timestamp()` +— Stellar's ledger close time in seconds, agreed by SCP consensus and +required to be monotonic, not a value any single validator picks. The +manipulation surface is small and points the harmless way: nudging the clock +forward can only end a pause *sooner*, backward isn't possible, and no +fund-recovery path consults a clock at all. Wall-clock seconds rather than +ledger sequence (which the upgrade timelocks use) because a pause duration is +negotiated between humans mid-incident — "give us six hours" — and seconds say +that directly. + +#### Hot-path cost + +The guard runs on every guarded entrypoint, so its cost is measured rather +than assumed, using the SDK's budget metering. Tests live in +`contracts/escrow/src/test.rs` under "hot-path cost". + +| Measurement | CPU instructions | Δ | +|---|---:|---:| +| Trivial instance-storage view (`get_storage_version`) — the floor | 51,549 | — | +| `paused_scopes()`, **no pause record** | 51,717 | **+168** | +| `paused_scopes()`, live pause record | 77,023 | +25,474 | +| `create_appointment`, **no pause record** | 336,299 | — | +| `create_appointment`, live record on another scope | 365,219 | +8.6% | +| `create_appointment` rejected while paused | 78,216 | 23% of the above | + +> ⚠️ **These are SDK-test-metered numbers, not absolute Wasm costs or fees.** +> Per the SDK's own documentation, native Rust test execution underestimates +> both CPU and memory relative to the compiled Wasm, and this harness charges +> no Wasm instantiation. They are meaningful as a *relative* comparison of +> the same call with and without a pause record — which is the question being +> asked — and should not be used to size a transaction fee. + +The shape that matters: **in normal operation — no pause ever set, which is +the state the contracts are in essentially always — the guard costs ~168 +instructions against a ~336,000-instruction booking.** That is a rounding +error. The ~25,000 figure is deserializing the `PauseState` record, and is +paid only *while an incident is in progress*, which is precisely when +degraded throughput is the point. It is also why `reason` is length-capped: +the cap is what keeps the incident-time cost bounded too. + +A rejected call costs about 23% of the work it replaces, because the guard is +the first statement of each guarded entrypoint, ahead of auth and every other +storage access. That makes a pause a usable response to an entrypoint being +hammered rather than an amplifier. + +No micro-optimization is warranted at these numbers. The guard is one +instance-storage read plus two integer comparisons, and the instance entry is +already in the footprint of any invocation that touches admin or governance +state — so it is a hit on an entry the host has loaded regardless, not an +extra ledger read. The tests fence *relative* behaviour (a deliberately loose +"must not approach doubling") rather than absolute numbers, so an SDK bump +doesn't produce a brittle CI failure. + +#### Pausing from the CLI + +```sh +# Halt new bookings for 6 hours (any one governance signer) +stellar contract invoke --id $ESCROW --source signer1 --network testnet \ + -- pause --caller $SIGNER_1 --scopes 1 --duration_secs 21600 \ + --reason "INC-412 milestone accounting" + +# Halt everything the breaker can reach, for the 7-day maximum +stellar contract invoke --id $ESCROW --source signer1 --network testnet \ + -- pause --caller $SIGNER_1 --scopes 7 --duration_secs 604800 --reason "INC-412" + +# Refunds and disputes keep working throughout — no scope reaches them +stellar contract invoke --id $ESCROW --source client --network testnet \ + -- cancel_appointment --appointment_id 1 + +# Bring intake back early, leaving settlement halted on its original deadline +stellar contract invoke --id $ESCROW --source signer2 --network testnet \ + -- unpause --caller $SIGNER_2 --scopes 1 + +# What's halted right now, until when, and why +stellar contract invoke --id $ESCROW --source signer1 --network testnet \ + -- get_pause_state +``` + +**Broadcasting to every contract.** The scope vocabulary is shared, so one +mask goes to all four — including `reputation`, which has no intake +entrypoint, since a scope a contract doesn't use is a no-op rather than an +error. The four are separate deployments with separate storage, so this is +N transactions, not one: they needn't land in the same ledger, and a partial +sweep is a valid state rather than a corrupt one, because each contract's +guard reads only its own record. `scripts/broadcast-pause.sh` does the sweep: + +```sh +export ESCROW=… REPUTATION=… LOYALTY_TOKEN=… LOYALTY_EMISSIONS=… +SIGNER=my-key ./scripts/broadcast-pause.sh pause 7 21600 "INC-412 triage" +SIGNER=my-key ./scripts/broadcast-pause.sh status +SIGNER=my-key ./scripts/broadcast-pause.sh unpause 1 +``` + +Note each contract has its **own** governance signer set; if they differ, run +the script once per key with only the matching ids exported. + ## Prerequisites - Rust with the `wasm32v1-none` target: `rustup target add wasm32v1-none` @@ -245,6 +534,7 @@ stellar contract invoke --id $LOYALTY --source admin --network testnet \ - `resolve_dispute(appointment_id: u64, refund_to_client: bool)` — admin-only - `get_appointment(appointment_id: u64) -> Appointment` - `propose_upgrade`, `approve_upgrade`, `cancel_upgrade`, `migrate`, `get_signers`, `get_upgrade_threshold`, `get_pending_upgrade`, `get_storage_version` — see [Upgrade governance](#upgrade-governance) +- `pause`, `unpause`, `get_pause_state`, `paused_scopes`, `is_paused` — see [Emergency circuit breaker](#emergency-circuit-breaker) #### Storage layout @@ -276,6 +566,10 @@ stellar contract invoke --id $LOYALTY --source admin --network testnet \ | `AlreadyMigrated` | 17 | `migrate` targeting a version already applied or behind the current one. | | `NothingToMigrate` | 18 | `migrate` called when the stored version is already current. | +Codes 19-36 (milestone escrow and signer rotation) are documented in +`src/lib.rs`; codes 37-41 are the circuit breaker's, listed in +[Emergency circuit breaker](#emergency-circuit-breaker). + #### CLI usage ```sh @@ -322,8 +616,10 @@ stellar contract invoke --id $ESCROW --source admin --network testnet \ > of this PR: `initialize` now also takes a `governance_init: GovernanceInit`, > and the contract has the same `propose_upgrade`/`approve_upgrade`/ > `cancel_upgrade`/`migrate` surface described in -> [Upgrade governance](#upgrade-governance) — see the source and -> `src/test.rs` for the actual current interface. +> [Upgrade governance](#upgrade-governance), plus the `pause`/`unpause` +> surface described in +> [Emergency circuit breaker](#emergency-circuit-breaker) — see the source +> and `src/test.rs` for the actual current interface. - `submit_review(appointment_id: u64, client: Address, worker: Address, rating: u32, comment: String)` — 1-5 stars, one review per `appointment_id` - `get_rating(worker: Address) -> Rating { count, sum }` @@ -375,6 +671,7 @@ stellar contract invoke --id $REPUTATION --source admin --network testnet \ - `mint(to: Address, amount: i128)` — minter-only - `transfer`, `transfer_from`, `approve`, `allowance`, `burn`, `balance`, `decimals`, `name`, `symbol` — standard SEP-41 token surface - `propose_upgrade`, `approve_upgrade`, `cancel_upgrade`, `migrate`, `get_signers`, `get_upgrade_threshold`, `get_pending_upgrade`, `get_storage_version` — see [Upgrade governance](#upgrade-governance) +- `pause`, `unpause`, `get_pause_state`, `paused_scopes`, `is_paused` — see [Emergency circuit breaker](#emergency-circuit-breaker) #### Storage layout @@ -408,6 +705,10 @@ stellar contract invoke --id $REPUTATION --source admin --network testnet \ | `AlreadyMigrated` | 16 | `migrate` targeting a version already applied or behind the current one. | | `NothingToMigrate` | 17 | `migrate` called when the stored version is already current. | +Codes 18-23 (signer rotation) are documented in `src/lib.rs`; codes 24-28 are +the circuit breaker's, listed in +[Emergency circuit breaker](#emergency-circuit-breaker). + #### CLI usage ```sh @@ -469,6 +770,7 @@ allocations left unclaimed past a deadline. `get_schedule(beneficiary) -> Schedule`, `get_config() -> Config`, `get_admin() -> Address`, `get_token() -> Address` — read-only views - `propose_upgrade`, `approve_upgrade`, `cancel_upgrade`, `migrate`, `get_signers`, `get_upgrade_threshold`, `get_pending_upgrade`, `get_storage_version` — see [Upgrade governance](#upgrade-governance) +- `pause`, `unpause`, `get_pause_state`, `paused_scopes`, `is_paused` — see [Emergency circuit breaker](#emergency-circuit-breaker) #### Vesting & rate-limit model @@ -524,6 +826,10 @@ the admin can never reclaim allocations that are still vesting. | `AlreadyMigrated` | 22 | `migrate` targeting a version already applied or behind the current one. | | `NothingToMigrate` | 23 | `migrate` called when the stored version is already current. | +Codes 24-29 (signer rotation) are documented in `src/lib.rs`; codes 30-34 are +the circuit breaker's, listed in +[Emergency circuit breaker](#emergency-circuit-breaker). + #### Authorization & safety notes - **Trust root is the admin.** Only the admin can register or reclaim @@ -716,12 +1022,17 @@ stellar contract invoke --id $DISPUTES --source juror --network testnet \ (that responsibility sits with whatever system calls `create_appointment` and `submit_review` with real appointment IDs — today, nothing does, since the Java backend isn't integrated yet). -- **Upgrade path exists; pause does not.** All four contracts have a - multi-sig-gated code-upgrade and storage-migration path (see - [Upgrade governance](#upgrade-governance)) — fixing a deployed bug no - longer requires deploying a new contract and migrating callers manually. - There's still no emergency pause switch, and the governance signer set is - fixed at `initialize` with no rotation flow yet. +- **A pause is a liveness risk, not a custody risk — by construction.** All + four contracts can now be halted per-scope (see + [Emergency circuit breaker](#emergency-circuit-breaker)), and any *single* + governance signer can do it. Be clear about what that buys an attacker who + compromises one key: they can block new bookings, payouts and attestations + for up to 7 days per call, and can re-arm each time a window lapses. They + cannot touch a balance that already exists — refunds, disputes and token + transfers of held balances carry no pause check at all — and they cannot + make a halt outlive their own presence, because expiry is evaluated on + every read. Sizing `MAX_PAUSE_DURATION` is the knob that trades response + headroom against that griefing window. - **The upgrade path's actual Wasm swap is untested by `cargo test`.** `propose_upgrade`/`approve_upgrade` crossing the configured threshold calls `update_current_contract_wasm`, which requires the target hash to diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index e514ed4..29ff95e 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -21,7 +21,7 @@ //! | `DataKey::Admin` | instance | `Address` | Admin/arbiter for dispute resolution | //! | `DataKey::Appointment(id)` | persistent | `Appointment` | Simple escrow state | //! | `DataKey::MilestoneEscrow(id)` | persistent | `MilestoneEscrow` | Milestone escrow state | -//! | `GovernanceDataKey::*` | instance | governance-guard types | M-of-N upgrade governance | +//! | `GovernanceDataKey::*` | instance | governance-guard types | M-of-N upgrade governance, signer rotation, and the emergency pause record | //! //! ## Authorization model //! @@ -31,14 +31,42 @@ //! - `raise_dispute` / `raise_milestone_dispute`: participant must authorize. //! - `resolve_dispute` / `resolve_milestone_dispute`: admin/arbiter must authorize. //! - `release_milestone_funds`: permissionless once conditions are met. +//! - `pause` / `unpause`: any single governance signer (not `admin`). //! - All functions follow checks-effects-interactions to prevent reentrancy. +//! +//! ## Emergency circuit breaker +//! +//! Scoped, self-expiring pausability from `guildworkman-governance-guard`; +//! see that crate's `pausable` module for the full rationale. In this +//! contract: +//! +//! | Entrypoint | Scope | +//! |---|---| +//! | `create_appointment`, `create_milestone_escrow`, `add_milestone` | `SCOPE_INTAKE` | +//! | `confirm_completion`, `approve_milestone`, `release_milestone_funds` | `SCOPE_SETTLEMENT` | +//! | `cancel_appointment`, `raise_dispute`, `resolve_dispute`, `raise_milestone_dispute`, `resolve_milestone_dispute` | **none — never pausable** | +//! +//! That last row is the design constraint, not an oversight: a pause must +//! never trap user funds, so every route by which an escrowed balance +//! reaches whoever is entitled to it is left unguarded. There is no scope +//! value, `ALL_SCOPES` included, that halts a refund or a dispute. A pause +//! can therefore stop new money entering and delay a discretionary payout, +//! but cannot strand money already held here — and it lapses on its own at +//! `expires_at` with no admin transaction required. +//! +//! `pause` carries a length-capped operator `reason` recorded alongside the +//! deadline, so `get_pause_state` answers "what is halted, until when, and +//! why" in one read. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Vec, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, String, Vec, }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; +pub use guildworkman_governance_guard::{ + PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_INTAKE, SCOPE_SETTLEMENT, +}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -165,6 +193,12 @@ pub enum Error { RotationTimelockActive = 34, RotationExpired = 35, RotationInProgress = 36, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 37, + InvalidPauseScope = 38, + InvalidPauseDuration = 39, + NotPaused = 40, + InvalidPauseReason = 41, } impl From for Error { @@ -186,6 +220,11 @@ impl From for Error { governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, governance::GovernanceError::RotationExpired => Error::RotationExpired, governance::GovernanceError::RotationInProgress => Error::RotationInProgress, + governance::GovernanceError::OperationPaused => Error::OperationPaused, + governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, + governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, + governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -315,7 +354,58 @@ impl EscrowContract { governance::current_storage_version(&env) } + // ----- Emergency circuit breaker ----- + + /// Halts `scopes` for `duration_secs` seconds, authorized by any single + /// governance signer. Returns the resulting pause record. + /// + /// In this contract [`SCOPE_INTAKE`] covers `create_appointment`, + /// `create_milestone_escrow` and `add_milestone`, while + /// [`SCOPE_SETTLEMENT`] covers `confirm_completion`, + /// `approve_milestone` and `release_milestone_funds`. **No scope + /// reaches `cancel_appointment`, `raise_dispute`, `resolve_dispute`, + /// `raise_milestone_dispute` or `resolve_milestone_dispute`** — every + /// route by which an escrowed balance can get back to whoever is + /// entitled to it is unguarded, so a pause can delay new business but + /// can never strand money already held here. + pub fn pause( + env: Env, + caller: Address, + scopes: u32, + duration_secs: u64, + reason: String, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) + } + + /// Clears `scopes` from the active pause early. Returns the scopes still + /// halted. Any single governance signer may call it — including one who + /// did not place the pause. + pub fn unpause(env: Env, caller: Address, scopes: u32) -> Result { + governance::unpause(&env, caller, scopes).map_err(Into::into) + } + + /// The active pause record, or `None` if nothing is halted — including + /// when a pause was placed but has since auto-expired. + pub fn get_pause_state(env: Env) -> Option { + governance::get_pause_state(&env) + } + + /// Bitmask of currently halted scopes; `0` when the contract is open. + pub fn paused_scopes(env: Env) -> u32 { + governance::paused_scopes(&env) + } + + /// Whether any bit of `scope` is currently halted. + pub fn is_paused(env: Env, scope: u32) -> bool { + governance::is_paused(&env, scope) + } + /// Client books a skilled worker and deposits `amount` of `token` into escrow. + /// + /// Guarded by [`SCOPE_INTAKE`]: this is new money walking into the + /// contract, which is the first thing to stop in an incident and the + /// one thing that provably cannot strand anything when stopped. pub fn create_appointment( env: Env, appointment_id: u64, @@ -324,6 +414,10 @@ impl EscrowContract { token: Address, amount: i128, ) -> Result<(), Error> { + // Guard first, before auth and before any storage read: a halted + // operation should cost nothing and reveal nothing beyond the + // already-public pause state. + governance::require_not_paused(&env, governance::SCOPE_INTAKE)?; client.require_auth(); if amount <= 0 { @@ -355,7 +449,15 @@ impl EscrowContract { } /// Client confirms the job was completed satisfactorily; releases funds to the worker. + /// + /// Guarded by [`SCOPE_SETTLEMENT`]. Pausing this withholds a payout, so + /// it is worth being explicit that it is a delay and not a seizure: the + /// client can still `cancel_appointment` for a full refund and either + /// party can still force `raise_dispute` → `resolve_dispute`, exactly + /// as they could before any of this existed. The escrowed amount stays + /// reachable by whoever is entitled to it throughout. pub fn confirm_completion(env: Env, appointment_id: u64) -> Result<(), Error> { + governance::require_not_paused(&env, governance::SCOPE_SETTLEMENT)?; let key = DataKey::Appointment(appointment_id); let mut appointment = Self::read_appointment(&env, &key)?; @@ -378,6 +480,11 @@ impl EscrowContract { } /// Client cancels before the job starts; refunds the client in full. + /// + /// **Deliberately unguarded by the circuit breaker.** This is the + /// client's fund-recovery path; pausing it would convert an incident + /// response into a hostage situation. There is no scope value that + /// halts this function. pub fn cancel_appointment(env: Env, appointment_id: u64) -> Result<(), Error> { let key = DataKey::Appointment(appointment_id); let mut appointment = Self::read_appointment(&env, &key)?; @@ -402,6 +509,10 @@ impl EscrowContract { /// Either the client or the worker can flag a disagreement, freezing the funds /// until the admin resolves it. + /// + /// **Deliberately unguarded by the circuit breaker** — raising a + /// dispute is how a party who cannot use the happy path reaches + /// `resolve_dispute`, which is itself a recovery route. pub fn raise_dispute(env: Env, appointment_id: u64, caller: Address) -> Result<(), Error> { caller.require_auth(); @@ -422,6 +533,10 @@ impl EscrowContract { /// Admin/arbiter resolves a dispute by sending the escrowed funds to whichever /// side is owed them. + /// + /// **Deliberately unguarded by the circuit breaker** — this is the + /// escrow's ultimate fund-recovery route and must stay reachable no + /// matter what else is halted. pub fn resolve_dispute( env: Env, appointment_id: u64, @@ -469,11 +584,14 @@ impl EscrowContract { /// Create a milestone-based escrow. `total_amount` is pulled from the client /// immediately. `arbiter` resolves disputes when `mode` is `AdminArbiter`; /// `hook_address` is called for `ExternalHook`. + /// + /// Guarded by [`SCOPE_INTAKE`] — new money entering the contract. pub fn create_milestone_escrow( env: Env, escrow_id: u64, init: MilestoneEscrowInit, ) -> Result<(), Error> { + governance::require_not_paused(&env, governance::SCOPE_INTAKE)?; init.client.require_auth(); if init.total_amount <= 0 { @@ -515,6 +633,10 @@ impl EscrowContract { /// Add a milestone to a funded escrow. Only the client can add milestones. /// The sum of all milestone amounts must equal `total_amount`. + /// + /// Guarded by [`SCOPE_INTAKE`]: a milestone is a new obligation carved + /// out of held funds, so it belongs with the other intake paths even + /// though it moves no tokens itself. pub fn add_milestone( env: Env, escrow_id: u64, @@ -522,6 +644,7 @@ impl EscrowContract { amount: i128, deadline: u32, ) -> Result { + governance::require_not_paused(&env, governance::SCOPE_INTAKE)?; let key = DataKey::MilestoneEscrow(escrow_id); let mut escrow = Self::read_milestone_escrow(&env, &key)?; @@ -558,7 +681,12 @@ impl EscrowContract { } /// Client approves a milestone, marking it ready for time-locked release. + /// + /// Guarded by [`SCOPE_SETTLEMENT`] — approval is the step that arms a + /// release, so halting settlement without halting approval would just + /// queue up a burst of releases the moment the pause lapsed. pub fn approve_milestone(env: Env, escrow_id: u64, milestone_index: u32) -> Result<(), Error> { + governance::require_not_paused(&env, governance::SCOPE_SETTLEMENT)?; let key = DataKey::MilestoneEscrow(escrow_id); let mut escrow = Self::read_milestone_escrow(&env, &key)?; @@ -593,11 +721,17 @@ impl EscrowContract { /// expired. Permissionless — anyone may call once the conditions are met. /// Checks-effects-interactions: milestone status is updated to `Released` /// before the token transfer. + /// + /// Guarded by [`SCOPE_SETTLEMENT`]. Being permissionless is exactly why + /// it has to be haltable: if the milestone accounting is ever wrong, + /// this is the entrypoint the error gets drained through, and no + /// authorization check stands in the way. pub fn release_milestone_funds( env: Env, escrow_id: u64, milestone_index: u32, ) -> Result { + governance::require_not_paused(&env, governance::SCOPE_SETTLEMENT)?; let key = DataKey::MilestoneEscrow(escrow_id); let mut escrow = Self::read_milestone_escrow(&env, &key)?; @@ -648,6 +782,9 @@ impl EscrowContract { /// The milestone must be in `Approved` or `Pending` status. The escrow /// transitions to `Disputed` and no further releases are possible until /// the dispute is resolved. + /// + /// **Deliberately unguarded by the circuit breaker** — the entry to a + /// recovery route is itself a recovery route. pub fn raise_milestone_dispute( env: Env, escrow_id: u64, @@ -694,6 +831,10 @@ impl EscrowContract { /// Resolve a disputed milestone. For `AdminArbiter` mode, only the arbiter /// can call. For `ExternalHook`, calls the hook contract. `refund_to_client` /// determines which party receives the milestone's funds. + /// + /// **Deliberately unguarded by the circuit breaker** — this is how + /// milestone funds reach their rightful owner when the happy path is + /// unavailable, including while [`SCOPE_SETTLEMENT`] is halted. pub fn resolve_milestone_dispute( env: Env, escrow_id: u64, diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index 47c8412..eb7265a 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -780,3 +780,611 @@ fn get_milestone_not_found_returns_error() { let res = ctx.contract.try_get_milestone(&1, &0); assert_eq!(res, Err(Ok(Error::MilestoneNotFound))); } + +// =========================================================================== +// Emergency circuit breaker +// =========================================================================== +// +// The primitive itself (scope arithmetic, expiry, auth) is unit-tested in +// `guildworkman-governance-guard`. What matters here is the wiring: that the +// intake and settlement entrypoints are guarded, and — the property the whole +// design rests on — that every fund-recovery entrypoint is *not*. + +/// Reason string for tests that don't exercise the field itself. Kept +/// non-empty so the round-trip through storage is actually covered by every +/// pause test rather than only the ones that look at it. +fn reason(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "INC-000 test") +} + +fn set_time(env: &Env, timestamp: u64) { + env.ledger().with_mut(|l| l.timestamp = timestamp); +} + +/// Halts every scope for an hour, from an arbitrary non-zero clock so that +/// "before" and "after the deadline" are both expressible. +fn pause_everything(ctx: &TestCtx) -> u64 { + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&ctx.contract.env), + ); + 1_000 +} + +// ----- Intake is halted ----- + +#[test] +fn paused_intake_blocks_new_appointments_and_moves_no_money() { + let ctx = setup(); + pause_everything(&ctx); + + let res = + ctx.contract + .try_create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + + // No funds entered the contract. + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn paused_intake_blocks_new_milestone_escrows_and_milestones() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&ctx.contract.env), + ); + + let res = ctx + .contract + .try_create_milestone_escrow(&2, &milestone_escrow_init(&ctx)); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + + let res = ctx + .contract + .try_add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + assert_eq!(res, Err(Ok(Error::OperationPaused))); +} + +// ----- Recovery survives a total pause ----- + +#[test] +fn a_client_can_still_cancel_and_be_refunded_while_everything_is_paused() { + // The headline guarantee: money already in escrow gets out, even with + // every scope the breaker knows about halted at once. + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); + + pause_everything(&ctx); + assert_eq!(ctx.contract.paused_scopes(), ALL_SCOPES); + + ctx.contract.cancel_appointment(&1); + + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); + assert_eq!(ctx.contract.get_appointment(&1).status, Status::Cancelled); + // Still paused afterwards — the recovery path is exempt, not a lift. + assert_eq!(ctx.contract.paused_scopes(), ALL_SCOPES); +} + +#[test] +fn disputes_can_still_be_raised_and_resolved_while_everything_is_paused() { + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + + pause_everything(&ctx); + + ctx.contract.raise_dispute(&1, &ctx.worker); + assert_eq!(ctx.contract.get_appointment(&1).status, Status::Disputed); + + ctx.contract.resolve_dispute(&1, &false); + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn milestone_disputes_resolve_while_everything_is_paused() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.raise_milestone_dispute(&1, &0, &ctx.client); + + pause_everything(&ctx); + + // Both halves of the milestone recovery route stay open. + ctx.contract.resolve_milestone_dispute(&1, &0, &true); + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +// ----- Scope isolation across entrypoints ----- + +#[test] +fn pausing_intake_alone_leaves_settlement_working() { + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&ctx.contract.env), + ); + + // Existing business settles normally; only new business is halted. + ctx.contract.confirm_completion(&1); + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); +} + +#[test] +fn pausing_settlement_alone_blocks_payout_but_not_new_appointments() { + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&ctx.contract.env), + ); + + let res = ctx.contract.try_confirm_completion(&1); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + assert_eq!(ctx.token_client.balance(&ctx.worker), 0); + + // Intake was never named, so it is untouched. + ctx.contract + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000); +} + +#[test] +fn paused_settlement_blocks_milestone_approval_and_release() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + set_ledger(&ctx.env, 201); + + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&ctx.contract.env), + ); + + // The permissionless release is exactly what the scope exists to stop. + let res = ctx.contract.try_release_milestone_funds(&1, &0); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); + + let res = ctx.contract.try_approve_milestone(&1, &0); + assert_eq!(res, Err(Ok(Error::OperationPaused))); +} + +// ----- Auto-expiry ----- + +#[test] +fn intake_resumes_on_its_own_once_the_pause_expires() { + let ctx = setup(); + let start = pause_everything(&ctx); + + let res = + ctx.contract + .try_create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + + // No unpause transaction. Only the ledger clock advances. + set_time(&ctx.env, start + 3_600); + + assert_eq!(ctx.contract.paused_scopes(), 0); + assert!(ctx.contract.get_pause_state().is_none()); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); +} + +#[test] +fn a_pause_longer_than_the_cap_is_refused_outright() { + let ctx = setup(); + set_time(&ctx.env, 1_000); + + let res = ctx.contract.try_pause( + &ctx.signers.get_unchecked(0), + &ALL_SCOPES, + &(MAX_PAUSE_DURATION + 1), + &reason(&ctx.contract.env), + ); + assert_eq!(res, Err(Ok(Error::InvalidPauseDuration))); + assert_eq!(ctx.contract.paused_scopes(), 0); +} + +// ----- Authorization (adversarial) ----- + +#[test] +fn a_non_signer_cannot_pause_the_escrow() { + let ctx = setup(); + let outsider = Address::generate(&ctx.env); + + let res = ctx + .contract + .try_pause(&outsider, &ALL_SCOPES, &3_600, &reason(&ctx.contract.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); + assert_eq!(ctx.contract.paused_scopes(), 0); + + // And business is genuinely unaffected, not merely reported as open. + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); +} + +#[test] +fn the_admin_arbiter_is_not_a_pause_authority() { + // `admin` resolves disputes; the breaker answers to the rotatable + // governance signer set instead. Pinning this down stops a later change + // from quietly widening who can halt the protocol. + let ctx = setup(); + let res = ctx + .contract + .try_pause(&ctx.admin, &ALL_SCOPES, &3_600, &reason(&ctx.contract.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn a_non_signer_cannot_unpause_the_escrow() { + let ctx = setup(); + pause_everything(&ctx); + let outsider = Address::generate(&ctx.env); + + let res = ctx.contract.try_unpause(&outsider, &ALL_SCOPES); + assert_eq!(res, Err(Ok(Error::NotASigner))); + assert_eq!(ctx.contract.paused_scopes(), ALL_SCOPES); +} + +#[test] +fn any_single_signer_can_lift_a_pause_another_signer_placed() { + // Deliberately unilateral in both directions: a responder who placed a + // pause and then went offline must not be able to wedge it in place. + let ctx = setup(); + pause_everything(&ctx); + + let remaining = ctx + .contract + .unpause(&ctx.signers.get_unchecked(2), &ALL_SCOPES); + assert_eq!(remaining, 0); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); +} + +// ----- Partial lift & views ----- + +#[test] +fn unpausing_intake_alone_reopens_bookings_while_settlement_stays_halted() { + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + pause_everything(&ctx); + + let remaining = ctx + .contract + .unpause(&ctx.signers.get_unchecked(1), &SCOPE_INTAKE); + assert_eq!(remaining & SCOPE_INTAKE, 0); + assert_eq!(remaining & SCOPE_SETTLEMENT, SCOPE_SETTLEMENT); + + ctx.contract + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000); + let res = ctx.contract.try_confirm_completion(&1); + assert_eq!(res, Err(Ok(Error::OperationPaused))); +} + +#[test] +fn pause_views_report_who_paused_and_until_when() { + let ctx = setup(); + let signer = ctx.signers.get_unchecked(1); + set_time(&ctx.env, 1_000); + ctx.contract + .pause(&signer, &SCOPE_INTAKE, &7_200, &reason(&ctx.contract.env)); + + let state = ctx.contract.get_pause_state().unwrap(); + assert_eq!(state.scopes, SCOPE_INTAKE); + assert_eq!(state.paused_by, signer); + assert_eq!(state.paused_at, 1_000); + assert_eq!(state.expires_at, 8_200); + + assert!(ctx.contract.is_paused(&SCOPE_INTAKE)); + assert!(!ctx.contract.is_paused(&SCOPE_SETTLEMENT)); +} + +// =========================================================================== +// Circuit breaker — hot-path cost +// =========================================================================== +// +// The guard runs on every guarded entrypoint, so its incremental cost is +// measured rather than assumed. Two caveats, both from the SDK's own docs: +// native Rust test execution underestimates CPU and memory relative to +// compiled Wasm, and this harness charges no Wasm instantiation. These are +// useful as *relative* comparisons between the same call with and without a +// pause record — which is the question being asked — not as fee estimates. +// +// Measured here (CPU instructions, this harness, at the time of writing): +// +// trivial instance-storage view (`get_storage_version`) 51,549 +// `paused_scopes()`, no pause record 51,717 (+168) +// `paused_scopes()`, live pause record 77,023 (+25,474) +// `create_appointment`, no pause record 336,299 +// `create_appointment`, live record on another scope 365,219 (+8.6%) +// `create_appointment` rejected while paused 78,216 (23%) +// +// The shape that matters: **in normal operation — no pause ever set, which +// is the state the contract is in essentially always — the guard costs on +// the order of 168 instructions**, a rounding error against a 336k- +// instruction booking. The ~25k figure is the deserialization of the +// `PauseState` struct and is only paid *while an incident is in progress*, +// when degraded throughput is the point. That is why the record is a single +// instance-storage entry with a length-capped `reason` rather than anything +// richer: the cap is what keeps the incident-time cost bounded too. +// +// No micro-optimization is warranted on these numbers. The guard is one +// instance-storage read plus two integer comparisons, and the instance entry +// is already in the footprint of any invocation that touches admin or +// governance state. + +fn cpu_cost_of(env: &Env, f: F) -> u64 { + let mut budget = env.cost_estimate().budget(); + budget.reset_default(); + f(); + env.cost_estimate().budget().cpu_instruction_cost() +} + +#[test] +fn the_guard_is_nearly_free_when_no_pause_has_ever_been_set() { + // The normal-operation path. Compared against a trivial view that also + // touches instance storage, so the delta isolates the guard rather than + // measuring invocation overhead. + let ctx = setup(); + let trivial = cpu_cost_of(&ctx.env, || { + ctx.contract.get_storage_version(); + }); + let guard = cpu_cost_of(&ctx.env, || { + ctx.contract.paused_scopes(); + }); + + assert!(guard >= trivial); + let overhead = guard - trivial; + assert!( + overhead < trivial / 10, + "guard overhead with no pause record should stay far below the cost \ + of the invocation it sits in: {overhead} vs {trivial}" + ); +} + +#[test] +fn guard_overhead_on_create_appointment_stays_bounded() { + // No pause record — what every call pays in normal operation. + let baseline_ctx = setup(); + let baseline = cpu_cost_of(&baseline_ctx.env, || { + baseline_ctx.contract.create_appointment( + &1, + &baseline_ctx.client, + &baseline_ctx.worker, + &baseline_ctx.token, + &10_000, + ); + }); + + // A live record covering a *different* scope: the guard loads and + // deserializes it on every call, the worst case for a call that still + // succeeds. + let loaded_ctx = setup(); + set_time(&loaded_ctx.env, 1_000); + loaded_ctx.contract.pause( + &loaded_ctx.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&loaded_ctx.contract.env), + ); + let with_record = cpu_cost_of(&loaded_ctx.env, || { + loaded_ctx.contract.create_appointment( + &1, + &loaded_ctx.client, + &loaded_ctx.worker, + &loaded_ctx.token, + &10_000, + ); + }); + + // A deliberately loose regression fence, not a claim about absolute + // cost: the guard is a storage read and two comparisons, so anything + // approaching a doubling means it stopped being that. + assert!( + with_record < baseline * 2, + "pause guard overhead grew unexpectedly: {baseline} -> {with_record} CPU insns" + ); + assert!(baseline > 0 && with_record > 0); +} + +#[test] +fn a_paused_call_costs_less_than_a_successful_one() { + // The guard is the first statement of each guarded entrypoint, ahead of + // auth and any other storage access, so rejection is strictly cheaper + // than doing the work. That is what makes a pause a usable response to + // an entrypoint being hammered, rather than an amplifier. + let ctx = setup(); + let allowed = cpu_cost_of(&ctx.env, || { + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + }); + + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&ctx.contract.env), + ); + let rejected = cpu_cost_of(&ctx.env, || { + let res = + ctx.contract + .try_create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + }); + + assert!( + rejected < allowed, + "paused rejection ({rejected}) should cost less than the work it replaces ({allowed})" + ); +} + +// =========================================================================== +// Circuit breaker — broadcast semantics +// =========================================================================== + +#[test] +fn a_scope_escrow_has_no_entrypoints_for_is_a_well_formed_no_op() { + // An operator sweeping every contract with one mask must not have to + // special-case which scopes a given contract implements. + let ctx = setup(); + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &governance::SCOPE_ATTESTATION, + &3_600, + &reason(&ctx.contract.env), + ); + + assert_eq!(ctx.contract.paused_scopes(), governance::SCOPE_ATTESTATION); + // Accepted and recorded, but escrow has no attestation entrypoint, so + // every one of its own paths stays open. + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.confirm_completion(&1); + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); +} + +// =========================================================================== +// Circuit breaker — event wire format +// =========================================================================== +// +// Off-chain indexers key off the exact topic ordering and data shape, so +// these are pinned by assertion rather than described in prose and hoped +// for. The README's "Events" table is generated from what these assert; if +// you change one, change both. + +#[test] +fn paused_event_has_the_documented_topics_and_data_shape() { + use soroban_sdk::{map, testutils::Events as _, vec, IntoVal, Map, Symbol, Val}; + + let ctx = setup(); + let signer = ctx.signers.get_unchecked(1); + let why = soroban_sdk::String::from_str(&ctx.env, "INC-412"); + set_time(&ctx.env, 1_000); + ctx.contract.pause(&signer, &SCOPE_INTAKE, &7_200, &why); + + // Prefix topics first, in declaration order, then the `#[topic]` + // fields — here just `caller`. + let topics: Vec = ( + Symbol::new(&ctx.env, "gov_pause"), + Symbol::new(&ctx.env, "paused"), + signer.clone(), + ) + .into_val(&ctx.env); + + // Non-topic fields become a map keyed by field name (`data_format` + // defaults to "map"), so field *order* is not part of the contract but + // field *names* are. + let data: Map = map![ + &ctx.env, + ( + Symbol::new(&ctx.env, "expires_at"), + 8_200u64.into_val(&ctx.env) + ), + (Symbol::new(&ctx.env, "reason"), why.into_val(&ctx.env)), + ( + Symbol::new(&ctx.env, "scopes"), + SCOPE_INTAKE.into_val(&ctx.env) + ), + ]; + + assert_eq!( + ctx.env.events().all(), + vec![ + &ctx.env, + ( + ctx.contract.address.clone(), + topics, + data.into_val(&ctx.env) + ) + ] + ); +} + +#[test] +fn unpaused_event_has_the_documented_topics_and_data_shape() { + use soroban_sdk::{map, testutils::Events as _, vec, IntoVal, Map, Symbol, Val}; + + let ctx = setup(); + let signer = ctx.signers.get_unchecked(2); + set_time(&ctx.env, 1_000); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&ctx.contract.env), + ); + ctx.contract.unpause(&signer, &SCOPE_INTAKE); + + let topics: Vec = ( + Symbol::new(&ctx.env, "gov_pause"), + Symbol::new(&ctx.env, "unpaused"), + signer.clone(), + ) + .into_val(&ctx.env); + + let remaining: u32 = SCOPE_SETTLEMENT | governance::SCOPE_ATTESTATION; + let data: Map = map![ + &ctx.env, + ( + Symbol::new(&ctx.env, "remaining_scopes"), + remaining.into_val(&ctx.env) + ), + ( + Symbol::new(&ctx.env, "scopes"), + SCOPE_INTAKE.into_val(&ctx.env) + ), + ]; + + assert_eq!( + ctx.env.events().all(), + vec![ + &ctx.env, + ( + ctx.contract.address.clone(), + topics, + data.into_val(&ctx.env) + ) + ] + ); +} diff --git a/soroban-contracts/contracts/governance-guard/src/lib.rs b/soroban-contracts/contracts/governance-guard/src/lib.rs index 73f1f45..855700f 100644 --- a/soroban-contracts/contracts/governance-guard/src/lib.rs +++ b/soroban-contracts/contracts/governance-guard/src/lib.rs @@ -1,7 +1,8 @@ #![no_std] -//! Shared multi-sig governance guard for gating contract upgrades and -//! storage migrations across the GuildWorkman core contracts. +//! Shared governance guard for the GuildWorkman core contracts: M-of-N +//! gating of contract upgrades and storage migrations, timelocked signer +//! rotation, and the emergency circuit breaker in [`pausable`]. //! //! This is a plain library, not a deployed contract on its own. Each //! contract that wants safe upgradeability depends on this crate as a path @@ -11,12 +12,16 @@ //! (its single `admin`, its config, etc.) and the governance state this //! crate manages. //! -//! Deliberately scoped to upgrade/migration only. It does not touch or +//! Deliberately scoped to protocol-level controls. It does not touch or //! replace each contract's existing single-admin auth for its normal //! operations (dispute resolution, config updates, minting, and so on) — -//! that would be a much larger, higher-risk change than this issue asked -//! for, and a compromised signer set should only ever be able to swap the -//! contract's code, not reach into its day-to-day admin powers. +//! that would be a much larger, higher-risk change than any of these +//! issues asked for, and a compromised signer set should only ever be able +//! to swap the contract's code or *halt* it, not reach into its day-to-day +//! admin powers. Note in particular that the circuit breaker can only +//! block operations, never redirect or seize funds: the strongest thing a +//! rogue signer gains from [`pausable`] is a bounded, self-expiring denial +//! of new business, and never custody of anything. //! //! ## Upgrade flow //! @@ -104,6 +109,10 @@ pub enum GovernanceDataKey { /// same time without clobbering each other. Appended last to keep the /// existing keys' encodings stable for already-deployed contracts. PendingRotation, + /// The single emergency-pause record, if any. See the [`pausable`] + /// module. Appended after `PendingRotation` for the same reason that + /// one was appended last: existing contracts' key encodings stay put. + PauseState, } #[contracttype] @@ -194,6 +203,24 @@ pub enum GovernanceError { /// at a time — this is what stops a lone signer from resetting a /// rotation's approvals or displacing a scheduled one. RotationInProgress = 16, + // --- Emergency circuit breaker (see the `pausable` module) --- + /// The entrypoint belongs to a scope that is currently paused. A + /// dedicated variant, deliberately not folded into any contract's + /// existing status error: "the protocol is halted, retry later" and + /// "this request was never valid" call for opposite reactions from a + /// client, and must not be reported identically. + OperationPaused = 17, + /// The scope mask passed to `pause`/`unpause` was empty or contained + /// bits outside `ALL_SCOPES`. + InvalidPauseScope = 18, + /// The requested pause duration was zero or exceeded + /// `MAX_PAUSE_DURATION`. + InvalidPauseDuration = 19, + /// `unpause` was called while nothing is in effect — either no pause + /// was ever placed, or the one that was has already auto-expired. + NotPaused = 20, + /// The `reason` passed to `pause` exceeded `MAX_PAUSE_REASON_LEN`. + InvalidPauseReason = 21, } /// How long a proposal stays open for approval before it must be @@ -717,5 +744,15 @@ pub fn get_pending_rotation(env: &Env) -> Option { .get(&GovernanceDataKey::PendingRotation) } +pub mod pausable; +pub use pausable::{ + get_pause_state, is_paused, pause, paused_scopes, require_not_paused, unpause, PauseState, + Paused, Unpaused, ALL_SCOPES, MAX_PAUSE_DURATION, MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, + SCOPE_INTAKE, SCOPE_SETTLEMENT, +}; + #[cfg(test)] mod test; + +#[cfg(test)] +mod pausable_test; diff --git a/soroban-contracts/contracts/governance-guard/src/pausable.rs b/soroban-contracts/contracts/governance-guard/src/pausable.rs new file mode 100644 index 0000000..625797a --- /dev/null +++ b/soroban-contracts/contracts/governance-guard/src/pausable.rs @@ -0,0 +1,535 @@ +//! Emergency circuit breaker: scoped, self-expiring pausability shared by +//! every contract that already depends on this crate. +//! +//! The point of this module is to give an incident responder something +//! between "watch it drain" and "upgrade under fire" — a way to halt the +//! *class* of operation that is being abused, immediately, without an +//! upgrade and without ever taking a user's ability to get their own money +//! back. +//! +//! Three properties are load-bearing, and each one exists to close a +//! failure mode that a plain `bool paused` flag would leave open: +//! +//! 1. **Scoped, not global.** A pause names a set of [scopes](#scopes) +//! rather than freezing the whole contract. Halting value *entering* +//! escrow is a safety measure; halting value *leaving* it is a hostage +//! situation. Only a scoped guard can express the first without the +//! second. +//! +//! 2. **Fund-recovery paths are never guarded.** This is enforced by +//! omission at the call sites, not by a runtime check here, so it is +//! worth stating plainly: `cancel_appointment`, every dispute +//! entrypoint, and the loyalty token's `transfer` / `transfer_from` / +//! `burn` of already-held balances carry no [`require_not_paused`] call +//! at all. There is no scope value — including [`ALL_SCOPES`] — that can +//! reach them. A pause therefore cannot strand a balance that already +//! exists; at worst it delays a *new* obligation from being created or +//! settled. +//! +//! 3. **Time-bound, enforced on read.** A pause carries an `expires_at` +//! ledger timestamp and is capped at [`MAX_PAUSE_DURATION`]. Expiry is +//! evaluated every time the guard is consulted, so a pause lapses on its +//! own — no unpause transaction, no live admin, no working key required. +//! An abandoned pause is indistinguishable from no pause at all. +//! +//! ## What this deliberately does *not* promise +//! +//! An admin who is present and hostile can call [`pause`] again the moment +//! the previous window lapses, indefinitely. That is not a hole this module +//! can close — the same signer set can already replace the contract's +//! entire code through the upgrade flow, so any "unstoppable freeze" +//! argument that survives auto-expiry survives the upgrade path too. The +//! guarantee on offer is narrower and honest: *an unattended pause always +//! clears*, and *no pause of any duration, attended or not, can stop a user +//! from recovering funds they already own* (property 2 above). Property 2 +//! is what makes the residual risk a liveness problem for new business +//! rather than a custody problem for existing balances. +//! +//! ## Authorization +//! +//! Pausing and unpausing are gated by [`require_signer`] — **any single +//! governance signer**, not the full M-of-N threshold. This mirrors +//! [`cancel_upgrade`](crate::cancel_upgrade), which is likewise unilateral, +//! and for the same reason: gathering a threshold takes time an incident +//! does not give you, and the action being authorized is bounded on every +//! axis that matters. A signer acting alone, or maliciously, can delay new +//! business for at most [`MAX_PAUSE_DURATION`] per call and cannot touch a +//! single existing balance. Requiring a threshold would buy no custody +//! safety and would cost the response window. +//! +//! Using the governance signer set rather than each contract's own single +//! `admin` is deliberate too: the signer set is M-of-N, survives the loss +//! of any one key, and is rotatable through the timelocked flow added in +//! #27 — [`propose_signer_rotation`](crate::propose_signer_rotation) → +//! [`approve_signer_rotation`](crate::approve_signer_rotation) → +//! [`execute_signer_rotation`](crate::execute_signer_rotation), documented +//! under "Signer rotation" in the crate root. An incident is exactly when a +//! single non-rotatable admin key is least trustworthy, and pause authority +//! should follow the identity that *can* be revoked. Note the asymmetry +//! this creates on purpose: rotating who may pause is timelocked and needs +//! threshold agreement, while exercising the pause is instant and +//! unilateral. Changing the guest list is a governance decision; pulling +//! the alarm is not. +//! +//! ## Scopes +//! +//! Scopes are a shared vocabulary across contracts so that off-chain +//! tooling can reason about a pause without a per-contract lookup table. +//! The bitmask is small on purpose — a scope only earns a bit if some +//! plausible incident wants it halted *independently* of the others. +//! +//! | Scope | Meaning | Guarded entrypoints | +//! |---|---|---| +//! | [`SCOPE_INTAKE`] | New value or new obligations entering the system | `escrow`: `create_appointment`, `create_milestone_escrow`, `add_milestone`; `loyalty-token`: `mint`; `loyalty-emissions`: `create_schedule` | +//! | [`SCOPE_SETTLEMENT`] | Discretionary happy-path payouts — *not* recovery | `escrow`: `confirm_completion`, `approve_milestone`, `release_milestone_funds`; `loyalty-emissions`: `claim` | +//! | [`SCOPE_ATTESTATION`] | Reputation writes | `reputation`: `submit_attestation` | +//! +//! A contract that has no entrypoint in some scope simply ignores a pause +//! naming it; pausing [`SCOPE_ATTESTATION`] on `escrow` is a well-formed +//! no-op rather than an error, so an operator can broadcast the same scope +//! mask to every contract during an incident without special-casing. +//! +//! ## What "enforced on read" means, precisely +//! +//! A **guard consultation** is one call to [`require_not_paused`], +//! [`is_paused`], [`paused_scopes`] or [`get_pause_state`]. Each one loads +//! the stored record and compares `env.ledger().timestamp()` against +//! `expires_at`; past the deadline it reports "not paused" and the stored +//! record is simply ignored. Nothing is written back, so the guard has no +//! side effects and a lapsed pause costs nothing to step over. The stale +//! record is overwritten by the next [`pause`] and removed by the next +//! [`unpause`]. +//! +//! Two consequences worth being explicit about: +//! +//! * **A pause cannot outlive its deadline even if every key is lost.** +//! Expiry needs no transaction, so there is no "someone must call +//! `unpause`" step that could fail to happen. +//! * **Expiry cannot land mid-transaction.** `env.ledger().timestamp()` is +//! the close time of the ledger the transaction executes in and is +//! constant for the whole invocation, including across cross-contract +//! calls. A single call can therefore never observe the guard as paused +//! at one point and open at another — the decision is the same at every +//! consultation within one transaction. +//! +//! ### Which clock, and what a validator could do with it +//! +//! `expires_at` is compared against `env.ledger().timestamp()` — Stellar's +//! ledger close time in seconds, agreed by SCP consensus and required to be +//! monotonically increasing, not a value any single validator picks. That +//! makes the manipulation surface small and, more importantly, *irrelevant +//! in the direction that would matter*: nudging the clock forward can only +//! end a pause sooner, and nudging it backward is not possible. There is no +//! clock manipulation that extends a halt or reaches a fund-recovery path, +//! because recovery paths consult no clock at all. +//! +//! Ledger sequence would have been the other option, and is what the +//! upgrade timelocks in the crate root use. Wall-clock seconds are the +//! better fit here: a pause duration is negotiated between humans during an +//! incident ("give us six hours"), and seconds say that directly, whereas a +//! ledger count only approximates it at an assumed close rate. +//! +//! ## Example flows +//! +//! Halt new business while an escrow bug is triaged, then bring it back in +//! stages. Every call below is one transaction from any single signer: +//! +//! ```text +//! pause(signer_a, SCOPE_INTAKE, 21_600, "INC-412 milestone accounting") +//! → new bookings rejected with OperationPaused +//! → refunds, disputes, existing settlements all still work +//! +//! pause(signer_a, SCOPE_INTAKE | SCOPE_SETTLEMENT, 86_400, "INC-412 widened") +//! → replaces the record outright: both scopes halted, clock restarted +//! +//! unpause(signer_b, SCOPE_INTAKE) +//! → bookings resume; settlement stays halted on the *original* deadline +//! +//! (no transaction at all) +//! → at expires_at, settlement resumes on its own +//! ``` +//! +//! **Broadcasting to several contracts.** The scope vocabulary is shared, so +//! an operator halting intake protocol-wide sends the same `pause(caller, +//! SCOPE_INTAKE, secs, reason)` to `escrow`, `loyalty-token` and +//! `loyalty-emissions` — and can include `reputation` in the same sweep even +//! though it has no intake entrypoint, since a scope a contract does not use +//! is a no-op rather than an error. The four are separate deployments with +//! separate storage, so this is N transactions, not one; they do not have to +//! land in the same ledger and nothing breaks if they land out of order or +//! if one fails, because each contract's guard reads only its own record. +//! `soroban-contracts/scripts/broadcast-pause.sh` does exactly this sweep. +//! +//! **What a pause does to accrued rights: nothing.** Vesting in +//! `loyalty-emissions` is a pure function of the ledger clock and keeps +//! accruing through a halt, so a blocked `claim` mints the identical amount +//! once it lifts. Escrowed balances are untouched and stay reachable by +//! their owner throughout via the unguarded recovery paths. Reputation +//! scores already recorded stay readable and keep decaying on schedule. In +//! every case a pause delays an *action*, never a *right*. +//! +//! ## Reentrancy +//! +//! Not a concern here, and worth saying why rather than leaving it to +//! inference. Soroban's host forbids reentrancy outright: a contract +//! already on the call stack cannot be re-entered, so there is no path by +//! which a guarded entrypoint could call out and be called back before its +//! own state writes land. [`pause`] and [`unpause`] additionally make no +//! cross-contract calls at all — they touch one instance-storage key and +//! publish an event — so they cannot yield control mid-update even in +//! principle. Transactions within a ledger are also strictly ordered, so +//! two signers acting "simultaneously" is really one after the other, and +//! the second simply observes the first's record. +//! +//! ### Why `SCOPE_SETTLEMENT` is not a fund trap +//! +//! Halting `confirm_completion` and `release_milestone_funds` does withhold +//! a payout the worker has arguably earned, so it deserves an explicit +//! argument rather than an assumption. Two things make it a delay and not a +//! seizure. First, `release_milestone_funds` is *permissionless* — anyone +//! may call it once the time-lock passes — which makes it precisely the +//! entrypoint an accounting bug would be drained through, so it has to be +//! haltable or the scope is decorative. Second, the pause changes no +//! party's power relative to the others: the client could already cancel a +//! `Funded` appointment for a full refund before any of this existed, and +//! either party could already force the funds into `resolve_dispute`. Both +//! of those paths stay open throughout a pause. The escrowed amount remains +//! reachable by whoever was entitled to it; only the *unilateral* release +//! button is on hold, and only until expiry. + +use soroban_sdk::{contractevent, contracttype, Address, Env, String}; + +use crate::{require_signer, GovernanceDataKey, GovernanceError}; + +/// New value or new obligations entering the system: escrow deposits, +/// fresh milestones, minted supply, newly registered emission schedules. +/// +/// This is the scope to reach for first in almost any incident — stopping +/// the bleeding means stopping new money from walking into a contract you +/// no longer trust, and it is the one scope that provably cannot strand +/// anything, since it only ever blocks value that has not yet arrived. +pub const SCOPE_INTAKE: u32 = 1 << 0; + +/// Discretionary happy-path payouts: releasing escrowed funds to a worker, +/// claiming vested emissions. +/// +/// Explicitly *not* the same thing as fund recovery — refunds, dispute +/// resolution and token transfers of already-held balances are unguarded +/// entirely and are unaffected by this or any other scope. See the module +/// notes on why halting settlement is a delay rather than a seizure. +pub const SCOPE_SETTLEMENT: u32 = 1 << 1; + +/// Reputation writes — attestation submission and the score accumulation +/// that follows from it. +/// +/// Separate from the value-bearing scopes because its incidents are +/// separate: a Sybil ring poisoning scores is a reason to stop accepting +/// attestations, and no reason at all to stop paying workers. +pub const SCOPE_ATTESTATION: u32 = 1 << 2; + +/// Every scope this crate defines. Also the validation mask — [`pause`] +/// and [`unpause`] reject any bit outside it, so a typo'd or +/// future-versioned scope fails loudly instead of silently pausing nothing. +pub const ALL_SCOPES: u32 = SCOPE_INTAKE | SCOPE_SETTLEMENT | SCOPE_ATTESTATION; + +/// Longest `reason` a [`pause`] call may carry, measured in **UTF-8 bytes** +/// — not characters. +/// +/// The distinction is invisible for ASCII and load-bearing for everything +/// else: `String::len()` reports bytes, so a 33-character reason made of +/// two-byte code points is 66 bytes and is rejected, even though a +/// character-count check would have passed it. A client validating this +/// before submitting must count bytes (`Buffer.byteLength(s, 'utf8')` in +/// JS, `len(s.encode('utf-8'))` in Python) or restrict itself to ASCII. +/// Both boundaries are pinned by tests. +/// +/// Bounded because the reason is written to instance storage, which every +/// subsequent invocation of the contract pays to load — an unbounded +/// operator-supplied string would make an incident note a permanent tax on +/// the hot path. 64 bytes is enough for an incident ticket reference or a +/// short phrase; anything longer belongs in the incident channel the +/// reference points at, not on-chain. An empty reason is allowed, so the +/// field never stands between a responder and a halt. +pub const MAX_PAUSE_REASON_LEN: u32 = 64; + +/// Longest window a single [`pause`] call may set, in seconds. 7 days. +/// +/// The cap is the whole reason a pause can't become a permanent freeze, so +/// it is chosen as the shortest duration that still comfortably covers a +/// real incident response — triage, patch, review, and an upgrade through +/// the M-of-N flow — over a weekend and across time zones. Anything longer +/// stops being "we are responding" and starts being "we have forgotten", +/// and forgetting is exactly the state auto-expiry exists to recover from. +pub const MAX_PAUSE_DURATION: u64 = 7 * 24 * 60 * 60; + +/// The single active pause record. +/// +/// Stored under [`GovernanceDataKey::PauseState`] in instance storage. Only +/// one record exists at a time: a second [`pause`] replaces the first +/// outright rather than layering, so the set of halted scopes and the +/// deadline are always readable from one place with no reconciliation. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PauseState { + /// Bitmask of halted scopes — some combination of [`SCOPE_INTAKE`], + /// [`SCOPE_SETTLEMENT`] and [`SCOPE_ATTESTATION`]. + pub scopes: u32, + /// Ledger timestamp at which this pause stops being in effect. The + /// guards evaluate this on every read, so no transaction is needed for + /// it to take hold. + pub expires_at: u64, + /// The signer who placed the pause. Recorded for attribution during and + /// after an incident; it grants no special rights — any signer may + /// [`unpause`], not just this one, so a responder going offline mid + /// incident cannot wedge the pause in place. + pub paused_by: Address, + /// Ledger timestamp the pause was placed at. Together with + /// `expires_at` this makes the chosen duration recoverable from state + /// alone, without correlating against the emitting transaction. + pub paused_at: u64, + /// Free-form operator context, at most [`MAX_PAUSE_REASON_LEN`] UTF-8 + /// *bytes* (not characters); may be empty. Carries no meaning to the + /// contract — it is never parsed or compared — and exists so an on-call + /// responder can answer "why is this halted?" from chain state alone, + /// rather than from a chat log nobody can find at 3am. + pub reason: String, +} + +/// Emitted whenever a pause is placed or replaced. +/// +/// Wire format, as consumed by an off-chain indexer — pinned by +/// `paused_event_has_the_documented_topics_and_data_shape` in the escrow +/// test suite, so it cannot drift from this comment silently: +/// +/// * **Topics** (ordered): `Symbol("gov_pause")`, `Symbol("paused")`, +/// `Address(caller)`. The two prefix symbols come first, then each +/// `#[topic]` field in declaration order. +/// * **Data**: a `Map` keyed by field name — `expires_at` +/// (`u64`), `reason` (`String`), `scopes` (`u32`). Because it is a map, +/// field *names* are part of the contract but their order is not. +/// +/// `expires_at` is the absolute ledger timestamp the pause lapses at, not a +/// duration, so a monitor that missed the transaction can still tell how +/// much of the window is left without knowing when it started. +#[contractevent(topics = ["gov_pause", "paused"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Paused { + #[topic] + pub caller: Address, + /// The full set of scopes halted after this call — the new state, not a + /// delta against whatever was halted before. + pub scopes: u32, + pub expires_at: u64, + /// Operator-supplied context; may be empty. See [`PauseState::reason`]. + pub reason: String, +} + +/// Emitted when scopes are cleared early by a signer. +/// +/// Wire format, pinned by +/// `unpaused_event_has_the_documented_topics_and_data_shape`: +/// +/// * **Topics** (ordered): `Symbol("gov_pause")`, `Symbol("unpaused")`, +/// `Address(caller)`. +/// * **Data**: a `Map` keyed by field name — `scopes` (`u32`), +/// `remaining_scopes` (`u32`). +/// +/// Deliberately *not* emitted on auto-expiry: expiry is a read-time +/// evaluation with no transaction behind it, so there is no execution +/// context to emit from. Monitors get `expires_at` up front in [`Paused`] +/// and should treat it as the authoritative end of the window unless an +/// `Unpaused` arrives sooner. +#[contractevent(topics = ["gov_pause", "unpaused"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Unpaused { + #[topic] + pub caller: Address, + /// The scopes this call cleared. + pub scopes: u32, + /// Scopes still halted afterwards — `0` when the pause is fully lifted. + pub remaining_scopes: u32, +} + +/// Rejects a scope mask that is empty or contains bits outside +/// [`ALL_SCOPES`]. +/// +/// Empty is rejected rather than treated as "no-op" because every call site +/// that could produce it — a miscomputed mask, a caller that meant +/// `ALL_SCOPES` — is a mistake the operator wants to hear about during an +/// incident, not a silently successful transaction that halted nothing. +fn validate_scopes(scopes: u32) -> Result<(), GovernanceError> { + if scopes == 0 || scopes & !ALL_SCOPES != 0 { + return Err(GovernanceError::InvalidPauseScope); + } + Ok(()) +} + +/// Halts `scopes` for `duration_secs` seconds from now, authorized by any +/// single governance signer. +/// +/// Replaces any existing pause outright — the new `scopes` mask and the new +/// deadline both take effect, which means calling this with a *narrower* +/// mask lifts the scopes left out of it. To keep an existing deadline while +/// releasing part of it, use [`unpause`] instead; this call always restarts +/// the clock. +/// +/// `reason` is free-form operator context of at most +/// [`MAX_PAUSE_REASON_LEN`] **UTF-8 bytes** — not characters, see that +/// constant — and may be empty. It is stored and emitted verbatim, never +/// interpreted. Over-length fails with +/// [`GovernanceError::InvalidPauseReason`] before anything is written. +/// +/// Fails with [`GovernanceError::InvalidPauseDuration`] for a zero duration +/// or one exceeding [`MAX_PAUSE_DURATION`] — the cap is enforced here, at +/// the only place a deadline is ever written, so there is no path to a +/// longer window than the constant allows. +pub fn pause( + env: &Env, + caller: Address, + scopes: u32, + duration_secs: u64, + reason: String, +) -> Result { + require_signer(env, &caller)?; + validate_scopes(scopes)?; + if duration_secs == 0 || duration_secs > MAX_PAUSE_DURATION { + return Err(GovernanceError::InvalidPauseDuration); + } + if reason.len() > MAX_PAUSE_REASON_LEN { + return Err(GovernanceError::InvalidPauseReason); + } + + let now = env.ledger().timestamp(); + let state = PauseState { + scopes, + // `now + duration_secs` cannot overflow: `duration_secs` is capped + // at MAX_PAUSE_DURATION above, and a u64 ledger timestamp within + // ~584 billion years of the epoch leaves room for another week. + expires_at: now + duration_secs, + paused_by: caller.clone(), + paused_at: now, + reason: reason.clone(), + }; + env.storage() + .instance() + .set(&GovernanceDataKey::PauseState, &state); + + Paused { + caller, + scopes, + expires_at: state.expires_at, + reason, + } + .publish(env); + Ok(state) +} + +/// Clears `scopes` from the active pause ahead of its deadline, authorized +/// by any single governance signer. Returns the scopes still halted +/// afterwards. +/// +/// Masked rather than all-or-nothing so an operator can bring the system +/// back a piece at a time — resume intake while settlement stays halted — +/// without the alternative of re-calling [`pause`], which would reset the +/// deadline and *extend* the freeze on everything else. Clearing the last +/// scope removes the record entirely. Passing [`ALL_SCOPES`] is the "lift +/// everything now" call. +/// +/// Fails with [`GovernanceError::NotPaused`] if nothing is currently +/// halted, including when a record exists but has already auto-expired — +/// an expired pause is not in effect, so there is nothing to lift. The +/// stale record is cleaned up in that case rather than left behind. +pub fn unpause(env: &Env, caller: Address, scopes: u32) -> Result { + require_signer(env, &caller)?; + validate_scopes(scopes)?; + + let Some(mut state) = read_state(env) else { + // Either nothing was ever set, or what is set has lapsed. Drop a + // lapsed record so it can't be mistaken for live state by anything + // reading storage directly. + env.storage() + .instance() + .remove(&GovernanceDataKey::PauseState); + return Err(GovernanceError::NotPaused); + }; + + state.scopes &= !scopes; + let remaining_scopes = state.scopes; + if remaining_scopes == 0 { + env.storage() + .instance() + .remove(&GovernanceDataKey::PauseState); + } else { + env.storage() + .instance() + .set(&GovernanceDataKey::PauseState, &state); + } + + Unpaused { + caller, + scopes, + remaining_scopes, + } + .publish(env); + Ok(remaining_scopes) +} + +/// The guard host contracts call at the top of a pausable entrypoint. +/// +/// `scope` is a mask; the call is rejected if *any* of its bits are +/// currently halted. Fails with [`GovernanceError::OperationPaused`], which +/// exists precisely so a paused call is distinguishable from a call that +/// was invalid on its own terms — reusing a status error here would make +/// "the protocol is halted" and "you asked for the wrong thing" report +/// identically to a client. +/// +/// Read-only: expiry is evaluated, never written back. That keeps the guard +/// free of side effects on the hot path and means a lapsed pause costs +/// nothing to step over. The stale record is overwritten by the next +/// [`pause`] and removed by the next [`unpause`]. +pub fn require_not_paused(env: &Env, scope: u32) -> Result<(), GovernanceError> { + if paused_scopes(env) & scope != 0 { + return Err(GovernanceError::OperationPaused); + } + Ok(()) +} + +/// The scopes currently halted, or `0` if none are — including when a +/// record exists but its deadline has passed. +/// +/// This is the single place expiry is decided; everything else in this +/// module reads through it, so there is no way for one code path to +/// consider a pause live while another considers it lapsed. +pub fn paused_scopes(env: &Env) -> u32 { + read_state(env).map(|s| s.scopes).unwrap_or(0) +} + +/// Whether every bit in `scope` — or any of them, for a multi-bit mask — is +/// currently halted. Convenience mirror of [`require_not_paused`] for +/// read-only callers and off-chain tooling. +pub fn is_paused(env: &Env, scope: u32) -> bool { + paused_scopes(env) & scope != 0 +} + +/// The active pause record, or `None` when nothing is in effect. +/// +/// Returns `None` for a stored-but-expired record rather than handing back +/// a state the guards no longer honour: a view that disagrees with +/// [`require_not_paused`] is worse than no view at all. +pub fn get_pause_state(env: &Env) -> Option { + read_state(env) +} + +/// Loads the record and applies expiry. Uses `>=` against the deadline so +/// `expires_at` is the first instant the pause is *not* in effect, matching +/// how the value reads to an operator ("halted until T"). +fn read_state(env: &Env) -> Option { + let state: PauseState = env + .storage() + .instance() + .get(&GovernanceDataKey::PauseState)?; + if env.ledger().timestamp() >= state.expires_at { + return None; + } + Some(state) +} diff --git a/soroban-contracts/contracts/governance-guard/src/pausable_test.rs b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs new file mode 100644 index 0000000..c83235e --- /dev/null +++ b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs @@ -0,0 +1,768 @@ +#![cfg(test)] + +//! Unit tests for the circuit-breaker primitive itself. Integration-level +//! proof that the guards are wired into the right entrypoints — and, more +//! importantly, that they are *absent* from the recovery entrypoints — lives +//! in each host contract's own test module. +//! +//! Same harness shape as `test.rs`: this crate has no `#[contract]`, so every +//! call runs inside `env.as_contract(...)` against a bare stand-in host, which +//! is what gives `env.storage()` a current contract to work on. + +use soroban_sdk::{ + contract, testutils::Address as _, testutils::Events as _, testutils::Ledger, Address, Env, + String, Vec, +}; + +use crate::{ + get_pause_state, init_governance, is_paused, pause, paused_scopes, require_not_paused, unpause, + GovernanceError, GovernanceInit, PauseState, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, SCOPE_INTAKE, SCOPE_SETTLEMENT, +}; + +#[contract] +struct TestHost; + +struct Ctx { + env: Env, + host: Address, + signers: Vec
, +} + +fn setup() -> Ctx { + let env = Env::default(); + env.mock_all_auths(); + let host = env.register(TestHost, ()); + + let mut signers = Vec::new(&env); + signers.push_back(Address::generate(&env)); + signers.push_back(Address::generate(&env)); + signers.push_back(Address::generate(&env)); + + env.as_contract(&host, || { + init_governance( + &env, + GovernanceInit { + signers: signers.clone(), + threshold: 2, + }, + ) + }) + .unwrap(); + + // Start from a non-zero clock so "before the pause" is expressible. + set_time(&env, 1_000); + + Ctx { env, host, signers } +} + +fn set_time(env: &Env, timestamp: u64) { + env.ledger().with_mut(|l| l.timestamp = timestamp); +} + +impl Ctx { + fn signer(&self, i: u32) -> Address { + self.signers.get_unchecked(i) + } + + /// Most tests don't care about the reason, so this helper supplies an + /// empty one; `pause_with_reason` is for the tests that do. + fn pause( + &self, + caller: Address, + scopes: u32, + duration: u64, + ) -> Result { + self.pause_with_reason(caller, scopes, duration, "") + } + + fn pause_with_reason( + &self, + caller: Address, + scopes: u32, + duration: u64, + reason: &str, + ) -> Result { + let reason = String::from_str(&self.env, reason); + self.env.as_contract(&self.host, || { + pause(&self.env, caller, scopes, duration, reason) + }) + } + + fn unpause(&self, caller: Address, scopes: u32) -> Result { + self.env + .as_contract(&self.host, || unpause(&self.env, caller, scopes)) + } + + fn scopes(&self) -> u32 { + self.env + .as_contract(&self.host, || paused_scopes(&self.env)) + } + + fn state(&self) -> Option { + self.env + .as_contract(&self.host, || get_pause_state(&self.env)) + } + + fn guard(&self, scope: u32) -> Result<(), GovernanceError> { + self.env + .as_contract(&self.host, || require_not_paused(&self.env, scope)) + } +} + +// --------------------------------------------------------------------------- +// Baseline +// --------------------------------------------------------------------------- + +#[test] +fn nothing_is_paused_before_any_pause() { + let ctx = setup(); + assert_eq!(ctx.scopes(), 0); + assert!(ctx.state().is_none()); + assert_eq!(ctx.guard(ALL_SCOPES), Ok(())); +} + +#[test] +fn scope_bits_are_distinct_and_all_scopes_covers_exactly_them() { + // A duplicated or overlapping bit would silently make one scope pause + // another — cheap to assert, catastrophic to get wrong. + assert_eq!(SCOPE_INTAKE & SCOPE_SETTLEMENT, 0); + assert_eq!(SCOPE_INTAKE & SCOPE_ATTESTATION, 0); + assert_eq!(SCOPE_SETTLEMENT & SCOPE_ATTESTATION, 0); + assert_eq!( + ALL_SCOPES, + SCOPE_INTAKE | SCOPE_SETTLEMENT | SCOPE_ATTESTATION + ); +} + +// --------------------------------------------------------------------------- +// Scope isolation +// --------------------------------------------------------------------------- + +#[test] +fn pausing_one_scope_leaves_the_others_open() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE, 3_600).unwrap(); + + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); + assert_eq!(ctx.guard(SCOPE_SETTLEMENT), Ok(())); + assert_eq!(ctx.guard(SCOPE_ATTESTATION), Ok(())); + assert_eq!(ctx.scopes(), SCOPE_INTAKE); +} + +#[test] +fn a_multi_scope_pause_halts_every_named_scope() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE | SCOPE_ATTESTATION, 3_600) + .unwrap(); + + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); + assert_eq!( + ctx.guard(SCOPE_ATTESTATION), + Err(GovernanceError::OperationPaused) + ); + assert_eq!(ctx.guard(SCOPE_SETTLEMENT), Ok(())); +} + +#[test] +fn a_guard_over_a_mask_trips_when_any_one_of_its_bits_is_paused() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_SETTLEMENT, 3_600).unwrap(); + + assert_eq!( + ctx.guard(SCOPE_INTAKE | SCOPE_SETTLEMENT), + Err(GovernanceError::OperationPaused) + ); +} + +#[test] +fn is_paused_agrees_with_the_guard() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_SETTLEMENT, 3_600).unwrap(); + + let paused = ctx + .env + .as_contract(&ctx.host, || is_paused(&ctx.env, SCOPE_SETTLEMENT)); + assert!(paused); + let open = ctx + .env + .as_contract(&ctx.host, || is_paused(&ctx.env, SCOPE_INTAKE)); + assert!(!open); +} + +// --------------------------------------------------------------------------- +// Auto-expiry +// --------------------------------------------------------------------------- + +#[test] +fn a_pause_lapses_on_its_own_with_no_unpause_transaction() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); + + // Nobody calls anything. Only the clock moves. + set_time(&ctx.env, 1_000 + 3_600); + + assert_eq!(ctx.guard(ALL_SCOPES), Ok(())); + assert_eq!(ctx.scopes(), 0); + assert!(ctx.state().is_none()); +} + +#[test] +fn a_pause_is_still_in_effect_one_second_before_its_deadline() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE, 3_600).unwrap(); + + set_time(&ctx.env, 1_000 + 3_599); + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); + + // `expires_at` is the first instant it is *not* in effect. + set_time(&ctx.env, 1_000 + 3_600); + assert_eq!(ctx.guard(SCOPE_INTAKE), Ok(())); +} + +#[test] +fn expiry_holds_for_a_maximum_length_pause_too() { + let ctx = setup(); + let state = ctx + .pause(ctx.signer(0), ALL_SCOPES, MAX_PAUSE_DURATION) + .unwrap(); + assert_eq!(state.expires_at, 1_000 + MAX_PAUSE_DURATION); + + set_time(&ctx.env, state.expires_at - 1); + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); + + set_time(&ctx.env, state.expires_at); + assert_eq!(ctx.guard(SCOPE_INTAKE), Ok(())); +} + +#[test] +fn a_duration_past_the_cap_is_rejected() { + let ctx = setup(); + let result = ctx.pause(ctx.signer(0), ALL_SCOPES, MAX_PAUSE_DURATION + 1); + assert_eq!(result, Err(GovernanceError::InvalidPauseDuration)); + assert_eq!(ctx.scopes(), 0); +} + +#[test] +fn a_zero_duration_is_rejected() { + let ctx = setup(); + let result = ctx.pause(ctx.signer(0), ALL_SCOPES, 0); + assert_eq!(result, Err(GovernanceError::InvalidPauseDuration)); + assert_eq!(ctx.scopes(), 0); +} + +// --------------------------------------------------------------------------- +// Re-pausing and replacement +// --------------------------------------------------------------------------- + +#[test] +fn re_pausing_replaces_scopes_and_restarts_the_clock() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE, 3_600).unwrap(); + + set_time(&ctx.env, 1_000 + 1_800); + let state = ctx.pause(ctx.signer(1), SCOPE_SETTLEMENT, 3_600).unwrap(); + + // The narrower mask lifted INTAKE; the deadline moved to the new call. + assert_eq!(state.scopes, SCOPE_SETTLEMENT); + assert_eq!(state.expires_at, 1_000 + 1_800 + 3_600); + assert_eq!(ctx.guard(SCOPE_INTAKE), Ok(())); + assert_eq!( + ctx.guard(SCOPE_SETTLEMENT), + Err(GovernanceError::OperationPaused) + ); +} + +#[test] +fn a_lapsed_pause_can_be_placed_again_by_a_present_admin() { + // Auto-expiry bounds an *unattended* pause. It does not claim to stop a + // signer who is present from re-arming, and this pins that reading down + // so nobody later mistakes the guarantee for something stronger — the + // custody guarantee is that recovery paths are never guarded at all. + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, MAX_PAUSE_DURATION) + .unwrap(); + + set_time(&ctx.env, 1_000 + MAX_PAUSE_DURATION); + assert_eq!(ctx.scopes(), 0); + + ctx.pause(ctx.signer(0), ALL_SCOPES, MAX_PAUSE_DURATION) + .unwrap(); + assert_eq!(ctx.scopes(), ALL_SCOPES); +} + +// --------------------------------------------------------------------------- +// Unpause +// --------------------------------------------------------------------------- + +#[test] +fn unpause_clears_only_the_named_scopes_and_keeps_the_deadline() { + let ctx = setup(); + let state = ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + + set_time(&ctx.env, 1_500); + let remaining = ctx.unpause(ctx.signer(1), SCOPE_INTAKE).unwrap(); + + assert_eq!(remaining, SCOPE_SETTLEMENT | SCOPE_ATTESTATION); + assert_eq!(ctx.guard(SCOPE_INTAKE), Ok(())); + assert_eq!( + ctx.guard(SCOPE_SETTLEMENT), + Err(GovernanceError::OperationPaused) + ); + // Partial lift must not extend the freeze on what stays halted. + assert_eq!(ctx.state().unwrap().expires_at, state.expires_at); +} + +#[test] +fn unpausing_the_last_scope_removes_the_record() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE | SCOPE_SETTLEMENT, 3_600) + .unwrap(); + + assert_eq!( + ctx.unpause(ctx.signer(0), SCOPE_INTAKE).unwrap(), + SCOPE_SETTLEMENT + ); + assert_eq!(ctx.unpause(ctx.signer(0), SCOPE_SETTLEMENT).unwrap(), 0); + + assert!(ctx.state().is_none()); + assert_eq!(ctx.guard(ALL_SCOPES), Ok(())); +} + +#[test] +fn unpause_with_all_scopes_lifts_everything_at_once() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + + assert_eq!(ctx.unpause(ctx.signer(2), ALL_SCOPES).unwrap(), 0); + assert_eq!(ctx.guard(ALL_SCOPES), Ok(())); +} + +#[test] +fn unpause_of_a_scope_that_was_never_paused_is_a_no_op_on_the_rest() { + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE, 3_600).unwrap(); + + assert_eq!( + ctx.unpause(ctx.signer(0), SCOPE_ATTESTATION).unwrap(), + SCOPE_INTAKE + ); + assert_eq!( + ctx.guard(SCOPE_INTAKE), + Err(GovernanceError::OperationPaused) + ); +} + +#[test] +fn unpause_with_nothing_paused_fails() { + let ctx = setup(); + assert_eq!( + ctx.unpause(ctx.signer(0), ALL_SCOPES), + Err(GovernanceError::NotPaused) + ); +} + +#[test] +fn unpause_after_auto_expiry_reports_not_paused_and_drops_the_stale_record() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + set_time(&ctx.env, 1_000 + 3_600); + + assert_eq!( + ctx.unpause(ctx.signer(0), ALL_SCOPES), + Err(GovernanceError::NotPaused) + ); + // Even the raw entry is gone, so nothing reading storage directly can + // mistake the lapsed record for live state. + let raw: Option = ctx.env.as_contract(&ctx.host, || { + ctx.env + .storage() + .instance() + .get(&crate::GovernanceDataKey::PauseState) + }); + assert!(raw.is_none()); +} + +// --------------------------------------------------------------------------- +// Authorization (adversarial) +// --------------------------------------------------------------------------- + +#[test] +fn a_non_signer_cannot_pause() { + let ctx = setup(); + let outsider = Address::generate(&ctx.env); + + assert_eq!( + ctx.pause(outsider, ALL_SCOPES, 3_600), + Err(GovernanceError::NotASigner) + ); + assert_eq!(ctx.scopes(), 0); +} + +#[test] +fn a_non_signer_cannot_unpause() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + let outsider = Address::generate(&ctx.env); + + assert_eq!( + ctx.unpause(outsider, ALL_SCOPES), + Err(GovernanceError::NotASigner) + ); + // The pause survived the failed attempt. + assert_eq!(ctx.scopes(), ALL_SCOPES); +} + +#[test] +fn any_single_signer_may_pause_and_any_other_may_lift_it() { + // Threshold is 2 for upgrades, but the breaker is deliberately + // unilateral in both directions — an incident does not wait for a + // quorum, and no responder can wedge a pause in place by going dark. + let ctx = setup(); + let threshold = ctx + .env + .as_contract(&ctx.host, || crate::get_threshold(&ctx.env)); + assert_eq!(threshold, 2); + + ctx.pause(ctx.signer(2), ALL_SCOPES, 3_600).unwrap(); + assert_eq!(ctx.unpause(ctx.signer(0), ALL_SCOPES).unwrap(), 0); +} + +#[test] +fn pause_before_governance_is_initialized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let host = env.register(TestHost, ()); + let caller = Address::generate(&env); + + let result = env.as_contract(&host, || { + pause(&env, caller, ALL_SCOPES, 3_600, String::from_str(&env, "")) + }); + assert_eq!(result, Err(GovernanceError::NotInitialized)); +} + +// --------------------------------------------------------------------------- +// Scope validation +// --------------------------------------------------------------------------- + +#[test] +fn an_empty_scope_mask_is_rejected_rather_than_silently_pausing_nothing() { + let ctx = setup(); + assert_eq!( + ctx.pause(ctx.signer(0), 0, 3_600), + Err(GovernanceError::InvalidPauseScope) + ); + assert_eq!( + ctx.unpause(ctx.signer(0), 0), + Err(GovernanceError::InvalidPauseScope) + ); +} + +#[test] +fn an_unknown_scope_bit_is_rejected() { + let ctx = setup(); + let bogus = 1 << 31; + assert_eq!( + ctx.pause(ctx.signer(0), bogus, 3_600), + Err(GovernanceError::InvalidPauseScope) + ); + // Even mixed in with a valid bit — a partially-typo'd mask is still a + // mask the operator did not mean. + assert_eq!( + ctx.pause(ctx.signer(0), SCOPE_INTAKE | bogus, 3_600), + Err(GovernanceError::InvalidPauseScope) + ); +} + +// --------------------------------------------------------------------------- +// State record & events +// --------------------------------------------------------------------------- + +#[test] +fn the_pause_record_carries_who_paused_and_the_window() { + let ctx = setup(); + let signer = ctx.signer(1); + let state = ctx.pause(signer.clone(), SCOPE_INTAKE, 7_200).unwrap(); + + assert_eq!(state.scopes, SCOPE_INTAKE); + assert_eq!(state.paused_by, signer); + assert_eq!(state.paused_at, 1_000); + assert_eq!(state.expires_at, 8_200); + assert_eq!(ctx.state(), Some(state)); +} + +#[test] +fn pause_and_unpause_each_emit_an_event() { + // `env.events().all()` reports the events of the most recent top-level + // invocation, so each call is checked right after it happens rather than + // against a running total. + let ctx = setup(); + + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + assert_eq!(ctx.env.events().all().events().len(), 1); + + ctx.unpause(ctx.signer(0), ALL_SCOPES).unwrap(); + assert_eq!(ctx.env.events().all().events().len(), 1); +} + +#[test] +fn a_rejected_pause_emits_nothing() { + let ctx = setup(); + + let _ = ctx.pause(ctx.signer(0), ALL_SCOPES, MAX_PAUSE_DURATION + 1); + assert_eq!(ctx.env.events().all().events().len(), 0); + + let _ = ctx.pause(Address::generate(&ctx.env), ALL_SCOPES, 3_600); + assert_eq!(ctx.env.events().all().events().len(), 0); + + let _ = ctx.pause(ctx.signer(0), 0, 3_600); + assert_eq!(ctx.env.events().all().events().len(), 0); +} + +#[test] +fn a_rejected_unpause_emits_nothing_and_leaves_the_pause_standing() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + + let _ = ctx.unpause(Address::generate(&ctx.env), ALL_SCOPES); + assert_eq!(ctx.env.events().all().events().len(), 0); + assert_eq!(ctx.scopes(), ALL_SCOPES); +} + +// --------------------------------------------------------------------------- +// Operator reason +// --------------------------------------------------------------------------- + +#[test] +fn the_reason_round_trips_through_storage_and_the_event() { + let ctx = setup(); + let state = ctx + .pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, "INC-412 accounting") + .unwrap(); + + // Checked before any further invocation: `events().all()` reports only + // the most recent top-level call. + assert_eq!(ctx.env.events().all().events().len(), 1); + + assert_eq!( + state.reason, + String::from_str(&ctx.env, "INC-412 accounting") + ); + // Read back through the view, not just the return value — the round trip + // through instance storage is the part that could silently drop it. + assert_eq!(ctx.state().unwrap().reason, state.reason); +} + +#[test] +fn an_empty_reason_is_allowed() { + // The field must never stand between a responder and a halt. + let ctx = setup(); + let state = ctx + .pause_with_reason(ctx.signer(0), ALL_SCOPES, 3_600, "") + .unwrap(); + assert_eq!(state.reason.len(), 0); + assert_eq!(ctx.scopes(), ALL_SCOPES); +} + +#[test] +fn a_reason_at_exactly_the_cap_is_accepted() { + let ctx = setup(); + let at_cap = "0123456789012345678901234567890123456789012345678901234567890123"; + assert_eq!(at_cap.len() as u32, MAX_PAUSE_REASON_LEN); + + let state = ctx + .pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, at_cap) + .unwrap(); + assert_eq!(state.reason.len(), MAX_PAUSE_REASON_LEN); +} + +#[test] +fn a_reason_over_the_cap_is_rejected_and_places_no_pause() { + let ctx = setup(); + let over_cap = "01234567890123456789012345678901234567890123456789012345678901234"; + assert_eq!(over_cap.len() as u32, MAX_PAUSE_REASON_LEN + 1); + + assert_eq!( + ctx.pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, over_cap), + Err(GovernanceError::InvalidPauseReason) + ); + assert_eq!(ctx.scopes(), 0); + assert_eq!(ctx.env.events().all().events().len(), 0); +} + +#[test] +fn replacing_a_pause_replaces_its_reason_too() { + let ctx = setup(); + ctx.pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, "first") + .unwrap(); + ctx.pause_with_reason(ctx.signer(1), SCOPE_SETTLEMENT, 3_600, "second") + .unwrap(); + + assert_eq!( + ctx.state().unwrap().reason, + String::from_str(&ctx.env, "second") + ); +} + +// --------------------------------------------------------------------------- +// Storage round-trip +// --------------------------------------------------------------------------- + +#[test] +fn the_whole_pause_record_survives_a_storage_round_trip() { + // `PauseState` is a `#[contracttype]` struct written to instance storage; + // this pins every field against a serialization regression, not just the + // ones the guards happen to read. + let ctx = setup(); + let signer = ctx.signer(1); + let returned = ctx + .pause_with_reason( + signer.clone(), + SCOPE_INTAKE | SCOPE_ATTESTATION, + 7_200, + "INC-9", + ) + .unwrap(); + + let raw: PauseState = ctx.env.as_contract(&ctx.host, || { + ctx.env + .storage() + .instance() + .get(&crate::GovernanceDataKey::PauseState) + .unwrap() + }); + + assert_eq!(raw, returned); + assert_eq!(raw.scopes, SCOPE_INTAKE | SCOPE_ATTESTATION); + assert_eq!(raw.paused_by, signer); + assert_eq!(raw.paused_at, 1_000); + assert_eq!(raw.expires_at, 8_200); + assert_eq!(raw.reason, String::from_str(&ctx.env, "INC-9")); +} + +// --------------------------------------------------------------------------- +// Ordering & the "concurrent" case +// --------------------------------------------------------------------------- +// +// Soroban has no concurrency to race: transactions in a ledger are strictly +// ordered, and the host forbids re-entering a contract already on the call +// stack. "Two signers acting at once" is therefore always one after the +// other, and what matters is that the second observes the first's record +// rather than a stale copy. These pin that. + +#[test] +fn a_second_signers_pause_observes_and_replaces_the_first() { + let ctx = setup(); + ctx.pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, "a") + .unwrap(); + let second = ctx + .pause_with_reason(ctx.signer(1), SCOPE_SETTLEMENT, 1_800, "b") + .unwrap(); + + assert_eq!(ctx.state().unwrap(), second); + assert_eq!(second.paused_by, ctx.signer(1)); + assert_eq!(ctx.scopes(), SCOPE_SETTLEMENT); +} + +#[test] +fn unpause_immediately_after_pause_in_the_same_ledger_leaves_nothing_halted() { + let ctx = setup(); + ctx.pause(ctx.signer(0), ALL_SCOPES, 3_600).unwrap(); + assert_eq!(ctx.unpause(ctx.signer(1), ALL_SCOPES).unwrap(), 0); + assert_eq!(ctx.scopes(), 0); + + // And a re-pause after that starts clean rather than resurrecting scopes. + let state = ctx.pause(ctx.signer(2), SCOPE_INTAKE, 3_600).unwrap(); + assert_eq!(state.scopes, SCOPE_INTAKE); + assert_eq!(ctx.scopes(), SCOPE_INTAKE); +} + +#[test] +fn repeated_guard_consultations_within_one_ledger_all_agree() { + // Expiry is evaluated per consultation, so the worry would be a call + // observing "paused" once and "open" a moment later. The ledger timestamp + // is fixed for the whole transaction, so it cannot. + let ctx = setup(); + ctx.pause(ctx.signer(0), SCOPE_INTAKE, 3_600).unwrap(); + + ctx.env.as_contract(&ctx.host, || { + let first = require_not_paused(&ctx.env, SCOPE_INTAKE); + let second = require_not_paused(&ctx.env, SCOPE_INTAKE); + let third = paused_scopes(&ctx.env); + assert_eq!(first, Err(GovernanceError::OperationPaused)); + assert_eq!(second, Err(GovernanceError::OperationPaused)); + assert_eq!(third, SCOPE_INTAKE); + }); + + // Cross the deadline and the same batch flips together, not piecemeal. + set_time(&ctx.env, 1_000 + 3_600); + ctx.env.as_contract(&ctx.host, || { + assert_eq!(require_not_paused(&ctx.env, SCOPE_INTAKE), Ok(())); + assert_eq!(require_not_paused(&ctx.env, ALL_SCOPES), Ok(())); + assert_eq!(paused_scopes(&ctx.env), 0); + }); +} + +// --------------------------------------------------------------------------- +// The reason cap is bytes, not characters +// --------------------------------------------------------------------------- +// +// `MAX_PAUSE_REASON_LEN` bounds the UTF-8 *byte* length, because that is what +// the storage entry actually costs and what `String::len()` reports. For +// ASCII the distinction is invisible, which is exactly why it needs pinning: +// a client that validates character count would happily submit a reason the +// contract rejects. These two tests are the executable statement of that. + +/// 32 × U+00E9, two bytes each: 32 characters, exactly 64 bytes. +const REASON_32_CHARS_64_BYTES: &str = "éééééééééééééééééééééééééééééééé"; + +/// 33 × U+00E9: 33 characters — comfortably under a 64-*character* limit — +/// but 66 bytes, which is over the cap. +const REASON_33_CHARS_66_BYTES: &str = "ééééééééééééééééééééééééééééééééé"; + +#[test] +fn a_multibyte_reason_is_measured_in_bytes_and_accepted_at_exactly_the_cap() { + let ctx = setup(); + let state = ctx + .pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, REASON_32_CHARS_64_BYTES) + .unwrap(); + + // The contract counts bytes: 32 characters weighing 64 bytes is exactly + // at the cap, not half of it. + assert_eq!(state.reason.len(), MAX_PAUSE_REASON_LEN); + assert_eq!(REASON_32_CHARS_64_BYTES.chars().count(), 32); + assert_eq!(REASON_32_CHARS_64_BYTES.len() as u32, MAX_PAUSE_REASON_LEN); +} + +#[test] +fn a_multibyte_reason_over_the_byte_cap_is_rejected_despite_a_short_char_count() { + let ctx = setup(); + + assert_eq!( + ctx.pause_with_reason(ctx.signer(0), SCOPE_INTAKE, 3_600, REASON_33_CHARS_66_BYTES), + Err(GovernanceError::InvalidPauseReason) + ); + // 33 characters would pass any character-count check; 66 bytes does not. + assert_eq!(REASON_33_CHARS_66_BYTES.chars().count(), 33); + assert_eq!( + REASON_33_CHARS_66_BYTES.len() as u32, + MAX_PAUSE_REASON_LEN + 2 + ); + assert_eq!(ctx.scopes(), 0); +} diff --git a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs index aa6ee3b..4860656 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs @@ -42,11 +42,15 @@ //! event draining emissions faster than intended. use soroban_sdk::{ - contract, contractclient, contracterror, contractimpl, contracttype, Address, BytesN, Env, Vec, + contract, contractclient, contracterror, contractimpl, contracttype, Address, BytesN, Env, + String, Vec, }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; +pub use guildworkman_governance_guard::{ + PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_INTAKE, SCOPE_SETTLEMENT, +}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -151,6 +155,12 @@ pub enum Error { RotationTimelockActive = 27, RotationExpired = 28, RotationInProgress = 29, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 30, + InvalidPauseScope = 31, + InvalidPauseDuration = 32, + NotPaused = 33, + InvalidPauseReason = 34, } impl From for Error { @@ -172,6 +182,11 @@ impl From for Error { governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, governance::GovernanceError::RotationExpired => Error::RotationExpired, governance::GovernanceError::RotationInProgress => Error::RotationInProgress, + governance::GovernanceError::OperationPaused => Error::OperationPaused, + governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, + governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, + governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -313,12 +328,63 @@ impl LoyaltyEmissions { governance::current_storage_version(&env) } + // ----- Emergency circuit breaker ----- + + /// Halts `scopes` for `duration_secs` seconds, authorized by any single + /// governance signer. Returns the resulting pause record. + /// + /// [`SCOPE_INTAKE`] covers `create_schedule` — new emission obligations + /// — and [`SCOPE_SETTLEMENT`] covers `claim`, the path that actually + /// mints. `claim` is haltable because it is the only place this engine + /// creates supply, so a vesting-math bug is drained through it and + /// nowhere else. + /// + /// Pausing `claim` withholds nothing permanently: vesting is a pure + /// function of the ledger clock and keeps accruing while halted, so a + /// beneficiary claims the identical amount afterwards, just later. The + /// one interaction worth stating explicitly is `reclaim`, which is + /// **deliberately unguarded** — see its own note. + pub fn pause( + env: Env, + caller: Address, + scopes: u32, + duration_secs: u64, + reason: String, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) + } + + /// Clears `scopes` from the active pause early. Returns the scopes still + /// halted. + pub fn unpause(env: Env, caller: Address, scopes: u32) -> Result { + governance::unpause(&env, caller, scopes).map_err(Into::into) + } + + /// The active pause record, or `None` if nothing is halted — including + /// when a pause was placed but has since auto-expired. + pub fn get_pause_state(env: Env) -> Option { + governance::get_pause_state(&env) + } + + /// Bitmask of currently halted scopes; `0` when the contract is open. + pub fn paused_scopes(env: Env) -> u32 { + governance::paused_scopes(&env) + } + + /// Whether any bit of `scope` is currently halted. + pub fn is_paused(env: Env, scope: u32) -> bool { + governance::is_paused(&env, scope) + } + /// Admin-only: register a linear vesting stream for `beneficiary`. /// /// `start` defaults to the current ledger when `0` is passed. `cliff` must /// not exceed `duration`, and `duration` must be positive. Only one active /// schedule per beneficiary; reclaim (or a future `total` top-up flow) is /// required before re-registering. + /// + /// Guarded by [`SCOPE_INTAKE`] — registering a schedule is taking on a + /// new emission obligation. #[allow(clippy::too_many_arguments)] pub fn create_schedule( env: Env, @@ -329,6 +395,7 @@ impl LoyaltyEmissions { duration: u32, reclaimable_after: u32, ) -> Result<(), Error> { + governance::require_not_paused(&env, governance::SCOPE_INTAKE)?; let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -379,7 +446,14 @@ impl LoyaltyEmissions { /// remaining per-window budget. A claim only errors if there is genuinely /// nothing available, or if *both* budgets are already exhausted for the /// window (so the caller can distinguish "come back later"). + /// + /// Guarded by [`SCOPE_SETTLEMENT`]. A halted claim is a delay, not a + /// loss: `vested_amount` is derived from the ledger clock and keeps + /// accruing through the pause, so nothing that was owed stops being + /// owed. The one place that could still bite is a `reclaimable_after` + /// deadline falling inside the pause window — see `reclaim`. pub fn claim(env: Env, beneficiary: Address) -> Result { + governance::require_not_paused(&env, governance::SCOPE_SETTLEMENT)?; beneficiary.require_auth(); let key = DataKey::Schedule(beneficiary.clone()); @@ -445,6 +519,21 @@ impl LoyaltyEmissions { /// /// Nothing is minted or burned here — reclaimed units simply never enter /// circulation, since the engine only ever mints on `claim`. + /// + /// **Deliberately unguarded by the circuit breaker.** Because it moves + /// no tokens, it can never take a balance a beneficiary already holds, + /// so it is not a path a pause needs to close. + /// + /// It is nonetheless the one action that could combine badly with a + /// paused `claim`: an allocation left unclaimed until + /// `reclaimable_after` can be reclaimed, and a long enough + /// [`SCOPE_SETTLEMENT`] pause spanning that deadline would deny a + /// beneficiary the window to claim first. `create_schedule` already + /// forbids `reclaimable_after` earlier than full vesting, and + /// [`MAX_PAUSE_DURATION`] caps any single pause at seven days, so the + /// exposure is bounded and visible rather than open-ended — but an + /// operator pausing settlement should still check for schedules whose + /// reclaim deadline falls inside the window. pub fn reclaim(env: Env, beneficiary: Address) -> Result { let admin = Self::require_admin(&env)?; admin.require_auth(); diff --git a/soroban-contracts/contracts/loyalty-emissions/src/test.rs b/soroban-contracts/contracts/loyalty-emissions/src/test.rs index 7a84762..b9f04a0 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/test.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/test.rs @@ -18,6 +18,10 @@ struct Fixture<'a> { token: RealLoyaltyTokenClient<'a>, admin: Address, signers: Vec
, + /// The underlying token's *own* governance signers — distinct from + /// `signers`, which govern the emission engine. Needed to exercise the + /// case where the token halts minting underneath a healthy engine. + token_signers: Vec
, } fn make_signers(env: &Env, n: u32) -> Vec
{ @@ -40,6 +44,7 @@ fn setup<'a>() -> Fixture<'a> { // Underlying loyalty token. let token_id = env.register(RealLoyaltyToken, ()); let token = RealLoyaltyTokenClient::new(&env, &token_id); + let token_signers = make_signers(&env, 1); token.initialize( &token_admin, &token_admin, // temporary minter, rotated to the engine below @@ -47,7 +52,7 @@ fn setup<'a>() -> Fixture<'a> { &String::from_str(&env, "GuildWorkman Points"), &String::from_str(&env, "GWP"), &governance::GovernanceInit { - signers: make_signers(&env, 1), + signers: token_signers.clone(), threshold: 1, }, ); @@ -80,6 +85,7 @@ fn setup<'a>() -> Fixture<'a> { token, admin, signers, + token_signers, } } @@ -602,3 +608,359 @@ fn migrate_by_non_signer_fails() { let result = f.emissions.try_migrate(&outsider); assert_eq!(result, Err(Ok(Error::NotASigner))); } + +// --------------------------------------------------------------------------- +// Emergency circuit breaker +// --------------------------------------------------------------------------- +// +// The breaker's own semantics live in `guildworkman-governance-guard`'s unit +// tests. These cover the engine's wiring, plus the one genuinely cross-contract +// case: the token halting mint underneath a healthy engine. + +/// Reason string for tests that don't exercise the field itself. Kept +/// non-empty so the round-trip through storage is actually covered by every +/// pause test rather than only the ones that look at it. +fn reason(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "INC-000 test") +} + +fn set_time(env: &Env, timestamp: u64) { + env.ledger().with_mut(|l| l.timestamp = timestamp); +} + +#[test] +fn paused_intake_blocks_new_schedules() { + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&f.emissions.env), + ); + + let res = f + .emissions + .try_create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + assert_eq!( + f.emissions.try_get_schedule(&user), + Err(Ok(Error::ScheduleNotFound)) + ); +} + +#[test] +fn paused_settlement_blocks_claims_and_mints_nothing() { + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&f.emissions.env), + ); + + let res = f.emissions.try_claim(&user); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + assert_eq!(f.token.balance(&user), 0); + assert_eq!(f.emissions.get_schedule(&user).claimed, 0); +} + +#[test] +fn a_halted_claim_loses_nothing_because_vesting_keeps_accruing() { + // The reason halting `claim` is a delay rather than a confiscation: + // `vested` is a pure function of the ledger clock, so the beneficiary + // gets the same units afterwards, only later. + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&f.emissions.env), + ); + assert_eq!( + f.emissions.try_claim(&user), + Err(Ok(Error::OperationPaused)) + ); + + // Vesting continued through the halt, and it lifts with no transaction. + set_ledger(&f.env, 1_900); + set_time(&f.env, 4_600); + assert_eq!(f.emissions.paused_scopes(), 0); + assert_eq!(f.emissions.claim(&user), 900); + assert_eq!(f.token.balance(&user), 900); +} + +#[test] +fn reclaim_stays_open_while_settlement_is_halted() { + // `reclaim` mints and burns nothing — it only marks an allocation as + // never-to-be-minted — so it is not a fund-recovery path that must stay + // open, but it is also not one a pause needs to close. Pinned here so + // the asymmetry documented on `reclaim` is a tested fact. + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 2_000); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&f.emissions.env), + ); + + assert_eq!(f.emissions.reclaim(&user), 1_000); + assert_eq!(f.token.balance(&user), 0); +} + +#[test] +fn views_stay_readable_while_everything_is_halted() { + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&f.emissions.env), + ); + + assert_eq!(f.emissions.vested(&user), 500); + assert_eq!(f.emissions.claimable(&user), 500); + assert_eq!(f.emissions.get_schedule(&user).total, 1_000); +} + +#[test] +fn pausing_the_token_alone_stops_claims_at_the_mint_boundary() { + // The engine holds the token's `minter` role, so halting token intake + // stops emissions even with the engine itself wide open. The failure + // surfaces as a trapped cross-contract invocation rather than this + // contract's own typed error, since the engine calls `mint` through the + // non-`try` client — what matters is that no supply is created. + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + f.token.pause( + &f.token_signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&f.token.env), + ); + + assert_eq!(f.emissions.paused_scopes(), 0); + assert!(f.emissions.try_claim(&user).is_err()); + assert_eq!(f.token.balance(&user), 0); + assert_eq!(f.emissions.get_schedule(&user).claimed, 0); + + // Lifting the token's halt is enough; the engine needed no change. + f.token + .unpause(&f.token_signers.get_unchecked(0), &SCOPE_INTAKE); + assert_eq!(f.emissions.claim(&user), 500); + assert_eq!(f.token.balance(&user), 500); +} + +#[test] +fn a_non_signer_cannot_pause_the_engine() { + let f = setup(); + let outsider = Address::generate(&f.env); + + let res = f + .emissions + .try_pause(&outsider, &ALL_SCOPES, &3_600, &reason(&f.emissions.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); + assert_eq!(f.emissions.paused_scopes(), 0); +} + +#[test] +fn the_emissions_admin_is_not_a_pause_authority() { + let f = setup(); + let res = f + .emissions + .try_pause(&f.admin, &ALL_SCOPES, &3_600, &reason(&f.emissions.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn a_pause_longer_than_the_cap_is_refused() { + let f = setup(); + set_time(&f.env, 1_000); + + let res = f.emissions.try_pause( + &f.signers.get_unchecked(0), + &ALL_SCOPES, + &(MAX_PAUSE_DURATION + 1), + &reason(&f.emissions.env), + ); + assert_eq!(res, Err(Ok(Error::InvalidPauseDuration))); + assert_eq!(f.emissions.paused_scopes(), 0); +} + +#[test] +fn unpausing_settlement_alone_reopens_claims_while_intake_stays_halted() { + let f = setup(); + let user = Address::generate(&f.env); + let other = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + f.emissions.pause( + &f.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&f.emissions.env), + ); + + let remaining = f + .emissions + .unpause(&f.signers.get_unchecked(1), &SCOPE_SETTLEMENT); + assert_eq!(remaining & SCOPE_SETTLEMENT, 0); + assert_eq!(remaining & SCOPE_INTAKE, SCOPE_INTAKE); + + assert_eq!(f.emissions.claim(&user), 500); + let res = f + .emissions + .try_create_schedule(&other, &1_000, &0, &0, &1_000, &2_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); +} + +// --------------------------------------------------------------------------- +// Cross-contract broadcast +// --------------------------------------------------------------------------- + +#[test] +fn one_scope_mask_broadcast_to_two_contracts_halts_only_the_intended_paths() { + // This fixture registers two real, separately-deployed contracts with + // independent storage and independent signer sets — the closest thing in + // a unit test to the protocol-wide sweep an operator would actually run. + // + // The sweep is N transactions, not one, and each contract's guard reads + // only its own record. What's asserted here is that the same mask lands + // correctly on both despite them implementing different subsets of it. + let f = setup(); + let user = Address::generate(&f.env); + set_ledger(&f.env, 1_000); + f.emissions + .create_schedule(&user, &1_000, &0, &0, &1_000, &2_000); + set_ledger(&f.env, 1_500); + set_time(&f.env, 1_000); + + // One mask, broadcast to both. ATTESTATION is meaningful to neither and + // rides along without special-casing. + let mask = SCOPE_INTAKE | governance::SCOPE_ATTESTATION; + let why = soroban_sdk::String::from_str(&f.env, "INC-77 protocol sweep"); + f.emissions + .pause(&f.signers.get_unchecked(0), &mask, &3_600, &why); + f.token + .pause(&f.token_signers.get_unchecked(0), &mask, &3_600, &why); + + assert_eq!(f.emissions.paused_scopes(), mask); + assert_eq!(f.token.paused_scopes(), mask); + + // Intake is halted on both: no new schedules, no new supply. + let other = Address::generate(&f.env); + assert_eq!( + f.emissions + .try_create_schedule(&other, &1_000, &0, &0, &1_000, &2_000), + Err(Ok(Error::OperationPaused)) + ); + + // Settlement was never named, so claiming still works at the emissions + // layer — but the token's halted mint stops it at the boundary, and no + // supply is created. Exactly the intended protocol-wide effect. + assert!(f.emissions.try_claim(&user).is_err()); + assert_eq!(f.token.balance(&user), 0); + assert_eq!(f.emissions.get_schedule(&user).claimed, 0); + + // Lifting the sweep on both restores normal service with no other change. + f.emissions + .unpause(&f.signers.get_unchecked(1), &ALL_SCOPES); + f.token + .unpause(&f.token_signers.get_unchecked(0), &ALL_SCOPES); + assert_eq!(f.emissions.claim(&user), 500); + assert_eq!(f.token.balance(&user), 500); +} + +#[test] +fn the_two_contracts_pause_records_are_fully_independent() { + // Separate deployments, separate storage: halting one must not be + // observable from the other, in either direction. + let f = setup(); + set_time(&f.env, 1_000); + + f.emissions.pause( + &f.signers.get_unchecked(0), + &SCOPE_SETTLEMENT, + &3_600, + &reason(&f.emissions.env), + ); + assert_eq!(f.emissions.paused_scopes(), SCOPE_SETTLEMENT); + assert_eq!(f.token.paused_scopes(), 0); + assert!(f.token.get_pause_state().is_none()); + + f.token.pause( + &f.token_signers.get_unchecked(0), + &SCOPE_INTAKE, + &7_200, + &reason(&f.token.env), + ); + assert_eq!(f.emissions.paused_scopes(), SCOPE_SETTLEMENT); + assert_eq!(f.token.paused_scopes(), SCOPE_INTAKE); + // Different deadlines, kept apart. + assert_eq!(f.emissions.get_pause_state().unwrap().expires_at, 4_600); + assert_eq!(f.token.get_pause_state().unwrap().expires_at, 8_200); +} + +#[test] +fn a_signer_of_one_contract_cannot_pause_the_other() { + // The two deployments have independent governance sets; authority must + // not leak across them just because they sit in the same protocol. + let f = setup(); + set_time(&f.env, 1_000); + + let res = f.token.try_pause( + &f.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&f.token.env), + ); + assert_eq!(res, Err(Ok(guildworkman_loyalty_token::Error::NotASigner))); + + let res = f.emissions.try_pause( + &f.token_signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&f.emissions.env), + ); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} diff --git a/soroban-contracts/contracts/loyalty-token/src/lib.rs b/soroban-contracts/contracts/loyalty-token/src/lib.rs index d0bbbbd..264f6a8 100644 --- a/soroban-contracts/contracts/loyalty-token/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-token/src/lib.rs @@ -11,7 +11,10 @@ use soroban_sdk::{ }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; +pub use guildworkman_governance_guard::{ + PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_INTAKE, +}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -69,6 +72,12 @@ pub enum Error { RotationTimelockActive = 21, RotationExpired = 22, RotationInProgress = 23, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 24, + InvalidPauseScope = 25, + InvalidPauseDuration = 26, + NotPaused = 27, + InvalidPauseReason = 28, } impl From for Error { @@ -90,6 +99,11 @@ impl From for Error { governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, governance::GovernanceError::RotationExpired => Error::RotationExpired, governance::GovernanceError::RotationInProgress => Error::RotationInProgress, + governance::GovernanceError::OperationPaused => Error::OperationPaused, + governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, + governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, + governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -234,6 +248,54 @@ impl LoyaltyToken { governance::current_storage_version(&env) } + // ----- Emergency circuit breaker ----- + + /// Halts `scopes` for `duration_secs` seconds, authorized by any single + /// governance signer. Returns the resulting pause record. + /// + /// **`mint` is the only guarded entrypoint in this contract**, under + /// [`SCOPE_INTAKE`] — it is the sole route by which supply that does + /// not yet exist can come into being, and therefore the only place a + /// bug or a compromised minter can inflate the ledger. + /// + /// `transfer`, `transfer_from`, `approve` and `burn` are unguarded and + /// stay callable through any pause, because they move *already-held* + /// balances. A holder's points are their property; a token that can + /// stop its holders from moving what they already own has not built a + /// circuit breaker, it has built a freeze. Halting mint stops the + /// bleeding without ever reaching into an existing balance. + pub fn pause( + env: Env, + caller: Address, + scopes: u32, + duration_secs: u64, + reason: String, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) + } + + /// Clears `scopes` from the active pause early. Returns the scopes still + /// halted. + pub fn unpause(env: Env, caller: Address, scopes: u32) -> Result { + governance::unpause(&env, caller, scopes).map_err(Into::into) + } + + /// The active pause record, or `None` if nothing is halted — including + /// when a pause was placed but has since auto-expired. + pub fn get_pause_state(env: Env) -> Option { + governance::get_pause_state(&env) + } + + /// Bitmask of currently halted scopes; `0` when the contract is open. + pub fn paused_scopes(env: Env) -> u32 { + governance::paused_scopes(&env) + } + + /// Whether any bit of `scope` is currently halted. + pub fn is_paused(env: Env, scope: u32) -> bool { + governance::is_paused(&env, scope) + } + /// Admin-only: rotate which address is allowed to mint reward points. pub fn set_minter(env: Env, new_minter: Address) -> Result<(), Error> { let admin = Self::require_admin(&env)?; @@ -243,7 +305,13 @@ impl LoyaltyToken { } /// Minter-only: award `amount` loyalty points to `to`. + /// + /// Guarded by [`SCOPE_INTAKE`] — the contract's only supply-creating + /// path. Note that `loyalty-emissions` holds this contract's `minter` + /// role, so halting intake here also stops emission `claim`s at the + /// token boundary even if the emission engine itself is left open. pub fn mint(env: Env, to: Address, amount: i128) -> Result<(), Error> { + governance::require_not_paused(&env, governance::SCOPE_INTAKE)?; if amount <= 0 { return Err(Error::InvalidAmount); } @@ -284,6 +352,8 @@ impl LoyaltyToken { Ok(()) } + /// **Deliberately unguarded by the circuit breaker** — moves a balance + /// the holder already owns. See `pause` for why no scope reaches it. pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> Result<(), Error> { from.require_auth(); if amount <= 0 { @@ -311,6 +381,9 @@ impl LoyaltyToken { Ok(()) } + /// **Deliberately unguarded by the circuit breaker** — a holder + /// destroying their own balance can never be the thing an incident + /// needs stopped. pub fn burn(env: Env, from: Address, amount: i128) -> Result<(), Error> { from.require_auth(); if amount <= 0 { diff --git a/soroban-contracts/contracts/loyalty-token/src/test.rs b/soroban-contracts/contracts/loyalty-token/src/test.rs index a5060e0..d7cac11 100644 --- a/soroban-contracts/contracts/loyalty-token/src/test.rs +++ b/soroban-contracts/contracts/loyalty-token/src/test.rs @@ -188,3 +188,196 @@ fn migrate_by_non_signer_fails() { let result = contract.try_migrate(&outsider); assert_eq!(result, Err(Ok(Error::NotASigner))); } + +// =========================================================================== +// Emergency circuit breaker +// =========================================================================== +// +// The breaker's own semantics are unit-tested in +// `guildworkman-governance-guard`. The property that matters here is narrow +// and absolute: `mint` is halted, and nothing a holder does with a balance +// they already own ever is. + +/// Reason string for tests that don't exercise the field itself. Kept +/// non-empty so the round-trip through storage is actually covered by every +/// pause test rather than only the ones that look at it. +fn reason(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "INC-000 test") +} + +fn set_time(env: &Env, timestamp: u64) { + use soroban_sdk::testutils::Ledger as _; + env.ledger().with_mut(|l| l.timestamp = timestamp); +} + +#[test] +fn paused_intake_blocks_minting() { + let (env, contract, signers) = setup_with_signers(); + let user = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&contract.env), + ); + + let res = contract.try_mint(&user, &1_000); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + assert_eq!(contract.balance(&user), 0); +} + +#[test] +fn holders_keep_full_control_of_existing_balances_while_everything_is_paused() { + // The whole point of guarding only `mint`: a holder's points are their + // property, and a halt must never reach them. + let (env, contract, signers) = setup_with_signers(); + let user = Address::generate(&env); + let other = Address::generate(&env); + let spender = Address::generate(&env); + contract.mint(&user, &1_000); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&contract.env), + ); + assert_eq!(contract.paused_scopes(), ALL_SCOPES); + + contract.transfer(&user, &other, &100); + assert_eq!(contract.balance(&other), 100); + + contract.approve(&user, &spender, &200, &10_000); + contract.transfer_from(&spender, &user, &other, &200); + assert_eq!(contract.balance(&other), 300); + + contract.burn(&user, &50); + assert_eq!(contract.balance(&user), 650); + + // And the pause is genuinely still in force — these worked because they + // are exempt, not because the halt lapsed. + assert_eq!(contract.paused_scopes(), ALL_SCOPES); + assert_eq!( + contract.try_mint(&user, &1), + Err(Ok(Error::OperationPaused)) + ); +} + +#[test] +fn minting_resumes_on_its_own_once_the_pause_expires() { + let (env, contract, signers) = setup_with_signers(); + let user = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&contract.env), + ); + assert_eq!( + contract.try_mint(&user, &1_000), + Err(Ok(Error::OperationPaused)) + ); + + // No unpause transaction; only the clock moves. + set_time(&env, 4_600); + + assert_eq!(contract.paused_scopes(), 0); + contract.mint(&user, &1_000); + assert_eq!(contract.balance(&user), 1_000); +} + +#[test] +fn a_non_signer_cannot_pause_the_token() { + let (env, contract, _signers) = setup_with_signers(); + let outsider = Address::generate(&env); + + let res = contract.try_pause(&outsider, &ALL_SCOPES, &3_600, &reason(&contract.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); + assert_eq!(contract.paused_scopes(), 0); +} + +#[test] +fn a_pause_longer_than_the_cap_is_refused() { + let (env, contract, signers) = setup_with_signers(); + set_time(&env, 1_000); + + let res = contract.try_pause( + &signers.get_unchecked(0), + &SCOPE_INTAKE, + &(MAX_PAUSE_DURATION + 1), + &reason(&contract.env), + ); + assert_eq!(res, Err(Ok(Error::InvalidPauseDuration))); + assert_eq!(contract.paused_scopes(), 0); +} + +#[test] +fn any_signer_can_lift_a_pause_placed_by_another() { + let (env, contract, signers) = setup_with_signers(); + let user = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&contract.env), + ); + assert_eq!(contract.unpause(&signers.get_unchecked(2), &ALL_SCOPES), 0); + + contract.mint(&user, &1_000); + assert_eq!(contract.balance(&user), 1_000); +} + +#[test] +fn scopes_the_token_has_no_entrypoints_for_are_well_formed_no_ops() { + // An operator sweeping every contract with one mask must not have to + // special-case which scopes a given contract implements. Settlement and + // attestation mean nothing here, and must not bleed into `mint`. + let (env, contract, signers) = setup_with_signers(); + let user = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &(governance::SCOPE_SETTLEMENT | governance::SCOPE_ATTESTATION), + &3_600, + &reason(&contract.env), + ); + + assert_eq!( + contract.paused_scopes(), + governance::SCOPE_SETTLEMENT | governance::SCOPE_ATTESTATION + ); + contract.mint(&user, &1_000); + assert_eq!(contract.balance(&user), 1_000); +} + +#[test] +fn an_over_long_pause_reason_is_rejected() { + let (env, contract, signers) = setup_with_signers(); + set_time(&env, 1_000); + + let too_long = soroban_sdk::String::from_str( + &env, + "01234567890123456789012345678901234567890123456789012345678901234", + ); + let res = contract.try_pause(&signers.get_unchecked(0), &SCOPE_INTAKE, &3_600, &too_long); + assert_eq!(res, Err(Ok(Error::InvalidPauseReason))); + assert_eq!(contract.paused_scopes(), 0); +} + +#[test] +fn the_pause_reason_is_readable_from_chain_state() { + let (env, contract, signers) = setup_with_signers(); + set_time(&env, 1_000); + let why = soroban_sdk::String::from_str(&env, "INC-412 mint overflow"); + contract.pause(&signers.get_unchecked(0), &SCOPE_INTAKE, &3_600, &why); + + assert_eq!(contract.get_pause_state().unwrap().reason, why); +} diff --git a/soroban-contracts/contracts/reputation/src/lib.rs b/soroban-contracts/contracts/reputation/src/lib.rs index d3505af..e9945c3 100644 --- a/soroban-contracts/contracts/reputation/src/lib.rs +++ b/soroban-contracts/contracts/reputation/src/lib.rs @@ -5,10 +5,15 @@ //! Computes time-decayed, stake-weighted scores from signed attestations //! while resisting Sybil, collusion, and self-dealing attacks. -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec, +}; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; +pub use guildworkman_governance_guard::{ + PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, +}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet — @@ -134,6 +139,12 @@ pub enum Error { RotationTimelockActive = 26, RotationExpired = 27, RotationInProgress = 28, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 29, + InvalidPauseScope = 30, + InvalidPauseDuration = 31, + NotPaused = 32, + InvalidPauseReason = 33, } impl From for Error { @@ -155,6 +166,11 @@ impl From for Error { governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, governance::GovernanceError::RotationExpired => Error::RotationExpired, governance::GovernanceError::RotationInProgress => Error::RotationInProgress, + governance::GovernanceError::OperationPaused => Error::OperationPaused, + governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, + governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, + governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -311,6 +327,51 @@ impl ReputationContract { governance::current_storage_version(&env) } + // ----- Emergency circuit breaker ----- + + /// Halts `scopes` for `duration_secs` seconds, authorized by any single + /// governance signer. Returns the resulting pause record. + /// + /// The only scope with teeth here is [`SCOPE_ATTESTATION`], which halts + /// `submit_attestation`. Scores hold no funds, so nothing in this + /// contract can be stranded by a pause; the reason to halt it is a + /// scoring incident — a Sybil ring or a collusion pattern poisoning the + /// aggregate — which is deliberately independent of anything happening + /// in `escrow`. Reads (`get_reputation_score_x10000` and friends) stay + /// open throughout: a consumer must always be able to see the scores + /// that already exist, including to notice they went bad. + pub fn pause( + env: Env, + caller: Address, + scopes: u32, + duration_secs: u64, + reason: String, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) + } + + /// Clears `scopes` from the active pause early. Returns the scopes still + /// halted. + pub fn unpause(env: Env, caller: Address, scopes: u32) -> Result { + governance::unpause(&env, caller, scopes).map_err(Into::into) + } + + /// The active pause record, or `None` if nothing is halted — including + /// when a pause was placed but has since auto-expired. + pub fn get_pause_state(env: Env) -> Option { + governance::get_pause_state(&env) + } + + /// Bitmask of currently halted scopes; `0` when the contract is open. + pub fn paused_scopes(env: Env) -> u32 { + governance::paused_scopes(&env) + } + + /// Whether any bit of `scope` is currently halted. + pub fn is_paused(env: Env, scope: u32) -> bool { + governance::is_paused(&env, scope) + } + pub fn update_config(env: Env, config: Config) -> Result<(), Error> { Self::require_admin(&env)?; Self::validate_config(&config)?; @@ -331,6 +392,9 @@ impl ReputationContract { // ----- Core submission ----- + /// Guarded by [`SCOPE_ATTESTATION`]: this is the contract's only + /// state-poisoning surface, and halting it is what buys time to ship a + /// scoring fix without also freezing payments in `escrow`. pub fn submit_attestation( env: Env, appointment_id: u64, @@ -339,6 +403,10 @@ impl ReputationContract { rating: u32, attestation_hash: BytesN<32>, ) -> Result<(), Error> { + // 0. Circuit breaker, ahead of everything else: a halted operation + // should cost nothing and touch no storage. + governance::require_not_paused(&env, governance::SCOPE_ATTESTATION)?; + // 1. Authorization (signed attestation via Soroban auth). client.require_auth(); diff --git a/soroban-contracts/contracts/reputation/src/test.rs b/soroban-contracts/contracts/reputation/src/test.rs index ac1d624..b48e119 100644 --- a/soroban-contracts/contracts/reputation/src/test.rs +++ b/soroban-contracts/contracts/reputation/src/test.rs @@ -669,3 +669,170 @@ fn migrate_with_nothing_to_migrate_fails() { let result = contract.try_migrate(&signers.get_unchecked(0)); assert_eq!(result, Err(Ok(Error::NothingToMigrate))); } + +// =========================================================================== +// Emergency circuit breaker +// =========================================================================== +// +// Scope arithmetic, expiry and authorization are unit-tested in +// `guildworkman-governance-guard`. These cover the wiring specific to this +// contract: `submit_attestation` is halted by `SCOPE_ATTESTATION` and nothing +// else in this contract is halted by anything. + +/// Reason string for tests that don't exercise the field itself. Kept +/// non-empty so the round-trip through storage is actually covered by every +/// pause test rather than only the ones that look at it. +fn reason(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "INC-000 test") +} + +fn set_time(env: &Env, timestamp: u64) { + env.ledger().with_mut(|l| l.timestamp = timestamp); +} + +#[test] +fn paused_attestations_are_rejected_and_write_no_state() { + let (env, contract, _admin, signers) = setup_with_governance(); + let client = Address::generate(&env); + let worker = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &SCOPE_ATTESTATION, + &3_600, + &reason(&contract.env), + ); + + let res = contract.try_submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env)); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + + // Nothing was recorded, so the same appointment is still reviewable + // once the halt lifts — a rejected call must not burn the one review. + assert_eq!(contract.get_attestation_count(&worker), 0); + contract.unpause(&signers.get_unchecked(0), &SCOPE_ATTESTATION); + contract.submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env)); + assert_eq!(contract.get_attestation_count(&worker), 1); +} + +#[test] +fn scores_stay_readable_while_attestations_are_paused() { + // Halting writes must not blind consumers to the scores that already + // exist — including the bad ones that prompted the halt. + let (env, contract, _admin, signers) = setup_with_governance(); + let client = Address::generate(&env); + let worker = Address::generate(&env); + contract.submit_attestation(&1, &client, &worker, &4, &dummy_hash(&env)); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&contract.env), + ); + + assert_eq!(contract.get_reputation_score_x10000(&worker), 40_000); + assert_eq!(contract.get_attestation_count(&worker), 1); + assert!(contract.get_attestation(&worker, &0).is_some()); +} + +#[test] +fn attestations_resume_on_their_own_once_the_pause_expires() { + let (env, contract, _admin, signers) = setup_with_governance(); + let client = Address::generate(&env); + let worker = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &SCOPE_ATTESTATION, + &3_600, + &reason(&contract.env), + ); + let res = contract.try_submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env)); + assert_eq!(res, Err(Ok(Error::OperationPaused))); + + // No unpause transaction; only the clock moves. + set_time(&env, 4_600); + + assert_eq!(contract.paused_scopes(), 0); + contract.submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env)); + assert_eq!(contract.get_attestation_count(&worker), 1); +} + +#[test] +fn a_scope_this_contract_has_no_entrypoints_for_is_a_well_formed_no_op() { + // Operators broadcast the same mask to every contract during an + // incident; a scope that means nothing here must not error and must not + // accidentally halt attestations either. + let (env, contract, _admin, signers) = setup_with_governance(); + let client = Address::generate(&env); + let worker = Address::generate(&env); + + set_time(&env, 1_000); + contract.pause( + &signers.get_unchecked(0), + &(governance::SCOPE_INTAKE | governance::SCOPE_SETTLEMENT), + &3_600, + &reason(&contract.env), + ); + + contract.submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env)); + assert_eq!(contract.get_attestation_count(&worker), 1); +} + +#[test] +fn a_non_signer_cannot_pause_reputation() { + let (env, contract, _admin, _signers) = setup_with_governance(); + let outsider = Address::generate(&env); + + let res = contract.try_pause(&outsider, &ALL_SCOPES, &3_600, &reason(&contract.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); + assert_eq!(contract.paused_scopes(), 0); +} + +#[test] +fn the_config_admin_is_not_a_pause_authority() { + // `admin` owns `update_config`/`set_stake`; the breaker answers to the + // governance signer set instead. + let (_env, contract, admin, _signers) = setup_with_governance(); + let res = contract.try_pause(&admin, &ALL_SCOPES, &3_600, &reason(&contract.env)); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn a_pause_longer_than_the_cap_is_refused() { + let (env, contract, _admin, signers) = setup_with_governance(); + set_time(&env, 1_000); + + let res = contract.try_pause( + &signers.get_unchecked(0), + &ALL_SCOPES, + &(MAX_PAUSE_DURATION + 1), + &reason(&contract.env), + ); + assert_eq!(res, Err(Ok(Error::InvalidPauseDuration))); + assert_eq!(contract.paused_scopes(), 0); +} + +#[test] +fn unpause_with_nothing_halted_is_rejected() { + let (_env, contract, _admin, signers) = setup_with_governance(); + let res = contract.try_unpause(&signers.get_unchecked(0), &ALL_SCOPES); + assert_eq!(res, Err(Ok(Error::NotPaused))); +} + +#[test] +fn pause_views_report_the_active_window() { + let (env, contract, _admin, signers) = setup_with_governance(); + let signer = signers.get_unchecked(2); + set_time(&env, 1_000); + contract.pause(&signer, &SCOPE_ATTESTATION, &7_200, &reason(&contract.env)); + + let state = contract.get_pause_state().unwrap(); + assert_eq!(state.scopes, SCOPE_ATTESTATION); + assert_eq!(state.paused_by, signer); + assert_eq!(state.expires_at, 8_200); + assert!(contract.is_paused(&SCOPE_ATTESTATION)); +} diff --git a/soroban-contracts/scripts/broadcast-pause.sh b/soroban-contracts/scripts/broadcast-pause.sh new file mode 100755 index 0000000..1cda578 --- /dev/null +++ b/soroban-contracts/scripts/broadcast-pause.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Broadcast an emergency pause (or lift one) across every GuildWorkman +# contract that carries the circuit breaker. +# +# The four contracts are separate deployments with separate storage, so this +# is N transactions, not one. They do not have to land in the same ledger and +# nothing breaks if they land out of order or if one fails — each contract's +# guard reads only its own record. That also means a partial sweep is a +# *valid* state, not a corrupt one: re-run the script to finish it. +# +# Scopes (bitmask, combine by adding): +# 1 INTAKE new value / new obligations entering the system +# 2 SETTLEMENT discretionary happy-path payouts (never recovery) +# 4 ATTESTATION reputation writes +# 7 ALL_SCOPES +# +# A scope a given contract has no entrypoints for is a well-formed no-op, so +# the same mask goes to all four without special-casing. +# +# Fund-recovery paths are NEVER affected by any mask: escrow refunds and +# disputes, token transfer/transfer_from/burn of held balances, and every +# read-only view stay callable throughout. +# +# Usage: +# SIGNER=my-key ./scripts/broadcast-pause.sh pause 7 21600 "INC-412 milestone accounting" +# SIGNER=my-key ./scripts/broadcast-pause.sh unpause 1 +# SIGNER=my-key ./scripts/broadcast-pause.sh status +# +# Required environment: +# SIGNER stellar-cli key name or secret; must be a governance signer +# on each contract it is used against +# NETWORK defaults to testnet +# ESCROW, REPUTATION, LOYALTY_TOKEN, LOYALTY_EMISSIONS +# contract ids; any left unset is skipped with a warning +# +# Note each contract has its OWN governance signer set. If they differ, run +# the script once per key with only the matching contract ids exported. + +set -euo pipefail + +NETWORK="${NETWORK:-testnet}" +ACTION="${1:-}" + +if [[ -z "${SIGNER:-}" ]]; then + echo "error: SIGNER is not set (stellar-cli key name or secret)" >&2 + exit 2 +fi + +# Resolve the signer's public key once; `pause` takes the caller address as +# an explicit argument in addition to authorizing the transaction. +CALLER="$(stellar keys address "$SIGNER" 2>/dev/null || echo "${SIGNER_ADDRESS:-}")" +if [[ -z "$CALLER" ]]; then + echo "error: could not resolve an address for SIGNER; set SIGNER_ADDRESS" >&2 + exit 2 +fi + +targets() { + local name id + for name in ESCROW REPUTATION LOYALTY_TOKEN LOYALTY_EMISSIONS; do + id="${!name:-}" + if [[ -z "$id" ]]; then + echo "warning: $name is unset — skipping" >&2 + continue + fi + printf '%s\t%s\n' "$name" "$id" + done +} + +invoke() { + local id="$1"; shift + stellar contract invoke --id "$id" --source "$SIGNER" --network "$NETWORK" -- "$@" +} + +case "$ACTION" in + pause) + SCOPES="${2:?usage: pause [reason]}" + DURATION="${3:?usage: pause [reason]}" + REASON="${4:-}" + # MAX_PAUSE_DURATION is 7 days; the contract rejects anything longer, but + # failing here is friendlier than four rejected transactions. + if (( DURATION <= 0 || DURATION > 604800 )); then + echo "error: duration must be 1..604800 seconds (7 days)" >&2 + exit 2 + fi + if (( ${#REASON} > 64 )); then + echo "error: reason must be at most 64 bytes" >&2 + exit 2 + fi + while IFS=$'\t' read -r name id; do + echo "==> pausing $name ($id) scopes=$SCOPES for ${DURATION}s" + invoke "$id" pause --caller "$CALLER" --scopes "$SCOPES" \ + --duration_secs "$DURATION" --reason "$REASON" || \ + echo "warning: $name failed — other contracts are unaffected, re-run to retry" >&2 + done < <(targets) + ;; + + unpause) + SCOPES="${2:?usage: unpause }" + while IFS=$'\t' read -r name id; do + echo "==> unpausing $name ($id) scopes=$SCOPES" + invoke "$id" unpause --caller "$CALLER" --scopes "$SCOPES" || \ + echo "warning: $name failed (already open?) — re-run to retry" >&2 + done < <(targets) + ;; + + status) + while IFS=$'\t' read -r name id; do + echo "==> $name ($id)" + invoke "$id" get_pause_state || true + done < <(targets) + ;; + + *) + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' + exit 2 + ;; +esac