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
31 changes: 31 additions & 0 deletions contracts/predict-iq/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ pub enum ErrorCode {
InvalidBetAmount = 106,
InsufficientBalance = 107,
OracleFailure = 108,
StalePrice = 109,
ConfidenceTooLow = 110,
InvalidTimestamp = 111,
CircuitBreakerOpen = 112,
DisputeWindowClosed = 113,
VotingNotStarted = 114,
VotingEnded = 115,
AlreadyVoted = 116,
FeeTooHigh = 117,
MarketNotActive = 118,
DeadlinePassed = 119,
CannotChangeOutcome = 120,
MarketNotDisputed = 121,
MarketNotPendingResolution = 122,
AdminNotSet = 123,
ContractPaused = 124,
GuardianNotSet = 125,
TooManyOutcomes = 126,
TooManyWinners = 127,
PayoutModeNotSupported = 128,
InsufficientDeposit = 129,
TimelockActive = 130,
UpgradeNotInitiated = 131,
InsufficientVotes = 132,
AlreadyVotedOnUpgrade = 133,
InvalidWasmHash = 134,
UpgradeFailed = 135,
ParentMarketNotResolved = 136,
ParentMarketInvalidOutcome = 137,
MarketIdOverflow = 138,
MarketIdCollision = 139,
CircuitBreakerOpen = 109,
DisputeWindowClosed = 110,
VotingNotStarted = 111,
Expand Down
2 changes: 2 additions & 0 deletions contracts/predict-iq/src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub mod governance;
pub mod markets;
pub mod monitoring;
pub mod oracles;
#[cfg(test)]
pub mod oracles_test;
pub mod resolution;
pub mod sac;
pub mod voting;
Expand Down
37 changes: 37 additions & 0 deletions contracts/predict-iq/src/modules/oracles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ pub fn fetch_pyth_price(_e: &Env, _config: &OracleConfig) -> Result<PythPrice, E
Err(ErrorCode::OracleFailure)
}

pub fn cast_external_timestamp(timestamp: i64) -> Result<u64, ErrorCode> {
timestamp
.try_into()
.map_err(|_| ErrorCode::InvalidTimestamp)
}

pub fn is_stale(current_time: u64, result_time: u64, max_age_seconds: u64) -> bool {
current_time.saturating_sub(result_time) > max_age_seconds
}

pub fn validate_price(e: &Env, price: &PythPrice, config: &OracleConfig) -> Result<(), ErrorCode> {
let current_time = e.ledger().timestamp();
let publish_time = cast_external_timestamp(price.publish_time)?;

// Check freshness
if is_stale(current_time, publish_time, config.max_staleness_seconds) {
/// Issue #41: Use saturating_abs to avoid overflow on i64::MIN.
/// Issue #49: publish_time is now u64 — no signed/unsigned mixing.
pub fn validate_price(e: &Env, price: &PythPrice, config: &OracleConfig) -> Result<(), ErrorCode> {
Expand All @@ -44,6 +60,7 @@ pub fn validate_price(e: &Env, price: &PythPrice, config: &OracleConfig) -> Resu
} else {
price.price
} as u64;
let max_conf = (price_abs * config.max_confidence_bps) / 10000;
let max_conf = (price_abs * config.max_confidence_bps as u64) / 10000;

if price.conf > max_conf {
Expand All @@ -60,6 +77,20 @@ pub fn resolve_with_pyth(
config: &OracleConfig,
) -> Result<u32, ErrorCode> {
let price = fetch_pyth_price(e, config)?;
let publish_time = cast_external_timestamp(price.publish_time)?;

// Convert price to outcome (implementation depends on market logic)
let outcome = determine_outcome(&price);

// Store result
e.storage()
.persistent()
.set(&OracleData::Result(market_id, 0), &outcome);
e.storage()
.persistent()
.set(&OracleData::LastUpdate(market_id, 0), &publish_time);

// Publish event
validate_price(e, &price, config)?;

let outcome = determine_outcome(&price);
Expand All @@ -81,6 +112,12 @@ pub fn resolve_with_pyth(
}

fn determine_outcome(price: &PythPrice) -> u32 {
// Placeholder logic - real implementation would use market-specific threshold
if price.price > 0 {
0
} else {
1
}
if price.price > 0 { 0 } else { 1 }
}

Expand Down
106 changes: 74 additions & 32 deletions contracts/predict-iq/src/modules/oracles_test.rs
Original file line number Diff line number Diff line change
@@ -1,74 +1,116 @@
#![cfg(test)]
use super::oracles::*;
use crate::types::OracleConfig;
use crate::errors::ErrorCode;
use soroban_sdk::{Env, Address, String, testutils::Address as _};
use crate::types::OracleConfig;
use soroban_sdk::{testutils::Address as _, Address, Env, String};

fn test_config(e: &Env) -> OracleConfig {
OracleConfig {
oracle_address: Address::generate(e),
feed_id: String::from_str(e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
}
}

#[test]
fn test_validate_fresh_price() {
let e = Env::default();

let config = OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200, // 2%
};


let config = test_config(&e);
let price = PythPrice {
price: 100000,
conf: 1000, // 1% of price
expo: -2,
publish_time: e.ledger().timestamp() as i64 - 60, // 1 minute old
};

let result = validate_price(&e, &price, &config);
assert!(result.is_ok());
}

#[test]
fn test_reject_stale_price() {
let e = Env::default();

let config = OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
};


let config = test_config(&e);
let price = PythPrice {
price: 100000,
conf: 1000,
expo: -2,
publish_time: e.ledger().timestamp() as i64 - 400, // 400 seconds old
};

let result = validate_price(&e, &price, &config);
assert_eq!(result, Err(ErrorCode::StalePrice));
}

#[test]
fn test_reject_low_confidence() {
let e = Env::default();

let config = OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200, // 2%
};


let config = test_config(&e);
let price = PythPrice {
price: 100000,
conf: 3000, // 3% of price - exceeds 2% threshold
expo: -2,
publish_time: e.ledger().timestamp() as i64 - 60,
};

let result = validate_price(&e, &price, &config);
assert_eq!(result, Err(ErrorCode::ConfidenceTooLow));
}

#[test]
fn test_cast_external_timestamp_rejects_negative_values() {
assert_eq!(
cast_external_timestamp(-1),
Err(ErrorCode::InvalidTimestamp)
);
}

#[test]
fn test_cast_external_timestamp_accepts_zero() {
assert_eq!(cast_external_timestamp(0), Ok(0));
}

#[test]
fn test_cast_external_timestamp_accepts_positive_values() {
assert_eq!(cast_external_timestamp(1_700_000_000), Ok(1_700_000_000));
}

#[test]
fn test_is_stale_returns_false_for_fresh_data() {
assert!(!is_stale(1_700_001_000, 1_700_000_900, 300));
}

#[test]
fn test_is_stale_returns_true_for_old_data() {
assert!(is_stale(1_700_001_000, 1_699_999_000, 300));
}

#[test]
fn test_is_stale_boundary_is_not_stale() {
assert!(!is_stale(1_700_001_000, 1_700_000_700, 300));
}

#[test]
fn test_is_stale_future_timestamp_does_not_underflow() {
assert!(!is_stale(1_700_000_000, 1_700_001_000, 300));
}

#[test]
fn test_validate_price_rejects_negative_publish_time() {
let e = Env::default();
let config = test_config(&e);
let price = PythPrice {
price: 100000,
conf: 1000,
expo: -2,
publish_time: -1,
};

let result = validate_price(&e, &price, &config);
assert_eq!(result, Err(ErrorCode::InvalidTimestamp));
}
27 changes: 27 additions & 0 deletions contracts/predict-iq/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ fn create_test_market(
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(e),
feed_id: String::from_str(e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -120,6 +123,9 @@ fn test_market_creation_fails_without_deposit() {
&types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -987,6 +993,9 @@ fn test_create_conditional_market_parent_not_resolved() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1039,6 +1048,9 @@ fn test_create_conditional_market_parent_wrong_outcome() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1091,6 +1103,9 @@ fn test_create_conditional_market_success() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1152,6 +1167,9 @@ fn test_place_bet_on_conditional_market_parent_not_resolved() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1213,6 +1231,9 @@ fn test_place_bet_on_conditional_market_parent_wrong_outcome() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1295,6 +1316,9 @@ fn test_multi_level_conditional_markets() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down Expand Up @@ -1365,6 +1389,9 @@ fn test_create_conditional_market_invalid_parent_outcome_idx() {
let oracle_config = types::OracleConfig {
oracle_address: Address::generate(&e),
feed_id: String::from_str(&e, "test_feed"),
min_responses: 1,
max_staleness_seconds: 300,
max_confidence_bps: 200,
min_responses: Some(1),
max_staleness_seconds: 3600,
max_confidence_bps: 200,
Expand Down
4 changes: 4 additions & 0 deletions contracts/predict-iq/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use soroban_sdk::{contracttype, Address, Map, String, Vec};
use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec};

#[contracttype]
Expand Down Expand Up @@ -88,6 +89,9 @@ pub struct LockedTokens {
pub struct OracleConfig {
pub oracle_address: Address,
pub feed_id: String,
pub min_responses: u32,
pub max_staleness_seconds: u64,
pub max_confidence_bps: u64,
pub min_responses: Option<u32>,
pub max_staleness_seconds: u64,
pub max_confidence_bps: u64,
Expand Down
Loading