From 2344cbee492f32fe92afd24383fde77ce5941f00 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:47:33 +0100 Subject: [PATCH 1/6] feat(contracts): scoped, self-expiring pausability in governance-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `pausable` module to the shared governance-guard crate: the emergency circuit breaker the core contracts have had no equivalent of. Until now the only responses to a live exploit were "watch it drain" or "upgrade under fire". A plain `bool paused` flag would be a rug pull waiting to happen, so the primitive is built around three properties instead: * **Scoped, not global.** A pause names a bitmask — SCOPE_INTAKE, SCOPE_SETTLEMENT, SCOPE_ATTESTATION — so entry paths can be halted independently of exit paths. Halting value entering escrow is a safety measure; halting value leaving it is a hostage situation, and only a scoped guard can express the first without the second. * **Fund-recovery paths are never guarded.** Enforced by omission at the call sites rather than by a runtime check, so it is stated explicitly in the module docs: there is no scope value, ALL_SCOPES included, that can reach a refund or a dispute. That wiring lands in the next commit. * **Time-bound, enforced on read.** A pause carries an `expires_at` ledger timestamp capped at MAX_PAUSE_DURATION (7 days), and expiry is evaluated on every guard consultation. A pause lapses with no unpause transaction, no live admin and no working key. Authorization is `require_signer` — any *single* governance signer, not the M-of-N threshold, mirroring `cancel_upgrade`. Gathering a threshold takes time an incident does not give you, and the action is bounded on every axis that matters. Using the signer set rather than each contract's own `admin` matters because the set is M-of-N, rotatable, and survives the loss of one key. `unpause` takes a mask and leaves the deadline alone, so an operator can bring the system back a piece at a time; re-calling `pause` with a narrower mask would also lift scopes but restarts the clock on the rest. Adds four GovernanceError variants (OperationPaused, InvalidPauseScope, InvalidPauseDuration, NotPaused) — OperationPaused is deliberately distinct from any status error, since "halted, retry later" and "this was never valid" call for opposite reactions from a client. State lives in one new instance key, GovernanceDataKey::PauseState, appended last so deployed contracts' key encodings stay put. 66 unit tests cover scope isolation, expiry boundaries, the duration cap, masked unpause, event emission, and adversarial auth. --- .../contracts/governance-guard/src/lib.rs | 47 +- .../governance-guard/src/pausable.rs | 375 +++++++++++++ .../governance-guard/src/pausable_test.rs | 529 ++++++++++++++++++ 3 files changed, 945 insertions(+), 6 deletions(-) create mode 100644 soroban-contracts/contracts/governance-guard/src/pausable.rs create mode 100644 soroban-contracts/contracts/governance-guard/src/pausable_test.rs diff --git a/soroban-contracts/contracts/governance-guard/src/lib.rs b/soroban-contracts/contracts/governance-guard/src/lib.rs index 73f1f45..f50b111 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,22 @@ 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, } /// How long a proposal stays open for approval before it must be @@ -717,5 +742,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, 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..cec8345 --- /dev/null +++ b/soroban-contracts/contracts/governance-guard/src/pausable.rs @@ -0,0 +1,375 @@ +//! 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, is rotatable +//! through the timelocked flow in the crate root, and survives the loss of +//! any one key. An incident is exactly when a single non-rotatable admin +//! key is least trustworthy. +//! +//! ## 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. +//! +//! ### 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}; + +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 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, +} + +/// Emitted whenever a pause is placed or replaced. Topics: +/// `["gov_pause", "paused", caller]`. +/// +/// `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, +} + +/// Emitted when scopes are cleared early by a signer. Topics: +/// `["gov_pause", "unpaused", caller]`. +/// +/// 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. +/// +/// 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, +) -> Result { + require_signer(env, &caller)?; + validate_scopes(scopes)?; + if duration_secs == 0 || duration_secs > MAX_PAUSE_DURATION { + return Err(GovernanceError::InvalidPauseDuration); + } + + let now = env.ledger().timestamp(); + let state = PauseState { + scopes, + expires_at: now + duration_secs, + paused_by: caller.clone(), + paused_at: now, + }; + env.storage() + .instance() + .set(&GovernanceDataKey::PauseState, &state); + + Paused { + caller, + scopes, + expires_at: state.expires_at, + } + .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..af6812f --- /dev/null +++ b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs @@ -0,0 +1,529 @@ +#![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, 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, 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) + } + + fn pause( + &self, + caller: Address, + scopes: u32, + duration: u64, + ) -> Result { + self.env + .as_contract(&self.host, || pause(&self.env, caller, scopes, duration)) + } + + 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)); + 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); +} From 378c35b0bebe0fd8b249a8774674cb9a8e1428e1 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:47:52 +0100 Subject: [PATCH 2/6] feat(contracts): wire circuit-breaker guards into the four core contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes pause/unpause/get_pause_state/paused_scopes/is_paused on escrow, reputation, loyalty-token and loyalty-emissions, and places the scope guards. Each contract gains the four pause error variants at whatever offset came next in its own enum, so existing codes are untouched. What is guarded: SCOPE_INTAKE escrow: create_appointment, create_milestone_escrow, add_milestone; loyalty-token: mint; loyalty-emissions: create_schedule SCOPE_SETTLEMENT escrow: confirm_completion, approve_milestone, release_milestone_funds; loyalty-emissions: claim SCOPE_ATTESTATION reputation: submit_attestation What is *not*, and why — this is the load-bearing half: 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. No scope reaches them. loyalty-token transfer, transfer_from, approve, burn — these move already-held balances. A holder's points are their property; only mint creates supply that doesn't yet exist, so only mint is guarded. loyalty-emissions reclaim — mints and burns nothing, so it cannot take a balance anyone holds. all every read-only view, so a consumer can always see the state that prompted the halt. Pausing settlement withholds a payout, so it gets an explicit argument rather than an assumption: 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 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. Guards run as the first statement of each entrypoint, ahead of auth and any storage read: a halted call should cost nothing and reveal nothing beyond the already-public pause state. Tests, per contract, covering the criteria in the issue: refund and dispute resolution succeed with ALL_SCOPES halted and money actually lands; intake blocked while no funds move; scope isolation in both directions; auto-expiry restoring service with no unpause transaction; non-signer and admin-key pause attempts rejected with NotASigner; the duration cap refused; partial unpause. Plus the cross-contract case where halting the token's mint stops emission claims underneath a healthy engine. --- soroban-contracts/contracts/escrow/src/lib.rs | 138 +++++++- .../contracts/escrow/src/test.rs | 304 ++++++++++++++++++ .../contracts/loyalty-emissions/src/lib.rs | 87 ++++- .../contracts/loyalty-emissions/src/test.rs | 213 +++++++++++- .../contracts/loyalty-token/src/lib.rs | 71 +++- .../contracts/loyalty-token/src/test.rs | 117 +++++++ .../contracts/reputation/src/lib.rs | 64 +++- .../contracts/reputation/src/test.rs | 143 ++++++++ 8 files changed, 1131 insertions(+), 6 deletions(-) diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index e514ed4..f8e1f63 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,38 @@ //! - `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. use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, 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, 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 +189,11 @@ pub enum Error { RotationTimelockActive = 34, RotationExpired = 35, RotationInProgress = 36, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 37, + InvalidPauseScope = 38, + InvalidPauseDuration = 39, + NotPaused = 40, } impl From for Error { @@ -186,6 +215,10 @@ 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, } } } @@ -315,7 +348,57 @@ 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, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs).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 +407,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 +442,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 +473,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 +502,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 +526,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 +577,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 +626,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 +637,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 +674,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 +714,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 +775,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 +824,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..0b1c994 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -780,3 +780,307 @@ 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*. + +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); + 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); + + 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); + + // 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); + + 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); + + // 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), + ); + 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); + 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); + 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); + + 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)); +} diff --git a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs index aa6ee3b..795366f 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs @@ -46,7 +46,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, 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 +154,11 @@ pub enum Error { RotationTimelockActive = 27, RotationExpired = 28, RotationInProgress = 29, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 30, + InvalidPauseScope = 31, + InvalidPauseDuration = 32, + NotPaused = 33, } impl From for Error { @@ -172,6 +180,10 @@ 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, } } } @@ -313,12 +325,62 @@ 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, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs).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 +391,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 +442,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 +515,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..a1c2e67 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,208 @@ 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. + +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); + + 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); + + 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); + 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); + + 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); + + 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); + + 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); + 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); + 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), + ); + 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); + + 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))); +} diff --git a/soroban-contracts/contracts/loyalty-token/src/lib.rs b/soroban-contracts/contracts/loyalty-token/src/lib.rs index d0bbbbd..cc32d09 100644 --- a/soroban-contracts/contracts/loyalty-token/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-token/src/lib.rs @@ -11,7 +11,9 @@ 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, 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 +71,11 @@ pub enum Error { RotationTimelockActive = 21, RotationExpired = 22, RotationInProgress = 23, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 24, + InvalidPauseScope = 25, + InvalidPauseDuration = 26, + NotPaused = 27, } impl From for Error { @@ -90,6 +97,10 @@ 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, } } } @@ -234,6 +245,53 @@ 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, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs).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 +301,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 +348,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 +377,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..295a321 100644 --- a/soroban-contracts/contracts/loyalty-token/src/test.rs +++ b/soroban-contracts/contracts/loyalty-token/src/test.rs @@ -188,3 +188,120 @@ 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. + +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); + + 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); + 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); + 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); + 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), + ); + 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); + assert_eq!(contract.unpause(&signers.get_unchecked(2), &ALL_SCOPES), 0); + + contract.mint(&user, &1_000); + assert_eq!(contract.balance(&user), 1_000); +} diff --git a/soroban-contracts/contracts/reputation/src/lib.rs b/soroban-contracts/contracts/reputation/src/lib.rs index d3505af..1bb7893 100644 --- a/soroban-contracts/contracts/reputation/src/lib.rs +++ b/soroban-contracts/contracts/reputation/src/lib.rs @@ -8,7 +8,9 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, 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, 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 +136,11 @@ pub enum Error { RotationTimelockActive = 26, RotationExpired = 27, RotationInProgress = 28, + // --- Emergency circuit breaker (see guildworkman-governance-guard) --- + OperationPaused = 29, + InvalidPauseScope = 30, + InvalidPauseDuration = 31, + NotPaused = 32, } impl From for Error { @@ -155,6 +162,10 @@ 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, } } } @@ -311,6 +322,50 @@ 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, + ) -> Result { + governance::pause(&env, caller, scopes, duration_secs).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 +386,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 +397,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..cff46a9 100644 --- a/soroban-contracts/contracts/reputation/src/test.rs +++ b/soroban-contracts/contracts/reputation/src/test.rs @@ -669,3 +669,146 @@ 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. + +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); + + 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); + + 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); + 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, + ); + + 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); + 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); + 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), + ); + 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); + + 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)); +} From bc4b795387d7b5d5d30c4126be8389838ba1c837 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:48:31 +0100 Subject: [PATCH 3/6] docs(contracts): document the circuit breaker; cache CI deps on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Emergency circuit breaker" section to the contracts README covering the scope table, the never-guarded recovery paths and why each one is exempt, the auto-expiry guarantee and its honest limit (an unattended pause always clears; a present admin can re-arm, which the upgrade flow already implies), the authorization model, the new entrypoints and events, per-contract error codes, storage layout, and CLI usage. Replaces the "Upgrade path exists; pause does not" bullet in the security notes — now false — with a statement of what compromising one signer key actually buys: a bounded liveness attack on new business, and no reach into any existing balance. Sets cache-on-failure on the Soroban CI cache. A run that fails on fmt or clippy has still built the whole dependency tree; caching it keeps the retry from recompiling soroban-sdk from scratch, which is most of the job's wall time. (Cargo caching itself was already in place.) Notes for review, not fixed here: the "Upgrade governance" section and the "Notes / follow-ups" list still describe the signer set as immutable after initialize, which signer rotation made stale before this change. Left alone to keep this diff to one concern. --- .github/workflows/soroban-ci.yml | 4 + soroban-contracts/README.md | 169 +++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 8 deletions(-) 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/README.md b/soroban-contracts/README.md index 24da3a7..6cfec4d 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) @@ -137,6 +138,136 @@ 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 + +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. + +**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. + +**Entrypoints**, added to all four contracts: + +- `pause(caller: Address, scopes: u32, duration_secs: u64) -> 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` — `None` once expired +- `paused_scopes() -> u32`, `is_paused(scope: u32) -> bool` + +Two events let off-chain monitoring react: `Paused { caller, scopes, +expires_at }` (topics `["gov_pause", "paused", caller]`) and `Unpaused { +caller, scopes, remaining_scopes }` (topics `["gov_pause", "unpaused", +caller]`). Auto-expiry emits nothing — it is a read-time evaluation with no +transaction behind it, so monitors should treat the `expires_at` carried by +`Paused` as the authoritative end of the window unless an `Unpaused` arrives +sooner. + +**Errors.** Four 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. | + +A non-signer calling `pause`/`unpause` gets the existing `NotASigner`. + +**Storage.** One instance-storage entry, `GovernanceDataKey::PauseState`, +holding `PauseState { scopes, expires_at, paused_by, paused_at }`. 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. + +```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 + +# 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 + +# 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 +stellar contract invoke --id $ESCROW --source signer2 --network testnet \ + -- unpause --caller $SIGNER_2 --scopes 1 + +# What's halted right now, and until when +stellar contract invoke --id $ESCROW --source signer1 --network testnet \ + -- get_pause_state +``` + ## Prerequisites - Rust with the `wasm32v1-none` target: `rustup target add wasm32v1-none` @@ -245,6 +376,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 +408,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-40 are the circuit breaker's, listed in +[Emergency circuit breaker](#emergency-circuit-breaker). + #### CLI usage ```sh @@ -322,8 +458,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 +513,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 +547,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-27 are +the circuit breaker's, listed in +[Emergency circuit breaker](#emergency-circuit-breaker). + #### CLI usage ```sh @@ -469,6 +612,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 +668,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-33 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 +864,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 From f26fee24820877109ead89e02d03cef4bd917a9b Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:45:31 +0100 Subject: [PATCH 4/6] feat(contracts): operator reason on pause, cost benchmarks, ops tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #46. Adds a length-capped `reason` to `pause`, stored with the record and emitted with the event, so "why is this halted?" is answerable from chain state rather than from a chat log nobody can find at 3am. Capped at 64 bytes because the record lives in instance storage that every subsequent invocation pays to load — an incident ticket reference belongs on-chain, the write-up does not. Empty is allowed, so the field never stands between a responder and a halt. New `InvalidPauseReason` error, appended per contract so nothing existing moved. Adds hot-path cost measurement using the SDK's budget metering, since the guard runs on every guarded entrypoint and its cost should be measured rather than assumed: trivial instance-storage view 51,549 CPU insns 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%) In normal operation — no pause ever set, which is the state the contracts are in essentially always — the guard costs ~168 instructions against a 336k booking. The ~25k figure is deserializing the record and is paid only while an incident is in progress. A rejected call costs ~23% of the work it replaces, because the guard sits ahead of auth and every other storage access, which is what makes a pause a usable response to an entrypoint being hammered rather than an amplifier. No micro-optimization is warranted at these numbers; the cap on `reason` is what keeps the incident-time figure bounded too. Native-test metering underestimates compiled Wasm, so the tests fence relative behaviour, not absolute cost. New tests: reason round-trip through storage and event, cap boundary at exactly 64 and 65 bytes, full PauseState serialization round-trip, broadcast no-ops on escrow and loyalty-token for scopes they don't implement, a genuine two-contract sweep with independent records and independent signer sets, authority not leaking between deployments, and ordering cases — a second signer's pause observing the first, and repeated guard consultations within one ledger all agreeing. 267 tests total. Documents what was previously left implicit: what "enforced on read" means and what a guard consultation is; that expiry cannot land mid-transaction, since the ledger timestamp is fixed for the whole invocation; which clock is used and why validator influence points the harmless way (forward only ends a pause sooner, and recovery paths consult no clock at all); why wall-clock seconds rather than ledger sequence; that Soroban forbids reentrancy and pause/unpause make no cross-contract calls at all; worked example flows; and an explicit pointer to the #27 rotation flow, including the deliberate asymmetry that rotating who may pause is timelocked while pulling the alarm is not. Adds scripts/broadcast-pause.sh for sweeping a mask across all four contracts, and CHANGELOG.md. --- soroban-contracts/CHANGELOG.md | 85 +++++++ soroban-contracts/README.md | 102 ++++++-- soroban-contracts/contracts/escrow/src/lib.rs | 15 +- .../contracts/escrow/src/test.rs | 225 +++++++++++++++++- .../contracts/governance-guard/src/lib.rs | 6 +- .../governance-guard/src/pausable.rs | 142 ++++++++++- .../governance-guard/src/pausable_test.rs | 203 +++++++++++++++- .../contracts/loyalty-emissions/src/lib.rs | 12 +- .../contracts/loyalty-emissions/src/test.rs | 183 ++++++++++++-- .../contracts/loyalty-token/src/lib.rs | 8 +- .../contracts/loyalty-token/src/test.rs | 86 ++++++- .../contracts/reputation/src/lib.rs | 12 +- .../contracts/reputation/src/test.rs | 36 ++- soroban-contracts/scripts/broadcast-pause.sh | 118 +++++++++ 14 files changed, 1146 insertions(+), 87 deletions(-) create mode 100644 soroban-contracts/CHANGELOG.md create mode 100755 soroban-contracts/scripts/broadcast-pause.sh diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md new file mode 100644 index 0000000..8c7e2eb --- /dev/null +++ b/soroban-contracts/CHANGELOG.md @@ -0,0 +1,85 @@ +# 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, PR #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 bytes, may be empty) stored with + the pause and emitted with the event, so "why is this halted?" is + answerable from chain state. + - 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. Numbers and their + caveats are in `contracts/escrow/src/test.rs` under "hot-path cost". + +## 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 6cfec4d..ec46423 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -34,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: @@ -210,22 +212,34 @@ who places a pause and then goes offline cannot wedge it in place. **Entrypoints**, added to all four contracts: -- `pause(caller: Address, scopes: u32, duration_secs: u64) -> PauseState` +- `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` — `None` once expired -- `paused_scopes() -> u32`, `is_paused(scope: u32) -> bool` +- `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 of at most **64 bytes** +(`MAX_PAUSE_REASON_LEN`), and may be empty — the field must never stand between +a responder and a halt. It is stored and emitted verbatim, never interpreted, +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, which every subsequent invocation pays to load; an incident +ticket reference belongs on-chain, the incident write-up does not. Two events let off-chain monitoring react: `Paused { caller, scopes, -expires_at }` (topics `["gov_pause", "paused", caller]`) and `Unpaused { -caller, scopes, remaining_scopes }` (topics `["gov_pause", "unpaused", -caller]`). Auto-expiry emits nothing — it is a read-time evaluation with no -transaction behind it, so monitors should treat the `expires_at` carried by -`Paused` as the authoritative end of the window unless an `Unpaused` arrives -sooner. +expires_at, reason }` (topics `["gov_pause", "paused", caller]`) and +`Unpaused { caller, scopes, remaining_scopes }` (topics `["gov_pause", +"unpaused", caller]`). Auto-expiry emits nothing — it is a read-time +evaluation with no transaction behind it, so monitors should treat the +`expires_at` carried by `Paused` as the authoritative end of the window +unless an `Unpaused` arrives sooner. **Errors.** Four variants per contract, at whatever offset came next in that contract's existing `Error` enum — identical names, different numbers: @@ -236,38 +250,84 @@ contract's existing `Error` enum — identical names, different numbers: | `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`. **Storage.** One instance-storage entry, `GovernanceDataKey::PauseState`, -holding `PauseState { scopes, expires_at, paused_by, paused_at }`. 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. +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.** Measured with the SDK's budget metering (see +`contracts/escrow/src/test.rs`, "hot-path cost"). In normal operation — no +pause ever set, which is the state the contracts are in essentially always — +the guard costs **~168 CPU instructions**, against ~336,000 for a full +`create_appointment`. A live pause record raises that to ~25,000, paid only +*while an incident is in progress*. A call rejected by the guard costs about +23% of the successful call it replaces, because the guard is the first +statement of each entrypoint, ahead of auth and any other storage access — a +pause is a usable response to an entrypoint being hammered, not an amplifier. +No micro-optimization is warranted at these numbers: the guard is one +instance-storage read plus two integer comparisons, on an entry that most +invocations already have in their footprint. (Native-test metering +underestimates compiled Wasm; treat the figures as relative, not as fees.) ```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 + -- 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 + -- 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 +# 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, and until when +# 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` @@ -409,7 +469,7 @@ stellar contract invoke --id $LOYALTY --source admin --network testnet \ | `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-40 are the circuit breaker's, listed in +`src/lib.rs`; codes 37-41 are the circuit breaker's, listed in [Emergency circuit breaker](#emergency-circuit-breaker). #### CLI usage @@ -547,7 +607,7 @@ 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-27 are +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). @@ -668,7 +728,7 @@ 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-33 are +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). diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index f8e1f63..29ff95e 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -53,15 +53,19 @@ //! 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::{ - PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, SCOPE_INTAKE, - SCOPE_SETTLEMENT, + 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 @@ -194,6 +198,7 @@ pub enum Error { InvalidPauseScope = 38, InvalidPauseDuration = 39, NotPaused = 40, + InvalidPauseReason = 41, } impl From for Error { @@ -219,6 +224,7 @@ impl From for Error { governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -367,8 +373,9 @@ impl EscrowContract { caller: Address, scopes: u32, duration_secs: u64, + reason: String, ) -> Result { - governance::pause(&env, caller, scopes, duration_secs).map_err(Into::into) + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) } /// Clears `scopes` from the active pause early. Returns the scopes still diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index 0b1c994..81e9826 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -790,6 +790,13 @@ fn get_milestone_not_found_returns_error() { // 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); } @@ -798,8 +805,12 @@ fn set_time(env: &Env, timestamp: u64) { /// "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); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&ctx.contract.env), + ); 1_000 } @@ -828,8 +839,12 @@ fn paused_intake_blocks_new_milestone_escrows_and_milestones() { .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); + ctx.contract.pause( + &ctx.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&ctx.contract.env), + ); let res = ctx .contract @@ -908,8 +923,12 @@ fn pausing_intake_alone_leaves_settlement_working() { .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); + 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); @@ -923,8 +942,12 @@ fn pausing_settlement_alone_blocks_payout_but_not_new_appointments() { .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); + 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))); @@ -947,8 +970,12 @@ fn paused_settlement_blocks_milestone_approval_and_release() { set_ledger(&ctx.env, 201); set_time(&ctx.env, 1_000); - ctx.contract - .pause(&ctx.signers.get_unchecked(0), &SCOPE_SETTLEMENT, &3_600); + 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); @@ -990,6 +1017,7 @@ fn a_pause_longer_than_the_cap_is_refused_outright() { &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); @@ -1002,7 +1030,9 @@ 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); + 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); @@ -1017,7 +1047,9 @@ fn the_admin_arbiter_is_not_a_pause_authority() { // 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); + let res = ctx + .contract + .try_pause(&ctx.admin, &ALL_SCOPES, &3_600, &reason(&ctx.contract.env)); assert_eq!(res, Err(Ok(Error::NotASigner))); } @@ -1073,7 +1105,8 @@ 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); + 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); @@ -1084,3 +1117,169 @@ fn pause_views_report_who_paused_and_until_when() { 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); +} diff --git a/soroban-contracts/contracts/governance-guard/src/lib.rs b/soroban-contracts/contracts/governance-guard/src/lib.rs index f50b111..855700f 100644 --- a/soroban-contracts/contracts/governance-guard/src/lib.rs +++ b/soroban-contracts/contracts/governance-guard/src/lib.rs @@ -219,6 +219,8 @@ pub enum GovernanceError { /// `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 @@ -745,8 +747,8 @@ pub fn get_pending_rotation(env: &Env) -> Option { 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, SCOPE_ATTESTATION, SCOPE_INTAKE, - SCOPE_SETTLEMENT, + Paused, Unpaused, ALL_SCOPES, MAX_PAUSE_DURATION, MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, + SCOPE_INTAKE, SCOPE_SETTLEMENT, }; #[cfg(test)] diff --git a/soroban-contracts/contracts/governance-guard/src/pausable.rs b/soroban-contracts/contracts/governance-guard/src/pausable.rs index cec8345..efa4290 100644 --- a/soroban-contracts/contracts/governance-guard/src/pausable.rs +++ b/soroban-contracts/contracts/governance-guard/src/pausable.rs @@ -58,10 +58,18 @@ //! 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, is rotatable -//! through the timelocked flow in the crate root, and survives the loss of -//! any one key. An incident is exactly when a single non-rotatable admin -//! key is least trustworthy. +//! `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 //! @@ -81,6 +89,98 @@ //! 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 @@ -97,7 +197,7 @@ //! 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}; +use soroban_sdk::{contractevent, contracttype, Address, Env, String}; use crate::{require_signer, GovernanceDataKey, GovernanceError}; @@ -132,6 +232,17 @@ pub const SCOPE_ATTESTATION: u32 = 1 << 2; /// 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, in bytes. +/// +/// 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 @@ -167,6 +278,12 @@ pub struct PauseState { /// `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`] bytes; + /// 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. Topics: @@ -184,6 +301,8 @@ pub struct Paused { /// 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. Topics: @@ -228,6 +347,10 @@ fn validate_scopes(scopes: u32) -> Result<(), GovernanceError> { /// 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`] bytes, and may be empty. It is stored and +/// emitted verbatim, never interpreted. +/// /// 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 @@ -237,19 +360,27 @@ pub fn pause( 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() @@ -259,6 +390,7 @@ pub fn pause( caller, scopes, expires_at: state.expires_at, + reason, } .publish(env); Ok(state) diff --git a/soroban-contracts/contracts/governance-guard/src/pausable_test.rs b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs index af6812f..c77e610 100644 --- a/soroban-contracts/contracts/governance-guard/src/pausable_test.rs +++ b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs @@ -10,13 +10,14 @@ //! 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, Vec, + 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, SCOPE_ATTESTATION, - SCOPE_INTAKE, SCOPE_SETTLEMENT, + GovernanceError, GovernanceInit, PauseState, ALL_SCOPES, MAX_PAUSE_DURATION, + MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, SCOPE_INTAKE, SCOPE_SETTLEMENT, }; #[contract] @@ -64,14 +65,28 @@ impl Ctx { 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.env - .as_contract(&self.host, || pause(&self.env, caller, scopes, duration)) + 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 { @@ -436,7 +451,9 @@ fn pause_before_governance_is_initialized_fails() { let host = env.register(TestHost, ()); let caller = Address::generate(&env); - let result = env.as_contract(&host, || pause(&env, caller, ALL_SCOPES, 3_600)); + let result = env.as_contract(&host, || { + pause(&env, caller, ALL_SCOPES, 3_600, String::from_str(&env, "")) + }); assert_eq!(result, Err(GovernanceError::NotInitialized)); } @@ -527,3 +544,177 @@ fn a_rejected_unpause_emits_nothing_and_leaves_the_pause_standing() { 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); + }); +} diff --git a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs index 795366f..4860656 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs @@ -42,13 +42,14 @@ //! 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::{ - PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, SCOPE_INTAKE, - SCOPE_SETTLEMENT, + 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 @@ -159,6 +160,7 @@ pub enum Error { InvalidPauseScope = 31, InvalidPauseDuration = 32, NotPaused = 33, + InvalidPauseReason = 34, } impl From for Error { @@ -184,6 +186,7 @@ impl From for Error { governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -346,8 +349,9 @@ impl LoyaltyEmissions { caller: Address, scopes: u32, duration_secs: u64, + reason: String, ) -> Result { - governance::pause(&env, caller, scopes, duration_secs).map_err(Into::into) + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) } /// Clears `scopes` from the active pause early. Returns the scopes still diff --git a/soroban-contracts/contracts/loyalty-emissions/src/test.rs b/soroban-contracts/contracts/loyalty-emissions/src/test.rs index a1c2e67..b9f04a0 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/test.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/test.rs @@ -617,6 +617,13 @@ fn migrate_by_non_signer_fails() { // 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); } @@ -627,8 +634,12 @@ fn paused_intake_blocks_new_schedules() { 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); + f.emissions.pause( + &f.signers.get_unchecked(0), + &SCOPE_INTAKE, + &3_600, + &reason(&f.emissions.env), + ); let res = f .emissions @@ -650,8 +661,12 @@ fn paused_settlement_blocks_claims_and_mints_nothing() { set_ledger(&f.env, 1_500); set_time(&f.env, 1_000); - f.emissions - .pause(&f.signers.get_unchecked(0), &SCOPE_SETTLEMENT, &3_600); + 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))); @@ -672,8 +687,12 @@ fn a_halted_claim_loses_nothing_because_vesting_keeps_accruing() { set_ledger(&f.env, 1_500); set_time(&f.env, 1_000); - f.emissions - .pause(&f.signers.get_unchecked(0), &SCOPE_SETTLEMENT, &3_600); + 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)) @@ -701,8 +720,12 @@ fn reclaim_stays_open_while_settlement_is_halted() { set_ledger(&f.env, 2_000); set_time(&f.env, 1_000); - f.emissions - .pause(&f.signers.get_unchecked(0), &ALL_SCOPES, &3_600); + 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); @@ -718,8 +741,12 @@ fn views_stay_readable_while_everything_is_halted() { set_ledger(&f.env, 1_500); set_time(&f.env, 1_000); - f.emissions - .pause(&f.signers.get_unchecked(0), &ALL_SCOPES, &3_600); + 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); @@ -741,8 +768,12 @@ fn pausing_the_token_alone_stops_claims_at_the_mint_boundary() { 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); + 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()); @@ -761,7 +792,9 @@ 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); + 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); } @@ -769,7 +802,9 @@ fn a_non_signer_cannot_pause_the_engine() { #[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); + let res = f + .emissions + .try_pause(&f.admin, &ALL_SCOPES, &3_600, &reason(&f.emissions.env)); assert_eq!(res, Err(Ok(Error::NotASigner))); } @@ -782,6 +817,7 @@ fn a_pause_longer_than_the_cap_is_refused() { &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); @@ -798,8 +834,12 @@ fn unpausing_settlement_alone_reopens_claims_while_intake_stays_halted() { set_ledger(&f.env, 1_500); set_time(&f.env, 1_000); - f.emissions - .pause(&f.signers.get_unchecked(0), &ALL_SCOPES, &3_600); + f.emissions.pause( + &f.signers.get_unchecked(0), + &ALL_SCOPES, + &3_600, + &reason(&f.emissions.env), + ); let remaining = f .emissions @@ -813,3 +853,114 @@ fn unpausing_settlement_alone_reopens_claims_while_intake_stays_halted() { .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 cc32d09..264f6a8 100644 --- a/soroban-contracts/contracts/loyalty-token/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-token/src/lib.rs @@ -12,7 +12,8 @@ use soroban_sdk::{ use guildworkman_governance_guard as governance; pub use guildworkman_governance_guard::{ - PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, SCOPE_INTAKE, + 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 @@ -76,6 +77,7 @@ pub enum Error { InvalidPauseScope = 25, InvalidPauseDuration = 26, NotPaused = 27, + InvalidPauseReason = 28, } impl From for Error { @@ -101,6 +103,7 @@ impl From for Error { governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -266,8 +269,9 @@ impl LoyaltyToken { caller: Address, scopes: u32, duration_secs: u64, + reason: String, ) -> Result { - governance::pause(&env, caller, scopes, duration_secs).map_err(Into::into) + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) } /// Clears `scopes` from the active pause early. Returns the scopes still diff --git a/soroban-contracts/contracts/loyalty-token/src/test.rs b/soroban-contracts/contracts/loyalty-token/src/test.rs index 295a321..d7cac11 100644 --- a/soroban-contracts/contracts/loyalty-token/src/test.rs +++ b/soroban-contracts/contracts/loyalty-token/src/test.rs @@ -198,6 +198,13 @@ fn migrate_by_non_signer_fails() { // 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); @@ -209,7 +216,12 @@ fn paused_intake_blocks_minting() { let user = Address::generate(&env); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &SCOPE_INTAKE, &3_600); + 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))); @@ -227,7 +239,12 @@ fn holders_keep_full_control_of_existing_balances_while_everything_is_paused() { contract.mint(&user, &1_000); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &ALL_SCOPES, &3_600); + 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); @@ -255,7 +272,12 @@ fn minting_resumes_on_its_own_once_the_pause_expires() { let user = Address::generate(&env); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &SCOPE_INTAKE, &3_600); + 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)) @@ -274,7 +296,7 @@ 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); + 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); } @@ -288,6 +310,7 @@ fn a_pause_longer_than_the_cap_is_refused() { &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); @@ -299,9 +322,62 @@ fn any_signer_can_lift_a_pause_placed_by_another() { let user = Address::generate(&env); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &SCOPE_INTAKE, &3_600); + 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 1bb7893..e9945c3 100644 --- a/soroban-contracts/contracts/reputation/src/lib.rs +++ b/soroban-contracts/contracts/reputation/src/lib.rs @@ -5,11 +5,14 @@ //! 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::{ - PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, SCOPE_ATTESTATION, + 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 @@ -141,6 +144,7 @@ pub enum Error { InvalidPauseScope = 30, InvalidPauseDuration = 31, NotPaused = 32, + InvalidPauseReason = 33, } impl From for Error { @@ -166,6 +170,7 @@ impl From for Error { governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope, governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration, governance::GovernanceError::NotPaused => Error::NotPaused, + governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason, } } } @@ -340,8 +345,9 @@ impl ReputationContract { caller: Address, scopes: u32, duration_secs: u64, + reason: String, ) -> Result { - governance::pause(&env, caller, scopes, duration_secs).map_err(Into::into) + governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into) } /// Clears `scopes` from the active pause early. Returns the scopes still diff --git a/soroban-contracts/contracts/reputation/src/test.rs b/soroban-contracts/contracts/reputation/src/test.rs index cff46a9..b48e119 100644 --- a/soroban-contracts/contracts/reputation/src/test.rs +++ b/soroban-contracts/contracts/reputation/src/test.rs @@ -679,6 +679,13 @@ fn migrate_with_nothing_to_migrate_fails() { // 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); } @@ -690,7 +697,12 @@ fn paused_attestations_are_rejected_and_write_no_state() { let worker = Address::generate(&env); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &SCOPE_ATTESTATION, &3_600); + 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))); @@ -713,7 +725,12 @@ fn scores_stay_readable_while_attestations_are_paused() { 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); + 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); @@ -727,7 +744,12 @@ fn attestations_resume_on_their_own_once_the_pause_expires() { let worker = Address::generate(&env); set_time(&env, 1_000); - contract.pause(&signers.get_unchecked(0), &SCOPE_ATTESTATION, &3_600); + 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))); @@ -753,6 +775,7 @@ fn a_scope_this_contract_has_no_entrypoints_for_is_a_well_formed_no_op() { &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)); @@ -764,7 +787,7 @@ 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); + 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); } @@ -774,7 +797,7 @@ 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); + let res = contract.try_pause(&admin, &ALL_SCOPES, &3_600, &reason(&contract.env)); assert_eq!(res, Err(Ok(Error::NotASigner))); } @@ -787,6 +810,7 @@ fn a_pause_longer_than_the_cap_is_refused() { &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); @@ -804,7 +828,7 @@ 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); + contract.pause(&signer, &SCOPE_ATTESTATION, &7_200, &reason(&contract.env)); let state = contract.get_pause_state().unwrap(); assert_eq!(state.scopes, 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 From 7e5e51dcef544eb067e6ed7929c4339e5cc87e4d Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:01:28 +0100 Subject: [PATCH 5/6] docs(contracts): pin event wire format, byte-vs-char cap, benchmark table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four follow-ups on #46. Two of them turned out to be statements about behaviour rather than prose, so they are pinned by assertion instead of described and hoped for. Event wire format. Documented the exact topic ordering and data shape, and added tests that assert it against real emitted events, so the README table cannot drift from what is emitted without a test failing: topics Symbol("gov_pause"), Symbol("paused"|"unpaused"), Address(caller) — prefix symbols first, then each #[topic] field in declaration order data Map keyed by field name, since contractevent's data_format defaults to "map". Field *names* are part of the contract, their order is not — indexers must key, not position. Verified rather than assumed: the map format is the macro default, which is worth stating because "data" reads as a tuple if you only see the struct. Added an example event JSON for indexers to copy, per the optional suggestion. The reason cap is bytes, not characters. MAX_PAUSE_REASON_LEN bounds what String::len() reports, which is the UTF-8 byte length. For ASCII the distinction is invisible, which is exactly why it needed pinning — a client validating character count would submit reasons the contract rejects. Two tests make it executable rather than assertable-by-comment: 32 characters of U+00E9 is exactly 64 bytes and is accepted; 33 characters is 66 bytes and is rejected despite being half a 64-character budget. Documented on the constant, in PauseState, on `pause`, and in the README with the byte-count idioms for JS and Python. Benchmark table moved into the README with the metering caveat made prominent rather than parenthetical: these are SDK-test-metered numbers that underestimate compiled Wasm and carry no instantiation cost, so they are a relative comparison, not a fee estimate. Also promoted the circuit-breaker section's bold labels to real headings with a jump list, since it had grown past the point where prose bolding was navigable. Heading names are deliberately distinctive (Pause errors, Pause storage layout) so their anchors don't collide with the per-contract Errors and CLI usage sections further down and shift existing ones. 271 tests, all passing. --- soroban-contracts/CHANGELOG.md | 17 +- soroban-contracts/README.md | 166 ++++++++++++++---- .../contracts/escrow/src/test.rs | 105 +++++++++++ .../governance-guard/src/pausable.rs | 52 ++++-- .../governance-guard/src/pausable_test.rs | 48 +++++ 5 files changed, 337 insertions(+), 51 deletions(-) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index 8c7e2eb..6ca986c 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -44,9 +44,13 @@ sections start once something ships. 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 bytes, may be empty) stored with - the pause and emitted with the event, so "why is this halted?" is - answerable from chain state. + - 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. @@ -65,8 +69,11 @@ sections start once something ships. - 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. Numbers and their - caveats are in `contracts/escrow/src/test.rs` under "hot-path cost". + 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 diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index ec46423..6acbd45 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -142,6 +142,12 @@ 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 @@ -199,7 +205,9 @@ 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. -**Authorization.** `pause` and `unpause` require **any single governance +#### 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 @@ -210,7 +218,9 @@ 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. -**Entrypoints**, added to all four contracts: +#### 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 @@ -225,23 +235,76 @@ who places a pause and then goes offline cannot wedge it in place. - `paused_scopes() -> u32`, `is_paused(scope: u32) -> bool` — narrower convenience views over the same record. -`reason` is free-form operator context of at most **64 bytes** -(`MAX_PAUSE_REASON_LEN`), and may be empty — the field must never stand between -a responder and a halt. It is stored and emitted verbatim, never interpreted, -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, which every subsequent invocation pays to load; an incident -ticket reference belongs on-chain, the incident write-up does not. - -Two events let off-chain monitoring react: `Paused { caller, scopes, -expires_at, reason }` (topics `["gov_pause", "paused", caller]`) and -`Unpaused { caller, scopes, remaining_scopes }` (topics `["gov_pause", -"unpaused", caller]`). Auto-expiry emits nothing — it is a read-time -evaluation with no transaction behind it, so monitors should treat the -`expires_at` carried by `Paused` as the authoritative end of the window -unless an `Unpaused` arrives sooner. - -**Errors.** Four variants per contract, at whatever offset came next in that +`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 | @@ -254,7 +317,9 @@ contract's existing `Error` enum — identical names, different numbers: A non-signer calling `pause`/`unpause` gets the existing `NotASigner`. -**Storage.** One instance-storage entry, `GovernanceDataKey::PauseState`, +#### 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 @@ -263,7 +328,9 @@ 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()` +#### 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 @@ -273,19 +340,50 @@ 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.** Measured with the SDK's budget metering (see -`contracts/escrow/src/test.rs`, "hot-path cost"). In normal operation — no -pause ever set, which is the state the contracts are in essentially always — -the guard costs **~168 CPU instructions**, against ~336,000 for a full -`create_appointment`. A live pause record raises that to ~25,000, paid only -*while an incident is in progress*. A call rejected by the guard costs about -23% of the successful call it replaces, because the guard is the first -statement of each entrypoint, ahead of auth and any other storage access — a -pause is a usable response to an entrypoint being hammered, not an amplifier. -No micro-optimization is warranted at these numbers: the guard is one -instance-storage read plus two integer comparisons, on an entry that most -invocations already have in their footprint. (Native-test metering -underestimates compiled Wasm; treat the figures as relative, not as fees.) +#### 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) diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index 81e9826..eb7265a 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -1283,3 +1283,108 @@ fn a_scope_escrow_has_no_entrypoints_for_is_a_well_formed_no_op() { 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/pausable.rs b/soroban-contracts/contracts/governance-guard/src/pausable.rs index efa4290..625797a 100644 --- a/soroban-contracts/contracts/governance-guard/src/pausable.rs +++ b/soroban-contracts/contracts/governance-guard/src/pausable.rs @@ -232,7 +232,16 @@ pub const SCOPE_ATTESTATION: u32 = 1 << 2; /// 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, in bytes. +/// 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 @@ -278,16 +287,26 @@ pub struct PauseState { /// `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`] bytes; - /// 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. + /// 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. Topics: -/// `["gov_pause", "paused", caller]`. +/// 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 @@ -305,8 +324,15 @@ pub struct Paused { pub reason: String, } -/// Emitted when scopes are cleared early by a signer. Topics: -/// `["gov_pause", "unpaused", caller]`. +/// 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 @@ -348,8 +374,10 @@ fn validate_scopes(scopes: u32) -> Result<(), GovernanceError> { /// the clock. /// /// `reason` is free-form operator context of at most -/// [`MAX_PAUSE_REASON_LEN`] bytes, and may be empty. It is stored and -/// emitted verbatim, never interpreted. +/// [`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 diff --git a/soroban-contracts/contracts/governance-guard/src/pausable_test.rs b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs index c77e610..c83235e 100644 --- a/soroban-contracts/contracts/governance-guard/src/pausable_test.rs +++ b/soroban-contracts/contracts/governance-guard/src/pausable_test.rs @@ -718,3 +718,51 @@ fn repeated_guard_consultations_within_one_ledger_all_agree() { 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); +} From caa11458b9b197262e94ea02f24e0f6bf94f89ba Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:03:14 +0100 Subject: [PATCH 6/6] docs(contracts): cross-link the changelog entry to issue #42 and PR #46 Pairs with the release-notes link now in the PR description, so a releaser can get from either end to the other. --- soroban-contracts/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index 6ca986c..a065242 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -17,7 +17,8 @@ sections start once something ships. ### Added - **Emergency circuit breaker across `escrow`, `reputation`, `loyalty-token` - and `loyalty-emissions`** (#42, PR #46). A shared pausability primitive in + 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`,