From 38b5dda49b7d4650a2c37ae89788fdbc9f0f2b94 Mon Sep 17 00:00:00 2001 From: Dopezapha Date: Wed, 27 May 2026 06:12:35 +0100 Subject: [PATCH] feat: bound recovery history and add admin prune --- contracts/predictify-hybrid/src/lib.rs | 91 +++-- contracts/predictify-hybrid/src/recovery.rs | 379 ++++++++++++++++++-- 2 files changed, 397 insertions(+), 73 deletions(-) diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 1a835553..086429bb 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -20,6 +20,8 @@ static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; // Module declarations - all modules enabled mod admin; +#[cfg(test)] +mod admin_auth_audit_tests; pub mod audit_trail; mod balances; mod batch_operations; @@ -40,16 +42,12 @@ mod market_analytics; mod market_id_generator; mod markets; mod metadata_limits; -mod tokens; #[cfg(test)] mod metadata_limits_tests; +mod monitoring; #[cfg(test)] mod multi_admin_multisig_tests; -#[cfg(test)] -mod admin_auth_audit_tests; -mod monitoring; mod oracles; -pub mod tokens; mod performance_benchmarks; mod queries; mod rate_limiter; @@ -60,6 +58,7 @@ mod statistics; mod storage; #[cfg(test)] mod storage_layout_tests; +pub mod tokens; mod types; mod upgrade_manager; mod utils; @@ -181,7 +180,8 @@ pub struct PredictifyHybrid; const PERCENTAGE_DENOMINATOR: i128 = 10000; -const ORACLE_FAILURE_PRIMARY_THEN_FALLBACK_REASON: &str = "Primary oracle failed, fallback also failed"; +const ORACLE_FAILURE_PRIMARY_THEN_FALLBACK_REASON: &str = + "Primary oracle failed, fallback also failed"; const ORACLE_FAILURE_PRIMARY_ONLY_REASON: &str = "Primary oracle failed and no fallback configured"; fn resolution_timeout_reached(env: &Env, market: &Market) -> bool { @@ -266,9 +266,18 @@ impl PredictifyHybrid { /// # Events /// /// Emits `contract_initialized` and `platform_fee_set` events on successful initialization. - pub fn initialize(env: Env, admin: Address, platform_fee_percentage: Option, allowed_assets: Option>) -> Result<(), Error> { + pub fn initialize( + env: Env, + admin: Address, + platform_fee_percentage: Option, + allowed_assets: Option>, + ) -> Result<(), Error> { // Check for re-initialization attempt (critical security check) - if env.storage().persistent().has(&Symbol::new(&env, "platform_fee")) { + if env + .storage() + .persistent() + .has(&Symbol::new(&env, "platform_fee")) + { return Err(Error::InvalidState); } @@ -788,7 +797,7 @@ impl PredictifyHybrid { Some(c) => (true, c.clone()), None => (false, OracleConfig::none_sentinel(&env)), }; - + // Create a new event let event = Event { id: event_id.clone(), @@ -942,7 +951,11 @@ impl PredictifyHybrid { } // Respect bet_deadline if set, otherwise use end_time - let cutoff = if market.bet_deadline > 0 { market.bet_deadline } else { market.end_time }; + let cutoff = if market.bet_deadline > 0 { + market.bet_deadline + } else { + market.end_time + }; if env.ledger().timestamp() >= cutoff { panic_with_error!(env, Error::MarketClosed); } @@ -1803,7 +1816,12 @@ impl PredictifyHybrid { &market_id, claim_period_seconds, ); - EventEmitter::emit_market_claim_period_updated(&env, &admin, &market_id, claim_period_seconds); + EventEmitter::emit_market_claim_period_updated( + &env, + &admin, + &market_id, + claim_period_seconds, + ); } /// Set treasury recipient for unclaimed winnings sweeps (admin only). @@ -1942,7 +1960,9 @@ impl PredictifyHybrid { return Err(Error::InvalidInput); } - market.claimed.set(user.clone(), ClaimInfo::new(&env, payout)); + market + .claimed + .set(user.clone(), ClaimInfo::new(&env, payout)); swept_total = swept_total.checked_add(payout).ok_or(Error::InvalidInput)?; } @@ -1986,7 +2006,9 @@ impl PredictifyHybrid { return Err(Error::InvalidInput); } - market.claimed.set(user.clone(), ClaimInfo::new(&env, payout)); + market + .claimed + .set(user.clone(), ClaimInfo::new(&env, payout)); swept_total = swept_total.checked_add(payout).ok_or(Error::InvalidInput)?; } @@ -3984,7 +4006,8 @@ impl PredictifyHybrid { market_id, additional_days, reason, - ).unwrap_or_else(|e| panic_with_error!(env, e)); + ) + .unwrap_or_else(|e| panic_with_error!(env, e)); Ok(()) } @@ -5276,8 +5299,9 @@ impl PredictifyHybrid { if let Err(e) = crate::recovery::RecoveryManager::assert_is_admin(&env, &admin) { panic_with_error!(env, e); } - let result = match crate::recovery::RecoveryManager::recover_market_state(&env, &admin, &market_id) - { + let result = match crate::recovery::RecoveryManager::recover_market_state( + &env, &admin, &market_id, + ) { Ok(res) => res, Err(e) => panic_with_error!(env, e), }; @@ -5356,6 +5380,21 @@ impl PredictifyHybrid { .unwrap_or_else(|_| String::from_str(&env, "unknown")) } + /// Remove the oldest `count` completed recovery history entries for a market (admin only). + /// + /// Active (unresolved) recovery state is never pruned. `count` is capped at 30. + /// + /// # Errors + /// * `Unauthorized` - Caller is not admin + pub fn prune_recovery_history( + env: Env, + admin: Address, + market_id: Symbol, + count: u32, + ) -> Result { + crate::recovery::RecoveryManager::prune_recovery_history(&env, &admin, &market_id, count) + } + // ===== VERSIONING FUNCTIONS ===== /// Track contract version for versioning system @@ -6976,23 +7015,3 @@ impl PredictifyHybrid { #[cfg(any())] mod test; - -fn assert_can_participate(env: &Env, user: &Address, event: &Event) { - if event.is_private { - let is_allowed = event.allowlist.iter().any(|addr| addr == user); - if !is_allowed { - panic!("User not allowlisted for private event"); - } - } -} -let event = get_event(&env, event_id); - -assert_can_participate(&env, &user, &event); - -/// Places a bet on a given event. -/// -/// # Panics -/// - If the event is private and the caller is not in the allowlist. -/// -/// # Security -/// Enforces event-level access control before any state mutation. \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index 33d585f2..949fc89c 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -1,5 +1,5 @@ use alloc::format; -use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec}; +use soroban_sdk::{contracttype, panic_with_error, Address, Env, Map, String, Symbol, Vec}; use crate::events::EventEmitter; use crate::markets::MarketStateManager; @@ -8,6 +8,16 @@ use crate::Error; const DEFAULT_UNCLAIMED_CLAIM_PERIOD_SECONDS: u64 = 90 * 24 * 60 * 60; +/// Maximum completed recovery records retained per market. +/// +/// Bounds persistent storage growth under repeated recovery events. Active +/// (unresolved) recovery state is stored separately and is never counted toward +/// this cap. +pub const MAX_RECOVERY_HISTORY_PER_MARKET: u32 = 100; + +/// Maximum entries removable in a single admin prune call (gas safety). +pub const MAX_RECOVERY_PRUNE_BATCH: u32 = 30; + // ===== RECOVERY TYPES ===== #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -37,36 +47,175 @@ pub struct RecoveryData { pub safety_score: i128, } +/// One completed recovery event in per-market history. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryHistoryEntry { + pub record: MarketRecovery, + pub recorded_at: u64, +} + pub struct RecoveryStorage; impl RecoveryStorage { #[inline(always)] - fn records_key(env: &Env) -> Symbol { + fn active_key(env: &Env) -> Symbol { Symbol::new(env, "recovery_records") } + + #[inline(always)] + fn history_key(env: &Env) -> Symbol { + Symbol::new(env, "recovery_history") + } + #[inline(always)] fn status_key(env: &Env) -> Symbol { Symbol::new(env, "recovery_status_map") } - pub fn load(env: &Env, market_id: &Symbol) -> Option { - let records: Map = env + #[inline(always)] + fn migrated_key(env: &Env) -> Symbol { + Symbol::new(env, "recovery_v2_migrated") + } + + /// Split legacy `recovery_records` (active + completed mixed) into active + history maps. + fn ensure_migrated(env: &Env) { + if env .storage() .persistent() - .get(&Self::records_key(env)) + .get(&Self::migrated_key(env)) + .unwrap_or(false) + { + return; + } + + let legacy: Map = env + .storage() + .persistent() + .get(&Self::active_key(env)) .unwrap_or(Map::new(env)); - records.get(market_id.clone()) - } - pub fn save(env: &Env, record: &MarketRecovery) { - let mut records: Map = env + let mut active = Map::new(env); + let mut history_map: Map> = env .storage() .persistent() - .get(&Self::records_key(env)) + .get(&Self::history_key(env)) .unwrap_or(Map::new(env)); - records.set(record.market_id.clone(), record.clone()); + + for (market_id, record) in legacy.iter() { + if record.recovered { + Self::push_history_entry(env, &mut history_map, &market_id, &record); + } else { + active.set(market_id, record); + } + } + + env.storage() + .persistent() + .set(&Self::active_key(env), &active); + env.storage() + .persistent() + .set(&Self::history_key(env), &history_map); + env.storage() + .persistent() + .set(&Self::migrated_key(env), &true); + } + + fn load_active_map(env: &Env) -> Map { + Self::ensure_migrated(env); + env.storage() + .persistent() + .get(&Self::active_key(env)) + .unwrap_or(Map::new(env)) + } + + fn load_history_map(env: &Env) -> Map> { + Self::ensure_migrated(env); + env.storage() + .persistent() + .get(&Self::history_key(env)) + .unwrap_or(Map::new(env)) + } + + fn load_history(env: &Env, market_id: &Symbol) -> Vec { + Self::load_history_map(env) + .get(market_id.clone()) + .unwrap_or(Vec::new(env)) + } + + fn save_history(env: &Env, market_id: &Symbol, history: &Vec) { + let mut history_map = Self::load_history_map(env); + history_map.set(market_id.clone(), history.clone()); env.storage() .persistent() - .set(&Self::records_key(env), &records); + .set(&Self::history_key(env), &history_map); + } + + fn trim_history(env: &Env, history: &mut Vec) { + while history.len() > MAX_RECOVERY_HISTORY_PER_MARKET { + history.remove(0); + } + } + + fn push_history_entry( + env: &Env, + history_map: &mut Map>, + market_id: &Symbol, + record: &MarketRecovery, + ) { + let mut history = history_map.get(market_id.clone()).unwrap_or(Vec::new(env)); + history.push_back(RecoveryHistoryEntry { + record: record.clone(), + recorded_at: env.ledger().timestamp(), + }); + Self::trim_history(env, &mut history); + history_map.set(market_id.clone(), history); + } + + /// Active (unresolved) recovery, if any. + pub fn load_active(env: &Env, market_id: &Symbol) -> Option { + Self::load_active_map(env).get(market_id.clone()) + } + + /// Latest recovery state: active first, otherwise most recent history entry. + pub fn load(env: &Env, market_id: &Symbol) -> Option { + if let Some(active) = Self::load_active(env, market_id) { + return Some(active); + } + let history = Self::load_history(env, market_id); + let len = history.len(); + if len == 0 { + return None; + } + history.get(len - 1).map(|entry| entry.record.clone()) + } + + pub fn history_len(env: &Env, market_id: &Symbol) -> u32 { + Self::load_history(env, market_id).len() + } + + pub fn save(env: &Env, record: &MarketRecovery) { + Self::ensure_migrated(env); + let market_id = record.market_id.clone(); + + if record.recovered { + let mut history_map = Self::load_history_map(env); + Self::push_history_entry(env, &mut history_map, &market_id, record); + env.storage() + .persistent() + .set(&Self::history_key(env), &history_map); + + let mut active = Self::load_active_map(env); + active.remove(market_id.clone()); + env.storage() + .persistent() + .set(&Self::active_key(env), &active); + } else { + let mut active = Self::load_active_map(env); + active.set(market_id.clone(), record.clone()); + env.storage() + .persistent() + .set(&Self::active_key(env), &active); + } let mut status_map: Map = env .storage() @@ -78,7 +227,7 @@ impl RecoveryStorage { } else { String::from_str(env, "pending") }; - status_map.set(record.market_id.clone(), status); + status_map.set(market_id, status); env.storage() .persistent() .set(&Self::status_key(env), &status_map); @@ -92,6 +241,43 @@ impl RecoveryStorage { .unwrap_or(Map::new(env)); status_map.get(market_id.clone()) } + + /// Remove the oldest `count` completed recovery records for a market (admin only). + /// + /// Never removes the active (unresolved) recovery entry for the market. + pub fn prune_history( + env: &Env, + admin: &Address, + market_id: &Symbol, + count: u32, + ) -> Result { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(env, "Admin")) + .unwrap_or_else(|| panic_with_error!(env, Error::AdminNotSet)); + + if admin != &stored_admin { + return Err(Error::Unauthorized); + } + + let count = core::cmp::min(count, MAX_RECOVERY_PRUNE_BATCH); + let mut history = Self::load_history(env, market_id); + if history.is_empty() || count == 0 { + return Ok(0); + } + + let mut removed = 0u32; + while removed < count && history.len() > 0 { + history.remove(0); + removed += 1; + } + + Self::save_history(env, market_id, &history); + Ok(removed) + } } pub struct UnclaimedWinningsPolicy; @@ -189,7 +375,9 @@ impl UnclaimedWinningsPolicy { } pub fn set_treasury(env: &Env, treasury: &Address) { - env.storage().persistent().set(&Self::treasury_key(env), treasury); + env.storage() + .persistent() + .set(&Self::treasury_key(env), treasury); } pub fn get_treasury(env: &Env) -> Option
{ @@ -246,10 +434,24 @@ impl RecoveryManager { pub fn get_recovery_status(env: &Env, market_id: &Symbol) -> Result { RecoveryStorage::status(env, market_id).ok_or(Error::InvalidState) } + + /// Prune oldest completed recovery history entries for a market (admin only). + pub fn prune_recovery_history( + env: &Env, + admin: &Address, + market_id: &Symbol, + count: u32, + ) -> Result { + RecoveryStorage::prune_history(env, admin, market_id, count) + } /// Perform recovery for a market. This operation is privileged and requires the caller to be /// the configured admin. The `actor` address will be recorded in emitted events for full /// visibility and auditability. - pub fn recover_market_state(env: &Env, actor: &Address, market_id: &Symbol) -> Result { + pub fn recover_market_state( + env: &Env, + actor: &Address, + market_id: &Symbol, + ) -> Result { // Ensure caller is admin (defense-in-depth; callers should also enforce auth). Self::assert_is_admin(env, actor)?; @@ -368,29 +570,33 @@ impl RecoveryManager { ); Ok(total_refunded) } - - } +} // ===== EVENT INTEGRATION ===== impl EventEmitter { - /// Emit a recovery event that includes the acting admin and optional amount. - pub fn emit_recovery_event( - env: &Env, - admin: &Address, - market_id: &Symbol, - action: &String, - status: &String, - amount: Option, - ) { - let topic = Symbol::new(env, "recovery_evt"); - // Publish a tuple: (action, status, amount, timestamp) - let amt = amount.unwrap_or(0); - env.events().publish( - (topic, admin.clone(), market_id.clone()), - (action.clone(), status.clone(), amt, env.ledger().timestamp()), - ); - } + /// Emit a recovery event that includes the acting admin and optional amount. + pub fn emit_recovery_event( + env: &Env, + admin: &Address, + market_id: &Symbol, + action: &String, + status: &String, + amount: Option, + ) { + let topic = Symbol::new(env, "recovery_evt"); + // Publish a tuple: (action, status, amount, timestamp) + let amt = amount.unwrap_or(0); + env.events().publish( + (topic, admin.clone(), market_id.clone()), + ( + action.clone(), + status.clone(), + amt, + env.ledger().timestamp(), + ), + ); } +} // Helper for symbol -> string representation (Soroban lacks direct to_string for Symbol) fn symbol_to_string(env: &Env, sym: &Symbol) -> String { @@ -649,10 +855,109 @@ mod tests { #[test] fn test_recovery_storage_keys() { let test = RecoveryTest::new(); - // Test that storage keys are properly generated - let records_key = RecoveryStorage::records_key(&test.env); + let active_key = RecoveryStorage::active_key(&test.env); + let history_key = RecoveryStorage::history_key(&test.env); let status_key = RecoveryStorage::status_key(&test.env); - assert_ne!(records_key.to_string(), status_key.to_string()); + assert_ne!(active_key.to_string(), status_key.to_string()); + assert_ne!(history_key.to_string(), status_key.to_string()); + } + + fn setup_admin_env() -> (Env, Address, Address, Symbol) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let market_id = Symbol::new(&env, "market_prune"); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "Admin"), &admin); + }); + (env, admin, contract_id, market_id) + } + + fn completed_record(env: &Env, market_id: &Symbol, tag: &str) -> MarketRecovery { + let mut actions = Vec::new(env); + actions.push_back(String::from_str(env, tag)); + MarketRecovery { + market_id: market_id.clone(), + actions, + issues_detected: Vec::new(env), + recovered: true, + partial_refund_total: 0, + last_action: Some(String::from_str(env, tag)), + } + } + + fn pending_record(env: &Env, market_id: &Symbol) -> MarketRecovery { + MarketRecovery { + market_id: market_id.clone(), + actions: Vec::new(env), + issues_detected: Vec::new(env), + recovered: false, + partial_refund_total: 0, + last_action: Some(String::from_str(env, "pending")), + } + } + + #[test] + fn test_recovery_history_capped_per_market() { + let (env, _admin, contract_id, market_id) = setup_admin_env(); + let cap = MAX_RECOVERY_HISTORY_PER_MARKET as usize + 5; + env.as_contract(&contract_id, || { + for i in 0..cap { + let tag = format!("event_{}", i); + RecoveryStorage::save(&env, &completed_record(&env, &market_id, &tag)); + } + assert_eq!( + RecoveryStorage::history_len(&env, &market_id), + MAX_RECOVERY_HISTORY_PER_MARKET + ); + assert!(RecoveryStorage::load_active(&env, &market_id).is_none()); + }); + } + + #[test] + fn test_prune_preserves_active_recovery() { + let (env, admin, contract_id, market_id) = setup_admin_env(); + env.as_contract(&contract_id, || { + for i in 0..5 { + RecoveryStorage::save( + &env, + &completed_record(&env, &market_id, &format!("done_{}", i)), + ); + } + RecoveryStorage::save(&env, &pending_record(&env, &market_id)); + assert_eq!(RecoveryStorage::history_len(&env, &market_id), 5); + + let removed = RecoveryStorage::prune_history(&env, &admin, &market_id, 3).unwrap(); + assert_eq!(removed, 3); + assert_eq!(RecoveryStorage::history_len(&env, &market_id), 2); + let active = RecoveryStorage::load_active(&env, &market_id).expect("active kept"); + assert!(!active.recovered); + }); + } + + #[test] + fn test_prune_count_greater_than_stored() { + let (env, admin, contract_id, market_id) = setup_admin_env(); + env.as_contract(&contract_id, || { + RecoveryStorage::save(&env, &completed_record(&env, &market_id, "only")); + let removed = RecoveryStorage::prune_history(&env, &admin, &market_id, 100).unwrap(); + assert_eq!(removed, 1); + assert_eq!(RecoveryStorage::history_len(&env, &market_id), 0); + }); + } + + #[test] + fn test_prune_requires_admin() { + let (env, _admin, contract_id, market_id) = setup_admin_env(); + let intruder = Address::generate(&env); + env.as_contract(&contract_id, || { + RecoveryStorage::save(&env, &completed_record(&env, &market_id, "x")); + let result = RecoveryStorage::prune_history(&env, &intruder, &market_id, 1); + assert_eq!(result, Err(Error::Unauthorized)); + }); } #[test]