diff --git a/Cargo.lock b/Cargo.lock index 729499bd..30e45726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "ahash" diff --git a/contracts/predictify-hybrid/src/audit_trail.rs b/contracts/predictify-hybrid/src/audit_trail.rs index c8b79b19..5aa09442 100644 --- a/contracts/predictify-hybrid/src/audit_trail.rs +++ b/contracts/predictify-hybrid/src/audit_trail.rs @@ -28,8 +28,9 @@ pub enum AuditAction { FeesWithdrawn, FeeConfigUpdated, - // Oracle & Config Actions + // Token & Oracle Actions OracleConfigUpdated, + TokenVerified, BetLimitsUpdated, // Resolution & Disputes diff --git a/contracts/predictify-hybrid/src/circuit_breaker.rs b/contracts/predictify-hybrid/src/circuit_breaker.rs index 0f2eeffe..452c1067 100644 --- a/contracts/predictify-hybrid/src/circuit_breaker.rs +++ b/contracts/predictify-hybrid/src/circuit_breaker.rs @@ -1,4 +1,5 @@ use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec}; +use soroban_sdk::Storage; use crate::admin::AdminAccessControl; use crate::errors::Error; @@ -50,6 +51,14 @@ pub struct CircuitBreakerConfig { pub recovery_timeout: u64, // Time to wait before attempting recovery pub half_open_max_requests: u32, // Max requests in half-open state pub auto_recovery_enabled: bool, // Whether to auto-recover + pub half_open_quota: HalfOpenQuota, // Quota-based scheduler for half-open +} + +#[derive(Clone, Debug)] +#[contracttype] +pub struct HalfOpenQuota { + pub calls_per_minute: u32, + pub evaluation_window_s: u64, } #[derive(Clone, Debug)] @@ -71,6 +80,22 @@ pub struct CircuitBreakerState { pub half_open_since: u64, } +// Temporary tracking for half-open admission window +#[derive(Clone, Debug)] +#[contracttype] +pub struct HalfOpenWindow { + pub admitted: u32, + pub completed: u32, + pub failures: u32, + pub window_start: u64, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub enum CircuitBreakerTempData { + HalfOpenWindow, +} + #[derive(Clone, Debug, PartialEq, Eq)] #[contracttype] pub enum PauseScope { @@ -132,6 +157,7 @@ impl CircuitBreaker { recovery_timeout: 300, // 5 minutes recovery timeout half_open_max_requests: 3, // 3 requests in half-open state auto_recovery_enabled: true, // Enable auto-recovery + half_open_quota: HalfOpenQuota { calls_per_minute: 3, evaluation_window_s: 60 }, }; let state = CircuitBreakerState { @@ -220,6 +246,11 @@ impl CircuitBreaker { env.storage() .instance() .set(&Symbol::new(env, Self::STATE_KEY), state); + // When closing or opening, clear any temporary half-open window tracking + if state.state != BreakerState::HalfOpen { + let key = CircuitBreakerTempData::HalfOpenWindow; + env.storage().temporary().set(&key, &HalfOpenWindow { admitted: 0, completed: 0, failures: 0, window_start: 0 }); + } Ok(()) } @@ -310,8 +341,15 @@ impl CircuitBreaker { } }, BreakerState::HalfOpen => { + // Use quota-based admission for half-open state where configured. let config = Self::get_config(env)?; - Ok(state.half_open_requests < config.half_open_max_requests) + if config.half_open_quota.calls_per_minute > 0 { + // Attempt to admit the call (this will increment temporary counters) + let admitted = Self::half_open_admit(env, &config)?; + Ok(admitted) + } else { + Ok(state.half_open_requests < config.half_open_max_requests) + } } } } @@ -456,6 +494,68 @@ impl CircuitBreaker { Ok(false) } + /// Admission helper for half-open quota scheduling. + /// Returns Ok(true) if the call is admitted, Ok(false) if not admitted (and state may transition). + fn half_open_admit(env: &Env, config: &CircuitBreakerConfig) -> Result { + let current_time = env.ledger().timestamp(); + let key = CircuitBreakerTempData::HalfOpenWindow; + + let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow { + admitted: 0, + completed: 0, + failures: 0, + window_start: current_time, + }); + + // Reset window if expired + if current_time >= window.window_start.saturating_add(config.half_open_quota.evaluation_window_s) { + window.admitted = 0; + window.completed = 0; + window.failures = 0; + window.window_start = current_time; + } + + if window.admitted < config.half_open_quota.calls_per_minute { + // Admit this call (count admitted but decision occurs after completion) + window.admitted = window.admitted.saturating_add(1); + env.storage().temporary().set(&key, &window); + env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400); + return Ok(true); + } + + // Quota already admitted for this window; decide based on failures recorded + let mut state = Self::get_state(env)?; + if window.failures == 0 { + // No failures among admitted calls -> close + state.state = BreakerState::Closed; + state.failure_count = 0; + state.half_open_requests = 0; + Self::update_state(env, &state)?; + let _ = Self::emit_circuit_breaker_event( + env, + BreakerAction::Resume, + BreakerCondition::ManualOverride, + &String::from_str(env, "Quota exhausted with no failures: closing circuit"), + None, + ); + return Ok(false); + } else { + // There were failures -> reopen + state.state = BreakerState::Open; + state.opened_time = current_time; + state.half_open_requests = 0; + Self::update_state(env, &state)?; + let _ = Self::emit_circuit_breaker_event( + env, + BreakerAction::Trigger, + BreakerCondition::HighErrorRate, + &String::from_str(env, "Quota exhausted with failures: reopening circuit"), + None, + ); + return Ok(false); + } + } + // ===== RECOVERY MECHANISMS ===== /// Circuit breaker recovery by admin @@ -566,19 +666,33 @@ impl CircuitBreaker { state.half_open_requests = 0; state.half_open_since = 0; - let _ = Self::emit_circuit_breaker_event( - env, - BreakerAction::Resume, - BreakerCondition::ManualOverride, - &String::from_str(env, "Auto-recovery: circuit breaker closed"), - None, - ); - crate::monitoring::ContractMonitor::emit_pause_transition_hook( - env, - &String::from_str(env, "unpaused"), - None, - &String::from_str(env, "auto_recovery"), - ); + // Persist updated window + env.storage().temporary().set(&key, &window); + env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400); + } else { + // Backwards compatible path + state.half_open_requests += 1; + + let config = Self::get_config(env)?; + if state.half_open_requests >= config.half_open_max_requests { + state.state = BreakerState::Closed; + state.failure_count = 0; + state.half_open_requests = 0; + + let _ = Self::emit_circuit_breaker_event( + env, + BreakerAction::Resume, + BreakerCondition::ManualOverride, + &String::from_str(env, "Auto-recovery: circuit breaker closed"), + None, + ); + crate::monitoring::ContractMonitor::emit_pause_transition_hook( + env, + &String::from_str(env, "unpaused"), + None, + &String::from_str(env, "auto_recovery"), + ); + } } } @@ -597,6 +711,13 @@ impl CircuitBreaker { // If in half-open state, open the circuit breaker if state.state == BreakerState::HalfOpen { + // Record failure in temporary half-open window if present + let key = CircuitBreakerTempData::HalfOpenWindow; + let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow { admitted: 0, completed: 0, failures: 0, window_start: current_time }); + window.failures = window.failures.saturating_add(1); + window.completed = window.completed.saturating_add(1); + env.storage().temporary().set(&key, &window); + state.state = BreakerState::Open; state.opened_time = current_time; state.half_open_requests = 0; @@ -800,6 +921,14 @@ impl CircuitBreaker { return Err(Error::InvalidInput); } + if config.half_open_quota.calls_per_minute == 0 { + return Err(Error::InvalidInput); + } + + if config.half_open_quota.evaluation_window_s == 0 { + return Err(Error::InvalidInput); + } + Ok(()) } @@ -881,7 +1010,13 @@ impl CircuitBreakerUtils { BreakerState::Open => Ok(false), BreakerState::HalfOpen => { let config = CircuitBreaker::get_config(env)?; - Ok(state.half_open_requests < config.half_open_max_requests) + if config.half_open_quota.calls_per_minute > 0 { + // Try to admit a call under quota-based scheduling + let admitted = CircuitBreaker::half_open_admit(env, &config)?; + Ok(admitted) + } else { + Ok(state.half_open_requests < config.half_open_max_requests) + } } } } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index ed3f3ffb..1e84c4f7 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -7114,6 +7114,90 @@ impl PredictifyHybrid { /// # Errors /// /// This entrypoint surfaces contract errors via panic in internal calls. + /// Verify SAC token decimals match declared value (admin only). + /// + /// This function performs a critical security check on SAC tokens to prevent + /// denomination mistakes that have caused real on-chain losses. It verifies that + /// the token's on-chain decimals() value matches what was declared during registration. + /// + /// This can be called: + /// - Automatically during token registration (via add_global_verified/add_event_verified) + /// - Manually by admin as part of periodic audits or security reviews + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The administrator address (must be authorized) + /// * `token_contract` - Address of the token contract to verify + /// * `declared_decimals` - The decimals value that was declared during registration + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Decimals match (token is safe) + /// - `Err(Error::TokenDecimalsMismatch)` - Mismatch detected (token rejected) + /// - `Err(Error::Unauthorized)` - Caller is not admin + /// + /// # Cross-Contract Call + /// + /// This function performs a cross-contract call to the token contract's + /// `decimals()` function using the Soroban token interface. + /// + /// # Security Notes + /// + /// - Verifies via on-chain decimals() call (cannot be spoofed) + /// - Mismatch indicates potential token misconfiguration + /// - Rejected tokens cannot be used for betting/payouts + /// - All registration paths should use verified variants + /// + /// # Example + /// + /// ```rust,ignore + /// let token_contract = Address::from_string("GBUQW..."); + /// PredictifyHybrid::re_verify_token(&env, &admin, &token_contract, 7)?; + /// // Returns Ok if decimals match, TokenDecimalsMismatch error otherwise + /// ``` + /// + /// # Errors + /// + /// Returns [`Error`] when: + /// - `Error::Unauthorized` - Caller is not the contract admin + /// - `Error::TokenDecimalsMismatch` - On-chain decimals don't match declared value + /// - Other errors from cross-contract call or storage operations + /// + /// # Events + /// + /// Emits audit trail record of verification attempt (success or failure). + pub fn re_verify_token( + env: Env, + admin: Address, + token_contract: Address, + declared_decimals: u32, + ) -> Result<(), Error> { + // Verify admin authorization + Self::require_primary_admin(&env, &admin)?; + + // Create temporary asset for verification + let asset = crate::tokens::Asset { + contract: token_contract.clone(), + symbol: Symbol::new(&env, "TEMP"), + decimals: declared_decimals, + }; + + // Perform decimals verification via cross-contract call + crate::tokens::verify_token_decimals(&env, &asset)?; + + // Record verification in audit trail + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::TokenVerified, + admin.clone(), + Map::new(&env), + ); + + Ok(()) + } + /// /// # Events /// diff --git a/contracts/predictify-hybrid/src/tokens.rs b/contracts/predictify-hybrid/src/tokens.rs index 8153725f..9b680f2b 100644 --- a/contracts/predictify-hybrid/src/tokens.rs +++ b/contracts/predictify-hybrid/src/tokens.rs @@ -213,6 +213,66 @@ impl TokenRegistry { global_assets.iter().any(|a| a == *asset) } + /// Adds an asset to the global allowed registry with decimals verification. + /// + /// This function performs a critical security check by verifying that the + /// declared decimals match the on-chain SAC decimals() value. This prevents + /// denomination mistakes that have caused real losses on other Stellar protocols. + /// + /// # Errors + /// * `Error::TokenDecimalsMismatch` if declared decimals don't match on-chain value. + /// + /// # Security Notes + /// - Performs cross-contract call to token's decimals() function + /// - Rejects registration if mismatch detected + /// - Should only be called by admin + pub fn add_global_verified(env: &Env, asset: &Asset) -> Result<(), Error> { + // Verify decimals before registration + verify_token_decimals(env, asset)?; + + let global_key = Symbol::new(env, "allowed_assets_global"); + let mut global_assets: Vec = env + .storage() + .persistent() + .get(&global_key) + .unwrap_or(Vec::new(env)); + if !global_assets.iter().any(|a| a == *asset) { + global_assets.push_back(asset.clone()); + env.storage().persistent().set(&global_key, &global_assets); + } + Ok(()) + } + + /// Adds an asset to a specific market's allowed registry with decimals verification. + /// + /// # Parameters + /// * `env` - Soroban environment. + /// * `market_id` - Market identifier. + /// * `asset` - The asset to register. + /// + /// # Errors + /// * `Error::TokenDecimalsMismatch` if declared decimals don't match on-chain value. + pub fn add_event_verified(env: &Env, market_id: &Symbol, asset: &Asset) -> Result<(), Error> { + // Verify decimals before registration + verify_token_decimals(env, asset)?; + + let event_key = Symbol::new(env, "allowed_assets_evt"); + let per_event_empty: soroban_sdk::Map> = soroban_sdk::Map::new(env); + let mut per_event: soroban_sdk::Map> = env + .storage() + .persistent() + .get(&event_key) + .unwrap_or(per_event_empty); + let empty_assets: Vec = Vec::new(env); + let mut assets: Vec = per_event.get(market_id.clone()).unwrap_or(empty_assets); + if !assets.iter().any(|a| a == *asset) { + assets.push_back(asset.clone()); + per_event.set(market_id.clone(), assets); + env.storage().persistent().set(&event_key, &per_event); + } + Ok(()) + } + /// Adds an asset to the global allowed registry. pub fn add_global(env: &Env, asset: &Asset) { let global_key = Symbol::new(env, "allowed_assets_global"); @@ -508,6 +568,65 @@ pub fn validate_token_operation( Ok(()) } +// ===== SAC DECIMALS VERIFICATION ===== + +/// Verifies that a token's declared decimals match the on-chain value. +/// +/// This is a critical security check that prevents denomination mistakes. +/// Real-world on-chain losses have occurred on other Stellar protocols when +/// tokens with mismatched decimals were trusted without verification. +/// +/// # Parameters +/// * `env` - Soroban environment. +/// * `asset` - The asset to verify. Uses the declared decimals value. +/// +/// # Returns +/// * `Ok(())` if the declared decimals match the SAC's decimals() output. +/// * `Err(Error::TokenDecimalsMismatch)` if they don't match. +/// +/// # Cross-Contract Call +/// This function performs a cross-contract call to the token contract's +/// `decimals()` function using the Soroban token interface. +/// +/// # Example +/// ```rust,ignore +/// let asset = Asset::new(token_contract, "USDC".into(), 7); +/// verify_token_decimals(&env, &asset)?; // Verifies on-chain +/// ``` +pub fn verify_token_decimals(env: &Env, asset: &Asset) -> Result<(), Error> { + // Create a token client for cross-contract call + let client = token::Client::new(env, &asset.contract); + + // Call the on-chain decimals() function + let on_chain_decimals: u32 = client.decimals(); + + // Compare with declared decimals + if on_chain_decimals != asset.decimals { + return Err(Error::TokenDecimalsMismatch); + } + + Ok(()) +} + +/// Batch verification of multiple assets' decimals. +/// +/// Useful for verifying all globally allowed assets or market-specific assets +/// during initialization or periodic audits. +/// +/// # Parameters +/// * `env` - Soroban environment. +/// * `assets` - Vector of assets to verify. +/// +/// # Returns +/// * `Ok(())` if all assets pass verification. +/// * `Err(Error::TokenDecimalsMismatch)` if any asset fails (first failure only). +pub fn verify_token_decimals_batch(env: &Env, assets: &Vec) -> Result<(), Error> { + for asset in assets.iter() { + verify_token_decimals(env, &asset)?; + } + Ok(()) +} + #[cfg(test)] mod test { use super::*;