Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions contracts/predict-iq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,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::types::Bet> {
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::types::Market> {
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::types::Market> {
crate::modules::queries::get_markets_by_status(&e, status, offset, limit)
}

pub fn get_guardians_paginated(
e: Env,
offset: u32,
limit: u32,
) -> Vec<crate::types::Guardian> {
crate::modules::queries::get_guardians_paginated(&e, offset, limit)
}

pub fn pause(e: Env) -> Result<(), ErrorCode> {
crate::modules::circuit_breaker::pause(&e)
}
Expand Down
41 changes: 37 additions & 4 deletions contracts/predict-iq/src/modules/circuit_breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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: FnOnce(&Env)>(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);
});
}
}
198 changes: 168 additions & 30 deletions contracts/predict-iq/src/modules/oracles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(
(
Expand All @@ -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
Expand Down Expand Up @@ -223,6 +226,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 = market.oracle_config.oracle_address;
crate::modules::events::emit_oracle_result_set(e, market_id, oracle_id, oracle_addr, outcome);
Expand Down Expand Up @@ -306,9 +310,52 @@ 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<u32, ErrorCode> {
let min_responses = config.min_responses.unwrap_or(1);

let key = OracleData::OracleResponses(market_id);
let responses: Map<u32, u32> = 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<u32, u32> = 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);
}

/* … truncated 3836 chars — edit only what you need near the top … */
// 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<u32> = 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)
}

/// Permissionless: fetch the Pyth price for an Active market past its
/// resolution deadline and store it as oracle 0's result.
Expand All @@ -327,3 +374,94 @@ pub fn resolve_via_oracle(e: &Env, market_id: u64) -> Result<u32, ErrorCode> {
}
Ok(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(()));
});
}
}
8 changes: 7 additions & 1 deletion contracts/predict-iq/src/modules/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading