From ddccc12ac9bb6ff816365a71e98c4512b0e0c509 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 26 Jul 2026 03:01:09 -0700 Subject: [PATCH 1/2] feat: 7-day unpause cool-off --- contracts/predictify-hybrid/src/err.rs | 2 + contracts/predictify-hybrid/src/markets.rs | 7 ++++ .../src/tests/oracle_validation_tests.rs | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 456b8583..31265880 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -28,6 +28,8 @@ pub enum Error { Overflow = 672, MaxBetCapExceeded = 673, InvalidCap = 674, + /// The cool-off period is still active. Cannot unpause/resume the market. + CoolOffPeriodActive = 675, // ===== USER OPERATION ERRORS (100-112) ===== /// User is not authorized to perform the requested action. Typically returned when /// a non-admin attempts to call admin-only functions. diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 57c360d7..95c885bb 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -3445,6 +3445,13 @@ impl MarketPauseManager { return Err(Error::InvalidState); } + // Enforce 7-day cool-off period before manual unpausing/resuming + let current_time = env.ledger().timestamp(); + let cool_off_seconds = 7 * 24 * 3600; // 7 days in seconds + if current_time < pause_info.paused_at.saturating_add(cool_off_seconds) { + return Err(Error::CoolOffPeriodActive); + } + env.storage().persistent().remove(&market_id); Self::emit_resume_event(env, market_id, &admin); diff --git a/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs b/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs index 13c28825..06da4ee6 100644 --- a/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs +++ b/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs @@ -306,6 +306,43 @@ fn test_auto_pause_already_paused_is_noop() { }); } +#[test] +fn test_market_unpause_cool_off() { + let env = Env::default(); + let contract_id = env.register_contract(None, crate::PredictifyHybrid); + let market_id = setup_auto_pause_env(&env, &contract_id); + + env.as_contract(&contract_id, || { + let admin: Address = env.storage().persistent().get(&Symbol::new(&env, "Admin")).unwrap(); + + // Initial state: not paused + assert!(!MarketPauseManager::is_market_paused(&env, &market_id).unwrap()); + + // Set initial ledger timestamp + env.ledger().with_mut(|li| li.timestamp = 10_000); + + // Pause the market for 24 hours + MarketPauseManager::pause_market(&env, admin.clone(), &market_id, 24).unwrap(); + assert!(MarketPauseManager::is_market_paused(&env, &market_id).unwrap()); + + // Attempting to resume immediately (0 seconds passed) should fail with CoolOffPeriodActive + let err = MarketPauseManager::resume_market(&env, admin.clone(), &market_id).unwrap_err(); + assert_eq!(err, Error::CoolOffPeriodActive); + + // Fast forward 6 days (518,400 seconds passed) + env.ledger().with_mut(|li| li.timestamp = 10_000 + 6 * 24 * 3600); + let err = MarketPauseManager::resume_market(&env, admin.clone(), &market_id).unwrap_err(); + assert_eq!(err, Error::CoolOffPeriodActive); + + // Fast forward to exactly 7 days (604,800 seconds passed) + env.ledger().with_mut(|li| li.timestamp = 10_000 + 7 * 24 * 3600); + MarketPauseManager::resume_market(&env, admin.clone(), &market_id).unwrap(); + + // Resumed successfully + assert!(!MarketPauseManager::is_market_paused(&env, &market_id).unwrap()); + }); +} + #[test] fn test_auto_pause_config_per_event_override() { let env = Env::default(); From 9f0464d587bfdd8b5407bbbf639ba56b79861b86 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 26 Jul 2026 03:35:48 -0700 Subject: [PATCH 2/2] test: fuzz oracles --- contracts/oracles/Cargo.toml | 5 + contracts/oracles/fuzz/Cargo.toml | 13 ++ contracts/oracles/fuzz/targets/main.rs | 199 ++++++++++++++++++++++++ contracts/oracles/tests/oracle_tests.rs | 195 +++++++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 contracts/oracles/fuzz/Cargo.toml create mode 100644 contracts/oracles/fuzz/targets/main.rs create mode 100644 contracts/oracles/tests/oracle_tests.rs diff --git a/contracts/oracles/Cargo.toml b/contracts/oracles/Cargo.toml index 1c301ea7..8ef3ae41 100644 --- a/contracts/oracles/Cargo.toml +++ b/contracts/oracles/Cargo.toml @@ -17,3 +17,8 @@ soroban-sdk = { workspace = true, features = ["testutils"] } [[test]] name = "err_stab" path = "tests/err_stab.rs" + +[[test]] +name = "oracle_tests" +path = "tests/oracle_tests.rs" + diff --git a/contracts/oracles/fuzz/Cargo.toml b/contracts/oracles/fuzz/Cargo.toml new file mode 100644 index 00000000..6a897856 --- /dev/null +++ b/contracts/oracles/fuzz/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oracles-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata.cargo-fuzz] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +oracles = { path = ".." } +soroban-sdk = { version = "25.0.0", features = ["testutils"] } diff --git a/contracts/oracles/fuzz/targets/main.rs b/contracts/oracles/fuzz/targets/main.rs new file mode 100644 index 00000000..c74550af --- /dev/null +++ b/contracts/oracles/fuzz/targets/main.rs @@ -0,0 +1,199 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{ + contract, contractimpl, Address, Env, String as SorobanString, Vec as SorobanVec, + testutils::Address as _, +}; +use oracles::{OraclesContract, OraclesContractClient, OraclePriceData}; + +/// A mock oracle contract whose behavior is dynamically configurable via instances storage. +#[contract] +pub struct FuzzMockOracle; + +#[contractimpl] +impl FuzzMockOracle { + /// Retrieve the current price, panicking if configured to fail. + pub fn get_price(env: Env, _feed_id: SorobanString) -> i128 { + if env.storage().instance().get(&soroban_sdk::symbol_short!("panic")).unwrap_or(false) { + panic!("oracle failed"); + } + env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("price")) + .unwrap_or(0i128) + } + + /// Retrieve the enriched price metadata, panicking if configured to fail either get_pdata or overall. + pub fn get_pdata(env: Env, _feed_id: SorobanString) -> OraclePriceData { + if env.storage().instance().get(&soroban_sdk::symbol_short!("panic")).unwrap_or(false) || + env.storage().instance().get(&soroban_sdk::symbol_short!("panic_pd")).unwrap_or(false) { + panic!("oracle failed"); + } + let price = env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("price")) + .unwrap_or(0i128); + let publish_time = env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("time")) + .unwrap_or(0u64); + let confidence = env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("conf")) + .unwrap_or(None); + let exponent = env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("exp")) + .unwrap_or(0i32); + + OraclePriceData { + price, + publish_time, + confidence, + exponent, + } + } + + /// Retrieve health status, panicking if configured to fail. + pub fn is_live(env: Env) -> bool { + if env.storage().instance().get(&soroban_sdk::symbol_short!("panic")).unwrap_or(false) { + panic!("oracle failed"); + } + env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("live")) + .unwrap_or(true) + } +} + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, OraclesContract); + let client = OraclesContractClient::new(&env, &contract_id); + + // Pre-register 5 mock oracle addresses. + let mut oracle_addresses = SorobanVec::new(&env); + for _ in 0..5 { + oracle_addresses.push_back(env.register_contract(None, FuzzMockOracle)); + } + + let mut idx = 0; + while idx < data.len() { + let action_type = data[idx] % 5; + idx += 1; + + match action_type { + 0 => { + // AddOracle: registers an oracle and configures its mock behavior. + if idx + 26 > data.len() { + break; + } + let oracle_idx = (data[idx] % 5) as u32; + let price = i128::from_be_bytes(data[idx+1..idx+17].try_into().unwrap()); + let time = u64::from_be_bytes(data[idx+17..idx+25].try_into().unwrap()); + let live = data[idx+25] & 1 != 0; + let panic_all = data[idx+25] & 2 != 0; + let panic_pdata = data[idx+25] & 4 != 0; + idx += 26; + + let oracle_addr = oracle_addresses.get(oracle_idx).unwrap(); + + env.as_contract(&oracle_addr, || { + env.storage().instance().set(&soroban_sdk::symbol_short!("price"), &price); + env.storage().instance().set(&soroban_sdk::symbol_short!("time"), &time); + env.storage().instance().set(&soroban_sdk::symbol_short!("live"), &live); + env.storage().instance().set(&soroban_sdk::symbol_short!("panic"), &panic_all); + env.storage().instance().set(&soroban_sdk::symbol_short!("panic_pd"), &panic_pdata); + + let conf = if price % 2 == 0 { Some(price / 10) } else { None }; + let exp = (price % 10) as i32; + env.storage().instance().set(&soroban_sdk::symbol_short!("conf"), &conf); + env.storage().instance().set(&soroban_sdk::symbol_short!("exp"), &exp); + }); + + let _ = client.add_oracle(&admin, &oracle_addr); + } + 1 => { + // RemoveOracle: removes a mock oracle. + if idx + 1 > data.len() { + break; + } + let oracle_idx = (data[idx] % 5) as u32; + idx += 1; + let oracle_addr = oracle_addresses.get(oracle_idx).unwrap(); + let _ = client.remove_oracle(&admin, &oracle_addr); + } + 2 => { + // GetPrice: queries price for a registered or unregistered oracle. + if idx + 9 > data.len() { + break; + } + let oracle_idx = (data[idx] % 6) as u32; // 0..4 = registered, 5 = unregistered + let mut feed_bytes = [0u8; 8]; + feed_bytes.copy_from_slice(&data[idx+1..idx+9]); + idx += 9; + + let oracle_addr = if oracle_idx < 5 { + oracle_addresses.get(oracle_idx).unwrap() + } else { + Address::generate(&env) + }; + + let feed_str = match std::str::from_utf8(&feed_bytes) { + Ok(s) => SorobanString::from_str(&env, s), + Err(_) => SorobanString::from_str(&env, "BTC/USD"), + }; + + let _ = client.get_price(&oracle_addr, &feed_str); + } + 3 => { + // GetPriceData: queries enriched price data. + if idx + 9 > data.len() { + break; + } + let oracle_idx = (data[idx] % 6) as u32; + let mut feed_bytes = [0u8; 8]; + feed_bytes.copy_from_slice(&data[idx+1..idx+9]); + idx += 9; + + let oracle_addr = if oracle_idx < 5 { + oracle_addresses.get(oracle_idx).unwrap() + } else { + Address::generate(&env) + }; + + let feed_str = match std::str::from_utf8(&feed_bytes) { + Ok(s) => SorobanString::from_str(&env, s), + Err(_) => SorobanString::from_str(&env, "BTC/USD"), + }; + + let _ = client.get_price_data(&oracle_addr, &feed_str); + } + 4 => { + // IsOracleHealthy: queries liveness checks. + if idx + 1 > data.len() { + break; + } + let oracle_idx = (data[idx] % 6) as u32; + idx += 1; + + let oracle_addr = if oracle_idx < 5 { + oracle_addresses.get(oracle_idx).unwrap() + } else { + Address::generate(&env) + }; + + let _ = client.is_oracle_healthy(&oracle_addr); + } + _ => unreachable!(), + } + } +}); diff --git a/contracts/oracles/tests/oracle_tests.rs b/contracts/oracles/tests/oracle_tests.rs new file mode 100644 index 00000000..5e958975 --- /dev/null +++ b/contracts/oracles/tests/oracle_tests.rs @@ -0,0 +1,195 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, Address, Env, String, + testutils::Address as _, +}; +use oracles::{OraclesContract, OraclesContractClient, Error, OraclePriceData}; + +#[contract] +pub struct MockOracle; + +#[contractimpl] +impl MockOracle { + pub fn get_price(_env: Env, _feed_id: String) -> i128 { + 12345 + } + pub fn get_pdata(_env: Env, _feed_id: String) -> OraclePriceData { + OraclePriceData { + price: 12345, + publish_time: 1000, + confidence: Some(10), + exponent: -8, + } + } + pub fn is_live(_env: Env) -> bool { + true + } +} + +#[contract] +pub struct MockOraclePriceOnly; + +#[contractimpl] +impl MockOraclePriceOnly { + pub fn get_price(_env: Env, _feed_id: String) -> i128 { + 54321 + } + pub fn is_live(_env: Env) -> bool { + true + } +} + +#[contract] +pub struct MockOracleUnhealthy; + +#[contractimpl] +impl MockOracleUnhealthy { + pub fn get_price(_env: Env, _feed_id: String) -> i128 { + 0 + } + pub fn is_live(_env: Env) -> bool { + false + } +} + +#[contract] +pub struct MockOracleFailing; + +#[contractimpl] +impl MockOracleFailing { + // Empty implementation to cause dynamic invocations to fail/return None +} + +#[test] +fn test_oracle_lifecycle_and_queries() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, OraclesContract); + let client = OraclesContractClient::new(&env, &contract_id); + + // 1. Initial list should be empty + assert_eq!(client.list_oracles().len(), 0); + + // Register a valid mock oracle + let oracle_addr = env.register_contract(None, MockOracle); + client.add_oracle(&admin, &oracle_addr).unwrap(); + + // Verify it is registered + let list = client.list_oracles(); + assert_eq!(list.len(), 1); + assert_eq!(list.get(0).unwrap(), oracle_addr); + + // Test duplicate registration (should be a no-op) + client.add_oracle(&admin, &oracle_addr).unwrap(); + assert_eq!(client.list_oracles().len(), 1); + + // 2. Query price + let feed = String::from_str(&env, "BTC/USD"); + let price = client.get_price(&oracle_addr, &feed).unwrap(); + assert_eq!(price, 12345); + + // 3. Query price data (using get_pdata) + let pdata = client.get_price_data(&oracle_addr, &feed).unwrap(); + assert_eq!(pdata.price, 12345); + assert_eq!(pdata.publish_time, 1000); + assert_eq!(pdata.confidence, Some(10)); + assert_eq!(pdata.exponent, -8); + + // 4. Check health + assert!(client.is_oracle_healthy(&oracle_addr).unwrap()); + + // 5. Register Price-only oracle + let price_only_addr = env.register_contract(None, MockOraclePriceOnly); + client.add_oracle(&admin, &price_only_addr).unwrap(); + + // Verify list contains both + let list = client.list_oracles(); + assert_eq!(list.len(), 2); + assert_eq!(list.get(1).unwrap(), price_only_addr); + + // Query price from price-only oracle + assert_eq!(client.get_price(&price_only_addr, &feed).unwrap(), 54321); + + // Query price data from price-only oracle (should fallback) + let fallback_pdata = client.get_price_data(&price_only_addr, &feed).unwrap(); + assert_eq!(fallback_pdata.price, 54321); + assert_eq!(fallback_pdata.publish_time, env.ledger().timestamp()); + assert_eq!(fallback_pdata.confidence, None); + assert_eq!(fallback_pdata.exponent, 0); + + // 6. Register Unhealthy oracle + let unhealthy_addr = env.register_contract(None, MockOracleUnhealthy); + client.add_oracle(&admin, &unhealthy_addr).unwrap(); + assert!(!client.is_oracle_healthy(&unhealthy_addr).unwrap()); + + // 7. Register Failing oracle + let failing_addr = env.register_contract(None, MockOracleFailing); + client.add_oracle(&admin, &failing_addr).unwrap(); + + // Failing oracle is not healthy + assert!(!client.is_oracle_healthy(&failing_addr).unwrap()); + + // Failing oracle get_price returns error + let price_res = client.get_price(&failing_addr, &feed); + assert_eq!(price_res.unwrap_err(), Error::OracleUnavailable); + + // Failing oracle get_price_data returns error + let pdata_res = client.get_price_data(&failing_addr, &feed); + assert_eq!(pdata_res.unwrap_err(), Error::OracleUnavailable); + + // 8. Query unregistered oracle + let unregistered_addr = Address::generate(&env); + assert_eq!( + client.get_price(&unregistered_addr, &feed).unwrap_err(), + Error::InvalidOracleConfig + ); + assert_eq!( + client.get_price_data(&unregistered_addr, &feed).unwrap_err(), + Error::InvalidOracleConfig + ); + assert_eq!( + client.is_oracle_healthy(&unregistered_addr).unwrap_err(), + Error::InvalidOracleConfig + ); + + // 9. Remove oracle + client.remove_oracle(&admin, &oracle_addr).unwrap(); + let list = client.list_oracles(); + assert_eq!(list.len(), 3); + assert!(!list.iter().any(|x| x == oracle_addr)); + + // Remove nonexistent oracle (should be a no-op) + client.remove_oracle(&admin, &oracle_addr).unwrap(); + assert_eq!(client.list_oracles().len(), 3); +} + +#[test] +#[should_panic] +fn test_add_oracle_requires_auth() { + let env = Env::default(); + // Do not call mock_all_auths + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let contract_id = env.register_contract(None, OraclesContract); + let client = OraclesContractClient::new(&env, &contract_id); + + // This should panic due to missing authentication/authorization + let _ = client.add_oracle(&admin, &oracle); +} + +#[test] +#[should_panic] +fn test_remove_oracle_requires_auth() { + let env = Env::default(); + // Do not call mock_all_auths + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let contract_id = env.register_contract(None, OraclesContract); + let client = OraclesContractClient::new(&env, &contract_id); + + // This should panic due to missing authentication/authorization + let _ = client.remove_oracle(&admin, &oracle); +}