diff --git a/contracts/market/src/deposit.rs b/contracts/market/src/deposit.rs index f58a665..52c5a2f 100644 --- a/contracts/market/src/deposit.rs +++ b/contracts/market/src/deposit.rs @@ -81,6 +81,12 @@ pub fn deposit_collateral( // Authorization user.require_auth(); + // Emergency mode: deposits are blocked unless mode is Normal + crate::validation::require_emergency_mode_allows( + &env, + &[crate::types::EmergencyMode::Normal], + )?; + // Reentrancy guard: held for the remainder of this call, released // automatically when it goes out of scope. let _guard = DepositReentrancyGuard::acquire(&env)?; diff --git a/contracts/market/src/error.rs b/contracts/market/src/error.rs index 59ecf77..7e88959 100644 --- a/contracts/market/src/error.rs +++ b/contracts/market/src/error.rs @@ -251,6 +251,14 @@ pub enum ContractError { /// All state-mutating operations are temporarily disabled. ContractPaused = 91, + /// The requested operation is blocked by the current emergency mode + /// (Issue #662). Check [`get_emergency_mode`] for the active mode. + /// + /// In `TradingHalted`: deposits, trades, and market creation are blocked. + /// In `SettleOnly`: only settlement and withdrawal are allowed. + /// In `GlobalFreeze`: all non-admin operations are blocked. + EmergencyModeActive = 92, + // ========== Security Errors (100-109) ========== /// A reentrant call was detected (e.g. a token contract calling back into /// `deposit_collateral` before the initial call has finished). @@ -381,6 +389,7 @@ mod tests { (ContractError::ResolutionNotFinalized, 80), (ContractError::NotInitialized, 90), (ContractError::ContractPaused, 91), + (ContractError::EmergencyModeActive, 92), (ContractError::ReentrantCall, 100), (ContractError::NoPendingFeeChange, 110), (ContractError::TimelockNotElapsed, 111), diff --git a/contracts/market/src/events.rs b/contracts/market/src/events.rs index 78dd59e..18b3402 100644 --- a/contracts/market/src/events.rs +++ b/contracts/market/src/events.rs @@ -93,6 +93,30 @@ pub fn emit_emergency_pause_toggled(env: &Env, paused: bool) { } +/// Event emitted when the coordinated emergency mode is changed (Issue #662). +#[contractevent] +#[derive(Clone, Debug)] +pub struct EmergencyModeChanged { + #[topic] + pub version: u32, + #[topic] + pub new_mode: crate::types::EmergencyMode, + pub admin: Address, + pub changed_at: u64, +} + +/// Emit event when the emergency mode changes. +pub fn emit_emergency_mode_changed(env: &Env, new_mode: &crate::types::EmergencyMode, admin: &Address) { + EmergencyModeChanged { + version: EVENT_VERSION, + new_mode: new_mode.clone(), + admin: admin.clone(), + changed_at: env.ledger().timestamp(), + } + .publish(env); +} + + #[contractevent] #[derive(Clone, Debug)] pub struct MarketCreated { diff --git a/contracts/market/src/lib.rs b/contracts/market/src/lib.rs index d647470..9f4911f 100644 --- a/contracts/market/src/lib.rs +++ b/contracts/market/src/lib.rs @@ -337,6 +337,11 @@ impl MarketContract { ) -> Result { validation::require_initialized(&env)?; validation::require_not_paused(&env)?; + // Emergency mode: market creation is only allowed in Normal mode + validation::require_emergency_mode_allows( + &env, + &[crate::types::EmergencyMode::Normal], + )?; // 1. Verify creator is admin creator.require_auth(); let admin = storage::get_admin(&env)?; @@ -479,6 +484,15 @@ impl MarketContract { expires_at: u64, ) -> Result<(), ContractError> { validation::require_not_paused(&env)?; + // Emergency mode: resolve is blocked in SettleOnly and GlobalFreeze; + // allowed in Normal and TradingHalted. + validation::require_emergency_mode_allows( + &env, + &[ + crate::types::EmergencyMode::Normal, + crate::types::EmergencyMode::TradingHalted, + ], + )?; resolver.require_auth(); let market_id = validation::parse_market_id(&market_id)?; // Step 1: Load and validate market @@ -651,6 +665,52 @@ impl MarketContract { storage::is_paused(&env) } + /// Set the coordinated emergency mode (Issue #662). + /// + /// Only the stored admin may call this. The mode is shared (or mirrored) + /// across the Market, Treasury, and Resolution contracts. Operators should + /// set it on all three contracts with the same value for coordinated + /// behaviour. + /// + /// # Mode effects + /// + /// | Mode | Blocked operations | + /// |------------------|--------------------------------------------------------| + /// | `Normal` | (none — all operations allowed) | + /// | `TradingHalted` | deposit, trade, create market, propose resolution | + /// | `SettleOnly` | deposit, trade, create market, resolve, propose | + /// | `GlobalFreeze` | all non-admin operations | + /// + /// In `TradingHalted` and `SettleOnly`, withdraw and settle remain + /// available so users can always exit during an incident. + /// + /// # Errors + /// - [`ContractError::NotAdmin`] — `admin` is not the stored admin. + /// - [`ContractError::EmergencyModeActive`] — when setting to a mode other + /// than `Normal` from `GlobalFreeze` (must unpause first ... actually no, + /// this is the admin changing the mode, so always allowed). + pub fn set_emergency_mode( + env: Env, + admin: Address, + new_mode: crate::types::EmergencyMode, + ) -> Result<(), ContractError> { + validation::require_initialized(&env)?; + admin.require_auth(); + let stored_admin = storage::get_admin(&env)?; + if admin != stored_admin { + return Err(ContractError::NotAdmin); + } + storage::set_emergency_mode(&env, &new_mode); + events::emit_emergency_mode_changed(&env, &new_mode, &admin); + Ok(()) + } + + /// Return the current coordinated emergency mode. + /// Defaults to `Normal` when never explicitly set. + pub fn get_emergency_mode(env: Env) -> crate::types::EmergencyMode { + storage::get_emergency_mode(&env) + } + /// Cancel a market before it is resolved, halting all further trading. /// /// Only the stored admin may call this. The market must still be @@ -939,6 +999,11 @@ impl MarketContract { market_price: i128, ) -> Result { validation::require_not_paused(&env)?; + // Emergency mode: trading is blocked unless mode is Normal + validation::require_emergency_mode_allows( + &env, + &[crate::types::EmergencyMode::Normal], + )?; // 1. Authorization user.require_auth(); diff --git a/contracts/market/src/settlement.rs b/contracts/market/src/settlement.rs index 8f471c5..2591214 100644 --- a/contracts/market/src/settlement.rs +++ b/contracts/market/src/settlement.rs @@ -185,6 +185,18 @@ pub fn execute_settlement( /// Emits `PositionSettled` with the payout amount. pub fn settle_position(env: &Env, user: &Address, market_id: u32) -> Result { user.require_auth(); + // Emergency mode: settlement is blocked only in GlobalFreeze; + // allowed in Normal, TradingHalted, and SettleOnly. + crate::validation::require_emergency_mode_allows( + env, + &[ + crate::types::EmergencyMode::Normal, + crate::types::EmergencyMode::TradingHalted, + crate::types::EmergencyMode::SettleOnly, + ], + )?; + + let market = storage::get_market(env, market_id)?.ok_or(ContractError::MarketNotFound)?; let mut position = @@ -241,6 +253,16 @@ pub fn batch_settle_positions( market_id: u32, users: Vec
, ) -> Result { + // Emergency mode: settlement is blocked only in GlobalFreeze + crate::validation::require_emergency_mode_allows( + env, + &[ + crate::types::EmergencyMode::Normal, + crate::types::EmergencyMode::TradingHalted, + crate::types::EmergencyMode::SettleOnly, + ], + )?; + // Guard: reject empty batches immediately to surface caller bugs early // rather than silently returning 0 with no indication anything was wrong. if users.is_empty() { @@ -335,6 +357,16 @@ pub fn settle_positions_page( start_index: u32, limit: u32, ) -> Result<(i128, u32, bool), ContractError> { + // Emergency mode: settlement is blocked only in GlobalFreeze + crate::validation::require_emergency_mode_allows( + env, + &[ + crate::types::EmergencyMode::Normal, + crate::types::EmergencyMode::TradingHalted, + crate::types::EmergencyMode::SettleOnly, + ], + )?; + let market = storage::get_market(env, market_id)?.ok_or(ContractError::MarketNotFound)?; if market.status != MarketStatus::Resolved { return Err(ContractError::MarketNotResolved); diff --git a/contracts/market/src/storage.rs b/contracts/market/src/storage.rs index 6e35099..22369d2 100644 --- a/contracts/market/src/storage.rs +++ b/contracts/market/src/storage.rs @@ -1,5 +1,5 @@ use crate::error::ContractError; -use crate::types::{Market, PendingFeeRateChange, Position}; +use crate::types::{EmergencyMode, Market, PendingFeeRateChange, Position}; use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; /// Bump this constant whenever the storage layout changes in a breaking way. @@ -31,9 +31,10 @@ use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; /// 5. Initialize: `stellar contract invoke ... -- initialize --admin ` /// 6. Verify old deployment returns `UpgradeRequired` error /// -/// ## Current version: 4 +/// ## Current version: 5 /// /// ### Version history: +/// - **v5:** Added `EmergencyMode` storage for coordinated emergency mode (#662) /// - **v4:** Added per-adapter-type `AdapterEnabled` flag for the Reflector/Pyth /// Ed25519 fallback path (#488) /// - **v3:** Added Treasury, Outcome Token, Resolution Contract, Threshold Signers @@ -41,7 +42,7 @@ use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; /// - **v1:** Initial storage layout /// /// See `STORAGE_MIGRATION_GUIDE.md` and `MIGRATION.md` for detailed history. -pub const STORAGE_VERSION: u32 = 4; +pub const STORAGE_VERSION: u32 = 5; #[contracttype] pub enum StorageKey { @@ -389,6 +390,25 @@ pub fn set_paused(env: &Env, paused: bool) { env.storage().persistent().set(&StorageKey::Paused, &paused); } +// --- Emergency Mode (Issue #662) --- + +/// Return the current coordinated emergency mode. Defaults to `Normal` when +/// unset (freshly initialized contract has never had the mode changed). +pub fn get_emergency_mode(env: &Env) -> EmergencyMode { + env.storage() + .persistent() + .get(&StorageKey::EmergencyMode) + .unwrap_or(EmergencyMode::Normal) +} + +/// Set the coordinated emergency mode. Only the admin may call this (enforced +/// in `lib.rs`). +pub fn set_emergency_mode(env: &Env, mode: &EmergencyMode) { + env.storage() + .persistent() + .set(&StorageKey::EmergencyMode, mode); +} + // --- Oracle Adapter Enabled Flag (#488) --- pub fn get_pending_market_oracle(env: &Env, market_id: u32) -> Option { diff --git a/contracts/market/src/types.rs b/contracts/market/src/types.rs index 38decf1..cc0e723 100644 --- a/contracts/market/src/types.rs +++ b/contracts/market/src/types.rs @@ -9,6 +9,24 @@ pub enum MarketStatus { Canceled, } +/// Coordinated emergency mode shared (or mirrored) across Market, +/// Treasury, and Resolution contracts (Issue #662). +/// +/// | Variant | Effect | +/// |-----------------|--------------------------------------------------------------| +/// | `Normal` | All operations allowed. | +/// | `TradingHalted` | Reject deposit/trade/propose; allow withdraw + settle/resolve.| +/// | `SettleOnly` | Only settle & withdraw; block resolve & propose. | +/// | `GlobalFreeze` | Everything blocked except admin unpause/management. | +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum EmergencyMode { + Normal, + TradingHalted, + SettleOnly, + GlobalFreeze, +} + /// Represents the oracle adapter type used for market resolution. /// /// This enum determines which oracle adapter (Ed25519, Reflector, or Pyth) diff --git a/contracts/market/src/validation.rs b/contracts/market/src/validation.rs index af5f8c4..171c096 100644 --- a/contracts/market/src/validation.rs +++ b/contracts/market/src/validation.rs @@ -1,5 +1,5 @@ use crate::error::ContractError; -use crate::types::MarketStatus; +use crate::types::{EmergencyMode, MarketStatus}; use soroban_sdk::{Address, Env, String}; /// Minimum collateral deposit in stroops (1 USDC = 10_000_000 stroops). @@ -321,6 +321,29 @@ pub fn require_not_paused(env: &Env) -> Result<(), ContractError> { Ok(()) } +/// Guard: reject operations that are not permitted under the current emergency +/// mode (Issue #662). +/// +/// `allowed_modes` specifies the set of modes under which the guarded operation +/// is permitted. If the current mode is not in this set, the call is rejected +/// with [`ContractError::EmergencyModeActive`]. +/// +/// # Example +/// ```ignore +/// // Allow only in Normal or TradingHalted (e.g. settle/withdraw) +/// require_emergency_mode_allows(env, &[EmergencyMode::Normal, EmergencyMode::TradingHalted])?; +/// ``` +pub fn require_emergency_mode_allows( + env: &Env, + allowed_modes: &[EmergencyMode], +) -> Result<(), ContractError> { + let current = crate::storage::get_emergency_mode(env); + if !allowed_modes.contains(¤t) { + return Err(ContractError::EmergencyModeActive); + } + Ok(()) +} + /// Validate that an admin address is a user account (not a contract address). /// /// Soroban contracts can hold an `Address` that is either a user account diff --git a/contracts/market/src/withdraw.rs b/contracts/market/src/withdraw.rs index ccd9eed..06e3392 100644 --- a/contracts/market/src/withdraw.rs +++ b/contracts/market/src/withdraw.rs @@ -92,6 +92,17 @@ pub fn withdraw_unused_collateral( ) -> Result<(), ContractError> { user.require_auth(); + // Emergency mode: withdrawals are blocked only in GlobalFreeze; + // allowed in Normal, TradingHalted, and SettleOnly. + validation::require_emergency_mode_allows( + &env, + &[ + crate::types::EmergencyMode::Normal, + crate::types::EmergencyMode::TradingHalted, + crate::types::EmergencyMode::SettleOnly, + ], + )?; + // 1. Validate amount is positive and within safe range. validation::validate_collateral_amount(amount)?; diff --git a/contracts/resolution/src/error.rs b/contracts/resolution/src/error.rs index ad857e8..213f09d 100644 --- a/contracts/resolution/src/error.rs +++ b/contracts/resolution/src/error.rs @@ -42,4 +42,7 @@ pub enum ContractError { Unauthorized = 40, NotAdmin = 41, AlreadyInitialized = 42, + /// The operation is blocked by the current emergency mode (Issue #662). + /// Check [`crate::storage::get_emergency_mode`] for the active mode. + EmergencyModeActive = 50, } diff --git a/contracts/resolution/src/events.rs b/contracts/resolution/src/events.rs index fe17652..5bcfe5f 100644 --- a/contracts/resolution/src/events.rs +++ b/contracts/resolution/src/events.rs @@ -309,3 +309,23 @@ pub fn emit_candidate_appealed(env: &Env, candidate: &crate::types::ResolutionCa } .publish(env); } + +// ── Emergency Mode (Issue #662) ────────────────────────────────────────────── + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ResolutionEmergencyModeChanged { + #[topic] + pub new_mode: crate::types::EmergencyMode, + pub admin: Address, + pub changed_at: u64, +} + +pub fn emit_emergency_mode_changed(env: &Env, new_mode: &crate::types::EmergencyMode, admin: &Address) { + ResolutionEmergencyModeChanged { + new_mode: new_mode.clone(), + admin: admin.clone(), + changed_at: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/resolution/src/lib.rs b/contracts/resolution/src/lib.rs index c70e194..7882786 100644 --- a/contracts/resolution/src/lib.rs +++ b/contracts/resolution/src/lib.rs @@ -53,7 +53,7 @@ pub mod types; mod test; use crate::error::ContractError; -use crate::types::{CandidateStatus, MarketStatus, ResolutionCandidate, ResolutionConfig}; +use crate::types::{CandidateStatus, EmergencyMode, MarketStatus, ResolutionCandidate, ResolutionConfig}; use soroban_sdk::token::Client as TokenClient; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String}; use soroban_sdk::{IntoVal, Symbol, Val, Vec}; @@ -253,6 +253,11 @@ impl ResolutionContract { ) -> Result { proposer.require_auth(); let config = storage::get_config(&env); + // Emergency mode: resolution proposals are blocked unless mode is Normal + require_emergency_mode_allows( + &env, + &[EmergencyMode::Normal], + )?; validate_uri(&evidence_uri)?; validate_challenge_window(challenge_window_seconds)?; if bond_amount < MIN_BOND_AMOUNT { @@ -343,6 +348,14 @@ impl ResolutionContract { bond_amount: i128, ) -> Result<(), ContractError> { challenger.require_auth(); + // Emergency mode: challenges are blocked in SettleOnly and GlobalFreeze + require_emergency_mode_allows( + &env, + &[ + EmergencyMode::Normal, + EmergencyMode::TradingHalted, + ], + )?; validate_uri(&challenge_uri)?; if bond_amount < MIN_CHALLENGE_BOND_AMOUNT { return Err(ContractError::InsufficientChallengeBond); @@ -404,6 +417,11 @@ impl ResolutionContract { ) -> Result<(), ContractError> { proposer.require_auth(); let config = storage::get_config(&env); + // Emergency mode: appeals are blocked unless mode is Normal + require_emergency_mode_allows( + &env, + &[EmergencyMode::Normal], + )?; validate_uri(&evidence_uri)?; validate_challenge_window(challenge_window_seconds)?; @@ -462,6 +480,15 @@ impl ResolutionContract { ) -> Result { finalizer.require_auth(); let config = storage::get_config(&env); + // Emergency mode: finalization is blocked only in GlobalFreeze + require_emergency_mode_allows( + &env, + &[ + EmergencyMode::Normal, + EmergencyMode::TradingHalted, + EmergencyMode::SettleOnly, + ], + )?; let mut candidate = storage::get_candidate(&env, candidate_id).ok_or(ContractError::CandidateNotFound)?; @@ -554,6 +581,11 @@ impl ResolutionContract { amount: i128, ) -> Result<(), ContractError> { proposer.require_auth(); + // Emergency mode: collateral deposits are blocked unless mode is Normal + require_emergency_mode_allows( + &env, + &[EmergencyMode::Normal], + )?; if amount <= 0 { return Err(ContractError::InvalidCollateral); } diff --git a/contracts/resolution/src/types.rs b/contracts/resolution/src/types.rs index 6e572ba..f9170dc 100644 --- a/contracts/resolution/src/types.rs +++ b/contracts/resolution/src/types.rs @@ -14,6 +14,21 @@ pub enum MarketStatus { Canceled, } +/// Mirrored emergency mode coordinated with the Market contract (Issue #662). +/// Kept as a local mirror for the same reason as [`MarketStatus`] — the +/// resolution crate cannot depend on the market crate directly. +/// +/// Defaults to `Normal` when never explicitly set. Only the admin may change +/// this value. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum EmergencyMode { + Normal, + TradingHalted, + SettleOnly, + GlobalFreeze, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub enum CandidateStatus { diff --git a/contracts/treasury/src/error.rs b/contracts/treasury/src/error.rs index 66d127a..d8ae90f 100644 --- a/contracts/treasury/src/error.rs +++ b/contracts/treasury/src/error.rs @@ -51,6 +51,10 @@ pub enum TreasuryError { /// The treasury is paused; fee collection and withdrawals are suspended. ContractPaused = 50, + /// The operation is blocked by the current emergency mode (Issue #662). + /// Check [`crate::storage::get_emergency_mode`] for the active mode. + EmergencyModeActive = 51, + // ── Arithmetic (60–69) ──────────────────────────────────────────────────── /// Arithmetic operation overflowed. ArithmeticOverflow = 60, @@ -71,7 +75,8 @@ mod tests { assert_eq!(TreasuryError::CallerNotMarket as u32, 40); assert_eq!(TreasuryError::Unauthorized as u32, 41); assert_eq!(TreasuryError::AlreadyInitialized as u32, 42); - assert_eq!(TreasuryError::ArithmeticOverflow as u32, 60); assert_eq!(TreasuryError::ContractPaused as u32, 50); + assert_eq!(TreasuryError::EmergencyModeActive as u32, 51); + assert_eq!(TreasuryError::ArithmeticOverflow as u32, 60); } } diff --git a/contracts/treasury/src/events.rs b/contracts/treasury/src/events.rs index 9e060af..d28b73d 100644 --- a/contracts/treasury/src/events.rs +++ b/contracts/treasury/src/events.rs @@ -17,6 +17,7 @@ //! | `MarketRemoved` | `market_removed` | //! | `StakeholdersUpdated` | `stakeholders_updated` | //! | `FeesDistributed` | `fees_distributed` | +//! | `EmergencyModeChanged` | `emergency_mode_changed` | use soroban_sdk::{contractevent, Address, Env}; @@ -303,3 +304,23 @@ pub fn emit_treasury_unpaused(env: &Env, admin: &Address) { } .publish(env); } + +// ── Emergency mode (Issue #662) ────────────────────────────────────────────── + +#[contractevent] +#[derive(Clone, Debug)] +pub struct TreasuryEmergencyModeChanged { + #[topic] + pub new_mode: crate::storage::EmergencyMode, + pub admin: Address, + pub changed_at: u64, +} + +pub fn emit_emergency_mode_changed(env: &Env, new_mode: &crate::storage::EmergencyMode, admin: &Address) { + TreasuryEmergencyModeChanged { + new_mode: new_mode.clone(), + admin: admin.clone(), + changed_at: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index 8198518..bdaebce 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -98,6 +98,15 @@ impl TreasuryContract { if storage::is_paused(&env) { return Err(TreasuryError::ContractPaused); } + // Emergency mode: fee collection is blocked only in GlobalFreeze + require_emergency_mode_allows( + &env, + &[ + storage::EmergencyMode::Normal, + storage::EmergencyMode::TradingHalted, + storage::EmergencyMode::SettleOnly, + ], + )?; if !storage::is_authorized_market(&env, &caller) { return Err(TreasuryError::CallerNotMarket); } @@ -154,6 +163,15 @@ impl TreasuryContract { if storage::is_paused(&env) { return Err(TreasuryError::ContractPaused); } + // Emergency mode: fee withdrawal is blocked only in GlobalFreeze + require_emergency_mode_allows( + &env, + &[ + storage::EmergencyMode::Normal, + storage::EmergencyMode::TradingHalted, + storage::EmergencyMode::SettleOnly, + ], + )?; let admin = storage::get_admin(&env)?; if caller != admin { return Err(TreasuryError::Unauthorized); @@ -392,6 +410,33 @@ impl TreasuryContract { Ok(()) } + /// Set the mirrored emergency mode (Issue #662). + /// + /// Only the treasury admin may call this. Operators should keep this value + /// in sync with the Market and Resolution contracts for coordinated behaviour. + pub fn set_emergency_mode( + env: Env, + caller: Address, + new_mode: storage::EmergencyMode, + ) -> Result<(), TreasuryError> { + caller.require_auth(); + if !storage::has_admin(&env) { + return Err(TreasuryError::NotInitialized); + } + let admin = storage::get_admin(&env)?; + if caller != admin { + return Err(TreasuryError::Unauthorized); + } + storage::set_emergency_mode(&env, &new_mode); + events::emit_emergency_mode_changed(&env, &new_mode, &caller); + Ok(()) + } + + /// Return the current mirrored emergency mode. + pub fn get_emergency_mode(env: Env) -> storage::EmergencyMode { + storage::get_emergency_mode(&env) + } + // ── Stakeholder fee distribution (#485) ──────────────────────────────────── /// Configure the stakeholder revenue-share list (admin only). @@ -459,6 +504,15 @@ impl TreasuryContract { if storage::is_paused(&env) { return Err(TreasuryError::ContractPaused); } + // Emergency mode: fee distribution is blocked only in GlobalFreeze + require_emergency_mode_allows( + &env, + &[ + storage::EmergencyMode::Normal, + storage::EmergencyMode::TradingHalted, + storage::EmergencyMode::SettleOnly, + ], + )?; let admin = storage::get_admin(&env)?; if caller != admin { return Err(TreasuryError::Unauthorized); @@ -548,3 +602,20 @@ impl TreasuryContract { storage::get_total_collected(&env) } } + +/// Guard: reject operations that are not permitted under the current emergency +/// mode (Issue #662). +/// +/// `allowed_modes` specifies the set of modes under which the guarded operation +/// is permitted. If the current mode is not in this set, the call is rejected +/// with [`TreasuryError::EmergencyModeActive`]. +fn require_emergency_mode_allows( + env: &Env, + allowed_modes: &[storage::EmergencyMode], +) -> Result<(), TreasuryError> { + let current = storage::get_emergency_mode(env); + if !allowed_modes.contains(¤t) { + return Err(TreasuryError::EmergencyModeActive); + } + Ok(()) +} diff --git a/contracts/treasury/src/storage.rs b/contracts/treasury/src/storage.rs index 764a901..3327db5 100644 --- a/contracts/treasury/src/storage.rs +++ b/contracts/treasury/src/storage.rs @@ -7,11 +7,12 @@ use soroban_sdk::{contracttype, Address, Env, Vec}; /// `initialize()` writes this value so that future migrations can detect stale deployments. /// /// ## Version history +/// - **v3:** Added `EmergencyMode` for coordinated emergency mode (#662). /// - **v2:** Completed the multi-market `AuthorizedMarkets` registry /// (`add_market`/`remove_market`/`list_markets`/`is_authorized_market`) and /// added the `Stakeholders` fee-distribution list (#485). /// - **v1:** Initial storage layout. -pub const STORAGE_VERSION: u32 = 2; +pub const STORAGE_VERSION: u32 = 3; // ── Storage keys ────────────────────────────────────────────────────────────── @@ -257,6 +258,36 @@ pub fn set_stakeholders(env: &Env, stakeholders: &Vec<(Address, u32)>) { .set(&StorageKey::Stakeholders, stakeholders); } +// ── Emergency Mode (Issue #662) ───────────────────────────────────────────── + +/// Mirrored emergency mode coordinated with the Market contract. Defaults to +/// `Normal` when never explicitly set. Only the admin may change this value. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EmergencyMode { + Normal, + TradingHalted, + SettleOnly, + GlobalFreeze, +} + +/// Return the current mirrored emergency mode. Defaults to `Normal` when unset. +pub fn get_emergency_mode(env: &Env) -> EmergencyMode { + env.storage() + .instance() + .get(&StorageKey::EmergencyMode) + .unwrap_or(EmergencyMode::Normal) +} + +/// Set the mirrored emergency mode. Only the admin may call this (enforced in +/// `lib.rs`). Operators should keep this value in sync with the Market and +/// Resolution contracts for coordinated behaviour. +pub fn set_emergency_mode(env: &Env, mode: &EmergencyMode) { + env.storage() + .instance() + .set(&StorageKey::EmergencyMode, mode); +} + #[cfg(test)] mod tests { use super::*;