diff --git a/contracts/predictify-hybrid/src/errors.rs b/contracts/predictify-hybrid/src/errors.rs index 5cc740c0..3bf3d3b1 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 @@ -1076,59 +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::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", } } @@ -1197,57 +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::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/events.rs b/contracts/predictify-hybrid/src/events.rs index 50483087..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, 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 6e07dd9e..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, @@ -352,6 +353,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(); @@ -393,6 +396,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, @@ -452,6 +457,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(); @@ -490,6 +497,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, @@ -1691,37 +1700,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. 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/property_based_tests.rs b/contracts/predictify-hybrid/src/property_based_tests.rs index c2633b39..30005c7e 100644 --- a/contracts/predictify-hybrid/src/property_based_tests.rs +++ b/contracts/predictify-hybrid/src/property_based_tests.rs @@ -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 8e22e0df..b456f4dc 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, ); @@ -1736,11 +1784,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)?; @@ -1835,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/test.rs b/contracts/predictify-hybrid/src/test.rs index a216681d..a5da4e50 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, || { @@ -3238,10 +3244,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); diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 58c3502c..178f596f 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) @@ -840,6 +848,8 @@ impl Market { outcomes: Vec, end_time: u64, oracle_config: OracleConfig, + fallback_oracle_config: Option, + resolution_timeout: u64, state: MarketState, ) -> Self { Self { @@ -848,6 +858,8 @@ impl Market { outcomes, end_time, oracle_config, + fallback_oracle_config, + resolution_timeout, oracle_result: None, votes: Map::new(env), stakes: Map::new(env), @@ -2643,8 +2655,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 084fe8c6..c061d224 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -1902,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(); @@ -1937,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 { @@ -2966,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"), @@ -2978,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"), @@ -4126,6 +4142,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 71e09b05..61595fcf 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -1592,10 +1592,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 @@ -1619,10 +1622,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, ); @@ -1652,10 +1658,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, );