diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..776e6fa6 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -4017,6 +4017,35 @@ impl EventEmitter { (addresses.clone(), admin.clone(), env.ledger().timestamp()), ); } + + /// Emit a monitor queue overflow event when the bounded queue evicts the oldest entry. + /// + /// This event signals that the queue was at capacity and a new event caused an + /// eviction. Off-chain indexers should consume this to track data loss and adjust + /// their polling cadence accordingly. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `overflow_count` - Cumulative number of overflow evictions since initialization. + /// * `evicted_event_id` - The `event_id` of the evicted `MonitorEvent`, if available. + /// * `capacity` - The configured capacity of the bounded queue. + pub fn emit_monitor_queue_overflow( + env: &Env, + overflow_count: u64, + evicted_event_id: Option, + capacity: u32, + ) { + env.events().publish( + (symbol_short!("mon_ovf"),), + ( + overflow_count, + evicted_event_id, + capacity, + env.ledger().timestamp(), + ), + ); + } } // ===== EVENT LOGGING AND MONITORING ===== diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..ee0f3f46 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -5660,6 +5660,131 @@ impl PredictifyHybrid { crate::recovery::RecoveryManager::prune_recovery_history(&env, &admin, &market_id, count) } + // ===== PER-MARKET RECOVERY TIMELOCK ENTRYPOINTS ===== + + /// Initiate a per-market recovery request with an admin timelock. + /// + /// Creates a pending recovery request for the specified market. The recovery + /// action cannot be executed until the timelock period (default 24 hours) has + /// elapsed. Only contract admins may call this function. + /// + /// # Arguments + /// * `admin` - The admin initiating the recovery (must be authenticated) + /// * `market_id` - The target market + /// * `action` - The recovery action (ReconstructState, CancelMarket, or ForceResolve) + /// * `reason` - Human-readable explanation for the recovery + /// + /// # Errors + /// * `RecoveryAlreadyPending` - A recovery is already pending for this market + /// * `MarketNotRecoverable` - Market is in a non-recoverable state + /// * `InvalidRecoveryAction` - Action is invalid for the market's current state + pub fn initiate_market_recovery( + env: Env, + admin: Address, + market_id: Symbol, + action: crate::recovery::PerMarketRecoveryAction, + reason: String, + ) -> crate::recovery::PendingMarketRecovery { + Self::require_primary_admin_or_panic(&env, &admin); + + match crate::recovery::RecoveryTimelockManager::initiate_recovery( + &env, + &admin, + &market_id, + &action, + &reason, + ) { + Ok(request) => { + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::ErrorRecovered, + admin.clone(), + Map::new(&env), + ); + request + } + Err(e) => panic_with_error!(env, e), + } + } + + /// Execute a pending per-market recovery request after the timelock has expired. + /// + /// This can only be called after the timelock period initiated by + /// `initiate_market_recovery` has elapsed. Only contract admins may call this. + /// + /// # Arguments + /// * `admin` - The admin executing the recovery (must be authenticated) + /// * `market_id` - The target market + /// + /// # Errors + /// * `RecoveryRequestNotFound` - No pending request for this market + /// * `RecoveryTimelockActive` - The timelock has not yet expired + pub fn execute_market_recovery(env: Env, admin: Address, market_id: Symbol) -> bool { + Self::require_primary_admin_or_panic(&env, &admin); + + match crate::recovery::RecoveryTimelockManager::execute_recovery(&env, &admin, &market_id) + { + Ok(success) => { + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::ErrorRecovered, + admin.clone(), + Map::new(&env), + ); + success + } + Err(e) => panic_with_error!(env, e), + } + } + + /// Cancel a pending per-market recovery request. + /// + /// Removes the pending request so the recovery action will not be executed. + /// Only contract admins may call this. + /// + /// # Arguments + /// * `admin` - The admin cancelling the recovery (must be authenticated) + /// * `market_id` - The target market + /// + /// # Errors + /// * `RecoveryRequestNotFound` - No pending request for this market + pub fn cancel_market_recovery(env: Env, admin: Address, market_id: Symbol) { + Self::require_primary_admin_or_panic(&env, &admin); + + match crate::recovery::RecoveryTimelockManager::cancel_recovery( + &env, + &admin, + &market_id, + ) { + Ok(()) => { + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::ErrorRecovered, + admin.clone(), + Map::new(&env), + ); + } + Err(e) => panic_with_error!(env, e), + } + } + + /// Returns the pending recovery request for a market, if any. + /// + /// Read-only query; no authentication required. + pub fn get_pending_market_recovery( + env: Env, + market_id: Symbol, + ) -> Option { + crate::recovery::RecoveryTimelockManager::get_pending(&env, &market_id) + } + + /// Returns the current recovery timelock configuration. + /// + /// Read-only query; no authentication required. + pub fn get_recovery_timelock_config(env: Env) -> crate::recovery::RecoveryTimelockConfig { + crate::recovery::RecoveryTimelockManager::get_config(&env) + } + // ===== VERSIONING FUNCTIONS ===== /// Track contract version for versioning system diff --git a/contracts/predictify-hybrid/src/monitor.rs b/contracts/predictify-hybrid/src/monitor.rs new file mode 100644 index 00000000..7dcd431f --- /dev/null +++ b/contracts/predictify-hybrid/src/monitor.rs @@ -0,0 +1,870 @@ +#![allow(dead_code)] + +use alloc::format; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, Vec}; + +use crate::errors::Error; +use crate::events::EventEmitter; + +// ===== CONSTANTS ===== + +/// Default maximum number of events the bounded queue can hold. +pub const DEFAULT_QUEUE_CAPACITY: u32 = 128; + +/// Minimum allowed queue capacity. +pub const MIN_QUEUE_CAPACITY: u32 = 1; + +/// Maximum allowed queue capacity. +pub const MAX_QUEUE_CAPACITY: u32 = 10_000; + +// ===== STORAGE KEYS ===== + +/// Persistent storage key prefix for queue state. +const QUEUE_STATE_KEY: &str = "mon_q_state"; + +/// Persistent storage key prefix for queue entries. +const QUEUE_ENTRY_KEY: &str = "mon_q_entry"; + +/// Persistent storage key prefix for overflow tracking. +const QUEUE_OVERFLOW_KEY: &str = "mon_q_overflow"; + +// ===== TYPES ===== + +/// Category of a monitor event. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MonitorEventCategory { + /// Market lifecycle event (created, ended, resolved, etc.). + Market, + /// Oracle health or data event. + Oracle, + /// Bet placement or cancellation event. + Bet, + /// Fee collection event. + Fee, + /// Dispute event. + Dispute, + /// Circuit breaker state change. + CircuitBreaker, + /// Admin action event. + Admin, + /// Generic system event. + System, +} + +/// Severity level for a monitor event. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum MonitorEventSeverity { + /// Informational event, no action needed. + Info, + /// Warning event, may need attention. + Warning, + /// Critical event, requires immediate attention. + Critical, +} + +/// A single monitor event stored in the bounded queue. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MonitorEvent { + /// Unique event identifier. + pub event_id: Symbol, + /// Category of the event. + pub category: MonitorEventCategory, + /// Severity level. + pub severity: MonitorEventSeverity, + /// Human-readable event message. + pub message: String, + /// Optional related market identifier. + pub market_id: Option, + /// Optional actor address that triggered the event. + pub actor: Option
, + /// Ledger timestamp when the event was created. + pub timestamp: u64, + /// Additional key-value metadata for the event. + pub metadata: Map, +} + +/// State of the bounded monitor queue, stored in persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MonitorQueueState { + /// Head index (next position to pop from). + pub head: u32, + /// Tail index (next position to push to). + pub tail: u32, + /// Current number of events in the queue. + pub len: u32, + /// Maximum capacity of the queue. + pub capacity: u32, + /// Total number of events pushed since initialization. + pub total_pushed: u64, + /// Total number of events popped since initialization. + pub total_popped: u64, + /// Total number of overflow events (events discarded due to full queue). + pub overflow_count: u64, + /// Timestamp of the last overflow event. + pub last_overflow_timestamp: u64, +} + +/// Summary statistics for the bounded queue. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MonitorQueueStats { + /// Current number of events in the queue. + pub current_len: u32, + /// Maximum capacity of the queue. + pub capacity: u32, + /// Total events pushed since initialization. + pub total_pushed: u64, + /// Total events popped since initialization. + pub total_popped: u64, + /// Total overflow events discarded. + pub overflow_count: u64, + /// Timestamp of the last overflow. + pub last_overflow_timestamp: u64, + /// Whether the queue is currently full. + pub is_full: bool, + /// Whether the queue is currently empty. + pub is_empty: bool, +} + +impl Default for MonitorQueueState { + fn default() -> Self { + Self { + head: 0, + tail: 0, + len: 0, + capacity: DEFAULT_QUEUE_CAPACITY, + total_pushed: 0, + total_popped: 0, + overflow_count: 0, + last_overflow_timestamp: 0, + } + } +} + +// ===== BOUNDED MONITOR QUEUE ===== + +/// A FIFO bounded queue for contract monitor events with overflow tracking. +/// +/// The queue is backed by persistent Soroban storage and uses a circular buffer +/// design. When the queue reaches capacity, pushing a new event discards the +/// oldest event and emits an `OverflowEvent` so that off-chain indexers can +/// track data loss. +/// +/// # Design +/// +/// - **Circular buffer**: `head` points to the oldest event, `tail` to the next +/// write slot. Indices wrap around using modulo arithmetic. +/// - **Capacity bounds**: Must be within `[MIN_QUEUE_CAPACITY, MAX_QUEUE_CAPACITY]`. +/// - **Overflow**: When `len == capacity`, the oldest event is evicted before the +/// new event is written. The overflow counter and timestamp are updated, and an +/// `OverflowEvent` is emitted. +/// - **Atomic operations**: Each push/pop reads the queue state, performs the +/// mutation, and writes the state back in a single storage transaction. +/// +/// # Storage Layout +/// +/// - `{QUEUE_STATE_KEY}` → `MonitorQueueState` +/// - `{QUEUE_ENTRY_KEY}_{index}` → `MonitorEvent` +/// - `{QUEUE_OVERFLOW_KEY}` → overflow metadata +pub struct BoundedMonitorQueue; + +impl BoundedMonitorQueue { + /// Initialize a new bounded queue with the given capacity. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `capacity` - Maximum number of events the queue can hold. Must be + /// between `MIN_QUEUE_CAPACITY` and `MAX_QUEUE_CAPACITY` inclusive. + /// + /// # Errors + /// + /// Returns `Error::InvalidInput` if `capacity` is outside the allowed range. + /// + /// # Panics + /// + /// Panics if the queue has already been initialized (re-initialization is + /// prevented). + pub fn initialize(env: &Env, capacity: u32) -> Result<(), Error> { + if capacity < MIN_QUEUE_CAPACITY || capacity > MAX_QUEUE_CAPACITY { + return Err(Error::InvalidInput); + } + + let state_key = Symbol::new(env, QUEUE_STATE_KEY); + if env.storage().persistent().has(&state_key) { + panic!("monitor queue already initialized"); + } + + let state = MonitorQueueState { + head: 0, + tail: 0, + len: 0, + capacity, + total_pushed: 0, + total_popped: 0, + overflow_count: 0, + last_overflow_timestamp: 0, + }; + + env.storage().persistent().set(&state_key, &state); + Ok(()) + } + + /// Returns the current queue state from persistent storage. + /// + /// # Panics + /// + /// Panics if the queue has not been initialized. + pub fn get_state(env: &Env) -> MonitorQueueState { + let state_key = Symbol::new(env, QUEUE_STATE_KEY); + env.storage() + .persistent() + .get(&state_key) + .expect("monitor queue not initialized") + } + + /// Write queue state back to persistent storage. + fn store_state(env: &Env, state: &MonitorQueueState) { + let state_key = Symbol::new(env, QUEUE_STATE_KEY); + env.storage().persistent().set(&state_key, state); + } + + /// Compute the storage key for a queue entry at the given logical index. + fn entry_key(env: &Env, index: u32) -> Symbol { + Symbol::new(env, &format!("{}_{}", QUEUE_ENTRY_KEY, index)) + } + + /// Push a monitor event onto the back of the queue. + /// + /// If the queue is at capacity, the oldest event is evicted and an + /// overflow event is emitted via `EventEmitter::emit_monitor_queue_overflow` + /// so that off-chain indexers can track data loss. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `event` - The monitor event to enqueue. + /// + /// # Returns + /// + /// * `Ok(true)` - Event enqueued but caused an eviction (overflow). + /// * `Ok(false)` - Event enqueued normally. + /// * `Err(Error)` - Storage or validation error. + pub fn push(env: &Env, event: &MonitorEvent) -> Result { + let mut state = Self::get_state(env); + let mut overflowed = false; + let mut evicted_event_id: Option = None; + + // If queue is full, read the evicted event's id before removing it. + if state.len == state.capacity { + let head_key = Self::entry_key(env, state.head); + if let Some(evicted) = + env.storage().persistent().get::(&head_key) + { + evicted_event_id = Some(evicted.event_id); + } + Self::remove_entry(env, state.head); + state.head = (state.head + 1) % state.capacity; + state.len = state.len.saturating_sub(1); + state.overflow_count = state.overflow_count.saturating_add(1); + state.last_overflow_timestamp = env.ledger().timestamp(); + overflowed = true; + + // Emit overflow event for off-chain indexers. + Self::emit_overflow_event(env, &state, evicted_event_id); + } + + // Write the new event at the tail position. + let entry_key = Self::entry_key(env, state.tail); + env.storage().persistent().set(&entry_key, event); + + state.tail = (state.tail + 1) % state.capacity; + state.len = state.len.saturating_add(1); + state.total_pushed = state.total_pushed.saturating_add(1); + + Self::store_state(env, &state); + Ok(overflowed) + } + + /// Pop the oldest event from the front of the queue. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// + /// # Returns + /// + /// * `Ok(Some(event))` - The oldest event if the queue is non-empty. + /// * `Ok(None)` - The queue is empty. + pub fn pop(env: &Env) -> Result, Error> { + let mut state = Self::get_state(env); + + if state.len == 0 { + return Ok(None); + } + + let entry_key = Self::entry_key(env, state.head); + let event: MonitorEvent = env + .storage() + .persistent() + .get(&entry_key) + .ok_or(Error::InvalidState)?; + + Self::remove_entry(env, state.head); + + state.head = (state.head + 1) % state.capacity; + state.len = state.len.saturating_sub(1); + state.total_popped = state.total_popped.saturating_add(1); + + Self::store_state(env, &state); + Ok(Some(event)) + } + + /// Peek at the oldest event without removing it. + /// + /// # Returns + /// + /// * `Ok(Some(event))` - The oldest event if the queue is non-empty. + /// * `Ok(None)` - The queue is empty. + pub fn peek(env: &Env) -> Result, Error> { + let state = Self::get_state(env); + + if state.len == 0 { + return Ok(None); + } + + let entry_key = Self::entry_key(env, state.head); + let event: Option = env.storage().persistent().get(&entry_key); + Ok(event) + } + + /// Drain up to `max_count` events from the queue, returning them in FIFO order. + /// + /// If `max_count` is 0 or the queue is empty, returns an empty vector. + /// Each drained event is removed from the queue. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `max_count` - Maximum number of events to drain. Use `u32::MAX` to drain all. + /// + /// # Returns + /// + /// A vector of drained events in FIFO order (oldest first). + pub fn drain(env: &Env, max_count: u32) -> Result, Error> { + let mut result = Vec::new(env); + let count = max_count.min(Self::get_state(env).len); + + for _ in 0..count { + match Self::pop(env)? { + Some(event) => result.push_back(event), + None => break, + } + } + + Ok(result) + } + + /// Remove all events from the queue, resetting it to empty. + /// + /// This does **not** reset the overflow counter or total push/pop counters. + /// Use `reset` for a full reset. + pub fn clear(env: &Env) { + let mut state = Self::get_state(env); + + // Remove all stored entries. + for i in 0..state.capacity { + Self::remove_entry(env, i); + } + + state.head = 0; + state.tail = 0; + state.len = 0; + + Self::store_state(env, &state); + } + + /// Fully reset the queue to its initial empty state, clearing all counters. + pub fn reset(env: &Env) { + let state_key = Symbol::new(env, QUEUE_STATE_KEY); + let state = Self::get_state(env); + + // Remove all stored entries. + for i in 0..state.capacity { + Self::remove_entry(env, i); + } + + let fresh = MonitorQueueState { + head: 0, + tail: 0, + len: 0, + capacity: state.capacity, + total_pushed: 0, + total_popped: 0, + overflow_count: 0, + last_overflow_timestamp: 0, + }; + + env.storage().persistent().set(&state_key, &fresh); + } + + /// Return summary statistics for the queue. + pub fn stats(env: &Env) -> MonitorQueueStats { + let state = Self::get_state(env); + MonitorQueueStats { + current_len: state.len, + capacity: state.capacity, + total_pushed: state.total_pushed, + total_popped: state.total_popped, + overflow_count: state.overflow_count, + last_overflow_timestamp: state.last_overflow_timestamp, + is_full: state.len == state.capacity, + is_empty: state.len == 0, + } + } + + /// Return the current number of events in the queue. + pub fn len(env: &Env) -> u32 { + Self::get_state(env).len + } + + /// Return `true` if the queue contains no events. + pub fn is_empty(env: &Env) -> bool { + Self::get_state(env).len == 0 + } + + /// Return `true` if the queue is at capacity. + pub fn is_full(env: &Env) -> bool { + let state = Self::get_state(env); + state.len == state.capacity + } + + /// Return the configured capacity of the queue. + pub fn capacity(env: &Env) -> u32 { + Self::get_state(env).capacity + } + + /// Return all events currently in the queue in FIFO order without removing them. + /// + /// This allocates a `Vec` proportional to queue length. Use only for + /// read-only inspection (e.g., debugging, snapshotting). + pub fn peek_all(env: &Env) -> Result, Error> { + let state = Self::get_state(env); + let mut result = Vec::new(env); + + if state.len == 0 { + return Ok(result); + } + + let mut idx = state.head; + for _ in 0..state.len { + let entry_key = Self::entry_key(env, idx); + if let Some(event) = env.storage().persistent().get::(&entry_key) { + result.push_back(event); + } + idx = (idx + 1) % state.capacity; + } + + Ok(result) + } + + // ===== PRIVATE HELPERS ===== + + /// Delete a single entry from storage. + fn remove_entry(env: &Env, index: u32) { + let key = Self::entry_key(env, index); + env.storage().persistent().remove(&key); + } + + /// Emit an overflow event when an eviction occurs. + /// + /// Delegates to `EventEmitter::emit_monitor_queue_overflow` so that all + /// monitor overflow events flow through the centralized event system. + fn emit_overflow_event( + env: &Env, + state: &MonitorQueueState, + evicted_event_id: Option, + ) { + EventEmitter::emit_monitor_queue_overflow( + env, + state.overflow_count, + evicted_event_id, + state.capacity, + ); + } +} + +// ===== TESTS ===== + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + fn setup() -> (Env, soroban_sdk::Address) { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + BoundedMonitorQueue::initialize(&env, 4).unwrap(); + }); + (env, contract_id) + } + + fn make_event(env: &Env, id: &str, category: MonitorEventCategory) -> MonitorEvent { + MonitorEvent { + event_id: Symbol::new(env, id), + category, + severity: MonitorEventSeverity::Info, + message: String::from_str(env, "test event"), + market_id: None, + actor: None, + timestamp: env.ledger().timestamp(), + metadata: Map::new(env), + } + } + + #[test] + fn initialize_sets_capacity() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + BoundedMonitorQueue::initialize(&env, 10).unwrap(); + assert_eq!(BoundedMonitorQueue::capacity(&env), 10); + assert_eq!(BoundedMonitorQueue::len(&env), 0); + assert!(BoundedMonitorQueue::is_empty(&env)); + assert!(!BoundedMonitorQueue::is_full(&env)); + }); + } + + #[test] + #[should_panic(expected = "monitor queue already initialized")] + fn initialize_rejects_double_init() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + BoundedMonitorQueue::initialize(&env, 10).unwrap(); + }); + } + + #[test] + fn initialize_rejects_zero_capacity() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + let result = BoundedMonitorQueue::initialize(&env, 0); + assert_eq!(result, Err(Error::InvalidInput)); + }); + } + + #[test] + fn initialize_rejects_capacity_above_max() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + let result = BoundedMonitorQueue::initialize(&env, MAX_QUEUE_CAPACITY + 1); + assert_eq!(result, Err(Error::InvalidInput)); + }); + } + + #[test] + fn push_increments_len() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let ev = make_event(&env, "e1", MonitorEventCategory::Market); + let overflowed = BoundedMonitorQueue::push(&env, &ev).unwrap(); + assert!(!overflowed); + assert_eq!(BoundedMonitorQueue::len(&env), 1); + assert_eq!(BoundedMonitorQueue::stats(&env).total_pushed, 1); + }); + } + + #[test] + fn pop_returns_fifo_order() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let ev1 = make_event(&env, "e1", MonitorEventCategory::Market); + let ev2 = make_event(&env, "e2", MonitorEventCategory::Oracle); + let ev3 = make_event(&env, "e3", MonitorEventCategory::Bet); + + BoundedMonitorQueue::push(&env, &ev1).unwrap(); + BoundedMonitorQueue::push(&env, &ev2).unwrap(); + BoundedMonitorQueue::push(&env, &ev3).unwrap(); + + let popped1 = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + let popped2 = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + let popped3 = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + + assert_eq!(popped1.event_id, Symbol::new(&env, "e1")); + assert_eq!(popped2.event_id, Symbol::new(&env, "e2")); + assert_eq!(popped3.event_id, Symbol::new(&env, "e3")); + + assert!(BoundedMonitorQueue::is_empty(&env)); + assert_eq!(BoundedMonitorQueue::stats(&env).total_popped, 3); + }); + } + + #[test] + fn pop_empty_queue_returns_none() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let result = BoundedMonitorQueue::pop(&env).unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn overflow_evicts_oldest_and_returns_true() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + // Fill queue to capacity (4). + for i in 0..4 { + let ev = make_event(&env, &format!("e{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + assert!(BoundedMonitorQueue::is_full(&env)); + + // Push one more — should evict e0. + let overflow_ev = make_event(&env, "overflow", MonitorEventCategory::System); + let overflowed = BoundedMonitorQueue::push(&env, &overflow_ev).unwrap(); + assert!(overflowed); + + let stats = BoundedMonitorQueue::stats(&env); + assert_eq!(stats.overflow_count, 1); + assert_eq!(stats.current_len, 4); // still at capacity + + // The oldest surviving event should be e1. + let first = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + assert_eq!(first.event_id, Symbol::new(&env, "e1")); + }); + } + + #[test] + fn multiple_overflows_track_cumulative_count() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + // Fill to capacity. + for i in 0..4 { + let ev = make_event(&env, &format!("e{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + // Push 3 more, each causes overflow. + for i in 0..3 { + let ev = make_event( + &env, + &format!("extra{}", i), + MonitorEventCategory::System, + ); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + let stats = BoundedMonitorQueue::stats(&env); + assert_eq!(stats.overflow_count, 3); + assert_eq!(stats.total_pushed, 7); + assert_eq!(stats.current_len, 4); + + // Queue should contain e3, extra0, extra1, extra2. + let first = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + assert_eq!(first.event_id, Symbol::new(&env, "e3")); + }); + } + + #[test] + fn peek_does_not_remove() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let ev = make_event(&env, "peek_test", MonitorEventCategory::Market); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + + let peeked = BoundedMonitorQueue::peek(&env).unwrap().unwrap(); + assert_eq!(peeked.event_id, Symbol::new(&env, "peek_test")); + + // Queue length unchanged. + assert_eq!(BoundedMonitorQueue::len(&env), 1); + }); + } + + #[test] + fn peek_empty_returns_none() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + assert!(BoundedMonitorQueue::peek(&env).unwrap().is_none()); + }); + } + + #[test] + fn drain_returns_up_to_max_count() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + for i in 0..3 { + let ev = make_event(&env, &format!("d{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + let drained = BoundedMonitorQueue::drain(&env, 2).unwrap(); + assert_eq!(drained.len(), 2); + assert_eq!(BoundedMonitorQueue::len(&env), 1); + + // Drained events are in FIFO order. + assert_eq!(drained.get(0).unwrap().event_id, Symbol::new(&env, "d0")); + assert_eq!(drained.get(1).unwrap().event_id, Symbol::new(&env, "d1")); + }); + } + + #[test] + fn drain_all_with_large_max() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + for i in 0..2 { + let ev = make_event(&env, &format!("d{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + let drained = BoundedMonitorQueue::drain(&env, u32::MAX).unwrap(); + assert_eq!(drained.len(), 2); + assert!(BoundedMonitorQueue::is_empty(&env)); + }); + } + + #[test] + fn drain_empty_queue_returns_empty_vec() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let drained = BoundedMonitorQueue::drain(&env, 10).unwrap(); + assert_eq!(drained.len(), 0); + }); + } + + #[test] + fn clear_removes_all_events() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + for i in 0..3 { + let ev = make_event(&env, &format!("c{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + BoundedMonitorQueue::clear(&env); + assert!(BoundedMonitorQueue::is_empty(&env)); + assert_eq!(BoundedMonitorQueue::len(&env), 0); + + // Counters preserved. + assert_eq!(BoundedMonitorQueue::stats(&env).total_pushed, 3); + }); + } + + #[test] + fn reset_clears_everything() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + for i in 0..3 { + let ev = make_event(&env, &format!("r{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + BoundedMonitorQueue::reset(&env); + let stats = BoundedMonitorQueue::stats(&env); + assert_eq!(stats.current_len, 0); + assert_eq!(stats.total_pushed, 0); + assert_eq!(stats.total_popped, 0); + assert_eq!(stats.overflow_count, 0); + }); + } + + #[test] + fn peek_all_returns_fifo_without_removing() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + let ev1 = make_event(&env, "a", MonitorEventCategory::Market); + let ev2 = make_event(&env, "b", MonitorEventCategory::Oracle); + BoundedMonitorQueue::push(&env, &ev1).unwrap(); + BoundedMonitorQueue::push(&env, &ev2).unwrap(); + + let all = BoundedMonitorQueue::peek_all(&env).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all.get(0).unwrap().event_id, Symbol::new(&env, "a")); + assert_eq!(all.get(1).unwrap().event_id, Symbol::new(&env, "b")); + + // Queue unchanged. + assert_eq!(BoundedMonitorQueue::len(&env), 2); + }); + } + + #[test] + fn push_pop_wrap_around_indices() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + // Fill and drain to move head/tail past 0. + for i in 0..4 { + let ev = make_event(&env, &format!("w{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + // Drain 2: head=2, tail=0 (wrapped). + BoundedMonitorQueue::drain(&env, 2).unwrap(); + + // Push 3 more: should wrap tail around. + for i in 0..3 { + let ev = make_event( + &env, + &format!("wrap{}", i), + MonitorEventCategory::System, + ); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + + // Queue now has 5 entries: w2, w3, wrap0, wrap1, wrap2. + // But capacity is 4, so the last push of wrap2 should have evicted w2. + assert_eq!(BoundedMonitorQueue::len(&env), 4); + + let first = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + assert_eq!(first.event_id, Symbol::new(&env, "w3")); + }); + } + + #[test] + fn stats_matches_manual_tracking() { + let (env, contract_id) = setup(); + env.as_contract(&contract_id, || { + for i in 0..6 { + let ev = make_event(&env, &format!("s{}", i), MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + } + BoundedMonitorQueue::pop(&env).unwrap(); + + let stats = BoundedMonitorQueue::stats(&env); + assert_eq!(stats.capacity, 4); + assert_eq!(stats.current_len, 3); // 4 pushed, 1 popped + assert_eq!(stats.total_pushed, 6); + assert_eq!(stats.total_popped, 1); + assert_eq!(stats.overflow_count, 2); // 2 evictions from the 5th and 6th push + assert!(!stats.is_full); + assert!(!stats.is_empty); + }); + } + + #[test] + fn capacity_boundaries() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + // Minimum valid capacity. + BoundedMonitorQueue::initialize(&env, MIN_QUEUE_CAPACITY).unwrap(); + assert_eq!(BoundedMonitorQueue::capacity(&env), 1); + + // Push and immediately pop to test single-slot queue. + let ev = make_event(&env, "min", MonitorEventCategory::System); + BoundedMonitorQueue::push(&env, &ev).unwrap(); + assert!(BoundedMonitorQueue::is_full(&env)); + + let ev2 = make_event(&env, "min2", MonitorEventCategory::System); + let overflowed = BoundedMonitorQueue::push(&env, &ev2).unwrap(); + assert!(overflowed); + + let popped = BoundedMonitorQueue::pop(&env).unwrap().unwrap(); + assert_eq!(popped.event_id, Symbol::new(&env, "min2")); + }); + } +} diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index 68477118..c6a245bc 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -5,6 +5,7 @@ use soroban_sdk::{contracttype, vec, Address, Env, Map, String, Symbol, Vec}; use crate::err::Error; use crate::events::EventEmitter; +use crate::monitor::{BoundedMonitorQueue, MonitorEvent, MonitorEventCategory, MonitorEventSeverity}; use crate::types::{Market, MarketState, OracleConfig, OracleProvider}; /// Comprehensive monitoring system for Predictify contract health and performance. /// @@ -516,6 +517,125 @@ impl ContractMonitor { ); } + // ===== BOUNDED QUEUE INTEGRATION ===== + + /// Record a monitor event into the bounded queue. + /// + /// Wraps a `MonitorEvent` and pushes it into the `BoundedMonitorQueue`. + /// When the queue is at capacity, the oldest event is evicted and an + /// overflow event is emitted automatically. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `event` - The monitor event to record. + /// + /// # Returns + /// + /// * `Ok(true)` - Event recorded but caused an eviction (overflow). + /// * `Ok(false)` - Event recorded normally. + /// * `Err(Error)` - Queue not initialized or storage error. + pub fn record_event(env: &Env, event: &MonitorEvent) -> Result { + BoundedMonitorQueue::push(env, event) + } + + /// Record a monitoring alert into the bounded queue. + /// + /// Converts a `MonitoringAlert` into a `MonitorEvent` and pushes it + /// into the `BoundedMonitorQueue`. The alert's severity is mapped to + /// `MonitorEventSeverity` and its type to `MonitorEventCategory`. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `alert` - The monitoring alert to record. + /// + /// # Returns + /// + /// * `Ok(true)` - Alert recorded but caused an overflow. + /// * `Ok(false)` - Alert recorded normally. + /// * `Err(Error)` - Queue not initialized or storage error. + pub fn record_alert(env: &Env, alert: &MonitoringAlert) -> Result { + let severity = match alert.severity { + AlertSeverity::Info => MonitorEventSeverity::Info, + AlertSeverity::Warning => MonitorEventSeverity::Warning, + AlertSeverity::Critical | AlertSeverity::Emergency => { + MonitorEventSeverity::Critical + } + }; + + let category = match alert.alert_type { + MonitoringAlertType::MarketHealth => MonitorEventCategory::Market, + MonitoringAlertType::OracleHealth => MonitorEventCategory::Oracle, + MonitoringAlertType::FeeCollection => MonitorEventCategory::Fee, + MonitoringAlertType::DisputeResolution => MonitorEventCategory::Dispute, + MonitoringAlertType::Security | MonitoringAlertType::Performance => { + MonitorEventCategory::System + } + MonitoringAlertType::SystemOverload + | MonitoringAlertType::DataIntegrity + | MonitoringAlertType::NetworkIssues => MonitorEventCategory::CircuitBreaker, + MonitoringAlertType::Custom => MonitorEventCategory::Admin, + }; + + let mut meta = alert.metadata.clone(); + meta.set( + String::from_str(env, "alert_id"), + alert.alert_id.clone(), + ); + + let event = MonitorEvent { + event_id: Symbol::new(env, "alert"), + category, + severity, + message: alert.description.clone(), + market_id: None, + actor: None, + timestamp: alert.timestamp, + metadata: meta, + }; + + Self::record_event(env, &event) + } + + /// Drain up to `max_count` events from the bounded queue. + /// + /// Returns drained events in FIFO order (oldest first) and removes them + /// from the queue. Useful for off-chain indexers to consume events in + /// batches. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment. + /// * `max_count` - Maximum number of events to drain. Use `u32::MAX` for all. + /// + /// # Returns + /// + /// A vector of drained events in FIFO order. + pub fn drain_events(env: &Env, max_count: u32) -> Result, Error> { + BoundedMonitorQueue::drain(env, max_count) + } + + /// Peek at the oldest event in the bounded queue without removing it. + /// + /// # Returns + /// + /// * `Ok(Some(event))` - The oldest event if the queue is non-empty. + /// * `Ok(None)` - The queue is empty. + pub fn peek_next_event(env: &Env) -> Result, Error> { + BoundedMonitorQueue::peek(env) + } + + /// Return current bounded queue statistics. + /// + /// Provides a snapshot of queue utilization, overflow history, and + /// throughput counters for monitoring dashboards. + pub fn get_queue_stats( + env: &Env, + ) -> Result { + Ok(BoundedMonitorQueue::stats(env)) + } + /// Validate monitoring data integrity pub fn validate_monitoring_data(env: &Env, data: &MonitoringData) -> Result { // Validate timestamp @@ -1446,6 +1566,216 @@ mod tests { assert_eq!(env.events().all().events().len(), 1); } + + // ===== BOUNDED QUEUE INTEGRATION TESTS ===== + + fn make_monitor_event(env: &Env, id: &str) -> crate::monitor::MonitorEvent { + crate::monitor::MonitorEvent { + event_id: Symbol::new(env, id), + category: MonitorEventCategory::System, + severity: MonitorEventSeverity::Info, + message: String::from_str(env, "test"), + market_id: None, + actor: None, + timestamp: env.ledger().timestamp(), + metadata: Map::new(env), + } + } + + fn setup_queue(env: &Env, contract_id: &soroban_sdk::Address, capacity: u32) { + env.as_contract(contract_id, || { + crate::monitor::BoundedMonitorQueue::initialize(env, capacity).unwrap(); + }); + } + + #[test] + fn record_event_pushes_into_queue() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 8); + let ev = make_monitor_event(&env, "rec1"); + + let overflowed = env + .as_contract(&contract_id, || ContractMonitor::record_event(&env, &ev)) + .unwrap(); + assert!(!overflowed); + + let stats = env + .as_contract(&contract_id, || ContractMonitor::get_queue_stats(&env)) + .unwrap(); + assert_eq!(stats.current_len, 1); + assert_eq!(stats.total_pushed, 1); + } + + #[test] + fn record_event_returns_overflow_when_full() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 2); + + env.as_contract(&contract_id, || { + let ev1 = make_monitor_event(&env, "a"); + let ev2 = make_monitor_event(&env, "b"); + ContractMonitor::record_event(&env, &ev1).unwrap(); + ContractMonitor::record_event(&env, &ev2).unwrap(); + + // Third push should overflow. + let ev3 = make_monitor_event(&env, "c"); + let overflowed = ContractMonitor::record_event(&env, &ev3).unwrap(); + assert!(overflowed); + + let stats = ContractMonitor::get_queue_stats(&env).unwrap(); + assert_eq!(stats.overflow_count, 1); + assert_eq!(stats.current_len, 2); + }); + } + + #[test] + fn drain_events_returns_fifo() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 4); + + env.as_contract(&contract_id, || { + ContractMonitor::record_event(&env, &make_monitor_event(&env, "x")).unwrap(); + ContractMonitor::record_event(&env, &make_monitor_event(&env, "y")).unwrap(); + ContractMonitor::record_event(&env, &make_monitor_event(&env, "z")).unwrap(); + + let drained = ContractMonitor::drain_events(&env, 2).unwrap(); + assert_eq!(drained.len(), 2); + assert_eq!(drained.get(0).unwrap().event_id, Symbol::new(&env, "x")); + assert_eq!(drained.get(1).unwrap().event_id, Symbol::new(&env, "y")); + + let stats = ContractMonitor::get_queue_stats(&env).unwrap(); + assert_eq!(stats.current_len, 1); + }); + } + + #[test] + fn peek_next_event_does_not_remove() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 4); + + env.as_contract(&contract_id, || { + ContractMonitor::record_event(&env, &make_monitor_event(&env, "peek1")).unwrap(); + + let peeked = ContractMonitor::peek_next_event(&env).unwrap().unwrap(); + assert_eq!(peeked.event_id, Symbol::new(&env, "peek1")); + + let stats = ContractMonitor::get_queue_stats(&env).unwrap(); + assert_eq!(stats.current_len, 1); + }); + } + + #[test] + fn peek_next_event_empty_queue_returns_none() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 4); + + env.as_contract(&contract_id, || { + let result = ContractMonitor::peek_next_event(&env).unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn record_alert_pushes_into_queue() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 8); + + let alert = MonitoringUtils::create_alert( + &env, + MonitoringAlertType::OracleHealth, + AlertSeverity::Critical, + String::from_str(&env, "Oracle down"), + String::from_str(&env, "Primary oracle unreachable"), + String::from_str(&env, "oracle"), + ); + + env.as_contract(&contract_id, || { + let overflowed = ContractMonitor::record_alert(&env, &alert).unwrap(); + assert!(!overflowed); + + let stats = ContractMonitor::get_queue_stats(&env).unwrap(); + assert_eq!(stats.current_len, 1); + }); + } + + #[test] + fn record_alert_maps_severity_correctly() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 8); + + let alert = MonitoringUtils::create_alert( + &env, + MonitoringAlertType::Security, + AlertSeverity::Emergency, + String::from_str(&env, "Intrusion"), + String::from_str(&env, "Unauthorized access attempt"), + String::from_str(&env, "auth"), + ); + + env.as_contract(&contract_id, || { + ContractMonitor::record_alert(&env, &alert).unwrap(); + + let peeked = ContractMonitor::peek_next_event(&env).unwrap().unwrap(); + assert_eq!(peeked.severity, MonitorEventSeverity::Critical); + assert_eq!(peeked.category, MonitorEventCategory::System); + }); + } + + #[test] + fn drain_events_empty_queue_returns_empty_vec() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 4); + + env.as_contract(&contract_id, || { + let drained = ContractMonitor::drain_events(&env, 10).unwrap(); + assert_eq!(drained.len(), 0); + }); + } + + #[test] + fn drain_events_large_max_drains_all() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 4); + + env.as_contract(&contract_id, || { + for i in 0..3 { + ContractMonitor::record_event(&env, &make_monitor_event(&env, &format!("d{}", i))) + .unwrap(); + } + + let drained = ContractMonitor::drain_events(&env, u32::MAX).unwrap(); + assert_eq!(drained.len(), 3); + assert!(ContractMonitor::get_queue_stats(&env).unwrap().is_empty); + }); + } + + #[test] + fn overflow_emits_event_with_evicted_id() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + setup_queue(&env, &contract_id, 1); + + env.as_contract(&contract_id, || { + ContractMonitor::record_event(&env, &make_monitor_event(&env, "first")).unwrap(); + let overflowed = + ContractMonitor::record_event(&env, &make_monitor_event(&env, "second")).unwrap(); + assert!(overflowed); + + let stats = ContractMonitor::get_queue_stats(&env).unwrap(); + assert_eq!(stats.overflow_count, 1); + assert_eq!(stats.current_len, 1); + assert_eq!(stats.total_pushed, 2); + }); + } } // ===== BOUNDED QUEUE TESTS ===== diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index ad958ea2..df98047e 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -18,6 +18,66 @@ pub const MAX_RECOVERY_HISTORY_PER_MARKET: u32 = 10; /// Maximum entries removable in a single admin prune call (gas safety). pub const MAX_RECOVERY_PRUNE_BATCH: u32 = 30; +// ===== PER-MARKET RECOVERY TIMELOCK ===== + +/// Default timelock delay before a per-market recovery action can be executed (24 hours). +pub const DEFAULT_RECOVERY_TIMELOCK_SECONDS: u64 = 24 * 60 * 60; + +/// Minimum allowed recovery timelock (1 hour). Prevents setting dangerously short windows. +pub const MIN_RECOVERY_TIMELOCK_SECONDS: u64 = 60 * 60; + +/// Maximum allowed recovery timelock (7 days). Prevents excessively long lockouts. +pub const MAX_RECOVERY_TIMELOCK_SECONDS: u64 = 7 * 24 * 60 * 60; + +/// Specifies the type of recovery action to perform on a market. +/// +/// Each variant maps to a specific recovery operation that can be +/// initiated with a timelock and executed after the delay. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PerMarketRecoveryAction { + /// Reconstruct market state from stored data (fix total_staked inconsistencies, etc.) + ReconstructState, + /// Cancel the market and refund all stakes to participants. + CancelMarket, + /// Force-resolve the market with a specified outcome (for stuck ended markets). + ForceResolve, +} + +/// A pending per-market recovery request protected by an admin timelock. +/// +/// Once initiated, the recovery action cannot be executed until `execute_after` +/// timestamp has been reached. This gives the admin a window to review and +/// cancel if the recovery was initiated in error. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingMarketRecovery { + /// The market this recovery targets. + pub market_id: Symbol, + /// The admin who initiated the recovery request. + pub initiated_by: Address, + /// The recovery action to execute. + pub action: PerMarketRecoveryAction, + /// Unix timestamp when this request was initiated. + pub initiated_at: u64, + /// Unix timestamp after which this recovery may be executed. + pub execute_after: u64, + /// Human-readable reason for the recovery. + pub reason: String, +} + +/// Configuration for the per-market recovery timelock. +/// +/// The timelock delay controls how long after initiation a recovery action +/// can actually be executed. Admin can tighten (increase) but not loosen +/// (decrease) this value once set. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryTimelockConfig { + /// Minimum number of seconds between initiation and execution. + pub timelock_seconds: u64, +} + // ===== RECOVERY TYPES ===== #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -731,6 +791,7 @@ mod tests { use super::*; use alloc::string::ToString; use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; struct RecoveryTest { env: Env,