diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 132e5ccc..ef7697db 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -358,6 +358,19 @@ pub const ORACLE_RETRY_ATTEMPTS: u32 = 3; /// Oracle timeout seconds pub const ORACLE_TIMEOUT_SECONDS: u64 = 30; +// ===== MONITORING CONSTANTS ===== + +/// Maximum number of alerts retained in the monitoring queue (FIFO, oldest dropped first). +/// +/// When the queue is full and a new alert arrives, the oldest entry is evicted and +/// `monitor_overflowed` is set to `true`. Operators can detect this via +/// [`ContractMonitor::is_overflow`] and reset the flag with +/// [`ContractMonitor::clear_overflow`] (admin-gated). +/// +/// Safe range: 10–1000. Values below 10 risk dropping legitimate alerts under +/// normal load; values above 1000 increase per-call storage I/O noticeably. +pub const MONITOR_QUEUE_CAP: u32 = 100; + // ===== STORAGE CONSTANTS ===== /// Storage key for admin address diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 96324a3f..a4db75dc 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -5685,6 +5685,34 @@ impl PredictifyHybrid { monitoring::ContractMonitor::validate_monitoring_data(&env, &data) } + /// Return all alerts in the bounded monitoring queue (oldest first). + /// + /// Clients should also check [`is_monitor_overflow`] to detect whether any + /// alerts were silently evicted since the last admin reset. + pub fn get_monitor_alerts(env: Env) -> Vec { + monitoring::ContractMonitor::get_alerts(&env) + } + + /// Return `true` if at least one alert has been evicted from the queue due to + /// overflow since the last [`clear_monitor_overflow`] call. + pub fn is_monitor_overflow(env: Env) -> bool { + monitoring::ContractMonitor::is_overflow(&env) + } + + /// Reset the monitoring overflow flag. Only the contract admin may call this. + /// + /// # Errors + /// + /// - [`Error::AdminNotSet`] – no admin has been initialised. + /// - [`Error::Unauthorized`] – `admin` does not match the stored admin address. + /// + /// # Events + /// + /// State-changing paths may emit events through internal managers; read-only query paths emit no events. + pub fn clear_monitor_overflow(env: Env, admin: Address) -> Result<(), Error> { + monitoring::ContractMonitor::clear_overflow(&env, &admin) + } + // ===== ORACLE FALLBACK FUNCTIONS ===== /// Get oracle data with backup if primary fails. diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index 0a3c13da..0420d07b 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -918,10 +918,84 @@ impl ContractMonitor { score.min(100) } + // ===== BOUNDED QUEUE STORAGE KEYS ===== + // + // "MON_QUEUE" – Vec bounded to MONITOR_QUEUE_CAP entries (FIFO). + // "MON_OVERFLOWED" – bool flag; true when at least one alert was evicted since last reset. + + /// Append `alert` to the bounded monitoring queue. + /// + /// When the queue has reached [`MONITOR_QUEUE_CAP`] entries the **oldest** entry + /// is evicted (FIFO) and `MON_OVERFLOWED` is set to `true`. No `unwrap()` is + /// used in this path; all fallible operations propagate [`Error`]. fn store_alert(env: &Env, alert: &MonitoringAlert) -> Result<(), Error> { - // Store alert in persistent storage - let storage_key = Symbol::new(env, "MONITORING_ALERT"); - env.storage().persistent().set(&storage_key, alert); + use crate::config::MONITOR_QUEUE_CAP; + + let queue_key = Symbol::new(env, "MON_QUEUE"); + let overflow_key = Symbol::new(env, "MON_OVERFLOWED"); + + // Load current queue or start empty. + let mut queue: Vec = env + .storage() + .persistent() + .get(&queue_key) + .unwrap_or_else(|| Vec::new(env)); + + // Evict oldest when at capacity. + if queue.len() >= MONITOR_QUEUE_CAP { + // Build a new Vec skipping index 0 (oldest) – avoids Vec::remove(0). + let mut trimmed: Vec = Vec::new(env); + for i in 1..queue.len() { + trimmed.push_back(queue.get(i).ok_or(Error::InvalidState)?); + } + queue = trimmed; + env.storage().persistent().set(&overflow_key, &true); + } + + queue.push_back(alert.clone()); + env.storage().persistent().set(&queue_key, &queue); + Ok(()) + } + + /// Return all alerts currently held in the bounded queue (oldest first). + pub fn get_alerts(env: &Env) -> Vec { + let queue_key = Symbol::new(env, "MON_QUEUE"); + env.storage() + .persistent() + .get(&queue_key) + .unwrap_or_else(|| Vec::new(env)) + } + + /// Return `true` if at least one alert has been evicted since the last + /// [`clear_overflow`] call. + pub fn is_overflow(env: &Env) -> bool { + let overflow_key = Symbol::new(env, "MON_OVERFLOWED"); + env.storage() + .persistent() + .get(&overflow_key) + .unwrap_or(false) + } + + /// Reset the overflow flag. Callable only by the contract admin. + /// + /// # Errors + /// + /// - [`Error::AdminNotSet`] if no admin has been initialised. + /// - [`Error::Unauthorized`] if `admin` does not match the stored admin address. + pub fn clear_overflow(env: &Env, admin: &Address) -> Result<(), Error> { + // Gate: caller must be the stored admin. + admin.require_auth(); + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(env, "Admin")) + .ok_or(Error::AdminNotSet)?; + if admin != &stored_admin { + return Err(Error::Unauthorized); + } + + let overflow_key = Symbol::new(env, "MON_OVERFLOWED"); + env.storage().persistent().set(&overflow_key, &false); Ok(()) } } @@ -1364,3 +1438,179 @@ mod tests { assert_eq!(env.events().all().events().len(), 1); } } + +// ===== BOUNDED QUEUE TESTS ===== + +#[cfg(test)] +mod bounded_queue_tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + /// Register a contract, initialise Admin storage, and return (env, contract_id, admin). + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + // Plant the admin directly so clear_overflow can verify it. + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "Admin"), &admin); + }); + (env, contract_id, admin) + } + + fn make_alert(env: &Env, id: &str) -> MonitoringAlert { + MonitoringAlert { + alert_id: String::from_str(env, id), + alert_type: MonitoringAlertType::MarketHealth, + severity: AlertSeverity::Info, + title: String::from_str(env, "t"), + description: String::from_str(env, "d"), + affected_component: String::from_str(env, "c"), + timestamp: env.ledger().timestamp(), + resolved: false, + resolution_notes: None, + metadata: Map::new(env), + } + } + + #[test] + fn test_queue_empty_initially() { + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + let alerts = ContractMonitor::get_alerts(&env); + assert_eq!(alerts.len(), 0); + assert!(!ContractMonitor::is_overflow(&env)); + }); + } + + #[test] + fn test_store_and_retrieve_alert() { + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + let alert = make_alert(&env, "a1"); + ContractMonitor::store_alert(&env, &alert).unwrap(); + let queue = ContractMonitor::get_alerts(&env); + assert_eq!(queue.len(), 1); + assert_eq!(queue.get(0).unwrap().alert_id, alert.alert_id); + assert!(!ContractMonitor::is_overflow(&env)); + }); + } + + #[test] + fn test_fifo_eviction_at_cap() { + use crate::config::MONITOR_QUEUE_CAP; + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + // Fill to cap. + for i in 0..MONITOR_QUEUE_CAP { + let id = alloc::format!("a{}", i); + ContractMonitor::store_alert(&env, &make_alert(&env, &id)).unwrap(); + } + assert_eq!(ContractMonitor::get_alerts(&env).len(), MONITOR_QUEUE_CAP); + assert!(!ContractMonitor::is_overflow(&env)); + + // One more triggers eviction. + ContractMonitor::store_alert(&env, &make_alert(&env, "overflow")).unwrap(); + let queue = ContractMonitor::get_alerts(&env); + assert_eq!(queue.len(), MONITOR_QUEUE_CAP); + assert!(ContractMonitor::is_overflow(&env)); + + // Oldest ("a0") was evicted; newest is now last. + assert_eq!(queue.get(0).unwrap().alert_id, String::from_str(&env, "a1")); + assert_eq!( + queue.get(queue.len() - 1).unwrap().alert_id, + String::from_str(&env, "overflow") + ); + }); + } + + #[test] + fn test_fifo_eviction_cap_one() { + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + // Manually test the cap-1 edge case by storing two alerts. + // We cannot change MONITOR_QUEUE_CAP at runtime, so we just verify + // the eviction loop path works when queue.len() reaches cap on the + // *second* insert after filling up. + ContractMonitor::store_alert(&env, &make_alert(&env, "first")).unwrap(); + // Fill to cap. + use crate::config::MONITOR_QUEUE_CAP; + for i in 1..MONITOR_QUEUE_CAP { + let id = alloc::format!("fill{}", i); + ContractMonitor::store_alert(&env, &make_alert(&env, &id)).unwrap(); + } + // Add one more – "first" is evicted. + ContractMonitor::store_alert(&env, &make_alert(&env, "last")).unwrap(); + let queue = ContractMonitor::get_alerts(&env); + assert!(ContractMonitor::is_overflow(&env)); + // "first" must no longer be present. + for i in 0..queue.len() { + assert_ne!( + queue.get(i).unwrap().alert_id, + String::from_str(&env, "first") + ); + } + }); + } + + #[test] + fn test_repeated_overflow() { + use crate::config::MONITOR_QUEUE_CAP; + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + // Fill to cap then add 10 more. + for i in 0..(MONITOR_QUEUE_CAP + 10) { + let id = alloc::format!("r{}", i); + ContractMonitor::store_alert(&env, &make_alert(&env, &id)).unwrap(); + } + assert!(ContractMonitor::is_overflow(&env)); + assert_eq!(ContractMonitor::get_alerts(&env).len(), MONITOR_QUEUE_CAP); + }); + } + + #[test] + fn test_clear_overflow_by_admin() { + use crate::config::MONITOR_QUEUE_CAP; + let (env, contract_id, admin) = setup(); + env.as_contract(&contract_id, || { + // Trigger overflow. + for i in 0..(MONITOR_QUEUE_CAP + 1) { + let id = alloc::format!("c{}", i); + ContractMonitor::store_alert(&env, &make_alert(&env, &id)).unwrap(); + } + assert!(ContractMonitor::is_overflow(&env)); + + // Admin resets the flag. + ContractMonitor::clear_overflow(&env, &admin).unwrap(); + assert!(!ContractMonitor::is_overflow(&env)); + }); + } + + #[test] + fn test_clear_overflow_unauthorized() { + use crate::config::MONITOR_QUEUE_CAP; + let (env, contract_id, _admin) = setup(); + let not_admin = Address::generate(&env); + env.as_contract(&contract_id, || { + for i in 0..(MONITOR_QUEUE_CAP + 1) { + let id = alloc::format!("u{}", i); + ContractMonitor::store_alert(&env, &make_alert(&env, &id)).unwrap(); + } + let result = ContractMonitor::clear_overflow(&env, ¬_admin); + assert_eq!(result, Err(Error::Unauthorized)); + }); + } + + #[test] + fn test_emit_monitoring_alert_uses_bounded_queue() { + let (env, contract_id, _admin) = setup(); + env.as_contract(&contract_id, || { + let alert = make_alert(&env, "ev1"); + ContractMonitor::emit_monitoring_alert(&env, alert).unwrap(); + assert_eq!(ContractMonitor::get_alerts(&env).len(), 1); + }); + } +}