From 88c148250d2ea96d57d4124e02d6a57baf23bcd8 Mon Sep 17 00:00:00 2001 From: Predictify Developer Date: Sun, 26 Jul 2026 13:35:23 +0100 Subject: [PATCH] feat: oracle staleness guard Add ledger-based staleness validation to reject oracle inputs older than max_age_ledgers. Changes: - Add publish_ledger field to OraclePriceData struct - Add max_age_ledgers field to GlobalOracleValidationConfig and EventOracleValidationConfig - Add DEFAULT_MAX_AGE_LEDGERS constant (10 ledgers) - Add ledger-based staleness check in validate_oracle_data function - Update oracle implementations to capture ledger sequence - Update public API functions to include max_age_ledgers parameter - Add focused tests for ledger staleness guard - Update all existing tests to include new fields --- contracts/predictify-hybrid/src/lib.rs | 5 + contracts/predictify-hybrid/src/oracles.rs | 157 +++++++++++++++++- .../tests/oracle_rolling_deviation_tests.rs | 1 + contracts/predictify-hybrid/src/types.rs | 6 + 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..b4b7068d 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -3886,6 +3886,7 @@ impl PredictifyHybrid { /// Set global oracle validation config (admin only). /// /// - `max_staleness_secs`: maximum allowed age in seconds. + /// - `max_age_ledgers`: maximum allowed age in ledgers. /// - `max_confidence_bps`: maximum confidence interval in basis points. /// Per-event overrides, if set, take precedence over this global config. /// @@ -3900,6 +3901,7 @@ impl PredictifyHybrid { env: Env, admin: Address, max_staleness_secs: u64, + max_age_ledgers: u32, max_confidence_bps: u32, max_deviation_bps: Option, ) -> Result<(), Error> { @@ -3907,6 +3909,7 @@ impl PredictifyHybrid { let config = GlobalOracleValidationConfig { max_staleness_secs, + max_age_ledgers, max_confidence_bps, max_deviation_bps, max_deviation_z_multiple: None, @@ -3941,6 +3944,7 @@ impl PredictifyHybrid { admin: Address, market_id: Symbol, max_staleness_secs: u64, + max_age_ledgers: u32, max_confidence_bps: u32, max_deviation_bps: Option, ) -> Result<(), Error> { @@ -3948,6 +3952,7 @@ impl PredictifyHybrid { let config = EventOracleValidationConfig { max_staleness_secs, + max_age_ledgers, max_confidence_bps, max_deviation_bps, max_deviation_z_multiple: None, diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index 3cef0de9..1445d074 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -105,6 +105,7 @@ pub trait OracleInterface { Ok(OraclePriceData { price, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }) @@ -896,6 +897,7 @@ impl OracleInterface for ReflectorOracle { return Ok(OraclePriceData { price: price_data.price, publish_time: price_data.timestamp, + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }); @@ -905,6 +907,7 @@ impl OracleInterface for ReflectorOracle { Ok(OraclePriceData { price, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }) @@ -2468,6 +2471,8 @@ pub struct OracleValidationConfigManager; impl OracleValidationConfigManager { /// Default maximum data staleness (60 seconds) const DEFAULT_MAX_STALENESS_SECS: u64 = 60; + /// Default maximum age in ledgers (10 ledgers) + const DEFAULT_MAX_AGE_LEDGERS: u32 = 10; /// Default maximum confidence interval (5% = 500 bps) const DEFAULT_MAX_CONFIDENCE_BPS: u32 = 500; /// Maximum allowed confidence interval (100% = 10_000 bps) @@ -2480,6 +2485,7 @@ impl OracleValidationConfigManager { .get(&OracleValidationKey::GlobalConfig) .unwrap_or_else(|| GlobalOracleValidationConfig { max_staleness_secs: Self::DEFAULT_MAX_STALENESS_SECS, + max_age_ledgers: Self::DEFAULT_MAX_AGE_LEDGERS, max_confidence_bps: Self::DEFAULT_MAX_CONFIDENCE_BPS, max_deviation_bps: None, max_deviation_z_multiple: None, @@ -2494,6 +2500,7 @@ impl OracleValidationConfigManager { ) -> Result<(), Error> { Self::validate_config_values( config.max_staleness_secs, + config.max_age_ledgers, config.max_confidence_bps, config.max_deviation_bps, config.max_deviation_z_multiple, @@ -2522,6 +2529,7 @@ impl OracleValidationConfigManager { ) -> Result<(), Error> { Self::validate_config_values( config.max_staleness_secs, + config.max_age_ledgers, config.max_confidence_bps, config.max_deviation_bps, config.max_deviation_z_multiple, @@ -2543,6 +2551,7 @@ impl OracleValidationConfigManager { if let Some(event_cfg) = Self::get_event_config(env, market_id) { GlobalOracleValidationConfig { max_staleness_secs: event_cfg.max_staleness_secs, + max_age_ledgers: event_cfg.max_age_ledgers, max_confidence_bps: event_cfg.max_confidence_bps, max_deviation_bps: event_cfg.max_deviation_bps, max_deviation_z_multiple: event_cfg.max_deviation_z_multiple, @@ -2608,7 +2617,9 @@ impl OracleValidationConfigManager { let config = Self::get_effective_config(env, market_id); let now = env.ledger().timestamp(); + let current_ledger = env.ledger().sequence(); let observed_age = now.saturating_sub(data.publish_time); + let observed_ledger_age = current_ledger.saturating_sub(data.publish_ledger); if observed_age > config.max_staleness_secs { EventEmitter::emit_oracle_validation_failed( @@ -2625,6 +2636,21 @@ impl OracleValidationConfigManager { return Err(Error::OracleStale); } + if observed_ledger_age > config.max_age_ledgers { + EventEmitter::emit_oracle_validation_failed( + env, + market_id, + &provider.name(), + feed_id, + &String::from_str(env, "stale_ledger_data"), + observed_ledger_age as u64, + config.max_age_ledgers as u64, + None, + config.max_confidence_bps, + ); + return Err(Error::OracleStale); + } + if *provider == OracleProvider::pyth() { if let Some(confidence) = data.confidence { let price_abs = if data.price < 0 { @@ -2766,11 +2792,12 @@ impl OracleValidationConfigManager { fn validate_config_values( max_staleness_secs: u64, + max_age_ledgers: u32, max_confidence_bps: u32, max_deviation_bps: Option, max_deviation_z_multiple: Option, ) -> Result<(), Error> { - if max_staleness_secs == 0 || max_confidence_bps == 0 { + if max_staleness_secs == 0 || max_age_ledgers == 0 || max_confidence_bps == 0 { return Err(Error::InvalidInput); } if max_confidence_bps > Self::MAX_CONFIDENCE_BPS { @@ -3484,6 +3511,7 @@ mod oracle_integration_tests { }); let config = GlobalOracleValidationConfig { max_staleness_secs: 10, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3492,6 +3520,7 @@ mod oracle_integration_tests { let data = OraclePriceData { price: 100_00, publish_time: env.ledger().timestamp().saturating_sub(11), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3517,6 +3546,107 @@ mod oracle_integration_tests { }); } + #[test] + fn test_oracle_validation_stale_ledger_rejected() { + let env = Env::default(); + let contract_id = env.register_contract(None, crate::PredictifyHybrid); + let market_id = Symbol::new(&env, "stale_ledger_market"); + + env.as_contract(&contract_id, || { + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + max_temp_entry_ttl: 100, + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_age_ledgers: 10, + max_confidence_bps: 500, + max_deviation_bps: None, + }; + OracleValidationConfigManager::set_global_config(&env, &config).unwrap(); + + // Data from ledger 85 (15 ledgers old, exceeds max_age_ledgers of 10) + let data = OraclePriceData { + price: 100_00, + publish_time: env.ledger().timestamp(), + publish_ledger: 85, + confidence: None, + exponent: 0, + }; + + let result = OracleValidationConfigManager::validate_oracle_data( + &env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(&env, "BTC/USD"), + &data, + ); + + assert_eq!(result.unwrap_err(), Error::OracleStale); + + let event: OracleValidationFailedEvent = env + .storage() + .persistent() + .get(&symbol_short!("orc_val")) + .unwrap(); + assert_eq!(event.reason, String::from_str(&env, "stale_ledger_data")); + assert_eq!(event.observed_age_secs, 15); + assert_eq!(event.max_age_secs, 10); + }); + } + + #[test] + fn test_oracle_validation_fresh_ledger_accepted() { + let env = Env::default(); + let contract_id = env.register_contract(None, crate::PredictifyHybrid); + let market_id = Symbol::new(&env, "fresh_ledger_market"); + + env.as_contract(&contract_id, || { + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + max_temp_entry_ttl: 100, + }); + + let config = GlobalOracleValidationConfig { + max_staleness_secs: 60, + max_age_ledgers: 10, + max_confidence_bps: 500, + max_deviation_bps: None, + }; + OracleValidationConfigManager::set_global_config(&env, &config).unwrap(); + + // Data from ledger 95 (5 ledgers old, within max_age_ledgers of 10) + let data = OraclePriceData { + price: 100_00, + publish_time: env.ledger().timestamp(), + publish_ledger: 95, + confidence: None, + exponent: 0, + }; + + let result = OracleValidationConfigManager::validate_oracle_data( + &env, + &market_id, + &OracleProvider::reflector(), + &String::from_str(&env, "BTC/USD"), + &data, + ); + + assert!(result.is_ok()); + }); + } + #[test] fn test_oracle_validation_confidence_too_wide_rejected() { let env = Env::default(); @@ -3526,6 +3656,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3534,6 +3665,7 @@ mod oracle_integration_tests { let data = OraclePriceData { price: 1_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: Some(100_00), // 10% confidence interval exponent: 0, }; @@ -3568,6 +3700,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3576,6 +3709,7 @@ mod oracle_integration_tests { let data = OraclePriceData { price: 1_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: Some(20_00), // 2% exponent: 0, }; @@ -3604,6 +3738,7 @@ mod oracle_integration_tests { }); let global = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3611,6 +3746,7 @@ mod oracle_integration_tests { let event_cfg = EventOracleValidationConfig { max_staleness_secs: 5, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3619,6 +3755,7 @@ mod oracle_integration_tests { let data = OraclePriceData { price: 1_000_00, publish_time: env.ledger().timestamp().saturating_sub(10), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3663,6 +3800,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: Some(500), // 5% }; @@ -3671,6 +3809,7 @@ mod oracle_integration_tests { let data = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3700,6 +3839,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: Some(500), // 5% }; @@ -3709,6 +3849,7 @@ mod oracle_integration_tests { let first = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3721,6 +3862,7 @@ mod oracle_integration_tests { let second = OraclePriceData { price: 103_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3741,6 +3883,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: Some(500), // 5% }; @@ -3749,6 +3892,7 @@ mod oracle_integration_tests { let first = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3761,6 +3905,7 @@ mod oracle_integration_tests { let second = OraclePriceData { price: 105_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3781,6 +3926,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: Some(500), // 5% }; @@ -3789,6 +3935,7 @@ mod oracle_integration_tests { let first = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3801,6 +3948,7 @@ mod oracle_integration_tests { let spike = OraclePriceData { price: 120_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3832,6 +3980,7 @@ mod oracle_integration_tests { env.as_contract(&contract_id, || { let config = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, // disabled }; @@ -3840,6 +3989,7 @@ mod oracle_integration_tests { let first = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3852,6 +4002,7 @@ mod oracle_integration_tests { let big_move = OraclePriceData { price: 150_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3873,6 +4024,7 @@ mod oracle_integration_tests { // Global has no deviation bound let global = GlobalOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: None, }; @@ -3881,6 +4033,7 @@ mod oracle_integration_tests { // Per-event sets a tight 2% bound let event_cfg = EventOracleValidationConfig { max_staleness_secs: 60, + max_age_ledgers: 10, max_confidence_bps: 500, max_deviation_bps: Some(200), }; @@ -3889,6 +4042,7 @@ mod oracle_integration_tests { let first = OraclePriceData { price: 100_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; @@ -3901,6 +4055,7 @@ mod oracle_integration_tests { let second = OraclePriceData { price: 103_000_00, publish_time: env.ledger().timestamp(), + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, }; diff --git a/contracts/predictify-hybrid/src/tests/oracle_rolling_deviation_tests.rs b/contracts/predictify-hybrid/src/tests/oracle_rolling_deviation_tests.rs index bb73ec57..60986ccd 100644 --- a/contracts/predictify-hybrid/src/tests/oracle_rolling_deviation_tests.rs +++ b/contracts/predictify-hybrid/src/tests/oracle_rolling_deviation_tests.rs @@ -151,6 +151,7 @@ fn make_price_data(env: &Env, price: i128, publish_time: u64) -> OraclePriceData OraclePriceData { price, publish_time, + publish_ledger: env.ledger().sequence(), confidence: None, exponent: 0, } diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index eda4492d..000bfa5b 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -1913,6 +1913,8 @@ pub struct OraclePriceData { pub price: i128, /// Publish time of the oracle data (unix timestamp seconds) pub publish_time: u64, + /// Publish ledger sequence of the oracle data + pub publish_ledger: u32, /// Confidence interval (absolute) in the same base units as `price` pub confidence: Option, /// Exponent/decimals scale used by the oracle (e.g., Pyth exponent) @@ -1925,6 +1927,8 @@ pub struct OraclePriceData { pub struct GlobalOracleValidationConfig { /// Maximum age of oracle data in seconds before it is rejected pub max_staleness_secs: u64, + /// Maximum age of oracle data in ledgers before it is rejected + pub max_age_ledgers: u32, /// Maximum allowed confidence interval in basis points (1/100 of a percent) pub max_confidence_bps: u32, /// Maximum allowed price deviation from the last accepted reading, in basis points. @@ -1945,6 +1949,8 @@ pub struct GlobalOracleValidationConfig { pub struct EventOracleValidationConfig { /// Maximum age of oracle data in seconds before it is rejected pub max_staleness_secs: u64, + /// Maximum age of oracle data in ledgers before it is rejected + pub max_age_ledgers: u32, /// Maximum allowed confidence interval in basis points (1/100 of a percent) pub max_confidence_bps: u32, /// Maximum allowed price deviation from the last accepted reading, in basis points.