From 5e61a9d53202f05d8405a0581a219ae6d49f6a80 Mon Sep 17 00:00:00 2001 From: victor isiguzor uzoma Date: Sat, 31 Jan 2026 02:19:50 -0800 Subject: [PATCH 1/2] feat: implement oracle fallback and resolution timeout --- contracts/predictify-hybrid/src/errors.rs | 16 +- contracts/predictify-hybrid/src/events.rs | 71 ++++- contracts/predictify-hybrid/src/lib.rs | 57 +--- .../src/property_based_tests.rs | 15 +- contracts/predictify-hybrid/src/resolution.rs | 114 ++++--- contracts/predictify-hybrid/src/test.rs | 285 ++++++++++++++---- contracts/predictify-hybrid/src/types.rs | 20 +- contracts/predictify-hybrid/src/validation.rs | 31 +- .../predictify-hybrid/src/validation_tests.rs | 13 +- contracts/predictify-hybrid/src/voting.rs | 9 + 10 files changed, 479 insertions(+), 152 deletions(-) diff --git a/contracts/predictify-hybrid/src/errors.rs b/contracts/predictify-hybrid/src/errors.rs index 5aa50300..7eff6d8f 100644 --- a/contracts/predictify-hybrid/src/errors.rs +++ b/contracts/predictify-hybrid/src/errors.rs @@ -46,6 +46,12 @@ pub enum Error { OracleUnavailable = 200, /// Invalid oracle configuration InvalidOracleConfig = 201, + /// Fallback oracle is unavailable or unhealthy + FallbackOracleUnavailable = 202, + /// Resolution timeout has been reached + ResolutionTimeoutReached = 203, + /// Refund process has been initiated + RefundStarted = 204, // ===== VALIDATION ERRORS ===== /// Invalid question format @@ -1087,10 +1093,15 @@ impl Error { Error::InvalidOutcome => "Invalid outcome choice", Error::AlreadyVoted => "User has already voted", Error::AlreadyBet => "User has already placed a bet on this market", - Error::BetsAlreadyPlaced => "Bets have already been placed on this market (cannot update)", + Error::BetsAlreadyPlaced => { + "Bets have already been placed on this market (cannot update)" + } Error::InsufficientBalance => "Insufficient balance for operation", Error::OracleUnavailable => "Oracle is unavailable", Error::InvalidOracleConfig => "Invalid oracle configuration", + Error::FallbackOracleUnavailable => "Fallback oracle is unavailable or unhealthy", + Error::ResolutionTimeoutReached => "Resolution timeout has been reached", + Error::RefundStarted => "Refund process has been initiated", Error::InvalidQuestion => "Invalid question format", Error::InvalidOutcomes => "Invalid outcomes provided", Error::InvalidDuration => "Invalid duration specified", @@ -1210,6 +1221,9 @@ impl Error { Error::InsufficientBalance => "INSUFFICIENT_BALANCE", Error::OracleUnavailable => "ORACLE_UNAVAILABLE", Error::InvalidOracleConfig => "INVALID_ORACLE_CONFIG", + Error::FallbackOracleUnavailable => "FALLBACK_ORACLE_UNAVAILABLE", + Error::ResolutionTimeoutReached => "RESOLUTION_TIMEOUT_REACHED", + Error::RefundStarted => "REFUND_STARTED", Error::InvalidQuestion => "INVALID_QUESTION", Error::InvalidOutcomes => "INVALID_OUTCOMES", Error::InvalidDuration => "INVALID_DURATION", diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 7dfdf0c7..208c05f4 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -999,6 +999,30 @@ pub struct GovernanceVoteCastEvent { pub timestamp: u64, } +/// Event emitted when a fallback oracle is used for market resolution. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FallbackUsedEvent { + /// Market ID + pub market_id: Symbol, + /// Primary oracle address + pub primary_oracle: Address, + /// Fallback oracle address + pub fallback_oracle: Address, + /// Event timestamp + pub timestamp: u64, +} + +/// Event emitted when a market resolution timeout is reached. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolutionTimeoutEvent { + /// Market ID + pub market_id: Symbol, + /// Timeout timestamp + pub timeout_timestamp: u64, +} + /// Governance proposal executed event #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -1444,6 +1468,33 @@ impl EventEmitter { Self::store_event(env, &symbol_short!("mkt_crt"), &event); } + /// Emit fallback used event + pub fn emit_fallback_used( + env: &Env, + market_id: &Symbol, + primary_oracle: &Address, + fallback_oracle: &Address, + ) { + let event = FallbackUsedEvent { + market_id: market_id.clone(), + primary_oracle: primary_oracle.clone(), + fallback_oracle: fallback_oracle.clone(), + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("fbk_used"), &event); + } + + /// Emit resolution timeout event + pub fn emit_resolution_timeout(env: &Env, market_id: &Symbol, timeout_timestamp: u64) { + let event = ResolutionTimeoutEvent { + market_id: market_id.clone(), + timeout_timestamp, + }; + + Self::store_event(env, &symbol_short!("res_tmo"), &event); + } + /// Emit event created event pub fn emit_event_created( env: &Env, @@ -1928,11 +1979,7 @@ impl EventEmitter { } /// Emit refund on oracle failure event (market cancelled, all bets refunded in full). - pub fn emit_refund_on_oracle_failure( - env: &Env, - market_id: &Symbol, - total_refunded: i128, - ) { + pub fn emit_refund_on_oracle_failure(env: &Env, market_id: &Symbol, total_refunded: i128) { let event = RefundOnOracleFailureEvent { market_id: market_id.clone(), total_refunded, @@ -2425,11 +2472,7 @@ impl EventEmitter { /// /// EventEmitter::emit_error_event(&env, Error::NothingToClaim, &context); /// ``` - pub fn emit_diagnostic_event( - env: &Env, - error: Error, - context: &crate::errors::ErrorContext, - ) { + pub fn emit_diagnostic_event(env: &Env, error: Error, context: &crate::errors::ErrorContext) { let error_code = error as u32; // Convert error enum to message string @@ -2567,11 +2610,15 @@ impl EventEmitter { ) { env.events().publish( (symbol_short!("bal_chg"), user, asset.clone()), - (operation.clone(), amount, new_balance, env.ledger().timestamp()), + ( + operation.clone(), + amount, + new_balance, + env.ledger().timestamp(), + ), ); } - /// Store event in persistent storage fn store_event(env: &Env, event_key: &Symbol, event_data: &T) where diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 7df78f12..6b328569 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -305,6 +305,8 @@ impl PredictifyHybrid { outcomes: Vec, duration_days: u32, oracle_config: OracleConfig, + fallback_oracle_config: Option, + resolution_timeout: u64, ) -> Symbol { // Authenticate that the caller is the admin admin.require_auth(); @@ -346,6 +348,8 @@ impl PredictifyHybrid { outcomes: outcomes.clone(), end_time, oracle_config, + fallback_oracle_config, + resolution_timeout, oracle_result: None, votes: Map::new(&env), total_staked: 0, @@ -405,6 +409,8 @@ impl PredictifyHybrid { outcomes: Vec, end_time: u64, oracle_config: OracleConfig, + fallback_oracle_config: Option, + resolution_timeout: u64, ) -> Symbol { // Authenticate that the caller is the admin admin.require_auth(); @@ -443,6 +449,8 @@ impl PredictifyHybrid { outcomes: outcomes.clone(), end_time, oracle_config, + fallback_oracle_config, + resolution_timeout, admin: admin.clone(), created_at: env.ledger().timestamp(), status: MarketState::Active, @@ -1350,37 +1358,8 @@ impl PredictifyHybrid { /// - Market must exist and be past its end time /// - Market must not already have an oracle result /// - Oracle contract must be accessible and responsive - pub fn fetch_oracle_result( - env: Env, - market_id: Symbol, - oracle_contract: Address, - ) -> Result { - // Get the market from storage - let market = env - .storage() - .persistent() - .get::(&market_id) - .ok_or(Error::MarketNotFound)?; - - // Validate market state - if market.oracle_result.is_some() { - return Err(Error::MarketAlreadyResolved); - } - - // Check if market has ended - let current_time = env.ledger().timestamp(); - if current_time < market.end_time { - return Err(Error::MarketClosed); - } - - // Get oracle result using the resolution module - let oracle_resolution = resolution::OracleResolutionManager::fetch_oracle_result( - &env, - &market_id, - &oracle_contract, - )?; - - Ok(oracle_resolution.oracle_result) + pub fn fetch_oracle_result(env: Env, market_id: Symbol) -> Result { + resolution::OracleResolutionManager::fetch_oracle_result(&env, &market_id) } /// Resolves a market automatically using oracle data and community consensus. @@ -2718,13 +2697,7 @@ impl PredictifyHybrid { env.storage().persistent().set(&market_id, &market); // Emit category update event - EventEmitter::emit_category_updated( - &env, - &market_id, - &old_category, - &category, - &admin, - ); + EventEmitter::emit_category_updated(&env, &market_id, &old_category, &category, &admin); Ok(()) } @@ -2843,13 +2816,7 @@ impl PredictifyHybrid { env.storage().persistent().set(&market_id, &market); // Emit tags update event - EventEmitter::emit_tags_updated( - &env, - &market_id, - &old_tags, - &tags, - &admin, - ); + EventEmitter::emit_tags_updated(&env, &market_id, &old_tags, &tags, &admin); Ok(()) } diff --git a/contracts/predictify-hybrid/src/property_based_tests.rs b/contracts/predictify-hybrid/src/property_based_tests.rs index 6dd5893f..30005c7e 100644 --- a/contracts/predictify-hybrid/src/property_based_tests.rs +++ b/contracts/predictify-hybrid/src/property_based_tests.rs @@ -49,7 +49,7 @@ impl PropertyBasedTestSuite { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let token_id = token_contract.address(); - + // Store TokenID env.as_contract(&contract_id, || { env.storage() @@ -63,7 +63,7 @@ impl PropertyBasedTestSuite { // Mint tokens to admin and users let stellar_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); stellar_client.mint(&admin, &1_000_000_000_000); // Mint ample funds - + for user in &users { stellar_client.mint(user, &1_000_000_000_000); } @@ -86,6 +86,7 @@ impl PropertyBasedTestSuite { pub fn generate_oracle_config(&self, threshold: i128, comparison: &str) -> OracleConfig { OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&self.env), feed_id: SorobanString::from_str(&self.env, "BTC/USD"), threshold, comparison: SorobanString::from_str(&self.env, comparison), @@ -177,6 +178,8 @@ proptest! { &outcomes, &duration_days, &oracle_config, + &None, + &0, ); // Verify market was created with correct properties @@ -225,6 +228,8 @@ proptest! { &outcomes, &duration_days, &oracle_config, + &None, + &0, ); let market = client.get_market(&market_id).unwrap(); @@ -276,6 +281,8 @@ proptest! { &outcomes, &30, &oracle_config, + &None, + &0, ); // Select user and outcome for voting @@ -313,6 +320,7 @@ proptest! { // Property: Valid oracle configuration should be accepted let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&suite.env), feed_id: SorobanString::from_str(&suite.env, &feed_id), threshold, comparison: SorobanString::from_str(&suite.env, comparison), @@ -340,6 +348,7 @@ proptest! { let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&suite.env), feed_id: SorobanString::from_str(&suite.env, "BTC/USD"), threshold, comparison: SorobanString::from_str(&suite.env, comparison), @@ -441,6 +450,8 @@ proptest! { &outcomes, &duration_days, &oracle_config, + &None, + &0, ); let initial_market = client.get_market(&market_id).unwrap(); diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index f2ca2b93..ffbd36d5 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -942,46 +942,88 @@ pub struct ResolutionValidation { pub struct OracleResolutionManager; impl OracleResolutionManager { - /// Fetch oracle result for a market - pub fn fetch_oracle_result( + /// Helper to fetch price and determine outcome from an oracle config + fn try_fetch_from_config( env: &Env, - market_id: &Symbol, - oracle_contract: &Address, - ) -> Result { + config: &crate::types::OracleConfig, + ) -> Result<(i128, String), Error> { + let oracle = + OracleFactory::create_oracle(config.provider.clone(), config.oracle_address.clone())?; + + let price = oracle.get_price(env, &config.feed_id)?; + + let outcome = + OracleUtils::determine_outcome(price, config.threshold, &config.comparison, env)?; + + Ok((price, outcome)) + } + + /// Fetch oracle result for a market with fallback support and timeout + pub fn fetch_oracle_result(env: &Env, market_id: &Symbol) -> Result { // Get the market from storage let mut market = MarketStateManager::get_market(env, market_id)?; + // 1. Check if resolution timeout has been reached + let current_time = env.ledger().timestamp(); + if current_time > market.end_time + market.resolution_timeout { + // Reached timeout without resolution, mark for refund + let old_state = market.state.clone(); + market.state = crate::types::MarketState::Cancelled; + MarketStateManager::update_market(env, market_id, &market); + + crate::events::EventEmitter::emit_resolution_timeout(env, market_id, current_time); + crate::events::EventEmitter::emit_state_change_event( + env, + market_id, + &old_state, + &crate::types::MarketState::Cancelled, + &soroban_sdk::String::from_str(env, "Resolution timeout reached, market cancelled"), + ); + + return Err(Error::ResolutionTimeoutReached); + } + // Validate market for oracle resolution OracleResolutionValidator::validate_market_for_oracle_resolution(env, &market)?; - // Get the price from the appropriate oracle using the factory pattern - let oracle = OracleFactory::create_oracle( - market.oracle_config.provider.clone(), - oracle_contract.clone(), - )?; - - // Perform external oracle call (reentrancy guard removed) - let price_result = oracle.get_price(env, &market.oracle_config.feed_id); - let price = price_result?; - - // Determine the outcome based on the price and threshold using OracleUtils - let outcome = OracleUtils::determine_outcome( - price, - market.oracle_config.threshold, - &market.oracle_config.comparison, - env, - )?; + // 2. Try primary oracle + let mut used_config = market.oracle_config.clone(); + let primary_result = Self::try_fetch_from_config(env, &used_config); + + let (price, outcome) = match primary_result { + Ok(res) => res, + Err(_) => { + // 3. Try fallback oracle if primary fails + if let Some(ref fallback_config) = market.fallback_oracle_config { + match Self::try_fetch_from_config(env, fallback_config) { + Ok(res) => { + crate::events::EventEmitter::emit_fallback_used( + env, + market_id, + &market.oracle_config.oracle_address, + &fallback_config.oracle_address, + ); + used_config = fallback_config.clone(); + res + } + Err(_) => return Err(Error::FallbackOracleUnavailable), + } + } else { + return Err(Error::OracleUnavailable); + } + } + }; // Create oracle resolution record let resolution = OracleResolution { market_id: market_id.clone(), oracle_result: outcome.clone(), price, - threshold: market.oracle_config.threshold, - comparison: market.oracle_config.comparison.clone(), - timestamp: env.ledger().timestamp(), - provider: market.oracle_config.provider.clone(), - feed_id: market.oracle_config.feed_id.clone(), + threshold: used_config.threshold, + comparison: used_config.comparison.clone(), + timestamp: current_time, + provider: used_config.provider.clone(), + feed_id: used_config.feed_id.clone(), }; // Store the result in the market @@ -989,9 +1031,15 @@ impl OracleResolutionManager { MarketStateManager::update_market(env, market_id, &market); // Emit oracle result event - let provider_str = soroban_sdk::String::from_str(env, "Oracle"); - let feed_str = market.oracle_config.feed_id.clone(); - let comparison_str = market.oracle_config.comparison.clone(); + let provider_str = match used_config.provider { + crate::types::OracleProvider::Reflector => { + soroban_sdk::String::from_str(env, "Reflector") + } + crate::types::OracleProvider::Pyth => soroban_sdk::String::from_str(env, "Pyth"), + _ => soroban_sdk::String::from_str(env, "Custom"), + }; + let feed_str = used_config.feed_id.clone(); + let comparison_str = used_config.comparison.clone(); crate::events::EventEmitter::emit_oracle_result( env, @@ -1000,7 +1048,7 @@ impl OracleResolutionManager { &provider_str, &feed_str, price, - market.oracle_config.threshold, + used_config.threshold, &comparison_str, ); @@ -1717,11 +1765,9 @@ impl ResolutionTesting { pub fn simulate_resolution_process( env: &Env, market_id: &Symbol, - oracle_contract: &Address, ) -> Result { // Fetch oracle result - let _oracle_resolution = - OracleResolutionManager::fetch_oracle_result(env, market_id, oracle_contract)?; + let _oracle_resolution = OracleResolutionManager::fetch_oracle_result(env, market_id)?; // Resolve market let market_resolution = MarketResolutionManager::resolve_market(env, market_id)?; diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs index c408ab28..07dae77c 100644 --- a/contracts/predictify-hybrid/src/test.rs +++ b/contracts/predictify-hybrid/src/test.rs @@ -140,10 +140,13 @@ impl PredictifyTest { &30, &OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&self.env), feed_id: String::from_str(&self.env, "BTC"), threshold: 2500000, comparison: String::from_str(&self.env, "gt"), }, + &None, + &0, ) } } @@ -168,10 +171,13 @@ fn test_create_market_successful() { &duration_days, &OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&test.env), feed_id: String::from_str(&test.env, "BTC"), threshold: 2500000, comparison: String::from_str(&test.env, "gt"), }, + &None, + &0, ); let market = test.env.as_contract(&test.contract_id, || { @@ -1517,8 +1523,18 @@ fn test_refund_on_oracle_failure_full_amount_per_user() { let amt1 = 10_000_000i128; let amt2 = 20_000_000i128; test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &amt1); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &amt2); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &amt1, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &amt2, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -1550,7 +1566,12 @@ fn test_refund_on_oracle_failure_no_double_refund() { let market_id = test.create_test_market(); let user1 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -1587,7 +1608,12 @@ fn test_refund_on_oracle_failure_after_timeout_any_caller() { let user1 = test.create_funded_user(); let any_caller = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -2865,11 +2891,20 @@ fn test_get_bet_after_claim() { let market_id = test.create_test_market(); test.env.mock_all_auths(); - client.place_bet(&test.user, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); // Advance time and resolve market let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -2900,7 +2935,12 @@ fn test_has_user_bet_returns_true_when_bet_exists() { let user = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let has_bet = client.has_user_bet(&market_id, &user); assert!(has_bet); @@ -2956,9 +2996,24 @@ fn test_get_market_bet_stats_with_bets() { let user3 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &20_000_000); - client.place_bet(&user3, &market_id, &String::from_str(&test.env, "yes"), &15_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &20_000_000, + ); + client.place_bet( + &user3, + &market_id, + &String::from_str(&test.env, "yes"), + &15_000_000, + ); let stats = client.get_market_bet_stats(&market_id); @@ -2994,8 +3049,18 @@ fn test_get_implied_probability_balanced_market() { test.env.mock_all_auths(); // Equal bets on both sides - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &10_000_000, + ); let yes_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "yes")); let no_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "no")); @@ -3016,8 +3081,18 @@ fn test_get_implied_probability_skewed_market() { test.env.mock_all_auths(); // Skewed bets: 80% on yes, 20% on no - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &80_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &20_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &80_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &20_000_000, + ); let yes_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "yes")); let no_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "no")); @@ -3072,8 +3147,18 @@ fn test_get_payout_multiplier_even_odds() { let user2 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &10_000_000, + ); let multiplier = client.get_payout_multiplier(&market_id, &String::from_str(&test.env, "yes")); @@ -3147,10 +3232,13 @@ fn test_get_market_returns_correct_data() { &30, &OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&test.env), feed_id: String::from_str(&test.env, "BTC_USD"), threshold: 100_000_0000000, comparison: String::from_str(&test.env, "gt"), }, + &None, + &0, ); let market = client.get_market(&market_id); @@ -3182,7 +3270,11 @@ fn test_get_market_after_resolution() { // Resolve the market let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -3203,7 +3295,10 @@ fn test_get_market_after_resolution() { assert!(market_result.is_some()); let market = market_result.unwrap(); assert_eq!(market.state, MarketState::Resolved); - assert_eq!(market.winning_outcome, Some(String::from_str(&test.env, "yes"))); + assert_eq!( + market.winning_outcome, + Some(String::from_str(&test.env, "yes")) + ); } // ===== Tests for get_market_analytics() ===== @@ -3321,7 +3416,12 @@ fn test_multiple_sequential_queries() { for i in 0..5 { let user = test.create_funded_user(); let amount = (i + 1) * 1_000_000; - client.place_bet(&user, &market_id, &String::from_str(&test.env, "yes"), &amount); + client.place_bet( + &user, + &market_id, + &String::from_str(&test.env, "yes"), + &amount, + ); } // Perform multiple queries @@ -3354,7 +3454,10 @@ fn test_query_with_very_long_outcome_name() { let client = PredictifyHybridClient::new(&test.env, &test.contract_id); let market_id = test.create_test_market(); - let long_outcome = String::from_str(&test.env, "this_is_a_very_long_outcome_name_that_probably_does_not_exist_in_the_market"); + let long_outcome = String::from_str( + &test.env, + "this_is_a_very_long_outcome_name_that_probably_does_not_exist_in_the_market", + ); let prob = client.get_implied_probability(&market_id, &long_outcome); @@ -3372,8 +3475,18 @@ fn test_get_market_bet_stats_consistency() { let user2 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &15_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &15_000_000, + ); let stats1 = client.get_market_bet_stats(&market_id); let stats2 = client.get_market_bet_stats(&market_id); @@ -3415,7 +3528,6 @@ fn test_implied_probability_sum_equals_100() { assert!(total >= 95 && total <= 105); // Allow small variance } - // ===== CORE FEE CALCULATION TESTS ===== #[test] @@ -3469,7 +3581,10 @@ fn test_withdraw_collected_fee() { // Set collected fees directly in storage test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &50_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &50_000_000i128); }); test.env.mock_all_auths(); @@ -3479,7 +3594,11 @@ fn test_withdraw_collected_fee() { // Verify fees were withdrawn let remaining = test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().get::(&fees_key).unwrap_or(0) + test.env + .storage() + .persistent() + .get::(&fees_key) + .unwrap_or(0) }); assert_eq!(remaining, 0); } @@ -3493,7 +3612,10 @@ fn test_withdraw_fees_non_admin() { // Set some fees test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &50_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &50_000_000i128); }); test.env.mock_all_auths(); @@ -3508,7 +3630,10 @@ fn test_withdraw_partial_fees() { // Set collected fees test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &100_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &100_000_000i128); }); test.env.mock_all_auths(); @@ -3518,7 +3643,11 @@ fn test_withdraw_partial_fees() { // Verify remaining fees let remaining = test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().get::(&fees_key).unwrap_or(0) + test.env + .storage() + .persistent() + .get::(&fees_key) + .unwrap_or(0) }); assert_eq!(remaining, 50_000_000); } @@ -3545,14 +3674,27 @@ fn test_fee_state_after_cancellation() { let stellar_client = StellarAssetClient::new(&test.env, &test.token_test.token_id); test.env.mock_all_auths(); stellar_client.mint(&test.user, &100_000_000); - client.place_bet(&test.user, &market_id, &String::from_str(&test.env, "yes"), &100_000_000); + client.place_bet( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_000_000, + ); // Cancel market - client.cancel_event(&test.admin, &market_id, &Some(String::from_str(&test.env, "Test"))); + client.cancel_event( + &test.admin, + &market_id, + &Some(String::from_str(&test.env, "Test")), + ); // Verify market is cancelled let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market.state, MarketState::Cancelled); } @@ -3573,11 +3715,20 @@ fn test_fee_complete_flow() { stellar_client.mint(&user1, &200_000_000); // Place bet - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &200_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &200_000_000, + ); // Verify market has staked amount let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market.total_staked, 200_000_000); @@ -3601,7 +3752,11 @@ fn test_fee_complete_flow() { // Market should be resolved let market_resolved = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market_resolved.state, MarketState::Resolved); } @@ -3626,10 +3781,10 @@ fn test_fee_amount_boundaries() { fn test_percentage_calculations_accuracy() { // Test percentage calculation accuracy let test_amounts = [ - (100_000_000, 2, 2_000_000), // 10 XLM @ 2% = 0.2 XLM - (500_000_000, 2, 10_000_000), // 50 XLM @ 2% = 1 XLM + (100_000_000, 2, 2_000_000), // 10 XLM @ 2% = 0.2 XLM + (500_000_000, 2, 10_000_000), // 50 XLM @ 2% = 1 XLM (1_000_000_000, 2, 20_000_000), // 100 XLM @ 2% = 2 XLM - (100_000_000, 5, 5_000_000), // 10 XLM @ 5% = 0.5 XLM + (100_000_000, 5, 5_000_000), // 10 XLM @ 5% = 0.5 XLM (100_000_000, 10, 10_000_000), // 10 XLM @ 10% = 1 XLM ]; @@ -3659,12 +3814,18 @@ fn test_initialize_with_default_fees() { client.initialize(&admin, &None); let stored_admin: Address = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "Admin")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "Admin")) + .unwrap() }); assert_eq!(stored_admin, admin); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 2); } @@ -3681,7 +3842,10 @@ fn test_initialize_with_custom_fees() { client.initialize(&admin, &Some(5)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 5); } @@ -3699,7 +3863,10 @@ fn test_initialize_valid_fee_bound() { client.initialize(&admin, &Some(0)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 0); } @@ -3715,7 +3882,10 @@ fn test_initialize_valid_fee_bound() { client.initialize(&admin, &Some(10)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 10); } @@ -3936,11 +4106,8 @@ fn test_query_events_by_category() { let client = PredictifyHybridClient::new(&test.env, &test.contract_id); let _market_id = test.create_test_market(); - let (entries, _) = client.query_events_by_category( - &String::from_str(&test.env, "BTC"), - &0u32, - &10u32, - ); + let (entries, _) = + client.query_events_by_category(&String::from_str(&test.env, "BTC"), &0u32, &10u32); assert!(!entries.is_empty()); let first = entries.get(0).unwrap(); assert_eq!(first.category, String::from_str(&test.env, "BTC")); @@ -4031,9 +4198,18 @@ fn test_archived_entry_has_archived_at_set() { let market_id = test.create_test_market(); test.env.mock_all_auths(); - client.vote(&test.user, &market_id, &String::from_str(&test.env, "yes"), &10_0000000); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_0000000, + ); let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -4178,7 +4354,11 @@ fn test_query_result_correctness_resolved_market() { &stake, ); let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -4202,7 +4382,10 @@ fn test_query_result_correctness_resolved_market() { assert_eq!(e.state, MarketState::Resolved); assert_eq!(e.winning_outcome, Some(String::from_str(&test.env, "yes"))); assert_eq!(e.total_staked, stake); - assert_eq!(e.question, String::from_str(&test.env, "Will BTC go above $25,000 by December 31?")); + assert_eq!( + e.question, + String::from_str(&test.env, "Will BTC go above $25,000 by December 31?") + ); assert!(e.outcomes.len() >= 2); } diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index a28b44ba..fd29e8f9 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -461,6 +461,8 @@ impl OracleProvider { pub struct OracleConfig { /// The oracle provider to use pub provider: OracleProvider, + /// The oracle contract address + pub oracle_address: Address, /// Oracle-specific identifier (e.g., "BTC/USD" for Pyth, "BTC" for Reflector) pub feed_id: String, /// Price threshold in cents (e.g., 10_000_00 = $10k) @@ -473,12 +475,14 @@ impl OracleConfig { /// Create a new oracle configuration pub fn new( provider: OracleProvider, + oracle_address: Address, feed_id: String, threshold: i128, comparison: String, ) -> Self { Self { provider, + oracle_address, feed_id, threshold, comparison, @@ -708,8 +712,12 @@ pub struct Market { pub outcomes: Vec, /// Market end time (Unix timestamp) pub end_time: u64, - /// Oracle configuration for this market + /// Oracle configuration for this market (primary) pub oracle_config: OracleConfig, + /// Fallback oracle configuration + pub fallback_oracle_config: Option, + /// Resolution timeout in seconds after end_time + pub resolution_timeout: u64, /// Oracle result (set after market ends) pub oracle_result: Option, /// User votes mapping (address -> outcome) @@ -838,6 +846,8 @@ impl Market { outcomes: Vec, end_time: u64, oracle_config: OracleConfig, + fallback_oracle_config: Option, + resolution_timeout: u64, state: MarketState, ) -> Self { Self { @@ -846,6 +856,8 @@ impl Market { outcomes, end_time, oracle_config, + fallback_oracle_config, + resolution_timeout, oracle_result: None, votes: Map::new(env), stakes: Map::new(env), @@ -2622,8 +2634,12 @@ pub struct Event { pub outcomes: Vec, /// When the event ends (Unix timestamp) pub end_time: u64, - /// Oracle configuration for result verification + /// Oracle configuration for result verification (primary) pub oracle_config: OracleConfig, + /// Fallback oracle configuration + pub fallback_oracle_config: Option, + /// Resolution timeout in seconds after end_time + pub resolution_timeout: u64, /// Administrative address that created/manages the event pub admin: Address, /// When the event was created (Unix timestamp) diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 20e5bbba..6f345f49 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -1299,7 +1299,10 @@ impl InputValidator { } /// Validate sufficient balance for withdrawal/transfer - pub fn validate_sufficient_balance(current: i128, required: i128) -> Result<(), ValidationError> { + pub fn validate_sufficient_balance( + current: i128, + required: i128, + ) -> Result<(), ValidationError> { if current < required { return Err(ValidationError::NumberOutOfRange); } @@ -1899,6 +1902,8 @@ impl MarketValidator { outcomes: &Vec, duration_days: &u32, oracle_config: &OracleConfig, + fallback_oracle_config: &Option, + resolution_timeout: &u64, ) -> ValidationResult { let mut result = ValidationResult::valid(); @@ -1934,6 +1939,18 @@ impl MarketValidator { result.add_error(); } + // Validate fallback oracle config if provided + if let Some(ref fallback) = fallback_oracle_config { + if let Err(_) = OracleValidator::validate_oracle_config(env, fallback) { + result.add_error(); + } + } + + // Validate resolution timeout + if let Err(_) = OracleConfigValidator::validate_resolution_timeout(resolution_timeout) { + result.add_error(); + } + // Add recommendations for optimization if result.is_valid { if question.len() < 50 { @@ -4123,6 +4140,18 @@ impl OracleConfigValidator { /// **Band Protocol & DIA:** /// - Not supported on Stellar network /// - Returns validation error + pub fn validate_resolution_timeout(timeout: &u64) -> Result<(), ValidationError> { + if *timeout < 3600 { + // 1 hour minimum + return Err(ValidationError::NumberOutOfRange); + } + if *timeout > 31_536_000 { + // 1 year maximum + return Err(ValidationError::NumberOutOfRange); + } + Ok(()) + } + pub fn validate_feed_id_format( feed_id: &String, provider: &OracleProvider, diff --git a/contracts/predictify-hybrid/src/validation_tests.rs b/contracts/predictify-hybrid/src/validation_tests.rs index f1c73d86..5cc165a2 100644 --- a/contracts/predictify-hybrid/src/validation_tests.rs +++ b/contracts/predictify-hybrid/src/validation_tests.rs @@ -624,6 +624,7 @@ fn test_validate_comprehensive_inputs() { let duration_days = 30; let oracle_config = OracleConfig { provider: OracleProvider::Pyth, + oracle_address: Address::generate(&env), feed_id: String::from_str(&env, "BTC/USD"), threshold: 100000, comparison: String::from_str(&env, "gt"), @@ -656,6 +657,7 @@ fn test_validate_market_creation() { let duration_days = 30; let oracle_config = OracleConfig { provider: OracleProvider::Pyth, + oracle_address: Address::generate(&env), feed_id: String::from_str(&env, "BTC/USD"), threshold: 100000, comparison: String::from_str(&env, "gt"), @@ -1206,8 +1208,9 @@ mod oracle_config_validator_tests { // ).is_err()); // // Invalid configuration - unsupported provider - // let unsupported_provider_config = OracleConfig::new( - // OracleProvider::BandProtocol, + // let oracle_config = OracleConfig::new( + // OracleProvider::Reflector, + // Address::generate(&env), // String::from_str(&env, "BTC/USD"), // 50_000_00, // String::from_str(&env, "gt") @@ -1387,8 +1390,9 @@ mod oracle_config_validator_tests { // Test Reflector-specific validation let reflector_config = OracleConfig::new( OracleProvider::Reflector, - String::from_str(&env, "BTC/USD"), - 50_000_00, + Address::generate(&env), + String::from_str(&env, "BTC_USD"), + 2500000, String::from_str(&env, "gt"), ); @@ -1407,6 +1411,7 @@ mod oracle_config_validator_tests { // Test Pyth-specific validation (should fail for provider support but pass format validation) let pyth_config = OracleConfig::new( OracleProvider::Pyth, + Address::generate(&env), String::from_str( &env, "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", diff --git a/contracts/predictify-hybrid/src/voting.rs b/contracts/predictify-hybrid/src/voting.rs index 10746527..34a7148b 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -1583,10 +1583,13 @@ mod tests { env.ledger().timestamp() + 86400, OracleConfig::new( OracleProvider::Pyth, + Address::generate(&env), String::from_str(&env, "BTC/USD"), 2500000, String::from_str(&env, "gt"), ), + None, + 0, crate::types::MarketState::Active, ); market.total_staked = 100_000_000; // 10 XLM @@ -1610,10 +1613,13 @@ mod tests { env.ledger().timestamp() + 86400, OracleConfig::new( OracleProvider::Pyth, + Address::generate(&env), String::from_str(&env, "BTC/USD"), 2500000, String::from_str(&env, "gt"), ), + None, + 0, crate::types::MarketState::Active, ); @@ -1643,10 +1649,13 @@ mod tests { env.ledger().timestamp() + 86400, OracleConfig::new( OracleProvider::Pyth, + Address::generate(&env), String::from_str(&env, "BTC/USD"), 2500000, String::from_str(&env, "gt"), ), + None, + 0, crate::types::MarketState::Active, ); From ae5072e50706f1db39458f3f83d77e9881ef656c Mon Sep 17 00:00:00 2001 From: victor isiguzor uzoma Date: Sat, 31 Jan 2026 03:47:37 -0800 Subject: [PATCH 2/2] Fix build failures and implement oracle fallback/timeout mechanisms --- contracts/predictify-hybrid/src/errors.rs | 216 +++++++++--------- .../src/event_creation_tests.rs | 15 ++ .../predictify-hybrid/src/integration_test.rs | 3 + contracts/predictify-hybrid/src/lib.rs | 1 + contracts/predictify-hybrid/src/markets.rs | 6 + contracts/predictify-hybrid/src/monitoring.rs | 4 + contracts/predictify-hybrid/src/resolution.rs | 1 + contracts/predictify-hybrid/src/validation.rs | 2 + 8 files changed, 140 insertions(+), 108 deletions(-) diff --git a/contracts/predictify-hybrid/src/errors.rs b/contracts/predictify-hybrid/src/errors.rs index 7eff6d8f..3bf3d3b1 100644 --- a/contracts/predictify-hybrid/src/errors.rs +++ b/contracts/predictify-hybrid/src/errors.rs @@ -1082,62 +1082,62 @@ impl Error { /// - **Debugging**: Understand error conditions during development pub fn description(&self) -> &'static str { match self { - Error::Unauthorized => "User is not authorized to perform this action", - Error::MarketNotFound => "Market not found", - Error::MarketClosed => "Market is closed", - Error::MarketAlreadyResolved => "Market is already resolved", - Error::MarketNotResolved => "Market is not resolved yet", - Error::NothingToClaim => "User has nothing to claim", - Error::AlreadyClaimed => "User has already claimed", - Error::InsufficientStake => "Insufficient stake amount", - Error::InvalidOutcome => "Invalid outcome choice", - Error::AlreadyVoted => "User has already voted", - Error::AlreadyBet => "User has already placed a bet on this market", - Error::BetsAlreadyPlaced => { + Self::Unauthorized => "User is not authorized to perform this action", + Self::MarketNotFound => "Market not found", + Self::MarketClosed => "Market is closed", + Self::MarketAlreadyResolved => "Market is already resolved", + Self::MarketNotResolved => "Market is not resolved yet", + Self::NothingToClaim => "User has nothing to claim", + Self::AlreadyClaimed => "User has already claimed", + Self::InsufficientStake => "Insufficient stake amount", + Self::InvalidOutcome => "Invalid outcome choice", + Self::AlreadyVoted => "User has already voted", + Self::AlreadyBet => "User has already placed a bet on this market", + Self::BetsAlreadyPlaced => { "Bets have already been placed on this market (cannot update)" } - Error::InsufficientBalance => "Insufficient balance for operation", - Error::OracleUnavailable => "Oracle is unavailable", - Error::InvalidOracleConfig => "Invalid oracle configuration", - Error::FallbackOracleUnavailable => "Fallback oracle is unavailable or unhealthy", - Error::ResolutionTimeoutReached => "Resolution timeout has been reached", - Error::RefundStarted => "Refund process has been initiated", - Error::InvalidQuestion => "Invalid question format", - Error::InvalidOutcomes => "Invalid outcomes provided", - Error::InvalidDuration => "Invalid duration specified", - Error::InvalidThreshold => "Invalid threshold value", - Error::InvalidComparison => "Invalid comparison operator", - Error::InvalidState => "Invalid state", - Error::InvalidInput => "Invalid input", - Error::InvalidFeeConfig => "Invalid fee configuration", - Error::ConfigurationNotFound => "Configuration not found", - Error::AlreadyDisputed => "Already disputed", - Error::DisputeVotingPeriodExpired => "Dispute voting period expired", - Error::DisputeVotingNotAllowed => "Dispute voting not allowed", - Error::DisputeAlreadyVoted => "Already voted in dispute", - Error::DisputeResolutionConditionsNotMet => "Dispute resolution conditions not met", - Error::DisputeFeeDistributionFailed => "Dispute fee distribution failed", - Error::DisputeEscalationNotAllowed => "Dispute escalation not allowed", - Error::ThresholdBelowMinimum => "Threshold below minimum", - Error::ThresholdExceedsMaximum => "Threshold exceeds maximum", - Error::FeeAlreadyCollected => "Fee already collected", - Error::InvalidOracleFeed => "Invalid oracle feed", - Error::NoFeesToCollect => "No fees to collect", - Error::InvalidExtensionDays => "Invalid extension days", - Error::ExtensionDaysExceeded => "Extension days exceeded", - Error::MarketExtensionNotAllowed => "Market extension not allowed", - Error::ExtensionFeeInsufficient => "Extension fee insufficient", - Error::AdminNotSet => "Admin address is not set (initialization missing)", - Error::DisputeTimeoutNotSet => "Dispute timeout not set", - - Error::DisputeTimeoutNotExpired => "Dispute timeout not expired", - Error::InvalidTimeoutHours => "Invalid timeout hours", - Error::DisputeTimeoutExtensionNotAllowed => "Dispute timeout extension not allowed", - Error::CircuitBreakerNotInitialized => "Circuit breaker not initialized", - Error::CircuitBreakerAlreadyOpen => "Circuit breaker is already open (paused)", - Error::CircuitBreakerNotOpen => "Circuit breaker is not open (cannot recover)", - Error::CircuitBreakerOpen => "Circuit breaker is open (operations blocked)", - Error::AlreadyInitialized => "Already Initialized", + Self::InsufficientBalance => "Insufficient balance for operation", + Self::OracleUnavailable => "Oracle is unavailable", + Self::InvalidOracleConfig => "Invalid oracle configuration", + Self::FallbackOracleUnavailable => "Fallback oracle is unavailable or unhealthy", + Self::ResolutionTimeoutReached => "Resolution timeout has been reached", + Self::RefundStarted => "Refund process has been initiated", + Self::InvalidQuestion => "Invalid question format", + Self::InvalidOutcomes => "Invalid outcomes provided", + Self::InvalidDuration => "Invalid duration specified", + Self::InvalidThreshold => "Invalid threshold value", + Self::InvalidComparison => "Invalid comparison operator", + Self::InvalidState => "Invalid state", + Self::InvalidInput => "Invalid input", + Self::InvalidFeeConfig => "Invalid fee configuration", + Self::ConfigurationNotFound => "Configuration not found", + Self::AlreadyDisputed => "Already disputed", + Self::DisputeVotingPeriodExpired => "Dispute voting period expired", + Self::DisputeVotingNotAllowed => "Dispute voting not allowed", + Self::DisputeAlreadyVoted => "Already voted in dispute", + Self::DisputeResolutionConditionsNotMet => "Dispute resolution conditions not met", + Self::DisputeFeeDistributionFailed => "Dispute fee distribution failed", + Self::DisputeEscalationNotAllowed => "Dispute escalation not allowed", + Self::ThresholdBelowMinimum => "Threshold below minimum", + Self::ThresholdExceedsMaximum => "Threshold exceeds maximum", + Self::FeeAlreadyCollected => "Fee already collected", + Self::InvalidOracleFeed => "Invalid oracle feed", + Self::NoFeesToCollect => "No fees to collect", + Self::InvalidExtensionDays => "Invalid extension days", + Self::ExtensionDaysExceeded => "Extension days exceeded", + Self::MarketExtensionNotAllowed => "Market extension not allowed", + Self::ExtensionFeeInsufficient => "Extension fee insufficient", + Self::AdminNotSet => "Admin address is not set (initialization missing)", + Self::DisputeTimeoutNotSet => "Dispute timeout not set", + + Self::DisputeTimeoutNotExpired => "Dispute timeout not expired", + Self::InvalidTimeoutHours => "Invalid timeout hours", + Self::DisputeTimeoutExtensionNotAllowed => "Dispute timeout extension not allowed", + Self::CircuitBreakerNotInitialized => "Circuit breaker not initialized", + Self::CircuitBreakerAlreadyOpen => "Circuit breaker is already open (paused)", + Self::CircuitBreakerNotOpen => "Circuit breaker is not open (cannot recover)", + Self::CircuitBreakerOpen => "Circuit breaker is open (operations blocked)", + Self::AlreadyInitialized => "Already Initialized", } } @@ -1206,60 +1206,60 @@ impl Error { /// - **Testing**: Verify specific error conditions in unit tests pub fn code(&self) -> &'static str { match self { - Error::Unauthorized => "UNAUTHORIZED", - Error::MarketNotFound => "MARKET_NOT_FOUND", - Error::MarketClosed => "MARKET_CLOSED", - Error::MarketAlreadyResolved => "MARKET_ALREADY_RESOLVED", - Error::MarketNotResolved => "MARKET_NOT_RESOLVED", - Error::NothingToClaim => "NOTHING_TO_CLAIM", - Error::AlreadyClaimed => "ALREADY_CLAIMED", - Error::InsufficientStake => "INSUFFICIENT_STAKE", - Error::InvalidOutcome => "INVALID_OUTCOME", - Error::AlreadyVoted => "ALREADY_VOTED", - Error::AlreadyBet => "ALREADY_BET", - Error::BetsAlreadyPlaced => "BETS_ALREADY_PLACED", - Error::InsufficientBalance => "INSUFFICIENT_BALANCE", - Error::OracleUnavailable => "ORACLE_UNAVAILABLE", - Error::InvalidOracleConfig => "INVALID_ORACLE_CONFIG", - Error::FallbackOracleUnavailable => "FALLBACK_ORACLE_UNAVAILABLE", - Error::ResolutionTimeoutReached => "RESOLUTION_TIMEOUT_REACHED", - Error::RefundStarted => "REFUND_STARTED", - Error::InvalidQuestion => "INVALID_QUESTION", - Error::InvalidOutcomes => "INVALID_OUTCOMES", - Error::InvalidDuration => "INVALID_DURATION", - Error::InvalidThreshold => "INVALID_THRESHOLD", - Error::InvalidComparison => "INVALID_COMPARISON", - Error::InvalidState => "INVALID_STATE", - Error::InvalidInput => "INVALID_INPUT", - Error::InvalidFeeConfig => "INVALID_FEE_CONFIG", - Error::ConfigurationNotFound => "CONFIGURATION_NOT_FOUND", - Error::AlreadyDisputed => "ALREADY_DISPUTED", - Error::DisputeVotingPeriodExpired => "DISPUTE_VOTING_PERIOD_EXPIRED", - Error::DisputeVotingNotAllowed => "DISPUTE_VOTING_NOT_ALLOWED", - Error::DisputeAlreadyVoted => "DISPUTE_ALREADY_VOTED", - Error::DisputeResolutionConditionsNotMet => "DISPUTE_RESOLUTION_CONDITIONS_NOT_MET", - Error::DisputeFeeDistributionFailed => "DISPUTE_FEE_DISTRIBUTION_FAILED", - Error::DisputeEscalationNotAllowed => "DISPUTE_ESCALATION_NOT_ALLOWED", - Error::ThresholdBelowMinimum => "THRESHOLD_BELOW_MINIMUM", - Error::ThresholdExceedsMaximum => "THRESHOLD_EXCEEDS_MAXIMUM", - Error::FeeAlreadyCollected => "FEE_ALREADY_COLLECTED", - Error::InvalidOracleFeed => "INVALID_ORACLE_FEED", - Error::NoFeesToCollect => "NO_FEES_TO_COLLECT", - Error::InvalidExtensionDays => "INVALID_EXTENSION_DAYS", - Error::ExtensionDaysExceeded => "EXTENSION_DAYS_EXCEEDED", - Error::MarketExtensionNotAllowed => "MARKET_EXTENSION_NOT_ALLOWED", - Error::ExtensionFeeInsufficient => "EXTENSION_FEE_INSUFFICIENT", - Error::AdminNotSet => "ADMIN_NOT_SET", - Error::DisputeTimeoutNotSet => "DISPUTE_TIMEOUT_NOT_SET", - - Error::DisputeTimeoutNotExpired => "DISPUTE_TIMEOUT_NOT_EXPIRED", - Error::InvalidTimeoutHours => "INVALID_TIMEOUT_HOURS", - Error::DisputeTimeoutExtensionNotAllowed => "DISPUTE_TIMEOUT_EXTENSION_NOT_ALLOWED", - Error::CircuitBreakerNotInitialized => "CIRCUIT_BREAKER_NOT_INITIALIZED", - Error::CircuitBreakerAlreadyOpen => "CIRCUIT_BREAKER_ALREADY_OPEN", - Error::CircuitBreakerNotOpen => "CIRCUIT_BREAKER_NOT_OPEN", - Error::CircuitBreakerOpen => "CIRCUIT_BREAKER_OPEN", - Error::AlreadyInitialized => "Already_Initialized", + Self::Unauthorized => "UNAUTHORIZED", + Self::MarketNotFound => "MARKET_NOT_FOUND", + Self::MarketClosed => "MARKET_CLOSED", + Self::MarketAlreadyResolved => "MARKET_ALREADY_RESOLVED", + Self::MarketNotResolved => "MARKET_NOT_RESOLVED", + Self::NothingToClaim => "NOTHING_TO_CLAIM", + Self::AlreadyClaimed => "ALREADY_CLAIMED", + Self::InsufficientStake => "INSUFFICIENT_STAKE", + Self::InvalidOutcome => "INVALID_OUTCOME", + Self::AlreadyVoted => "ALREADY_VOTED", + Self::AlreadyBet => "ALREADY_BET", + Self::BetsAlreadyPlaced => "BETS_ALREADY_PLACED", + Self::InsufficientBalance => "INSUFFICIENT_BALANCE", + Self::OracleUnavailable => "ORACLE_UNAVAILABLE", + Self::InvalidOracleConfig => "INVALID_ORACLE_CONFIG", + Self::FallbackOracleUnavailable => "FALLBACK_ORACLE_UNAVAILABLE", + Self::ResolutionTimeoutReached => "RESOLUTION_TIMEOUT_REACHED", + Self::RefundStarted => "REFUND_STARTED", + Self::InvalidQuestion => "INVALID_QUESTION", + Self::InvalidOutcomes => "INVALID_OUTCOMES", + Self::InvalidDuration => "INVALID_DURATION", + Self::InvalidThreshold => "INVALID_THRESHOLD", + Self::InvalidComparison => "INVALID_COMPARISON", + Self::InvalidState => "INVALID_STATE", + Self::InvalidInput => "INVALID_INPUT", + Self::InvalidFeeConfig => "INVALID_FEE_CONFIG", + Self::ConfigurationNotFound => "CONFIGURATION_NOT_FOUND", + Self::AlreadyDisputed => "ALREADY_DISPUTED", + Self::DisputeVotingPeriodExpired => "DISPUTE_VOTING_PERIOD_EXPIRED", + Self::DisputeVotingNotAllowed => "DISPUTE_VOTING_NOT_ALLOWED", + Self::DisputeAlreadyVoted => "DISPUTE_ALREADY_VOTED", + Self::DisputeResolutionConditionsNotMet => "DISPUTE_RESOLUTION_CONDITIONS_NOT_MET", + Self::DisputeFeeDistributionFailed => "DISPUTE_FEE_DISTRIBUTION_FAILED", + Self::DisputeEscalationNotAllowed => "DISPUTE_ESCALATION_NOT_ALLOWED", + Self::ThresholdBelowMinimum => "THRESHOLD_BELOW_MINIMUM", + Self::ThresholdExceedsMaximum => "THRESHOLD_EXCEEDS_MAXIMUM", + Self::FeeAlreadyCollected => "FEE_ALREADY_COLLECTED", + Self::InvalidOracleFeed => "INVALID_ORACLE_FEED", + Self::NoFeesToCollect => "NO_FEES_TO_COLLECT", + Self::InvalidExtensionDays => "INVALID_EXTENSION_DAYS", + Self::ExtensionDaysExceeded => "EXTENSION_DAYS_EXCEEDED", + Self::MarketExtensionNotAllowed => "MARKET_EXTENSION_NOT_ALLOWED", + Self::ExtensionFeeInsufficient => "EXTENSION_FEE_INSUFFICIENT", + Self::AdminNotSet => "ADMIN_NOT_SET", + Self::DisputeTimeoutNotSet => "DISPUTE_TIMEOUT_NOT_SET", + + Self::DisputeTimeoutNotExpired => "DISPUTE_TIMEOUT_NOT_EXPIRED", + Self::InvalidTimeoutHours => "INVALID_TIMEOUT_HOURS", + Self::DisputeTimeoutExtensionNotAllowed => "DISPUTE_TIMEOUT_EXTENSION_NOT_ALLOWED", + Self::CircuitBreakerNotInitialized => "CIRCUIT_BREAKER_NOT_INITIALIZED", + Self::CircuitBreakerAlreadyOpen => "CIRCUIT_BREAKER_ALREADY_OPEN", + Self::CircuitBreakerNotOpen => "CIRCUIT_BREAKER_NOT_OPEN", + Self::CircuitBreakerOpen => "CIRCUIT_BREAKER_OPEN", + Self::AlreadyInitialized => "Already_Initialized", } } } diff --git a/contracts/predictify-hybrid/src/event_creation_tests.rs b/contracts/predictify-hybrid/src/event_creation_tests.rs index e6a37376..6f1a882a 100644 --- a/contracts/predictify-hybrid/src/event_creation_tests.rs +++ b/contracts/predictify-hybrid/src/event_creation_tests.rs @@ -52,6 +52,7 @@ fn test_create_event_success() { let end_time = setup.env.ledger().timestamp() + 3600; // 1 hour from now let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&setup.env), feed_id: String::from_str(&setup.env, "BTC/USD"), threshold: 50000, comparison: String::from_str(&setup.env, "gt"), @@ -63,6 +64,8 @@ fn test_create_event_success() { &outcomes, &end_time, &oracle_config, + &None, + &0, ); // Verify event details using the new get_event method @@ -86,6 +89,7 @@ fn test_create_market_success() { let duration_days = 30; let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&setup.env), feed_id: String::from_str(&setup.env, "BTC/USD"), threshold: 50000, comparison: String::from_str(&setup.env, "gt"), @@ -97,6 +101,8 @@ fn test_create_market_success() { &outcomes, &duration_days, &oracle_config, + &None, + &0, ); assert!(client.get_market(&market_id).is_some()); @@ -118,6 +124,7 @@ fn test_create_event_unauthorized() { let end_time = setup.env.ledger().timestamp() + 3600; let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&setup.env), feed_id: String::from_str(&setup.env, "BTC/USD"), threshold: 50000, comparison: String::from_str(&setup.env, "gt"), @@ -129,6 +136,8 @@ fn test_create_event_unauthorized() { &outcomes, &end_time, &oracle_config, + &None, + &0, ); } @@ -147,6 +156,7 @@ fn test_create_event_invalid_end_time() { let end_time = setup.env.ledger().timestamp() - 3600; // Past time let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&setup.env), feed_id: String::from_str(&setup.env, "BTC/USD"), threshold: 50000, comparison: String::from_str(&setup.env, "gt"), @@ -158,6 +168,8 @@ fn test_create_event_invalid_end_time() { &outcomes, &end_time, &oracle_config, + &None, + &0, ); } @@ -172,6 +184,7 @@ fn test_create_event_empty_outcomes() { let end_time = setup.env.ledger().timestamp() - 3600; // Past time let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&setup.env), feed_id: String::from_str(&setup.env, "BTC/USD"), threshold: 50000, comparison: String::from_str(&setup.env, "gt"), @@ -183,5 +196,7 @@ fn test_create_event_empty_outcomes() { &outcomes, &end_time, &oracle_config, + &None, + &0, ); } diff --git a/contracts/predictify-hybrid/src/integration_test.rs b/contracts/predictify-hybrid/src/integration_test.rs index 41ab6c21..3a4b6dd7 100644 --- a/contracts/predictify-hybrid/src/integration_test.rs +++ b/contracts/predictify-hybrid/src/integration_test.rs @@ -91,10 +91,13 @@ impl IntegrationTestSuite { &duration_days, &OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::generate(&self.env), feed_id: String::from_str(&self.env, "BTC"), threshold: 2500000, comparison: String::from_str(&self.env, "gt"), }, + &None, + &0, ); self.market_ids.push_back(market_id.clone()); diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index a2fb05e1..8be3ded5 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -104,6 +104,7 @@ use crate::events::EventEmitter; use crate::graceful_degradation::{OracleBackup, OracleHealth}; use crate::market_id_generator::MarketIdGenerator; use crate::reentrancy_guard::ReentrancyGuard; +use crate::resolution::OracleResolution; use alloc::format; use soroban_sdk::{ contract, contractimpl, panic_with_error, Address, Env, Map, String, Symbol, Vec, diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 39d212a6..0f5f04b7 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -188,6 +188,7 @@ impl MarketCreator { pub fn create_reflector_market( _env: &Env, admin: Address, + oracle_address: Address, question: String, outcomes: Vec, duration_days: u32, @@ -197,6 +198,7 @@ impl MarketCreator { ) -> Result { let oracle_config = OracleConfig { provider: OracleProvider::Reflector, + oracle_address, feed_id: asset_symbol, threshold, comparison, @@ -268,6 +270,7 @@ impl MarketCreator { pub fn create_pyth_market( _env: &Env, admin: Address, + oracle_address: Address, question: String, outcomes: Vec, duration_days: u32, @@ -277,6 +280,7 @@ impl MarketCreator { ) -> Result { let oracle_config = OracleConfig { provider: OracleProvider::Pyth, + oracle_address, feed_id, threshold, comparison, @@ -348,6 +352,7 @@ impl MarketCreator { pub fn create_reflector_asset_market( _env: &Env, admin: Address, + oracle_address: Address, question: String, outcomes: Vec, duration_days: u32, @@ -358,6 +363,7 @@ impl MarketCreator { Self::create_reflector_market( _env, admin, + oracle_address, question, outcomes, duration_days, diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index df0de5fa..d8f52508 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -441,6 +441,10 @@ impl ContractMonitor { end_time: env.ledger().timestamp() + 86400, oracle_config: OracleConfig { provider: OracleProvider::Reflector, + oracle_address: Address::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), feed_id: String::from_str(env, "sample_feed"), threshold: 100, comparison: String::from_str(env, ">="), diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index 24a7047c..b456f4dc 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -1881,6 +1881,7 @@ mod tests { env.ledger().timestamp() + 86400, OracleConfig { provider: OracleProvider::Pyth, + oracle_address: Address::generate(&env), feed_id: String::from_str(&env, "BTC/USD"), threshold: 2500000, comparison: String::from_str(&env, "gt"), diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index bda49219..c061d224 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -2980,6 +2980,7 @@ impl ValidationTestingUtils { env.ledger().timestamp() + 86400, OracleConfig { provider: OracleProvider::Pyth, + oracle_address: Address::generate(env), feed_id: String::from_str(env, "BTC/USD"), threshold: 2500000, comparison: String::from_str(env, "gt"), @@ -2992,6 +2993,7 @@ impl ValidationTestingUtils { pub fn create_test_oracle_config(env: &Env) -> OracleConfig { OracleConfig { provider: OracleProvider::Pyth, + oracle_address: Address::generate(env), feed_id: String::from_str(env, "BTC/USD"), threshold: 2500000, comparison: String::from_str(env, "gt"),