diff --git a/contracts/predict-iq/src/errors.rs b/contracts/predict-iq/src/errors.rs index 967ea85d..a13fcbe3 100644 --- a/contracts/predict-iq/src/errors.rs +++ b/contracts/predict-iq/src/errors.rs @@ -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, diff --git a/contracts/predict-iq/src/modules/mod.rs b/contracts/predict-iq/src/modules/mod.rs index ce33cf13..4a2987ae 100644 --- a/contracts/predict-iq/src/modules/mod.rs +++ b/contracts/predict-iq/src/modules/mod.rs @@ -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; diff --git a/contracts/predict-iq/src/modules/oracles.rs b/contracts/predict-iq/src/modules/oracles.rs index 0f584870..abdb685a 100644 --- a/contracts/predict-iq/src/modules/oracles.rs +++ b/contracts/predict-iq/src/modules/oracles.rs @@ -27,6 +27,22 @@ pub fn fetch_pyth_price(_e: &Env, _config: &OracleConfig) -> Result Result { + 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> { @@ -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 { @@ -60,6 +77,20 @@ pub fn resolve_with_pyth( config: &OracleConfig, ) -> Result { 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); @@ -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 } } diff --git a/contracts/predict-iq/src/modules/oracles_test.rs b/contracts/predict-iq/src/modules/oracles_test.rs index a59eafb3..18f31f1b 100644 --- a/contracts/predict-iq/src/modules/oracles_test.rs +++ b/contracts/predict-iq/src/modules/oracles_test.rs @@ -1,28 +1,31 @@ #![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()); } @@ -30,22 +33,15 @@ fn test_validate_fresh_price() { #[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)); } @@ -53,22 +49,68 @@ fn test_reject_stale_price() { #[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)); +} diff --git a/contracts/predict-iq/src/test.rs b/contracts/predict-iq/src/test.rs index 33274f49..4475e630 100644 --- a/contracts/predict-iq/src/test.rs +++ b/contracts/predict-iq/src/test.rs @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/contracts/predict-iq/src/types.rs b/contracts/predict-iq/src/types.rs index 37d01b27..98c4d721 100644 --- a/contracts/predict-iq/src/types.rs +++ b/contracts/predict-iq/src/types.rs @@ -1,3 +1,4 @@ +use soroban_sdk::{contracttype, Address, Map, String, Vec}; use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec}; #[contracttype] @@ -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, pub max_staleness_seconds: u64, pub max_confidence_bps: u64,