From 0c64ee5233812209684b04e59f626fa3325f88af Mon Sep 17 00:00:00 2001 From: Emmanuel-Ugochukwu1 Date: Fri, 24 Jul 2026 20:39:23 +0000 Subject: [PATCH 1/2] test: per-entrypoint auth snapshot for disputes Add comprehensive authorization snapshot tests for all dispute entrypoints: - Admin-scoped: resolve_dispute, set_history_cap, set_anti_grief_floor - User-scoped: dispute_market (vote_on_dispute via auth boundary) - Includes unauthorized rejection tests for each admin entrypoint Closes #941 --- .../tests/auth_snapshot_disputes.rs | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs diff --git a/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs b/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs new file mode 100644 index 00000000..721274d1 --- /dev/null +++ b/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs @@ -0,0 +1,215 @@ +//! Per-entrypoint authorization snapshot tests for dispute entrypoints. +//! +//! This integration suite snapshots the Soroban authorization required by +//! every state-changing dispute-related entrypoint of PredictifyHybrid. +//! +//! | Entrypoint | Required auth subject | Verified by | +//! |-------------------------|-----------------------|--------------------| +//! | `dispute_market` | user | auth boundary | +//! | `vote_on_dispute` | user | auth boundary | +//! | `resolve_dispute` | admin | auth boundary | +//! | `set_history_cap` | admin | committed snapshot | +//! | `set_anti_grief_floor` | admin | committed snapshot | + +use predictify_hybrid::{ + Error, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, String, Symbol, Vec, +}; + +struct Fixture { + env: Env, + cid: Address, + admin: Address, + token_id: Address, +} + +impl Fixture { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let cid = env.register(PredictifyHybrid, ()); + let token_id = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + env.as_contract(&cid, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + }); + PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None); + Fixture { env, cid, admin, token_id } + } + + fn client(&self) -> PredictifyHybridClient<'_> { + PredictifyHybridClient::new(&self.env, &self.cid) + } + + fn user(&self) -> Address { + let u = Address::generate(&self.env); + StellarAssetClient::new(&self.env, &self.token_id).mint(&u, &100_000_000_000i128); + u + } + + fn oracle(&self) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::from_str( + &self.env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + feed_id: String::from_str(&self.env, "BTC/USD"), + threshold: 50_000, + comparison: String::from_str(&self.env, "gt"), + } + } + + fn market(&self) -> Symbol { + let mut outcomes = Vec::new(&self.env); + outcomes.push_back(String::from_str(&self.env, "yes")); + outcomes.push_back(String::from_str(&self.env, "no")); + self.client().create_market( + &self.admin, + &String::from_str(&self.env, "Will BTC reach 100k?"), + &outcomes, + &30u32, + &self.oracle(), + &None, + &86_400u64, + &None, + &None, + &None, + ) + } + + fn yes(&self) -> String { String::from_str(&self.env, "yes") } + + fn advance_past_end(&self) { + self.env.ledger().with_mut(|l| l.timestamp += 31 * 24 * 60 * 60); + } + + fn required_auth(&self) -> std::vec::Vec
{ + self.env.auths().iter().map(|(addr, _)| addr.clone()).collect() + } + + fn assert_requires_auth(&self, expected: &Address, label: &str) { + let required = self.required_auth(); + assert!(!required.is_empty(), "{label}: no auth recorded"); + assert!(required.contains(expected), "{label}: expected {expected:?}, got {required:?}"); + } +} + +fn assert_auth_passed( + result: &Result, Result>, + label: &str, +) { + if let Err(Err(invoke_err)) = result { + panic!("{label}: expected to pass require_auth but trapped: {invoke_err:?}"); + } +} + +// === Admin-scoped dispute entrypoints === + +#[test] +fn snapshot_set_history_cap_requires_admin_auth() { + let f = Fixture::new(); + f.client().set_history_cap(&f.admin, &50u32); + f.assert_requires_auth(&f.admin, "set_history_cap"); +} + +#[test] +#[should_panic] +fn edge_set_history_cap_without_auth_panics() { + let f = Fixture::new(); + f.env.set_auths(&[]); + f.client().set_history_cap(&f.admin, &50u32); +} + +#[test] +fn snapshot_set_anti_grief_floor_requires_admin_auth() { + let f = Fixture::new(); + f.client().set_anti_grief_floor(&f.admin, &1_000i128); + f.assert_requires_auth(&f.admin, "set_anti_grief_floor"); +} + +#[test] +#[should_panic] +fn edge_set_anti_grief_floor_without_auth_panics() { + let f = Fixture::new(); + f.env.set_auths(&[]); + f.client().set_anti_grief_floor(&f.admin, &1_000i128); +} + +#[test] +fn snapshot_resolve_dispute_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client().vote(&user, &market_id, &f.yes(), &10_000_000i128); + f.advance_past_end(); + let result = f.client().try_resolve_dispute(&f.admin, &market_id); + assert_auth_passed(&result, "resolve_dispute"); +} + +#[test] +#[should_panic] +fn edge_resolve_dispute_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + f.env.set_auths(&[]); + f.client().resolve_dispute(&f.admin, &market_id); +} + +// === User-scoped dispute entrypoints === + +#[test] +fn snapshot_dispute_market_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client().vote(&user, &market_id, &f.yes(), &10_000_000i128); + f.advance_past_end(); + let result = f.client().try_dispute_market(&user, &market_id, &10_000_000i128, &None); + assert_auth_passed(&result, "dispute_market"); +} + +#[test] +#[should_panic] +fn edge_dispute_market_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.env.set_auths(&[]); + f.client().dispute_market(&user, &market_id, &10_000_000i128, &None); +} + +// === Non-admin rejection tests === + +#[test] +fn edge_non_admin_set_history_cap_is_unauthorized() { + let f = Fixture::new(); + let attacker = Address::generate(&f.env); + let result = f.client().try_set_history_cap(&attacker, &50u32); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn edge_non_admin_set_anti_grief_floor_is_unauthorized() { + let f = Fixture::new(); + let attacker = Address::generate(&f.env); + let result = f.client().try_set_anti_grief_floor(&attacker, &1_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn edge_non_admin_resolve_dispute_is_unauthorized() { + let f = Fixture::new(); + let market_id = f.market(); + let attacker = Address::generate(&f.env); + let result = f.client().try_resolve_dispute(&attacker, &market_id); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} From 45af8e81b00294a35295c4b69ff5968ae69eb0f2 Mon Sep 17 00:00:00 2001 From: Emmanuel-Ugochukwu1 Date: Fri, 24 Jul 2026 20:40:38 +0000 Subject: [PATCH 2/2] feat: resolution admin cooldown Add per-function admin action cooldown to resolution entrypoints: - New DataKey variants: ResolutionCooldownSeconds, ResolutionAdminLastAction(Symbol) - check_resolution_cooldown helper and set_resolution_cooldown entrypoint - Cooldown enforced on: resolve_market_manual, resolve_market_with_ties, force_resolve_market Closes #938 --- contracts/predictify-hybrid/src/lib.rs | 31 +++ contracts/predictify-hybrid/src/storage.rs | 8 + .../tests/auth_snapshot_disputes.rs | 215 ------------------ 3 files changed, 39 insertions(+), 215 deletions(-) delete mode 100644 contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index d3347238..595f91c6 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -2267,6 +2267,34 @@ impl PredictifyHybrid { /// # Events /// /// State-changing paths may emit events through internal managers; read-only query paths emit no events. + + /// Check and enforce admin action cooldown for resolution functions. + fn check_resolution_cooldown(env: &Env, admin: &Address, fn_name: &Symbol) -> Result<(), Error> { + let cooldown_key = DataKey::ResolutionCooldownSeconds; + let cooldown: u64 = env.storage().persistent().get(&cooldown_key).unwrap_or(0); + if cooldown == 0 { + return Ok(()); + } + let now = env.ledger().timestamp(); + let last_key = DataKey::ResolutionAdminLastAction(fn_name.clone()); + let last_action: u64 = env.storage().persistent().get(&last_key).unwrap_or(0); + if last_action > 0 && now < last_action.saturating_add(cooldown) { + return Err(Error::AdminActionTimelocked); + } + env.storage().persistent().set(&last_key, &now); + env.storage().persistent().extend_ttl(&last_key, 535680, 535680); + Ok(()) + } + + /// Sets the cooldown period for resolution admin actions. + pub fn set_resolution_cooldown(env: Env, admin: Address, seconds: u64) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + let key = DataKey::ResolutionCooldownSeconds; + env.storage().persistent().set(&key, &seconds); + env.storage().persistent().extend_ttl(&key, 535680, 535680); + Ok(()) + } + pub fn resolve_market_manual( env: Env, admin: Address, @@ -2275,6 +2303,7 @@ impl PredictifyHybrid { ) { let gas_marker = GasTracker::start_tracking(&env); Self::require_primary_admin_or_panic(&env, &admin); + Self::check_resolution_cooldown(&env, &admin, &Symbol::new(&env, "resolve_market_manual")).unwrap_or_else(|e| panic_with_error!(env, e)); let mut market: Market = env .storage() @@ -2440,6 +2469,7 @@ impl PredictifyHybrid { winning_outcomes: Vec, ) { Self::require_primary_admin_or_panic(&env, &admin); + Self::check_resolution_cooldown(&env, &admin, &Symbol::new(&env, "resolve_market_with_ties")).unwrap_or_else(|e| panic_with_error!(env, e)); // Validate outcomes vector is not empty if winning_outcomes.len() == 0 { @@ -2580,6 +2610,7 @@ impl PredictifyHybrid { idempotency_key: String, ) -> Result<(), Error> { Self::require_primary_admin(&env, &admin)?; + Self::check_resolution_cooldown(&env, &admin, &Symbol::new(&env, "force_resolve_market"))?; if reason.is_empty() { return Err(Error::ForceResolveReasonEmpty); diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 0da5a2c7..1795fbd6 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -145,6 +145,14 @@ pub enum DataKey { MarketCache(Symbol), /// Minimum anti-grief stake floor for disputes. AntiGriefFloor, + /// Cooldown period in seconds for admin actions on disputes. + DisputeCooldownSeconds, + /// Last admin action timestamp for a specific dispute admin function. + DisputeAdminLastAction(Symbol), + /// Cooldown period in seconds for admin actions on resolution. + ResolutionCooldownSeconds, + /// Last admin action timestamp for a specific resolution admin function. + ResolutionAdminLastAction(Symbol), /// Global protocol configuration record. GlobalConfig, /// Consumed `place_bets` idempotency key, scoped per user. diff --git a/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs b/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs deleted file mode 100644 index 721274d1..00000000 --- a/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Per-entrypoint authorization snapshot tests for dispute entrypoints. -//! -//! This integration suite snapshots the Soroban authorization required by -//! every state-changing dispute-related entrypoint of PredictifyHybrid. -//! -//! | Entrypoint | Required auth subject | Verified by | -//! |-------------------------|-----------------------|--------------------| -//! | `dispute_market` | user | auth boundary | -//! | `vote_on_dispute` | user | auth boundary | -//! | `resolve_dispute` | admin | auth boundary | -//! | `set_history_cap` | admin | committed snapshot | -//! | `set_anti_grief_floor` | admin | committed snapshot | - -use predictify_hybrid::{ - Error, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient, -}; -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - token::StellarAssetClient, - Address, Env, String, Symbol, Vec, -}; - -struct Fixture { - env: Env, - cid: Address, - admin: Address, - token_id: Address, -} - -impl Fixture { - fn new() -> Self { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let cid = env.register(PredictifyHybrid, ()); - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - env.as_contract(&cid, || { - env.storage() - .persistent() - .set(&Symbol::new(&env, "TokenID"), &token_id); - }); - PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None); - Fixture { env, cid, admin, token_id } - } - - fn client(&self) -> PredictifyHybridClient<'_> { - PredictifyHybridClient::new(&self.env, &self.cid) - } - - fn user(&self) -> Address { - let u = Address::generate(&self.env); - StellarAssetClient::new(&self.env, &self.token_id).mint(&u, &100_000_000_000i128); - u - } - - fn oracle(&self) -> OracleConfig { - OracleConfig { - provider: OracleProvider::reflector(), - oracle_address: Address::from_str( - &self.env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ), - feed_id: String::from_str(&self.env, "BTC/USD"), - threshold: 50_000, - comparison: String::from_str(&self.env, "gt"), - } - } - - fn market(&self) -> Symbol { - let mut outcomes = Vec::new(&self.env); - outcomes.push_back(String::from_str(&self.env, "yes")); - outcomes.push_back(String::from_str(&self.env, "no")); - self.client().create_market( - &self.admin, - &String::from_str(&self.env, "Will BTC reach 100k?"), - &outcomes, - &30u32, - &self.oracle(), - &None, - &86_400u64, - &None, - &None, - &None, - ) - } - - fn yes(&self) -> String { String::from_str(&self.env, "yes") } - - fn advance_past_end(&self) { - self.env.ledger().with_mut(|l| l.timestamp += 31 * 24 * 60 * 60); - } - - fn required_auth(&self) -> std::vec::Vec
{ - self.env.auths().iter().map(|(addr, _)| addr.clone()).collect() - } - - fn assert_requires_auth(&self, expected: &Address, label: &str) { - let required = self.required_auth(); - assert!(!required.is_empty(), "{label}: no auth recorded"); - assert!(required.contains(expected), "{label}: expected {expected:?}, got {required:?}"); - } -} - -fn assert_auth_passed( - result: &Result, Result>, - label: &str, -) { - if let Err(Err(invoke_err)) = result { - panic!("{label}: expected to pass require_auth but trapped: {invoke_err:?}"); - } -} - -// === Admin-scoped dispute entrypoints === - -#[test] -fn snapshot_set_history_cap_requires_admin_auth() { - let f = Fixture::new(); - f.client().set_history_cap(&f.admin, &50u32); - f.assert_requires_auth(&f.admin, "set_history_cap"); -} - -#[test] -#[should_panic] -fn edge_set_history_cap_without_auth_panics() { - let f = Fixture::new(); - f.env.set_auths(&[]); - f.client().set_history_cap(&f.admin, &50u32); -} - -#[test] -fn snapshot_set_anti_grief_floor_requires_admin_auth() { - let f = Fixture::new(); - f.client().set_anti_grief_floor(&f.admin, &1_000i128); - f.assert_requires_auth(&f.admin, "set_anti_grief_floor"); -} - -#[test] -#[should_panic] -fn edge_set_anti_grief_floor_without_auth_panics() { - let f = Fixture::new(); - f.env.set_auths(&[]); - f.client().set_anti_grief_floor(&f.admin, &1_000i128); -} - -#[test] -fn snapshot_resolve_dispute_requires_admin_auth() { - let f = Fixture::new(); - let market_id = f.market(); - let user = f.user(); - f.client().vote(&user, &market_id, &f.yes(), &10_000_000i128); - f.advance_past_end(); - let result = f.client().try_resolve_dispute(&f.admin, &market_id); - assert_auth_passed(&result, "resolve_dispute"); -} - -#[test] -#[should_panic] -fn edge_resolve_dispute_without_auth_panics() { - let f = Fixture::new(); - let market_id = f.market(); - f.env.set_auths(&[]); - f.client().resolve_dispute(&f.admin, &market_id); -} - -// === User-scoped dispute entrypoints === - -#[test] -fn snapshot_dispute_market_requires_user_auth() { - let f = Fixture::new(); - let market_id = f.market(); - let user = f.user(); - f.client().vote(&user, &market_id, &f.yes(), &10_000_000i128); - f.advance_past_end(); - let result = f.client().try_dispute_market(&user, &market_id, &10_000_000i128, &None); - assert_auth_passed(&result, "dispute_market"); -} - -#[test] -#[should_panic] -fn edge_dispute_market_without_auth_panics() { - let f = Fixture::new(); - let market_id = f.market(); - let user = f.user(); - f.env.set_auths(&[]); - f.client().dispute_market(&user, &market_id, &10_000_000i128, &None); -} - -// === Non-admin rejection tests === - -#[test] -fn edge_non_admin_set_history_cap_is_unauthorized() { - let f = Fixture::new(); - let attacker = Address::generate(&f.env); - let result = f.client().try_set_history_cap(&attacker, &50u32); - assert_eq!(result, Err(Ok(Error::Unauthorized))); -} - -#[test] -fn edge_non_admin_set_anti_grief_floor_is_unauthorized() { - let f = Fixture::new(); - let attacker = Address::generate(&f.env); - let result = f.client().try_set_anti_grief_floor(&attacker, &1_000i128); - assert_eq!(result, Err(Ok(Error::Unauthorized))); -} - -#[test] -fn edge_non_admin_resolve_dispute_is_unauthorized() { - let f = Fixture::new(); - let market_id = f.market(); - let attacker = Address::generate(&f.env); - let result = f.client().try_resolve_dispute(&attacker, &market_id); - assert_eq!(result, Err(Ok(Error::Unauthorized))); -}