From 8d54a5bb10b8c7d01a29cac51fed738c8218d98c Mon Sep 17 00:00:00 2001 From: ink-mami Date: Tue, 20 Oct 2026 04:01:59 +0000 Subject: [PATCH] fix(contract): circuit breaker gating, Pyth expo scaling, oracle quorum, expose public APIs Closes #1705 Closes #1706 Closes #1707 Closes #1710 - #1705: high-risk ops now go through require_closed (Open blocks, HalfOpen probes counted) - #1706: normalise Pyth price to 1e-8 before comparing with strike_price - #1707: staleness check requires min_responses reports; resolution uses consensus when >1 - #1710: expose unlock_tokens, get_bet, get_markets(_by_status), get_guardians_paginated, circuit breaker state - restore oracles.rs tail truncated in 54df6f7 and drop dangling strike_thresholds reference --- contracts/predict-iq/src/lib.rs | 38 ++++ .../predict-iq/src/modules/circuit_breaker.rs | 41 +++- contracts/predict-iq/src/modules/oracles.rs | 199 +++++++++++++++--- .../predict-iq/src/modules/resolution.rs | 8 +- .../predict-iq/src/test_pyth_integration.rs | 14 +- 5 files changed, 263 insertions(+), 37 deletions(-) diff --git a/contracts/predict-iq/src/lib.rs b/contracts/predict-iq/src/lib.rs index baa612b1..0a8543c4 100644 --- a/contracts/predict-iq/src/lib.rs +++ b/contracts/predict-iq/src/lib.rs @@ -308,6 +308,44 @@ impl PredictIQ { crate::modules::admin::get_guardian(&e) } + pub fn get_circuit_breaker_state(e: Env) -> CircuitBreakerState { + crate::modules::circuit_breaker::get_state(&e) + } + + pub fn get_bet( + e: Env, + market_id: u64, + bettor: Address, + outcome: u32, + ) -> Option { + crate::modules::bets::get_bet(&e, market_id, bettor, outcome) + } + + pub fn unlock_tokens(e: Env, voter: Address, market_id: u64) -> Result<(), ErrorCode> { + crate::modules::voting::unlock_tokens(&e, voter, market_id) + } + + pub fn get_markets(e: Env, offset: u32, limit: u32) -> Vec { + crate::modules::queries::get_markets(&e, offset, limit) + } + + pub fn get_markets_by_status( + e: Env, + status: crate::types::MarketStatus, + offset: u32, + limit: u32, + ) -> Vec { + crate::modules::queries::get_markets_by_status(&e, status, offset, limit) + } + + pub fn get_guardians_paginated( + e: Env, + offset: u32, + limit: u32, + ) -> Vec { + crate::modules::queries::get_guardians_paginated(&e, offset, limit) + } + pub fn pause(e: Env) -> Result<(), ErrorCode> { crate::modules::circuit_breaker::pause(&e) } diff --git a/contracts/predict-iq/src/modules/circuit_breaker.rs b/contracts/predict-iq/src/modules/circuit_breaker.rs index 663fd559..29423922 100644 --- a/contracts/predict-iq/src/modules/circuit_breaker.rs +++ b/contracts/predict-iq/src/modules/circuit_breaker.rs @@ -134,11 +134,10 @@ pub fn unpause(e: &Env) -> Result<(), ErrorCode> { _set_state_internal(e, CircuitBreakerState::Closed) } +/// Issue #1705: Open and Paused block high-risk operations; HalfOpen admits a +/// limited number of probe operations (counted via `require_closed`). pub fn require_not_paused_for_high_risk(e: &Env) -> Result<(), ErrorCode> { - if get_state(e) == CircuitBreakerState::Paused { - return Err(ErrorCode::ContractPaused); - } - Ok(()) + require_closed(e) } /// Governance: update the circuit breaker threshold (admin only). @@ -290,3 +289,37 @@ mod threshold_tests { set_threshold(&e, 1).unwrap(); assert_eq!(get_threshold(&e), 1); } + +#[cfg(test)] +mod high_risk_gate_tests { + use super::*; + + fn with_contract(f: F) { + let e = Env::default(); + let id = e.register(crate::PredictIQ, ()); + e.as_contract(&id, || f(&e)); + } + + /// Issue #1705: Open blocks high-risk ops; HalfOpen probes are counted and + /// trip back to Open once the limit is reached. + #[test] + fn open_blocks_and_half_open_probes_are_counted() { + with_contract(|e| { + _set_state_internal(e, CircuitBreakerState::Open).unwrap(); + assert_eq!( + require_not_paused_for_high_risk(e), + Err(ErrorCode::ContractPaused) + ); + + _set_state_internal(e, CircuitBreakerState::HalfOpen).unwrap(); + for _ in 0..HALF_OPEN_MAX_OPS { + assert_eq!(require_not_paused_for_high_risk(e), Ok(())); + } + assert_eq!( + require_not_paused_for_high_risk(e), + Err(ErrorCode::ContractPaused) + ); + assert_eq!(get_state(e), CircuitBreakerState::Open); + }); + } +} diff --git a/contracts/predict-iq/src/modules/oracles.rs b/contracts/predict-iq/src/modules/oracles.rs index 45e6d505..df88fbef 100644 --- a/contracts/predict-iq/src/modules/oracles.rs +++ b/contracts/predict-iq/src/modules/oracles.rs @@ -88,7 +88,7 @@ pub fn validate_oracle_staleness( let num_oracles = config.min_responses.unwrap_or(1); let current_time = e.ledger().timestamp(); let max_staleness = effective_max_staleness(config); - let mut any_found = false; + let mut found = 0u32; for idx in 0..num_oracles { let last_update = e @@ -97,7 +97,7 @@ pub fn validate_oracle_staleness( .get::<_, u64>(&OracleData::LastUpdate(market_id, idx as u64)); if let Some(update_time) = last_update { - any_found = true; + found += 1; let age = current_time.saturating_sub(update_time); if age > max_staleness { crate::modules::monitoring::track_error(e); @@ -106,7 +106,8 @@ pub fn validate_oracle_staleness( } } - if any_found { + // Issue #1707: every one of the min_responses oracles must have reported. + if found >= num_oracles { Ok(()) } else { crate::modules::monitoring::track_error(e); @@ -134,6 +135,7 @@ pub fn resolve_with_pyth( &OracleData::LastUpdate(market_id, oracle_id as u64), &publish_time, ); + record_oracle_response(e, market_id, oracle_id, outcome)?; e.events().publish( ( @@ -147,36 +149,37 @@ pub fn resolve_with_pyth( Ok(outcome) } -/// Issue #1545: Determine the winning outcome index from a Pyth price. -/// -/// Binary markets (2 options) keep the original above/below-strike semantics: -/// outcome 0 when `price >= strike_price`, outcome 1 otherwise. -/// -/// Multi-outcome markets (3+ options) are resolved against ascending price -/// brackets supplied via `config.strike_thresholds`. The price is compared -/// against each threshold in order; the first threshold the price is below -/// selects that bracket's outcome index. A price at or above every threshold -/// selects the final (highest) outcome index. This lets auto-resolution pick -/// outcome indices >= 2 instead of silently collapsing to 0/1. -fn determine_outcome(price: &PythPrice, config: &OracleConfig) -> u32 { - let thresholds = config.strike_thresholds.as_ref(); - - // Multi-outcome bracket resolution when explicit thresholds are configured. - if let Some(thresholds) = thresholds { - if thresholds.len() >= 2 { - for (i, threshold) in thresholds.iter().enumerate() { - if price.price < threshold { - return i as u32; +/// Issue #1706: `strike_price` is expressed in `STRIKE_EXPO` (1e-8) fixed point. +/// The feed price (`price * 10^expo`) is normalised to that scale before comparing, +/// so feeds with any exponent (e.g. -8 for BTC, -2 for some FX feeds) resolve correctly. +pub const STRIKE_EXPO: i32 = -8; + +/// Rescale `price` from `10^expo` to `10^STRIKE_EXPO`, saturating on overflow. +pub fn normalize_price(price: i64, expo: i32) -> i128 { + let diff = expo - STRIKE_EXPO; + let p = price as i128; + if diff >= 0 { + match 10i128.checked_pow(diff as u32).and_then(|m| p.checked_mul(m)) { + Some(v) => v, + None => { + if p < 0 { + i128::MIN + } else { + i128::MAX } } - // Price at or above every threshold -> highest bracket. - return thresholds.len() as u32; + } + } else { + match 10i128.checked_pow((-diff) as u32) { + Some(d) => p / d, + None => 0, } } +} - // Binary fallback (unchanged legacy behavior). - let threshold = config.strike_price.unwrap_or(0); - if price.price >= threshold { +fn determine_outcome(price: &PythPrice, config: &OracleConfig) -> u32 { + let threshold = config.strike_price.unwrap_or(0) as i128; + if normalize_price(price.price, price.expo) >= threshold { 0 } else { 1 @@ -208,6 +211,7 @@ pub fn set_oracle_result( &OracleData::LastUpdate(market_id, oracle_id as u64), &e.ledger().timestamp(), ); + record_oracle_response(e, market_id, oracle_id, outcome)?; let oracle_addr = crate::modules::markets::get_market(e, market_id) .map(|m| m.oracle_config.oracle_address) @@ -293,6 +297,141 @@ pub fn record_oracle_response( /// Issue #509: Validate oracle consensus - requires min_responses confirmations pub fn validate_consensus( e: &Env, - + market_id: u64, + config: &OracleConfig, +) -> Result { + let min_responses = config.min_responses.unwrap_or(1); -/* … truncated 3836 chars — edit only what you need near the top … */ + let key = OracleData::OracleResponses(market_id); + let responses: Map = e + .storage() + .persistent() + .get(&key) + .ok_or(ErrorCode::OracleFailure)?; + + // Check if we have enough responses + if responses.len() < min_responses { + return Err(ErrorCode::OracleFailure); + } + + // Count votes for each outcome. + // Fix #1539: iterate by actual (oracle_index, outcome) pairs via Map::iter() + // instead of a synthetic positional index. Map::get(i) is a key lookup, not + // positional — with non-contiguous oracle indices the old loop silently + // skipped entries whose key didn't match the loop counter. + let mut outcome_votes: Map = Map::new(e); + for (_oracle_index, outcome) in responses.iter() { + let votes = outcome_votes.get(outcome).unwrap_or(0); + outcome_votes.set(outcome, votes + 1); + } + + // Find outcome with most votes (quorum). + // Fix #1539: iterate outcome_votes by actual (outcome_id, vote_count) pairs. + // The old loop used outcome_votes.get(i) which probes key i, not position i, + // so non-contiguous outcome IDs (e.g. outcomes 0 and 2 with len==2) would + // never check key 2 — its votes were silently ignored. + let mut consensus_outcome: Option = None; + let mut max_votes = 0u32; + for (outcome_id, votes) in outcome_votes.iter() { + if votes > max_votes { + max_votes = votes; + consensus_outcome = Some(outcome_id); + } + } + + let winning_outcome = consensus_outcome.ok_or(ErrorCode::OracleFailure)?; + } + + Ok(winning_outcome) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env, String}; + + fn valid_config(e: &Env) -> OracleConfig { + OracleConfig { + oracle_address: Address::generate(e), + feed_id: String::from_str( + e, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + max_staleness_seconds: MAX_STALENESS_SECONDS, + max_confidence_bps: 100, + strike_price: Some(100), + min_responses: Some(1), + } + } + + #[test] + fn verify_oracle_health_accepts_valid_feed_id() { + let e = Env::default(); + let config = valid_config(&e); + assert!(verify_oracle_health(&e, &config)); + assert!(validate_oracle_config(&e, &config).is_ok()); + } + + #[test] + fn verify_oracle_health_rejects_wrong_length_feed_id() { + let e = Env::default(); + let mut config = valid_config(&e); + config.feed_id = String::from_str(&e, "0123456789abcdef"); + assert!(!verify_oracle_health(&e, &config)); + assert_eq!( + validate_oracle_config(&e, &config), + Err(ErrorCode::OracleFailure) + ); + } + + #[test] + fn verify_oracle_health_rejects_non_hex_feed_id() { + let e = Env::default(); + let mut config = valid_config(&e); + config.feed_id = String::from_str( + &e, + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ); + assert!(!verify_oracle_health(&e, &config)); + assert_eq!( + validate_oracle_config(&e, &config), + Err(ErrorCode::OracleFailure) + ); + } + + #[test] + fn verify_oracle_health_rejects_empty_feed_id() { + let e = Env::default(); + let mut config = valid_config(&e); + config.feed_id = String::from_str(&e, ""); + assert!(!verify_oracle_health(&e, &config)); + assert_eq!( + validate_oracle_config(&e, &config), + Err(ErrorCode::OracleFailure) + ); + } + + /// Issue #1707: min_responses = 3 with a single fresh report must be rejected. + #[test] + fn staleness_requires_min_responses_reports() { + let e = Env::default(); + let id = e.register(crate::PredictIQ, ()); + let mut config = valid_config(&e); + config.min_responses = Some(3); + e.as_contract(&id, || { + e.storage() + .persistent() + .set(&OracleData::LastUpdate(1, 0), &e.ledger().timestamp()); + assert_eq!( + validate_oracle_staleness(&e, 1, &config), + Err(ErrorCode::OracleFailure) + ); + for i in 1..3u64 { + e.storage() + .persistent() + .set(&OracleData::LastUpdate(1, i), &e.ledger().timestamp()); + } + assert_eq!(validate_oracle_staleness(&e, 1, &config), Ok(())); + }); + } +} diff --git a/contracts/predict-iq/src/modules/resolution.rs b/contracts/predict-iq/src/modules/resolution.rs index 212d8a03..8829ee45 100644 --- a/contracts/predict-iq/src/modules/resolution.rs +++ b/contracts/predict-iq/src/modules/resolution.rs @@ -96,7 +96,13 @@ pub fn attempt_oracle_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCod oracles::validate_oracle_staleness(e, market_id, &market.oracle_config)?; // Attempt oracle resolution - if let Some(oracle_outcome) = oracles::get_oracle_result(e, market_id, 0) { + // Issue #1707: multi-oracle markets resolve via consensus, not just index 0. + let oracle_result = if market.oracle_config.min_responses.unwrap_or(1) > 1 { + oracles::validate_consensus(e, market_id, &market.oracle_config).ok() + } else { + oracles::get_oracle_result(e, market_id, 0) + }; + if let Some(oracle_outcome) = oracle_result { // Issue #1698: reject a resolution whose winning outcome has no stake, // otherwise the whole pool would be permanently locked (claim_winnings // returns NoWinnings to everyone and there is no refund path). diff --git a/contracts/predict-iq/src/test_pyth_integration.rs b/contracts/predict-iq/src/test_pyth_integration.rs index 0e771104..e13828ff 100644 --- a/contracts/predict-iq/src/test_pyth_integration.rs +++ b/contracts/predict-iq/src/test_pyth_integration.rs @@ -416,14 +416,14 @@ fn test_resolve_with_pyth_outcome_below_strike() { e.ledger().set_timestamp(1_700_000_060); let pyth_addr = e.register(MockPythContract, ()); - // strike_price = 10_000_000 → price (5_000_000) < strike → outcome 1 + // strike_price = $100,000 in 1e-8 units → price ($50,000, expo -2) < strike → outcome 1 let config = OracleConfig { oracle_address: pyth_addr, feed_id: btc_usd_feed_id(&e), min_responses: Some(1), max_staleness_seconds: u64::MAX, max_confidence_bps: 500, - strike_price: Some(10_000_000), + strike_price: Some(10_000_000_000_000), }; let result = resolve_with_pyth(&e, 2u64, 0u32, &config); @@ -515,3 +515,13 @@ fn test_contract_attempt_oracle_resolution_with_mock_pyth() { assert_eq!(market.status, MarketStatus::PendingResolution); assert_eq!(market.winning_outcome, Some(0)); } + +/// Issue #1706: prices are normalised to 1e-8 before comparing with strike_price. +#[test] +fn test_normalize_price_handles_negative_exponents() { + use crate::modules::oracles::normalize_price; + assert_eq!(normalize_price(5_000_000, -2), 5_000_000_000_000); + assert_eq!(normalize_price(6_500_000_000_000, -8), 6_500_000_000_000); + assert_eq!(normalize_price(65_000, 0), 6_500_000_000_000); + assert_eq!(normalize_price(123_456_789_012, -10), 1_234_567_890); +}