From 02058772123ec9bd754e9aac02e58c6bc7435be6 Mon Sep 17 00:00:00 2001 From: dreamgene Date: Thu, 26 Mar 2026 15:21:27 +0100 Subject: [PATCH] refactor: unify all internal time logic to u64 for safety --- contracts/predict-iq/src/errors.rs | 59 +++++----- contracts/predict-iq/src/modules/mod.rs | 2 + contracts/predict-iq/src/modules/oracles.rs | 55 ++++++--- .../predict-iq/src/modules/oracles_test.rs | 106 ++++++++++++------ contracts/predict-iq/src/test.rs | 48 ++++++-- contracts/predict-iq/src/types.rs | 6 +- 6 files changed, 186 insertions(+), 90 deletions(-) diff --git a/contracts/predict-iq/src/errors.rs b/contracts/predict-iq/src/errors.rs index 3f25cd56..59dc66a6 100644 --- a/contracts/predict-iq/src/errors.rs +++ b/contracts/predict-iq/src/errors.rs @@ -13,32 +13,35 @@ pub enum ErrorCode { InvalidBetAmount = 106, InsufficientBalance = 107, OracleFailure = 108, - CircuitBreakerOpen = 109, - DisputeWindowClosed = 110, - VotingNotStarted = 111, - VotingEnded = 112, - AlreadyVoted = 113, - FeeTooHigh = 114, - MarketNotActive = 115, - DeadlinePassed = 116, - CannotChangeOutcome = 117, - MarketNotDisputed = 118, - MarketNotPendingResolution = 119, - AdminNotSet = 120, - ContractPaused = 121, - GuardianNotSet = 122, - TooManyOutcomes = 123, - TooManyWinners = 124, - PayoutModeNotSupported = 125, - InsufficientDeposit = 126, - TimelockActive = 127, - UpgradeNotInitiated = 128, - InsufficientVotes = 129, - AlreadyVotedOnUpgrade = 130, - InvalidWasmHash = 131, - UpgradeFailed = 132, - ParentMarketNotResolved = 133, - ParentMarketInvalidOutcome = 134, - MarketIdOverflow = 135, - MarketIdCollision = 136, + 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, } diff --git a/contracts/predict-iq/src/modules/mod.rs b/contracts/predict-iq/src/modules/mod.rs index bd66b8b4..71348c06 100644 --- a/contracts/predict-iq/src/modules/mod.rs +++ b/contracts/predict-iq/src/modules/mod.rs @@ -8,4 +8,6 @@ pub mod governance; pub mod markets; pub mod monitoring; pub mod oracles; +#[cfg(test)] +pub mod oracles_test; pub mod voting; diff --git a/contracts/predict-iq/src/modules/oracles.rs b/contracts/predict-iq/src/modules/oracles.rs index bf00e1d0..9d112fe4 100644 --- a/contracts/predict-iq/src/modules/oracles.rs +++ b/contracts/predict-iq/src/modules/oracles.rs @@ -1,6 +1,6 @@ use crate::errors::ErrorCode; use crate::types::OracleConfig; -use soroban_sdk::{contracttype, Env}; +use soroban_sdk::{contracttype, Env, Symbol}; #[contracttype] pub enum OracleData { @@ -23,48 +23,71 @@ 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() as i64; - let age = current_time - price.publish_time; - + let current_time = e.ledger().timestamp(); + let publish_time = cast_external_timestamp(price.publish_time)?; + // Check freshness - if age > config.max_staleness_seconds as i64 { + if is_stale(current_time, publish_time, config.max_staleness_seconds) { return Err(ErrorCode::StalePrice); } - + // Check confidence: conf should be < max_confidence_bps% of price - let price_abs = if price.price < 0 { -price.price } else { price.price } as u64; + let price_abs = if price.price < 0 { + -price.price + } else { + price.price + } as u64; let max_conf = (price_abs * config.max_confidence_bps) / 10000; - + if price.conf > max_conf { return Err(ErrorCode::ConfidenceTooLow); } - + Ok(()) } pub fn resolve_with_pyth(e: &Env, market_id: u64, 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), &(price.publish_time as u64)); - + e.storage() + .persistent() + .set(&OracleData::Result(market_id, 0), &outcome); + e.storage() + .persistent() + .set(&OracleData::LastUpdate(market_id, 0), &publish_time); + // Publish event e.events().publish( (Symbol::new(e, "oracle_resolution"), market_id), (outcome, price.price, price.conf), ); - + Ok(outcome) } 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 + } } pub fn get_oracle_result(e: &Env, market_id: u64, _config: &OracleConfig) -> Option { 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 c22d33c7..2b72e789 100644 --- a/contracts/predict-iq/src/test.rs +++ b/contracts/predict-iq/src/test.rs @@ -32,7 +32,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; client.create_market( @@ -66,7 +68,9 @@ fn make_stored_market(e: &Env, id: u64) -> types::Market { oracle_config: types::OracleConfig { oracle_address: Address::generate(e), feed_id: String::from_str(e, "seeded_feed"), - min_responses: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }, total_staked: 0, payout_mode: types::PayoutMode::Pull, @@ -103,7 +107,9 @@ fn test_market_creation_fails_without_deposit() { &types::OracleConfig { oracle_address: Address::generate(&e), feed_id: String::from_str(&e, "test"), - min_responses: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }, &types::MarketTier::Basic, &native_token, @@ -192,7 +198,9 @@ fn test_market_id_overflow_returns_error() { &types::OracleConfig { oracle_address: Address::generate(&e), feed_id: String::from_str(&e, "overflow_feed"), - min_responses: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }, &types::MarketTier::Basic, &native_token, @@ -232,7 +240,9 @@ fn test_market_id_collision_returns_error() { &types::OracleConfig { oracle_address: Address::generate(&e), feed_id: String::from_str(&e, "collision_feed"), - min_responses: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }, &types::MarketTier::Basic, &native_token, @@ -920,7 +930,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let result = client.try_create_market( @@ -969,7 +981,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let result = client.try_create_market( @@ -1018,7 +1032,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let child_id = client.create_market( @@ -1076,7 +1092,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let child_id = client.create_market( @@ -1134,7 +1152,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let child_id = client.create_market( @@ -1213,7 +1233,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let level2_id = client.create_market( @@ -1280,7 +1302,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: Some(1), + min_responses: 1, + max_staleness_seconds: 300, + max_confidence_bps: 200, }; let result = client.try_create_market( diff --git a/contracts/predict-iq/src/types.rs b/contracts/predict-iq/src/types.rs index 14307d26..50b5f109 100644 --- a/contracts/predict-iq/src/types.rs +++ b/contracts/predict-iq/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, String, Vec, Map}; +use soroban_sdk::{contracttype, Address, Map, String, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,7 +86,9 @@ pub struct LockedTokens { pub struct OracleConfig { pub oracle_address: Address, pub feed_id: String, - pub min_responses: Option, // Optimized: None defaults to 1 + pub min_responses: u32, + pub max_staleness_seconds: u64, + pub max_confidence_bps: u64, } // Gas optimization constants