diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 00000000..0d6220eb --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1,12 @@ +feat: enforce cool-off period before manual market unpause + +Adds a 1-hour minimum cool-off (MIN_UNPAUSE_COOLOFF_SECONDS) that +must elapse after pause_market() before resume_market() can be +called by an admin. Prevents rapid pause/unpause cycling. + +- New Error::CooloffActive (419) +- checked_sub used for elapsed-time calculation (overflow-safe) +- auto_resume_on_expiry() unaffected — cool-off only gates the + manual admin resume path +- Tests: cool-off enforcement, boundary condition, regression + coverage for existing InvalidState/Unauthorized paths diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 456b8583..f3fb9563 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -148,6 +148,8 @@ pub enum Error { OperationWouldExceedBudget = 418, /// Admin address has not been set. Contract initialization is incomplete. AdminNotSet = 419, + /// The minimum cool-off period has not elapsed yet. + CooloffActive = 444, /// Asset decimals mismatch. Stored decimals differ from the live SAC decimals. /// This prevents silently inflated or deflated stakes via normalize_amount. AssetDecimalsMismatch = 439, diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index e63b03f0..12a30706 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -88,6 +88,8 @@ mod override_audit_tests; mod market_audit_tests; #[cfg(test)] mod test_audit_trail; +#[cfg(test)] +mod pause_cooloff_tests; // #[cfg(any())] // mod utils_tests; // THis is the band protocol wasm std_reference.wasm diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 57c360d7..84c4e228 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -3311,6 +3311,8 @@ mod tests { pub struct MarketPauseManager; impl MarketPauseManager { + const MIN_UNPAUSE_COOLOFF_SECONDS: u64 = 3600; + /// Maximum allowed pause duration in hours (7 days) const MAX_PAUSE_DURATION_HOURS: u32 = 168; @@ -3418,6 +3420,13 @@ impl MarketPauseManager { /// * `Error::Unauthorized` - Caller is not an authorized administrator /// * `Error::MarketNotFound` - Market doesn't exist /// * `Error::InvalidState` - Market is not currently paused + /// * `Error::CooloffActive` - The minimum cool-off period has not elapsed yet + /// + /// # Cool-off Period + /// + /// The manager enforces a minimum cool-off period before a manually paused market + /// can be resumed. If `resume_market` is called before this period elapses, it will + /// return `Error::CooloffActive`. /// /// # Example /// @@ -3445,6 +3454,16 @@ impl MarketPauseManager { return Err(Error::InvalidState); } + let elapsed = env + .ledger() + .timestamp() + .checked_sub(pause_info.paused_at) + .ok_or(Error::InvalidState)?; + + if elapsed < Self::MIN_UNPAUSE_COOLOFF_SECONDS { + return Err(Error::CooloffActive); + } + env.storage().persistent().remove(&market_id); Self::emit_resume_event(env, market_id, &admin); diff --git a/contracts/predictify-hybrid/src/pause_cooloff_tests.rs b/contracts/predictify-hybrid/src/pause_cooloff_tests.rs new file mode 100644 index 00000000..6f47fdcf --- /dev/null +++ b/contracts/predictify-hybrid/src/pause_cooloff_tests.rs @@ -0,0 +1,88 @@ +#[cfg(test)] +#[allow(unused_assignments)] +#[allow(unused_variables)] +#[allow(dead_code)] +mod pause_cooloff_tests { + use crate::markets::{MarketPauseManager, MarketStateManager}; + use crate::types::{Market, MarketPauseInfo, MarketState}; + use crate::err::Error; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{Address, Env, Symbol, vec, String}; + + #[test] + fn test_pause_cooloff_scenarios() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + // Set up admin + env.storage().persistent().set(&Symbol::new(&env, "Admin"), &admin); + + let market_id = Symbol::new(&env, "test_pause_market"); + let market = Market::new( + &env, + Address::generate(&env), + String::from_str(&env, "Will it rain?"), + vec![ + &env, + String::from_str(&env, "yes"), + String::from_str(&env, "no"), + ], + env.ledger().timestamp() + 86400, + crate::types::OracleConfig::new( + crate::types::OracleProvider::reflector(), + Address::generate(&env), + String::from_str(&env, "BTC/USD"), + 2_500_000, + String::from_str(&env, "gt"), + ), + None, + 86400, + MarketState::Active, + ); + MarketStateManager::store_market(&env, &market_id, &market).unwrap(); + + env.ledger().set_timestamp(100_000); + + // (d) resume on non-paused market still returns InvalidState unchanged + assert_eq!( + MarketPauseManager::resume_market(&env, admin.clone(), &market_id), + Err(Error::InvalidState) + ); + + // Pause the market + MarketPauseManager::pause_market(&env, admin.clone(), &market_id, 24).unwrap(); + + // (e) non-admin caller still gets Unauthorized before the cool-off check runs + let non_admin = Address::generate(&env); + assert_eq!( + MarketPauseManager::resume_market(&env, non_admin, &market_id), + Err(Error::Unauthorized) + ); + + // (a) resume during cool-off returns CooloffActive + assert_eq!( + MarketPauseManager::resume_market(&env, admin.clone(), &market_id), + Err(Error::CooloffActive) + ); + + // Move to right before the boundary + env.ledger().set_timestamp(100_000 + 3599); + assert_eq!( + MarketPauseManager::resume_market(&env, admin.clone(), &market_id), + Err(Error::CooloffActive) + ); + + // (c) exact boundary at MIN_UNPAUSE_COOLOFF_SECONDS + env.ledger().set_timestamp(100_000 + 3600); + + // (b) resume after cool-off elapses succeeds + assert_eq!( + MarketPauseManager::resume_market(&env, admin.clone(), &market_id), + Ok(()) + ); + }); + } +}