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
12 changes: 12 additions & 0 deletions commit_msg.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions contracts/predictify-hybrid/src/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
///
Expand Down Expand Up @@ -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);

Expand Down
88 changes: 88 additions & 0 deletions contracts/predictify-hybrid/src/pause_cooloff_tests.rs
Original file line number Diff line number Diff line change
@@ -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(())
);
});
}
}
Loading