From 2d90b97e041618d3a6ba26dc24e713ec249fecf9 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Mon, 21 Sep 2026 05:33:47 -0400 Subject: [PATCH] feat: harden oracle quorum, freshness, and canonical price persistence (#1376) --- contracts/predictify-hybrid/src/config.rs | 5 + .../src/event_topic_compat.rs | 1 + .../src/event_topic_compat_tests.rs | 3 + contracts/predictify-hybrid/src/events.rs | 13 + contracts/predictify-hybrid/src/lib.rs | 38 ++ .../src/oracle_hardening_tests.rs | 438 ++++++++++++++++++ contracts/predictify-hybrid/src/oracles.rs | 40 +- contracts/predictify-hybrid/src/resolution.rs | 217 +++++---- contracts/predictify-hybrid/src/types.rs | 33 +- contracts/predictify-hybrid/src/validation.rs | 2 +- 10 files changed, 685 insertions(+), 105 deletions(-) create mode 100644 contracts/predictify-hybrid/src/oracle_hardening_tests.rs diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 1f46cf6c..66230607 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -2314,6 +2314,11 @@ impl ConfigManager { Ok(()) } + /// Validate the contract configuration against all module rules. + pub fn validate_config(_env: &Env, config: &ContractConfig) -> Result<(), Error> { + ConfigValidator::validate_contract_config(config) + } + /// Retrieves the current contract configuration from persistent storage. /// /// This function loads the previously stored contract configuration from diff --git a/contracts/predictify-hybrid/src/event_topic_compat.rs b/contracts/predictify-hybrid/src/event_topic_compat.rs index 144307f8..ad521eaa 100644 --- a/contracts/predictify-hybrid/src/event_topic_compat.rs +++ b/contracts/predictify-hybrid/src/event_topic_compat.rs @@ -120,6 +120,7 @@ pub const TOPIC_REGISTRY: &[(&str, u32, &str)] = &[ ("orc_hlth", 1, "oracle_health"), ("orc_cons", 1, "oracle_consensus"), ("orc_med_q", 1, "oracle_median_queried"), + ("can_price", 1, "canonical_price_updated"), ("ora_deg", 1, "oracle_degraded"), ("ora_rec", 1, "oracle_recovered"), ("fbk_used", 1, "fallback_used"), diff --git a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs index b460c3f8..61d7a647 100644 --- a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs +++ b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs @@ -25,6 +25,9 @@ #![cfg(test)] +extern crate alloc; +use alloc::format; + use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec}; use crate::event_topic_compat::{ diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 8c04bbf0..4ef9ccf9 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -5643,6 +5643,19 @@ impl EventEmitter { ); } + /// Emit canonical price update event when a canonical price is persisted. + pub fn emit_canonical_price_updated( + env: &Env, + market_id: &Symbol, + price: i128, + timestamp: u64, + ) { + env.events().publish( + (symbol_short!("can_price"), market_id.clone()), + (price, timestamp), + ); + } + /// Emit admin override event when an admin manually overrides an oracle-verified result. pub fn emit_admin_override( env: &Env, diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b82600ba..ac7e97da 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -97,6 +97,8 @@ mod market_audit_tests; mod test_audit_trail; #[cfg(test)] mod event_topic_compat_tests; +#[cfg(test)] +mod oracle_hardening_tests; // #[cfg(any())] // mod utils_tests; // THis is the band protocol wasm std_reference.wasm @@ -8595,4 +8597,40 @@ impl PredictifyHybrid { .unwrap_or(0i128) } + /// Retrieve the canonical price for a market. + pub fn get_canonical_price(env: Env, market_id: Symbol) -> Option { + resolution::OracleResolutionManager::get_canonical_price(&env, &market_id) + } + + /// Retrieve the canonical price record for a market. + pub fn get_canonical_price_record( + env: Env, + market_id: Symbol, + ) -> Option { + resolution::OracleResolutionManager::get_canonical_price_record(&env, &market_id) + } + + /// Resolve a market using a three-oracle confidence-weighted median. + pub fn resolve_with_median( + env: Env, + market_id: Symbol, + ) -> Result { + resolution::OracleResolutionManager::resolve_with_median(&env, &market_id) + } + + /// Configure the three-oracle median configuration globally. + pub fn set_median_config( + env: Env, + admin: Address, + config: types::MedianOracleConfig, + ) -> Result<(), Error> { + Self::require_admin_permission(&env, &admin, AdminPermission::UpdateConfig)?; + resolution::OracleResolutionManager::set_median_config(&env, &config) + } + + /// Retrieve the global three-oracle median configuration. + pub fn get_median_config(env: Env) -> Result { + resolution::OracleResolutionManager::get_median_config(&env) + } + } diff --git a/contracts/predictify-hybrid/src/oracle_hardening_tests.rs b/contracts/predictify-hybrid/src/oracle_hardening_tests.rs new file mode 100644 index 00000000..1ad46925 --- /dev/null +++ b/contracts/predictify-hybrid/src/oracle_hardening_tests.rs @@ -0,0 +1,438 @@ +#![cfg(test)] + +use crate::err::Error; +use crate::oracles::OracleValidationConfigManager; +use crate::resolution::{MarketResolutionManager, OracleResolutionManager}; +use crate::types::{ + GlobalOracleValidationConfig, MedianOracleConfig, OraclePriceData, OracleProvider, + OracleQuote, +}; +use crate::PredictifyHybrid; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{symbol_short, Address, Env, String, Symbol, Vec}; + +struct TestFixture { + env: Env, + contract_id: Address, +} + +impl TestFixture { + fn new() -> Self { + let env = Env::default(); + let contract_id = env.register_contract(None, PredictifyHybrid); + Self { env, contract_id } + } +} + +fn build_quote( + provider: OracleProvider, + price: i128, + confidence_bps: u32, + weight_bps: u32, + included: bool, +) -> OracleQuote { + OracleQuote { + provider, + price, + confidence_bps, + weight_bps, + included, + } +} + +#[test] +fn test_stale_feed_cannot_settle() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "stale_test"); + + fixture.env.as_contract(&fixture.contract_id, || { + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_confidence_bps: 500, + max_deviation_bps: None, + max_deviation_z_multiple: None, + history_size: None, + auto_pause_duration_secs: None, + }; + OracleValidationConfigManager::set_global_config(env, &config).unwrap(); + + let stale_data = OraclePriceData { + price: 50_000, + publish_time: 900, + confidence: None, + exponent: 0, + }; + + let result = OracleValidationConfigManager::validate_oracle_data( + env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(env, "BTC/USD"), + &stale_data, + ); + + assert_eq!(result.unwrap_err(), Error::OracleStale); + }); +} + +#[test] +fn test_future_timestamp_rejected() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "future_test"); + + fixture.env.as_contract(&fixture.contract_id, || { + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_confidence_bps: 500, + max_deviation_bps: None, + max_deviation_z_multiple: None, + history_size: None, + auto_pause_duration_secs: None, + }; + OracleValidationConfigManager::set_global_config(env, &config).unwrap(); + + let future_data = OraclePriceData { + price: 50_000, + publish_time: 1050, + confidence: None, + exponent: 0, + }; + + let result = OracleValidationConfigManager::validate_oracle_data( + env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(env, "BTC/USD"), + &future_data, + ); + + assert_eq!(result.unwrap_err(), Error::OracleStale); + }); +} + +#[test] +fn test_non_positive_price_feed_rejected() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "sign_test"); + + fixture.env.as_contract(&fixture.contract_id, || { + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_confidence_bps: 500, + max_deviation_bps: None, + max_deviation_z_multiple: None, + history_size: None, + auto_pause_duration_secs: None, + }; + OracleValidationConfigManager::set_global_config(env, &config).unwrap(); + + let zero_price_data = OraclePriceData { + price: 0, + publish_time: 980, + confidence: None, + exponent: 0, + }; + + let result_zero = OracleValidationConfigManager::validate_oracle_data( + env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(env, "BTC/USD"), + &zero_price_data, + ); + assert_eq!(result_zero.unwrap_err(), Error::InvalidInput); + + let neg_price_data = OraclePriceData { + price: -100, + publish_time: 980, + confidence: None, + exponent: 0, + }; + + let result_neg = OracleValidationConfigManager::validate_oracle_data( + env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(env, "BTC/USD"), + &neg_price_data, + ); + assert_eq!(result_neg.unwrap_err(), Error::InvalidInput); + }); +} + +#[test] +fn test_valid_fresh_feed_accepted() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "valid_feed"); + + fixture.env.as_contract(&fixture.contract_id, || { + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_confidence_bps: 500, + max_deviation_bps: None, + max_deviation_z_multiple: None, + history_size: None, + auto_pause_duration_secs: None, + }; + OracleValidationConfigManager::set_global_config(env, &config).unwrap(); + + let valid_data = OraclePriceData { + price: 52_000, + publish_time: 980, + confidence: None, + exponent: 0, + }; + + let result = OracleValidationConfigManager::validate_oracle_data( + env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(env, "BTC/USD"), + &valid_data, + ); + assert!(result.is_ok()); + }); +} + +#[test] +fn test_quorum_boundaries_insufficient_sources() { + let fixture = TestFixture::new(); + let env = &fixture.env; + + let mut quotes: Vec = Vec::new(env); + quotes.push_back(build_quote(OracleProvider::pyth(), 50_000, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::reflector(), 50_100, 100, 5_000, true)); + + let min_sources: u32 = 3; + let prices = MarketResolutionManager::collect_included_sorted(env, "es); + assert_eq!(prices.len(), 2); + assert!((prices.len() as u32) < min_sources); +} + +#[test] +fn test_quorum_boundaries_exact_quorum() { + let fixture = TestFixture::new(); + let env = &fixture.env; + + let mut quotes: Vec = Vec::new(env); + quotes.push_back(build_quote(OracleProvider::pyth(), 50_000, 100, 3_333, true)); + quotes.push_back(build_quote(OracleProvider::reflector(), 50_100, 100, 3_334, true)); + quotes.push_back(build_quote(OracleProvider::band_protocol(), 50_200, 100, 3_333, true)); + + let min_sources: u32 = 3; + let prices = MarketResolutionManager::collect_included_sorted(env, "es); + assert_eq!(prices.len() as u32, min_sources); + + let median = MarketResolutionManager::weighted_median("es).unwrap(); + assert_eq!(median, 50_100); +} + +#[test] +fn test_outlier_filtering_breaks_quorum() { + let fixture = TestFixture::new(); + let env = &fixture.env; + + let mut quotes: Vec = Vec::new(env); + quotes.push_back(build_quote(OracleProvider::pyth(), 100, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::reflector(), 102, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::band_protocol(), 500, 100, 5_000, true)); + + let min_sources: u32 = 3; + let max_dev_bps: u32 = 500; + + let baseline_prices = MarketResolutionManager::collect_included_sorted(env, "es); + let baseline_median = MarketResolutionManager::simple_median(&baseline_prices); + assert_eq!(baseline_median, 102); + + let mut filtered_quotes: Vec = Vec::new(env); + let mut included_count: u32 = 0; + for q in quotes.iter() { + let mut out = q.clone(); + if out.price <= 0 { + out.included = false; + } else if out.included && baseline_median > 0 { + let abs_diff = if out.price > baseline_median { + out.price.saturating_sub(baseline_median) + } else { + baseline_median.saturating_sub(out.price) + }; + let dev_bps = (abs_diff as u64) + .saturating_mul(10_000) + .saturating_div(baseline_median as u64); + if dev_bps > max_dev_bps as u64 { + out.included = false; + } + } + if out.included { + included_count += 1; + } + filtered_quotes.push_back(out); + } + + assert_eq!(included_count, 2); + assert!(included_count < min_sources); +} + +#[test] +fn test_non_positive_quotes_excluded_from_quorum() { + let fixture = TestFixture::new(); + let env = &fixture.env; + + let mut quotes: Vec = Vec::new(env); + quotes.push_back(build_quote(OracleProvider::pyth(), 100, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::reflector(), 102, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::band_protocol(), 0, 100, 5_000, true)); + quotes.push_back(build_quote(OracleProvider::band_protocol(), -50, 100, 5_000, true)); + + let positive_prices = MarketResolutionManager::collect_included_sorted(env, "es); + assert_eq!(positive_prices.len(), 2); + assert_eq!(positive_prices.get(0).unwrap(), 100); + assert_eq!(positive_prices.get(1).unwrap(), 102); + + let median = MarketResolutionManager::weighted_median("es).unwrap(); + assert_eq!(median, 100); +} + +#[test] +fn test_canonical_price_atomic_persistence() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "canon_test"); + + fixture.env.as_contract(&fixture.contract_id, || { + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + assert_eq!(OracleResolutionManager::get_canonical_price(env, &market_id), None); + assert_eq!(OracleResolutionManager::get_canonical_price_record(env, &market_id), None); + + let outcome = String::from_str(env, "yes"); + OracleResolutionManager::store_canonical_price(env, &market_id, 55_000, 1000, &outcome); + + let price = OracleResolutionManager::get_canonical_price(env, &market_id); + assert_eq!(price, Some(55_000)); + + let record = OracleResolutionManager::get_canonical_price_record(env, &market_id).unwrap(); + assert_eq!(record.price, 55_000); + assert_eq!(record.timestamp, 1000); + assert_eq!(record.outcome, outcome); + + let val_res = OracleResolutionManager::validate_canonical_price_before_settlement(env, &market_id); + assert!(val_res.is_ok()); + }); +} + +#[test] +fn test_outage_missing_canonical_price_fails_closed() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "outage_missing"); + + fixture.env.as_contract(&fixture.contract_id, || { + let result = OracleResolutionManager::validate_canonical_price_before_settlement(env, &market_id); + assert_eq!(result.unwrap_err(), Error::OracleUnavailable); + }); +} + +#[test] +fn test_outage_non_positive_canonical_price_fails_closed() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "outage_zero"); + + fixture.env.as_contract(&fixture.contract_id, || { + let outcome = String::from_str(env, "no"); + OracleResolutionManager::store_canonical_price(env, &market_id, 0, 1000, &outcome); + + let result = OracleResolutionManager::validate_canonical_price_before_settlement(env, &market_id); + assert_eq!(result.unwrap_err(), Error::InvalidInput); + }); +} + +#[test] +fn test_outage_recovery_path() { + let fixture = TestFixture::new(); + let env = &fixture.env; + let market_id = Symbol::new(env, "recovery_mkt"); + + fixture.env.as_contract(&fixture.contract_id, || { + let failure_before = OracleResolutionManager::validate_canonical_price_before_settlement(env, &market_id); + assert_eq!(failure_before.unwrap_err(), Error::OracleUnavailable); + + let outcome = String::from_str(env, "yes"); + OracleResolutionManager::store_canonical_price(env, &market_id, 62_000, 1000, &outcome); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let success_after = OracleResolutionManager::validate_canonical_price_before_settlement(env, &market_id); + assert!(success_after.is_ok()); + assert_eq!(OracleResolutionManager::get_canonical_price(env, &market_id), Some(62_000)); + }); +} + +#[test] +fn test_set_median_config_boundaries() { + let fixture = TestFixture::new(); + let env = &fixture.env; + + fixture.env.as_contract(&fixture.contract_id, || { + let dummy_addr = Address::generate(env); + + let zero_sources_cfg = MedianOracleConfig { + pyth_address: dummy_addr.clone(), + reflector_address: dummy_addr.clone(), + band_address: dummy_addr.clone(), + min_sources: 0, + max_deviation_bps: 500, + }; + let err_zero = OracleResolutionManager::set_median_config(env, &zero_sources_cfg); + assert_eq!(err_zero.unwrap_err(), Error::InvalidOracleConfig); + + let excess_deviation_cfg = MedianOracleConfig { + pyth_address: dummy_addr.clone(), + reflector_address: dummy_addr.clone(), + band_address: dummy_addr.clone(), + min_sources: 2, + max_deviation_bps: 10_001, + }; + let err_dev = OracleResolutionManager::set_median_config(env, &excess_deviation_cfg); + assert_eq!(err_dev.unwrap_err(), Error::InvalidOracleConfig); + + let valid_cfg = MedianOracleConfig { + pyth_address: dummy_addr.clone(), + reflector_address: dummy_addr.clone(), + band_address: dummy_addr.clone(), + min_sources: 2, + max_deviation_bps: 500, + }; + let ok_res = OracleResolutionManager::set_median_config(env, &valid_cfg); + assert!(ok_res.is_ok()); + + let retrieved = OracleResolutionManager::get_median_config(env).unwrap(); + assert_eq!(retrieved.min_sources, 2); + assert_eq!(retrieved.max_deviation_bps, 500); + }); +} diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index ec62e77d..f0184e06 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -3012,14 +3012,18 @@ impl OracleValidationConfigManager { let now = env.ledger().timestamp(); let observed_age = now.saturating_sub(data.publish_time); - if observed_age > config.max_staleness_secs { + if data.publish_time > now || observed_age > config.max_staleness_secs { EventEmitter::emit_oracle_validation_failed( env, market_id, - &provider.name(), + &provider.name_with_env(env), feed_id, &String::from_str(env, "stale_data"), - observed_age, + if data.publish_time > now { + data.publish_time.saturating_sub(now) + } else { + observed_age + }, config.max_staleness_secs, None, config.max_confidence_bps, @@ -3032,6 +3036,21 @@ impl OracleValidationConfigManager { return Err(Error::OracleStale); } + if data.price <= 0 { + EventEmitter::emit_oracle_validation_failed( + env, + market_id, + &provider.name_with_env(env), + feed_id, + &String::from_str(env, "invalid_sign"), + observed_age, + config.max_staleness_secs, + None, + config.max_confidence_bps, + ); + return Err(Error::InvalidInput); + } + if *provider == OracleProvider::pyth() { if let Some(confidence) = data.confidence { let price_abs = if data.price < 0 { @@ -3055,7 +3074,7 @@ impl OracleValidationConfigManager { EventEmitter::emit_oracle_validation_failed( env, market_id, - &provider.name(), + &provider.name_with_env(env), feed_id, &String::from_str(env, "confidence_too_wide"), observed_age, @@ -3104,7 +3123,7 @@ impl OracleValidationConfigManager { EventEmitter::emit_oracle_validation_failed( env, market_id, - &provider.name(), + &provider.name_with_env(env), feed_id, &String::from_str(env, "rolling_median_outlier"), observed_age, @@ -3144,7 +3163,7 @@ impl OracleValidationConfigManager { EventEmitter::emit_oracle_validation_failed( env, market_id, - &provider.name(), + &provider.name_with_env(env), feed_id, &String::from_str(env, "price_deviation_exceeded"), observed_age, @@ -3404,7 +3423,7 @@ impl OracleIntegrationManager { oracle_result.price, oracle_result.threshold, &oracle_result.comparison, - &oracle_result.provider.name(), + &oracle_result.provider.name_with_env(env), &oracle_result.feed_id, oracle_result.confidence_score, oracle_result.sources_count, @@ -3699,6 +3718,13 @@ impl OracleIntegrationManager { &OracleIntegrationKey::OracleResult(market_id.clone()), result, ); + crate::resolution::OracleResolutionManager::store_canonical_price( + env, + market_id, + result.price, + result.timestamp, + &result.outcome, + ); Ok(()) } diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index c76e7a02..352b500b 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -384,7 +384,13 @@ impl OracleResolutionManager { } }; - // Create oracle resolution record + if price <= 0 { + return Err(Error::InvalidInput); + } + + Self::store_canonical_price(env, market_id, price, current_time, &outcome); + Self::validate_canonical_price_before_settlement(env, market_id)?; + let resolution = OracleResolution { market_id: market_id.clone(), oracle_result: outcome.clone(), @@ -396,11 +402,9 @@ impl OracleResolutionManager { feed_id: used_config.feed_id.clone(), }; - // Store the result in the market MarketStateManager::set_oracle_result(&mut market, outcome.clone()); MarketStateManager::update_market(env, market_id, &market); - // Emit oracle result event let provider_str = match used_config.provider { OracleProvider::Reflector => soroban_sdk::String::from_str(env, "Reflector"), OracleProvider::Pyth => soroban_sdk::String::from_str(env, "Pyth"), @@ -423,13 +427,74 @@ impl OracleResolutionManager { Ok(resolution) } + /// Store canonical price record atomically for a market. + pub fn store_canonical_price( + env: &Env, + market_id: &Symbol, + price: i128, + timestamp: u64, + outcome: &String, + ) { + let record = crate::types::CanonicalPriceRecord { + price, + timestamp, + outcome: outcome.clone(), + }; + env.storage() + .persistent() + .set(&(symbol_short!("can_price"), market_id.clone()), &record); + crate::events::EventEmitter::emit_canonical_price_updated( + env, + market_id, + price, + timestamp, + ); + } + + /// Retrieve canonical price record for a market. + pub fn get_canonical_price_record( + env: &Env, + market_id: &Symbol, + ) -> Option { + env.storage() + .persistent() + .get(&(symbol_short!("can_price"), market_id.clone())) + } + + /// Retrieve canonical price for a market. + pub fn get_canonical_price(env: &Env, market_id: &Symbol) -> Option { + Self::get_canonical_price_record(env, market_id).map(|record| record.price) + } + + /// Validate that the canonical price is present, non-stale, and valid before market settlement. + pub fn validate_canonical_price_before_settlement( + env: &Env, + market_id: &Symbol, + ) -> Result<(), Error> { + let record = Self::get_canonical_price_record(env, market_id) + .ok_or(Error::OracleUnavailable)?; + if record.price <= 0 { + return Err(Error::InvalidInput); + } + let now = env.ledger().timestamp(); + if record.timestamp > now { + return Err(Error::OracleStale); + } + let cfg = crate::oracles::OracleValidationConfigManager::get_effective_config( + env, market_id, + ); + let age = now.saturating_sub(record.timestamp); + if age > cfg.max_staleness_secs { + return Err(Error::OracleStale); + } + Ok(()) + } + /// Get oracle resolution for a market pub fn get_oracle_resolution( _env: &Env, _market_id: &Symbol, ) -> Result, Error> { - // For now, return None since we don't store complex types in storage - // In a real implementation, you would store this in a more sophisticated way Ok(None) } @@ -878,10 +943,14 @@ impl OracleResolutionManager { /// # Arguments /// - `env` – Soroban environment. /// - `config` – [`MedianOracleConfig`] to store globally. - pub fn set_median_config(env: &Env, config: &MedianOracleConfig) { + pub fn set_median_config(env: &Env, config: &MedianOracleConfig) -> Result<(), Error> { + if config.min_sources < 1 || config.max_deviation_bps > 10_000 { + return Err(Error::InvalidOracleConfig); + } env.storage() .persistent() .set(&symbol_short!("med_cfg"), config); + Ok(()) } /// Load the three-oracle median configuration from contract storage. @@ -974,24 +1043,15 @@ impl OracleResolutionManager { // ── 2. Load median config ──────────────────────────────────────── let med_cfg = Self::get_median_config(env)?; + if med_cfg.min_sources < 1 { + return Err(Error::InvalidOracleConfig); + } let feed_id = market.oracle_config.feed_id.clone(); let threshold = market.oracle_config.threshold; let comparison = market.oracle_config.comparison.clone(); - // ── 3. Fetch from all three oracles sequentially (fail-closed) ──────── - // - // Invariant: if any oracle that responds with price data returns data - // that is stale (age > max_staleness_secs), we abort the entire - // resolution with OracleStale rather than silently excluding the - // stale quote. This prevents resolution from proceeding on fewer - // sources than intended when staleness is the cause of exclusion. - // - // Oracles that are offline/unavailable (non-stale failures) are still - // excluded gracefully so that the min_sources check can determine - // whether enough fresh sources exist. let mut raw_quotes: Vec = Vec::new(env); - // Pyth (currently OracleUnavailable on Stellar; will be excluded gracefully). { let oracle = crate::oracles::PythOracle::new(med_cfg.pyth_address.clone()); raw_quotes.push_back(Self::fetch_quote_fail_closed( @@ -1002,7 +1062,6 @@ impl OracleResolutionManager { &feed_id, )?); } - // Reflector – primary Stellar oracle. { let oracle = crate::oracles::ReflectorOracle::new(med_cfg.reflector_address.clone()); raw_quotes.push_back(Self::fetch_quote_fail_closed( @@ -1013,7 +1072,6 @@ impl OracleResolutionManager { &feed_id, )?); } - // Band Protocol. { let oracle = crate::oracles::BandProtocolOracle::new(med_cfg.band_address.clone()); raw_quotes.push_back(Self::fetch_quote_fail_closed( @@ -1025,7 +1083,6 @@ impl OracleResolutionManager { )?); } - // ── 4. Unweighted baseline median for outlier detection ───────────── let baseline_prices = Self::collect_included_sorted(env, &raw_quotes); let initial_count = baseline_prices.len() as u32; if initial_count < med_cfg.min_sources { @@ -1033,31 +1090,30 @@ impl OracleResolutionManager { } let baseline_median = Self::simple_median(&baseline_prices); - // ── 5. Mark outliers ───────────────────────────────────────────────── let mut final_quotes: Vec = Vec::new(env); for q in raw_quotes.iter() { let mut out = q.clone(); - if out.included && baseline_median > 0 { + if out.price <= 0 { + out.included = false; + } else if out.included && baseline_median > 0 { let abs_diff: i128 = if out.price > baseline_median { out.price.saturating_sub(baseline_median) } else { baseline_median.saturating_sub(out.price) }; - // deviation_bps = |price - median| * 10_000 / median let deviation_bps: u64 = (abs_diff as u64) .saturating_mul(10_000) .saturating_div(baseline_median as u64); if deviation_bps > med_cfg.max_deviation_bps as u64 { - out.included = false; // Outlier: exclude from weighted median. + out.included = false; } } final_quotes.push_back(out); } - // ── 6. Enforce minimum source count ──────────────────────────────── let mut included_count: u32 = 0; for q in final_quotes.iter() { - if q.included { + if q.included && q.price > 0 { included_count += 1; } } @@ -1065,34 +1121,34 @@ impl OracleResolutionManager { return Err(Error::OracleNoConsensus); } - // ── 7. Confidence-weighted median ──────────────────────────────────── let weighted_median = Self::weighted_median(&final_quotes)?; + if weighted_median <= 0 { + return Err(Error::OracleNoConsensus); + } - // ── 8. Outcome determination ──────────────────────────────────────── let outcome = OracleUtils::determine_outcome(weighted_median, threshold, &comparison, env)?; - // ── 9. Persist oracle result and emit events ────────────────────── + Self::store_canonical_price(env, market_id, weighted_median, current_time, &outcome); + Self::validate_canonical_price_before_settlement(env, market_id)?; + MarketStateManager::set_oracle_result(&mut market, outcome.clone()); MarketStateManager::update_market(env, market_id, &market); - // Compute aggregate statistics for the consensus event. let avg_price = Self::average_included_price(&final_quotes); let price_var = Self::price_variance(&final_quotes, avg_price); let confidence_score = Self::aggregate_confidence(included_count, &final_quotes); - // Standard OracleConsensusReachedEvent for backward-compatible monitoring. crate::events::EventEmitter::emit_oracle_consensus_reached( env, market_id, &outcome, included_count, - 3, // total oracle sources attempted + 3, avg_price, price_var, ); - // Per-oracle detail event with the full quote vector. crate::events::EventEmitter::emit_oracle_median_quotes(env, market_id, &final_quotes); Ok(MedianResolutionResult { @@ -1190,16 +1246,19 @@ impl OracleResolutionManager { let cfg = crate::oracles::OracleValidationConfigManager::get_effective_config( env, market_id, ); - if observed_age > cfg.max_staleness_secs { - // Emit a validation-failed event so operators can detect - // which oracle was stale without reading contract state. + if data.publish_time > now || observed_age > cfg.max_staleness_secs { + let failure_age = if data.publish_time > now { + data.publish_time.saturating_sub(now) + } else { + observed_age + }; crate::events::EventEmitter::emit_oracle_validation_failed( env, market_id, &provider.name(), feed_id, &soroban_sdk::String::from_str(env, "stale_data"), - observed_age, + failure_age, cfg.max_staleness_secs, None, cfg.max_confidence_bps, @@ -1299,12 +1358,11 @@ impl OracleResolutionManager { let mut buf: [i128; 3] = [0; 3]; let mut n: usize = 0; for q in quotes.iter() { - if q.included && n < 3 { + if q.included && q.price > 0 && n < 3 { buf[n] = q.price; n += 1; } } - // Bubble-sort the first n elements (n ≤ 3, so O(n²) is negligible). for i in 0..n { for j in 0..n.saturating_sub(i + 1) { if buf[j] > buf[j + 1] { @@ -1312,7 +1370,6 @@ impl OracleResolutionManager { } } } - // Build Soroban Vec. let mut result: Vec = Vec::new(env); for i in 0..n { result.push_back(buf[i]); @@ -1320,13 +1377,7 @@ impl OracleResolutionManager { result } - /// Compute the unweighted simple median of a **sorted** price list. - /// - /// For an odd number of elements the true middle value is returned. - /// For an even number the arithmetic mean of the two middle values is - /// returned (integer truncation; acceptable precision for outlier - /// detection on typical oracle prices). - /// Returns 0 for an empty list (callers always guard with `min_sources`). + /// Compute the unweighted simple median of a sorted price list. fn simple_median(sorted: &Vec) -> i128 { let n = sorted.len() as usize; if n == 0 { @@ -1337,26 +1388,16 @@ impl OracleResolutionManager { } else { let lo = sorted.get((n / 2 - 1) as u32).unwrap_or(0); let hi = sorted.get((n / 2) as u32).unwrap_or(0); - // Overflow-safe average: avoids (lo + hi) overflow for large prices. (lo / 2) + (hi / 2) + ((lo % 2 + hi % 2) / 2) } } /// Compute the confidence-weighted median of the included quotes. - /// - /// Sorts the `(price, weight)` pairs ascending (using a fixed array so - /// no heap allocation is needed), then walks from the lowest price - /// upward accumulating weights until the cumulative weight first reaches - /// ⌈ total / 2 ⌉. The price at that point is the weighted median. - /// - /// # Errors - /// Returns [`Error::OracleNoConsensus`] when no included quotes exist. fn weighted_median(quotes: &Vec) -> Result { - // Collect at most 3 (price, weight) pairs. let mut pairs: [(i128, u32); 3] = [(0, 0); 3]; let mut n: usize = 0; for q in quotes.iter() { - if q.included && n < 3 { + if q.included && q.price > 0 && n < 3 { pairs[n] = (q.price, q.weight_bps.max(1)); n += 1; } @@ -1364,7 +1405,6 @@ impl OracleResolutionManager { if n == 0 { return Err(Error::OracleNoConsensus); } - // Insertion sort by price ascending. for i in 1..n { let mut j = i; while j > 0 && pairs[j - 1].0 > pairs[j].0 { @@ -1372,12 +1412,11 @@ impl OracleResolutionManager { j -= 1; } } - // Accumulate weights until ⌈ total / 2 ⌉ is reached. let mut total: u64 = 0; for i in 0..n { total = total.saturating_add(pairs[i].1 as u64); } - let half: u64 = (total + 1) / 2; // ceiling division + let half: u64 = (total + 1) / 2; let mut cumulative: u64 = 0; let mut result: i128 = 0; for i in 0..n { @@ -1387,17 +1426,18 @@ impl OracleResolutionManager { break; } } + if result <= 0 { + return Err(Error::OracleNoConsensus); + } Ok(result) } /// Arithmetic mean price of all included quotes. - /// Used to populate the `average_price` field of - /// [`OracleConsensusReachedEvent`]. fn average_included_price(quotes: &Vec) -> i128 { let mut sum: i128 = 0; let mut count: u32 = 0; for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { sum = sum.saturating_add(q.price); count += 1; } @@ -1410,17 +1450,11 @@ impl OracleResolutionManager { } /// Integer proxy for price variance among included quotes. - /// - /// Computes the mean of squared deviations from `avg`, scaling each - /// squared term down by 10 000 before accumulating to keep the value - /// within i128 range for typical oracle prices (up to ~10¹³ base units). - /// Used to populate the `price_variance` field of - /// [`OracleConsensusReachedEvent`]. fn price_variance(quotes: &Vec, avg: i128) -> i128 { let mut sum_sq: i128 = 0; let mut count: u32 = 0; for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { let diff = q.price.saturating_sub(avg); sum_sq = sum_sq .saturating_add(diff.saturating_mul(diff).saturating_div(10_000)); @@ -1675,13 +1709,18 @@ impl MarketResolutionManager { } validation?; - // Retrieve the oracle result let oracle_result = market .oracle_result .as_ref() .ok_or(Error::OracleUnavailable)? .clone(); + if let Some(record) = OracleResolutionManager::get_canonical_price_record(env, market_id) { + if record.price <= 0 { + return Err(Error::InvalidInput); + } + } + // Calculate community consensus let community_consensus = MarketAnalytics::calculate_community_consensus(&market); @@ -1910,7 +1949,7 @@ impl MarketResolutionManager { pub fn collect_included_sorted(env: &Env, quotes: &Vec) -> Vec { let mut prices: alloc::vec::Vec = alloc::vec::Vec::new(); for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { prices.push(q.price); } } @@ -1933,7 +1972,7 @@ impl MarketResolutionManager { pub fn weighted_median(quotes: &Vec) -> Result { let mut included: alloc::vec::Vec = alloc::vec::Vec::new(); for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { included.push(q); } } @@ -1962,7 +2001,7 @@ impl MarketResolutionManager { let mut sum: i128 = 0; let mut count: i128 = 0; for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { sum += q.price; count += 1; } @@ -1983,7 +2022,7 @@ impl MarketResolutionManager { let mut sum_sq: i128 = 0; let mut count: i128 = 0; for q in quotes.iter() { - if q.included { + if q.included && q.price > 0 { let diff = (q.price - mean).abs(); sum_sq += diff * diff / 10_000; count += 1; @@ -2031,18 +2070,28 @@ impl MarketResolutionManager { /// Store the median oracle configuration in persistent storage. #[allow(dead_code)] pub fn set_median_config(env: &Env, config: &MedianOracleConfig) { - env.storage() - .persistent() - .set(&Symbol::new(env, "MEDIAN_CFG"), config); + let _ = OracleResolutionManager::set_median_config(env, config); } /// Retrieve the median oracle configuration from persistent storage. #[allow(dead_code)] pub fn get_median_config(env: &Env) -> Result { - env.storage() - .persistent() - .get(&Symbol::new(env, "MEDIAN_CFG")) - .ok_or(Error::ConfigNotFound) + OracleResolutionManager::get_median_config(env) + } + + /// Retrieve the canonical price for a market. + #[allow(dead_code)] + pub fn get_canonical_price(env: &Env, market_id: &Symbol) -> Option { + OracleResolutionManager::get_canonical_price(env, market_id) + } + + /// Retrieve the canonical price record for a market. + #[allow(dead_code)] + pub fn get_canonical_price_record( + env: &Env, + market_id: &Symbol, + ) -> Option { + OracleResolutionManager::get_canonical_price_record(env, market_id) } } diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 9a6626a5..e35f4bcf 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -569,19 +569,17 @@ impl OracleProvider { /// ``` pub fn name(&self) -> String { let env = soroban_sdk::Env::default(); + self.name_with_env(&env) + } + + /// Returns the provider name using the given environment. + pub fn name_with_env(&self, env: &Env) -> String { match self.as_str() { - "reflector" => String::from_str(&env, "Reflector"), - "pyth" => String::from_str(&env, "Pyth Network"), - "band_protocol" => String::from_str(&env, "Band Protocol"), - "dia" => String::from_str(&env, "DIA"), - unknown => { - let prefix = String::from_str(&env, "Unknown Provider ("); - let suffix = String::from_str(&env, ")"); - // Use string slicing for soroban_sdk::String - let result = prefix.clone(); - // For simplicity, just return a basic message for unknown providers - String::from_str(&env, "Unknown Provider") - } + "reflector" => String::from_str(env, "Reflector"), + "pyth" => String::from_str(env, "Pyth Network"), + "band_protocol" => String::from_str(env, "Band Protocol"), + "dia" => String::from_str(env, "DIA"), + _ => String::from_str(env, "Unknown Provider"), } } @@ -2245,6 +2243,15 @@ pub struct MedianOracleConfig { pub min_sources: u32, } +/// Canonical price record persisted atomically before settlement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalPriceRecord { + pub price: i128, + pub timestamp: u64, + pub outcome: String, +} + /// Oracle source configuration for multi-oracle support. /// /// Defines a single oracle source with its configuration, weight, and status. @@ -4079,7 +4086,7 @@ impl Bet { /// println!("Unique bettors: {}", stats.unique_bettors); /// ``` #[contracttype] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct BetStats { /// Total number of bets placed on this market pub total_bets: u32, diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 6c50d534..b119976c 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -5704,7 +5704,7 @@ impl ContractInitializationValidator { .map_err(|_| Error::InvalidDuration)?; // Oracle configuration must be internally consistent before storage. - OracleValidator::validate_oracle_config_all_together(oracle_config) + OracleConfigValidator::validate_oracle_config_all_together(oracle_config) .map_err(|_| Error::InvalidOracleConfig)?; Ok(())