Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions contracts/predictify-hybrid/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::MonitoringAlert> {
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.
Expand Down
256 changes: 253 additions & 3 deletions contracts/predictify-hybrid/src/monitoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,10 +918,84 @@ impl ContractMonitor {
score.min(100)
}

// ===== BOUNDED QUEUE STORAGE KEYS =====
//
// "MON_QUEUE" – Vec<MonitoringAlert> 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<MonitoringAlert> = 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<MonitoringAlert> = 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<MonitoringAlert> {
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(())
}
}
Expand Down Expand Up @@ -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, &not_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);
});
}
}
Loading