Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/market/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
9 changes: 9 additions & 0 deletions contracts/market/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions contracts/market/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions contracts/market/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ impl MarketContract {
) -> Result<u32, ContractError> {
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)?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -939,6 +999,11 @@ impl MarketContract {
market_price: i128,
) -> Result<Position, ContractError> {
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();

Expand Down
32 changes: 32 additions & 0 deletions contracts/market/src/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128, ContractError> {
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 =
Expand Down Expand Up @@ -241,6 +253,16 @@ pub fn batch_settle_positions(
market_id: u32,
users: Vec<Address>,
) -> Result<i128, 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,
],
)?;

// 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() {
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions contracts/market/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -31,17 +31,18 @@ use soroban_sdk::{contracttype, Address, BytesN, Env, Vec};
/// 5. Initialize: `stellar contract invoke ... -- initialize --admin <addr>`
/// 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
/// - **v2:** Fixed locked_collateral semantics (#262)
/// - **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 {
Expand Down Expand Up @@ -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<crate::types::PendingBytesNChange> {
Expand Down
18 changes: 18 additions & 0 deletions contracts/market/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion contracts/market/src/validation.rs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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(&current) {
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
Expand Down
11 changes: 11 additions & 0 deletions contracts/market/src/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;

Expand Down
3 changes: 3 additions & 0 deletions contracts/resolution/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
20 changes: 20 additions & 0 deletions contracts/resolution/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading