From 6d421113eddf23629580248843cd6e4c5bc578d9 Mon Sep 17 00:00:00 2001 From: flourishbar Date: Fri, 3 Jul 2026 23:54:57 +0100 Subject: [PATCH] fix: avoid OracleResultEvent double-emission and resolve compilation issues --- contracts/predictify-hybrid/src/err.rs | 10 +- contracts/predictify-hybrid/src/events.rs | 2 +- contracts/predictify-hybrid/src/fees.rs | 4 +- contracts/predictify-hybrid/src/gas.rs | 20 +- .../src/gas_tracking_tests.rs | 14 +- contracts/predictify-hybrid/src/lib.rs | 166 ++++++- .../src/market_id_generator.rs | 88 ++-- contracts/predictify-hybrid/src/recovery.rs | 2 +- contracts/predictify-hybrid/src/resolution.rs | 451 +++++++++++++----- contracts/predictify-hybrid/src/storage.rs | 4 +- 10 files changed, 559 insertions(+), 202 deletions(-) diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 29996cd1..4058e760 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -193,8 +193,6 @@ pub enum Error { // ===== VALIDATION ERRORS (435-437) ===== /// Market ID already exists in the registry. Cannot create duplicate market IDs. DuplicateMarketId = 441, - /// Override replay detected. Nonce has already been used. - ReplayedOverride = 442, // ===== CIRCUIT BREAKER ERRORS ===== /// Circuit breaker has not been initialized. Initialize before use. @@ -227,6 +225,12 @@ pub enum Error { /// The effective fee (in basis points) exceeds the maximum the caller is willing to accept. /// The bet is rejected to protect the caller from unexpected fee changes. FeeExceedsMax = 508, + /// A place_bets batch with this idempotency key has already been successfully applied. + IdempotentBatchAlreadyApplied = 509, + /// Force-resolve idempotency key has already been used. Use a new unique key. + ForceResolveReplayed = 517, + /// Force-resolve reason is empty. Every force-resolve must be justified. + ForceResolveReasonEmpty = 518, /// No pending fee config commit was found for reveal or apply. NoPendingFeeCommit = 519, /// Fee config reveal was attempted too early (before timelock expiry). @@ -1478,7 +1482,7 @@ impl Error { "Bets have already been placed on this market (cannot update)" } Error::InsufficientBalance => "Insufficient balance for operation", - Error::InsufficientStorageRent => "Insufficient storage rent for persistent key allocation", + Error::InsufficientStorageRentBudget => "Insufficient storage rent for persistent key allocation", Error::OracleUnavailable => "Oracle is unavailable", Error::InvalidOracleConfig => "Invalid oracle configuration", Error::GasBudgetExceeded => "Gas budget exceeded", diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..d218603b 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -5142,7 +5142,7 @@ mod focused_dispute_tests { // topic2 = 1 (schema version) let mut found = false; - for event in events.iter() { + for event in events.events().iter() { if event.2.len() == 3 { let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 68a13ba9..22a1f178 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -1044,7 +1044,7 @@ impl FeeCalculator { } fn checked_bps_floor(amount: i128, bps: i128) -> Result { - Self::checked_mul_div_floor(amount, bps, crate::PERCENTAGE_DENOMINATOR) + Self::checked_mul_div_floor(amount, bps, crate::config::PERCENTAGE_DENOMINATOR) } /// Calculate platform fee for a market @@ -1155,7 +1155,7 @@ impl FeeCalculator { let fee_percentage = PLATFORM_FEE_PERCENTAGE; let user_share = - Self::checked_bps_floor(user_stake, crate::PERCENTAGE_DENOMINATOR - fee_percentage)?; + Self::checked_bps_floor(user_stake, crate::config::PERCENTAGE_DENOMINATOR - fee_percentage)?; let payout = Self::checked_mul_div_floor(user_share, total_pool, winning_total)?; Ok(payout) diff --git a/contracts/predictify-hybrid/src/gas.rs b/contracts/predictify-hybrid/src/gas.rs index 1fd6c822..26c26379 100644 --- a/contracts/predictify-hybrid/src/gas.rs +++ b/contracts/predictify-hybrid/src/gas.rs @@ -19,7 +19,7 @@ pub enum GasConfigKey { /// Represents consumed resources for an operation. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq, Default)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct GasUsage { pub cpu: u64, pub mem: u64, @@ -60,6 +60,15 @@ pub struct GasUsage { pub struct GasTracker; impl GasUsage { + pub fn new(env: &Env) -> Self { + Self { + cpu: 0, + mem: 0, + cpu_history: Vec::new(env), + history_index: 0, + history_count: 0, + } + } /// Adds a new CPU usage value to the rolling window buffer. /// Uses ring buffer semantics for O(1) insertion. /// Returns the moving average of the buffer contents. @@ -210,17 +219,18 @@ impl GasTracker { // Check if we've crossed the threshold if used > threshold { + use alloc::string::ToString; // Emit performance metric event let event = PerformanceMetricEvent { - metric_name: Symbol::new(env, "gas_low_water").into(), + metric_name: soroban_sdk::String::from_str(env, "gas_low_water"), value: used as i128, - unit: Symbol::new(env, "cpu").into(), - context: operation.into(), + unit: soroban_sdk::String::from_str(env, "cpu"), + context: soroban_sdk::String::from_str(env, &operation.to_string()), timestamp: env.ledger().timestamp(), }; env.events().publish( - (symbol_short!("performance_metric"), operation.clone()), + (Symbol::new(env, "performance_metric"), operation.clone()), event, ); } diff --git a/contracts/predictify-hybrid/src/gas_tracking_tests.rs b/contracts/predictify-hybrid/src/gas_tracking_tests.rs index 4c511e50..8a71a745 100644 --- a/contracts/predictify-hybrid/src/gas_tracking_tests.rs +++ b/contracts/predictify-hybrid/src/gas_tracking_tests.rs @@ -499,7 +499,7 @@ fn test_gas_operations_within_expected_ranges() { fn test_gas_usage_ring_buffer_initialization() { // Test: Ring buffer initializes correctly with empty state let env = Env::default(); - let mut usage = GasUsage::default(); + let mut usage = GasUsage::new(&env); assert_eq!(usage.history_count, 0); assert_eq!(usage.history_index, 0); @@ -510,7 +510,7 @@ fn test_gas_usage_ring_buffer_initialization() { fn test_gas_usage_add_to_history() { // Test: Adding values to ring buffer works correctly let env = Env::default(); - let mut usage = GasUsage::default(); + let mut usage = GasUsage::new(&env); // Add first value let avg1 = usage.add_to_history(&env, 100); @@ -534,7 +534,7 @@ fn test_gas_usage_add_to_history() { fn test_gas_usage_ring_buffer_wrap_around() { // Test: Ring buffer wraps around correctly when full let env = Env::default(); - let mut usage = GasUsage::default(); + let mut usage = GasUsage::new(&env); // Fill buffer to capacity (GAS_TRACKING_WINDOW_SIZE = 10) for i in 1..=10 { @@ -557,7 +557,7 @@ fn test_gas_usage_ring_buffer_wrap_around() { fn test_gas_usage_moving_average_empty_buffer() { // Test: Moving average returns 0 for empty buffer let env = Env::default(); - let usage = GasUsage::default(); + let usage = GasUsage::new(&env); // Empty buffer should return 0 let avg = usage.calculate_moving_average(&env); @@ -727,7 +727,7 @@ fn test_gas_usage_ring_buffer_o1_insertion() { // Test: Verify ring buffer insertion is O(1) by checking it doesn't // depend on buffer size for insertion time let env = Env::default(); - let mut usage = GasUsage::default(); + let mut usage = GasUsage::new(&env); // Fill buffer for i in 0..10 { @@ -744,8 +744,8 @@ fn test_gas_usage_ring_buffer_o1_insertion() { #[test] fn test_gas_usage_default_fields() { - // Test: Default GasUsage has all new fields initialized - let usage = GasUsage::default(); + let env = Env::default(); + let usage = GasUsage::new(&env); assert_eq!(usage.cpu, 0); assert_eq!(usage.mem, 0); diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..95f2b09b 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -47,6 +47,24 @@ mod validation; // mod validation_tests; // disabled - API drift mod versioning; mod voting; +mod market_analytics; +mod performance_benchmarks; +mod disputes; +mod edge_cases; +mod extensions; +mod graceful_degradation; +mod market_id_generator; +mod metadata_limits; +mod queries; +mod recovery; +mod statistics; +mod tokens; +mod rate_limiter; +mod dispute_multisig; +mod event_topic_catalog; +mod storage_tier_audit; +mod leaderboard; +mod lists; // #[cfg(any())] // mod voting_invariants; @@ -66,15 +84,17 @@ mod bandprotocol { // #[cfg(test)] // mod oracle_fallback_timeout_tests; -use bets::{BetStatus, BetStorage}; +use bets::BetStorage; use circuit_breaker::CircuitBreaker; -use err::Error; -use events::{ClaimInfo, EventEmitter}; +use crate::config::PERCENTAGE_DENOMINATOR; +use events::{EventEmitter, emit_deprecated}; use gas::BudgetGuard; use resolution::ResolutionOutcomeCache; use storage::BalanceStorage; use types::{Market, ReflectorAsset}; -use soroban_sdk::{contract, contractimpl, panic_with_error, symbol_short, Env, Symbol}; +use soroban_sdk::{ + contract, contractimpl, panic_with_error, symbol_short, Address, BytesN, Env, Map, String, Symbol, Vec, +}; // #[cfg(any())] // mod integration_test; @@ -89,7 +109,7 @@ use soroban_sdk::{contract, contractimpl, panic_with_error, symbol_short, Env, S // #[cfg(any())] // mod upgrade_manager_tests; #[cfg(test)] -mod capability_bitmap_tests; +// mod capability_bitmap_tests; #[cfg(test)] mod market_state_matrix_tests; @@ -162,19 +182,25 @@ pub mod errors { pub use audit_trail::{AuditAction, AuditRecord, AuditTrailHead, AuditTrailManager}; pub use types::*; -use crate::circuit_breaker::CircuitBreaker; +const SYM_PLATFORM_FEE: &str = "plat_fee"; +const SYM_ALLOWED_ASSETS: &str = "allowed"; +const SYM_ADMIN: &str = "Admin"; + +// Legacy symbol keys for backwards compatibility +const SYM_PLATFORM_FEE_LEGACY: &str = "platform_fee"; +const SYM_ALLOWED_ASSETS_LEGACY: &str = "allowed_assets"; + +const ORACLE_FAILURE_PRIMARY_ONLY_REASON: &str = "oracle_resolution_failed_primary_only"; +const ORACLE_FAILURE_PRIMARY_THEN_FALLBACK_REASON: &str = "oracle_resolution_failed_primary_then_fallback"; + use crate::config::{ ConfigManager, DEFAULT_PLATFORM_FEE_PERCENTAGE, MAX_PLATFORM_FEE_PERCENTAGE, MIN_PLATFORM_FEE_PERCENTAGE, }; -use crate::events::{emit_deprecated, EventEmitter}; use crate::gas::GasTracker; use crate::graceful_degradation::{OracleBackup, OracleHealth}; use crate::market_id_generator::MarketIdGenerator; use alloc::format; -use soroban_sdk::{ - contract, contractimpl, panic_with_error, symbol_short, Address, BytesN, Env, Map, String, Symbol, Vec, -}; impl From for Error { fn from(_err: crate::reentrancy_guard::GuardError) -> Self { @@ -200,6 +226,120 @@ pub struct PredictifyHybrid; #[contractimpl] impl PredictifyHybrid { + pub fn initialize( + env: Env, + admin: Address, + platform_fee_percentage: Option, + allowed_assets: Option>, + ) -> Result<(), Error> { + // Check for re-initialization attempt (critical security check) + if env + .storage() + .persistent() + .has(&Symbol::new(&env, SYM_PLATFORM_FEE)) + { + return Err(Error::InvalidState); + } + + // Determine platform fee (default 2% if not specified) + let fee_percentage = platform_fee_percentage.unwrap_or(DEFAULT_PLATFORM_FEE_PERCENTAGE); + + // Validate fee percentage bounds (0-10%) + if fee_percentage < MIN_PLATFORM_FEE_PERCENTAGE + || fee_percentage > MAX_PLATFORM_FEE_PERCENTAGE + { + return Err(Error::InvalidFeeConfig); + } + + // Initialize admin (includes re-initialization check) + admin::AdminInitializer::initialize(&env, &admin)?; + + // Initialize circuit breaker defaults required by write-gated entrypoints. + match crate::circuit_breaker::CircuitBreaker::initialize(&env) { + Ok(_) => (), + Err(e) => panic_with_error!(env, e), + } + + // Store platform fee configuration in persistent storage + env.storage() + .persistent() + .set(&Symbol::new(&env, SYM_PLATFORM_FEE), &fee_percentage); + + // Initialize rate limiter config + let rate_limit_config = crate::rate_limiter::RateLimitConfig { + voting_limit: 10_000, + dispute_limit: 1_000, + oracle_call_limit: 1_000, + bet_limit: 10_000, + events_per_admin_limit: 1_000, + time_window_seconds: 3_600, + }; + env.storage().persistent().set( + &crate::rate_limiter::RateLimiterData::Config, + &rate_limit_config, + ); + + // Store default contract configuration so validators have deterministic bounds + let mut default_config = crate::config::ConfigManager::get_development_config(&env); + default_config.fees.platform_fee_percentage = fee_percentage; + if let Err(e) = crate::config::ConfigManager::store_config(&env, &default_config) { + panic_with_error!(env, e); + } + + // Initialize allowed assets + if let Some(assets) = allowed_assets { + // Store custom allowed assets + env.storage() + .persistent() + .set(&Symbol::new(&env, SYM_ALLOWED_ASSETS), &assets); + } else { + // Initialize with defaults + crate::tokens::TokenRegistry::initialize_with_defaults(&env); + } + + // Emit contract initialized event + EventEmitter::emit_contract_initialized(&env, &admin, fee_percentage); + + // Emit platform fee set event + EventEmitter::emit_platform_fee_set(&env, fee_percentage, &admin); + + Ok(()) + } + + pub fn deposit( + env: Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { + if let Err(e) = + crate::circuit_breaker::CircuitBreaker::require_write_allowed(&env, "deposit") + { + return Err(e); + } + balances::BalanceManager::deposit(&env, user, asset, amount) + } + + pub fn withdraw( + env: Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { + if let Err(e) = + crate::circuit_breaker::CircuitBreaker::require_write_allowed(&env, "withdraw") + { + return Err(e); + } + if !crate::circuit_breaker::CircuitBreaker::are_withdrawals_allowed(&env)? { + return Err(crate::err::Error::CBOpen); + } + balances::BalanceManager::withdraw(&env, user, asset, amount) + } + + pub fn get_balance(env: Env, user: Address, asset: ReflectorAsset) -> Balance { + storage::BalanceStorage::get_balance(&env, &user, &asset) + } /// Distribute payouts to winning voters and bettors for a resolved market. /// /// This function iterates over all voters and bettors, calculates each winner's @@ -7560,12 +7700,12 @@ mod tests { // Store a resolution summary so ResolutionOutcomeCache::require succeeds. // (Adjust the key/type to match your actual resolution.rs implementation.) env.as_contract(&contract_id, || { - let summary = resolution::ResolutionSummary { + let summary = resolution::ResolvedOutcomeSummary { winning_total: 100_000_000i128, total_pool: 200_000_000i128, num_winning_outcomes: 1u32, }; - let cache_key = (Symbol::new(&env, "res_cache"), market_id.clone()); + let cache_key = (symbol_short!("res_out"), market_id.clone()); env.storage().persistent().set(&cache_key, &summary); }); @@ -7697,4 +7837,4 @@ mod tests { assert!(guard.consumed() == 0); // No instructions consumed yet in test host }); } -}mod dispute_multisig; +} diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 726d6803..a3e7380f 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -79,56 +79,7 @@ impl MarketIdGenerator { /// Maximum collision-retry attempts before giving up. pub const MAX_RETRIES: u32 = 10; - // ── Seed sealing methods ─────────────────────────────────────────────────── - - /// Check if the seed has been sealed. - /// - /// Returns `true` if the seed is sealed, preventing further regeneration. - /// - /// Check if the seed has been sealed. - /// - /// Returns `true` if the seed is sealed, preventing further regeneration. - /// - /// # Returns - /// - /// - `true` if the seed is sealed and cannot be regenerated - /// - `false` if the seed is still unsealed and can be regenerated - pub fn is_seed_sealed(env: &Env) -> bool { - env.storage() - .persistent() - .get(&Symbol::new(env, Self::SEED_SEALED_KEY)) - .unwrap_or(false) - } - /// Ensure the seed is not sealed before regeneration. - - /// - /// This safety check prevents any seed regeneration after sealing. - /// It provides explicit validation before attempting to regenerate the seed. - /// - /// # Panics - /// - /// - [`Error::InvalidState`] if attempting to regenerate an already sealed seed - fn ensure_seed_not_sealed(env: &Env) { - if Self::is_seed_sealed(env) { - panic_with_error!(env, Error::InvalidState); - } - } - - /// Bump TTL for seed-related storage to ensure long-term persistence. - /// - /// This ensures the seed sealing flag persists for the contract's entire lifetime. - /// - /// # Safety Note - /// - /// Uses the maximum allowed TTL to ensure the seed flag remains valid even as - /// the contract matures and storage entries age. - fn bump_seed_storage_ttl(env: &Env) { - let key = Symbol::new(env, Self::SEED_SEALED_KEY); - env.storage() - .persistent() - .extend_ttl(&key, env.storage().max_ttl(), env.storage().max_ttl()); - } // ── Public API ─────────────────────────────────────────────────────────── @@ -347,6 +298,45 @@ pub fn parse_market_id_components( } } + /// Get market ID registry with pagination + pub fn get_market_id_registry(env: &Env, start: u32, limit: u32) -> Vec { + let registry_key = Symbol::new(env, Self::REGISTRY_KEY); + let registry: Vec = env + .storage() + .persistent() + .get(®istry_key) + .unwrap_or(Vec::new(env)); + + let mut result = Vec::new(env); + let end = core::cmp::min(start + limit, registry.len()); + + for i in start..end { + if let Some(entry) = registry.get(i) { + result.push_back(entry); + } + } + result + } + + /// Register a newly created market ID + fn register_market_id(env: &Env, market_id: &Symbol, admin: &Address, timestamp: u64) { + let registry_key = Symbol::new(env, Self::REGISTRY_KEY); + let mut registry: Vec = env + .storage() + .persistent() + .get(®istry_key) + .unwrap_or(Vec::new(env)); + + registry.push_back(MarketIdRegistryEntry { + market_id: market_id.clone(), + admin: admin.clone(), + timestamp, + }); + + env.storage().persistent().set(®istry_key, ®istry); + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index ad958ea2..dc83d688 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -1,5 +1,5 @@ use alloc::format; -use soroban_sdk::{contracttype, panic_with_error, Address, Env, Map, String, Symbol, Vec}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Map, String, Symbol, Vec}; use crate::events::EventEmitter; use crate::markets::MarketStateManager; diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index 052d30d3..5d251fa0 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -234,6 +234,187 @@ pub struct OracleResolution { pub feed_id: String, } +pub struct OracleResolutionManager; + +impl OracleResolutionManager { + /// Helper to fetch price and determine outcome from an oracle config + fn try_fetch_from_config( + env: &Env, + market_id: &Symbol, + config: &crate::types::OracleConfig, + ) -> Result<(i128, String), Error> { + let oracle = + OracleFactory::create_oracle(config.provider.clone(), config.oracle_address.clone())?; + + let price_data = oracle.get_price_data(env, &config.feed_id)?; + crate::oracles::OracleValidationConfigManager::validate_oracle_data( + env, + market_id, + &config.provider, + &config.feed_id, + &price_data, + )?; + + let outcome = OracleUtils::determine_outcome( + price_data.price, + config.threshold, + &config.comparison, + env, + )?; + + Ok((price_data.price, outcome)) + } + + /// Fetch oracle result for a market with deterministic fallback ordering and timeout handling. + /// + /// The resolver attempts the primary oracle once. When `has_fallback` is `true`, it attempts the + /// fallback oracle once only after that primary failure. No oracle calls are made once + /// `ledger.timestamp() >= end_time + resolution_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. + // + // Safety invariant: a market with an active dispute must NOT be cancelled by the + // oracle resolution timeout. Cancelling while a dispute is open would permanently + // lock the dispute stakes and leave the market in an unresolvable state (deadlock). + // Instead we surface `ResolutionTimeoutReached` so the caller knows the oracle path + // is closed while the dispute process remains the authoritative resolution path. + let current_time = env.ledger().timestamp(); + if current_time >= market.end_time.saturating_add(market.resolution_timeout) { + crate::events::EventEmitter::emit_resolution_timeout(env, market_id, current_time); + return Err(Error::ResolutionTimeoutReached); + } + + // Validate market for oracle resolution + OracleResolutionValidator::validate_market_for_oracle_resolution(env, &market)?; + + // 2. Try primary oracle + let mut used_config = market.oracle_config.clone(); + let primary_result = Self::try_fetch_from_config(env, market_id, &used_config); + + let (price, outcome) = match primary_result { + Ok(res) => res, + Err(_) => { + // 3. Try fallback oracle if primary fails + if market.has_fallback { + let fallback_config = &market.fallback_oracle_config; + match Self::try_fetch_from_config(env, market_id, 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(_) => { + crate::events::EventEmitter::emit_manual_resolution_required( + env, + market_id, + &soroban_sdk::String::from_str( + env, + "oracle_resolution_failed_primary_then_fallback", + ), + ); + return Err(Error::FallbackOracleUnavailable); + } + } + } else { + crate::events::EventEmitter::emit_manual_resolution_required( + env, + market_id, + &soroban_sdk::String::from_str( + env, + "oracle_resolution_failed_primary_only", + ), + ); + return Err(Error::OracleUnavailable); + } + } + }; + + // Create oracle resolution record + let resolution = OracleResolution { + market_id: market_id.clone(), + oracle_result: outcome.clone(), + price, + 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 + MarketStateManager::set_oracle_result(&mut market, outcome.clone()); + MarketStateManager::update_market(env, market_id, &market); + + // Emit oracle result event + let provider_str = match used_config.provider { + OracleProvider::Reflector => soroban_sdk::String::from_str(env, "Reflector"), + 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, + market_id, + &outcome, + &provider_str, + &feed_str, + price, + used_config.threshold, + &comparison_str, + ); + + Ok(resolution) + } + + /// Get oracle resolution for a market + pub fn get_oracle_resolution( + _env: &Env, + _market_id: &Symbol, + ) -> Result, Error> { + // For now, return None since we don't store complex types in storage + // In a real implementation, you would store this in a more sophisticated way + Ok(None) + } + + /// Validate oracle resolution + pub fn validate_oracle_resolution( + _env: &Env, + resolution: &OracleResolution, + ) -> Result<(), Error> { + // Validate price is positive + if resolution.price <= 0 { + return Err(Error::InvalidInput); + } + + // Validate threshold is positive + if resolution.threshold <= 0 { + return Err(Error::InvalidInput); + } + + // Validate outcome is not empty + if resolution.oracle_result.is_empty() { + return Err(Error::InvalidInput); + } + + Ok(()) + } + + /// Calculate oracle confidence score + pub fn calculate_oracle_confidence(resolution: &OracleResolution) -> u32 { + OracleResolutionAnalytics::calculate_confidence_score(resolution) + } +} + /// Comprehensive market resolution result combining oracle data with community consensus. /// /// This structure represents the final resolution of a prediction market, incorporating @@ -522,6 +703,44 @@ pub struct ResolvedOutcomeSummary { pub num_winning_outcomes: u32, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MedianResolutionResult { + /// The market that was resolved. + pub market_id: Symbol, + /// Final market outcome ("yes" or "no"). + pub outcome: String, + /// Confidence-weighted median price used to determine the outcome. + pub weighted_median_price: i128, + /// Market threshold the price was compared against. + pub threshold: i128, + /// Comparison operator applied ("gt", "lt", "eq"). + pub comparison: String, + /// All three oracle quotes (Pyth, Reflector, Band) with their computed + /// weights and `included` flags for full audit transparency. + pub quotes: Vec, + /// Number of quotes that survived the outlier filter and contributed + /// to the weighted-median computation. + pub included_count: u32, + /// Aggregate confidence score in [0, 100]. + pub confidence_score: u32, + /// Ledger timestamp at the time of resolution. + pub timestamp: u64, +} + +/// Resolution analytics data +#[derive(Clone, Debug)] +#[contracttype] +pub struct ResolutionAnalytics { + pub total_resolutions: u32, + pub oracle_resolutions: u32, + pub community_resolutions: u32, + pub hybrid_resolutions: u32, + pub average_confidence: i128, + pub resolution_times: Vec, + pub outcome_distribution: Map, +} + /// Storage-backed cache for resolved market payout math. /// /// Time: O(V + B) once at `refresh`; O(1) on payout paths. @@ -533,131 +752,106 @@ impl ResolutionOutcomeCache { (symbol_short!("res_out"), market_id.clone()) } - let mut market: Market = env - .storage() - .persistent() - .get(&market_id) - .unwrap_or_else(|| { - soroban_sdk::panic_with_error!(env, Error::MarketNotFound); - }); - - // Check if market is resolved - let winning_outcomes = match &market.winning_outcomes { - Some(outcomes) => outcomes, - None => return Err(Error::MarketNotResolved), - }; - - // Get all bettors - let bettors = bets::BetStorage::get_all_bets_for_market(&env, &market_id); - - // Get fee from legacy storage (backward compatible) - let fee_percent = env - .storage() - .persistent() - .get(&Symbol::new(&env, "platform_fee")) - .unwrap_or(200); - - let mut has_unclaimed_winners = false; - - // Check voters - for (user, outcome) in market.votes.iter() { - if winning_outcomes.contains(&outcome) { - if !market - .claimed - .get((*user).clone()) - .map(|info| info.is_claimed()) - .unwrap_or(false) - { - has_unclaimed_winners = true; - break; + /// Remove cached summary (e.g. before outcome override). + pub fn invalidate(env: &Env, market_id: &Symbol) { + env.storage() + .persistent() + .remove(&Self::storage_key(market_id)); + } + + /// Compute winning total with explicit `market_id` (bet registry key). + pub fn compute_winning_total_for_market( + env: &Env, + market_id: &Symbol, + market: &Market, + winning_outcomes: &Vec, + ) -> Result { + let mut winning_total: i128 = 0; + + for (voter, outcome) in market.votes.iter() { + if winning_outcomes.contains(&outcome) { + winning_total = winning_total + .checked_add(market.stakes.get(voter.clone()).unwrap_or(0)) + .ok_or(Error::InvalidInput)?; } } - } - if !has_unclaimed_winners { + let bettors = BetStorage::get_all_bets_for_market(env, market_id); for user in bettors.iter() { - if let Some(bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { - if winning_outcomes.contains(&bet.outcome) - && !market - .claimed - .get((*user).clone()) - .map(|info| info.is_claimed()) - .unwrap_or(false) - { - has_unclaimed_winners = true; - break; + if market.votes.contains_key(user.clone()) { + continue; + } + if let Some(bet) = BetStorage::get_bet(env, market_id, &user) { + if winning_outcomes.contains(&bet.outcome) { + winning_total = winning_total + .checked_add(bet.amount) + .ok_or(Error::InvalidInput)?; } } } + Ok(winning_total) } - if !has_unclaimed_winners { - return Ok(0); - } - - let summary = resolution::ResolutionOutcomeCache::require(&env, &market_id, &market)?; - let winning_total = summary.winning_total; - if winning_total == 0 { - return Ok(0); - } - - let total_pool = summary.total_pool; - let fee_denominator = 10000i128; - let mut total_distributed: i128 = 0; + /// Scan O(V+B) stakes, compute pool math, and persist summary. + pub fn refresh( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result<(), Error> { + let outcomes = market + .winning_outcomes + .as_ref() + .ok_or(Error::MarketNotResolved)?; - // Create budget guard with 100,000 instruction threshold - let budget_guard = gas::BudgetGuard::new(&env, 100000); + let winning_total = + Self::compute_winning_total_for_market(env, market_id, market, outcomes)?; - // 1. Distribute to Voters - let mut voter_count = 0u32; - for (user, outcome) in market.votes.iter() { - if winning_outcomes.contains(&outcome) { - if market - .claimed - .get((*user).clone()) - .map(|info| info.is_claimed()) - .unwrap_or(false) - { + let mut total_pool = market.total_staked; + let bettors = BetStorage::get_all_bets_for_market(env, market_id); + for user in bettors.iter() { + if market.votes.contains_key(user.clone()) { continue; } - - let user_stake = market.stakes.get((*user).clone()).unwrap_or(0); - if user_stake > 0 { - let user_share = (user_stake - .checked_mul(fee_denominator - fee_percent) - .ok_or(Error::InvalidInput)?) - / fee_denominator; - let payout = (user_share - .checked_mul(total_pool) - .ok_or(Error::InvalidInput)?) - / winning_total; - - if payout >= 0 { - market - .claimed - .set((*user).clone(), ClaimInfo::new(&env, payout)); - if payout > 0 { - total_distributed = total_distributed - .checked_add(payout) - .ok_or(Error::InvalidInput)?; - - storage::BalanceStorage::add_balance( - &env, - &user, - &ReflectorAsset::Stellar, - payout, - )?; - - events::EventEmitter::emit_winnings_claimed(&env, &market_id, &user, payout); - } - } + if let Some(bet) = BetStorage::get_bet(env, market_id, &user) { + total_pool = total_pool + .checked_add(bet.amount) + .ok_or(Error::InvalidInput)?; } } - voter_count += 1; - if voter_count % 10 == 0 { - budget_guard.check()?; + let summary = ResolvedOutcomeSummary { + winning_total, + total_pool, + num_winning_outcomes: outcomes.len(), + }; + env.storage() + .persistent() + .set(&Self::storage_key(market_id), &summary); + Ok(()) + } + + pub fn get(env: &Env, market_id: &Symbol) -> Option { + env.storage() + .persistent() + .get(&Self::storage_key(market_id)) + } + + pub fn require( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result { + if let (Some(summary), Some(ref outcomes)) = + (Self::get(env, market_id), &market.winning_outcomes) + { + if summary.total_pool == market.total_staked + && summary.num_winning_outcomes == outcomes.len() + { + return Ok(summary); + } } + Self::refresh(env, market_id, market)?; + Self::get(env, market_id).ok_or(Error::MarketNotResolved) } /// Get oracle resolution for a market @@ -2735,21 +2929,40 @@ impl OracleCallbackResolver { market.outcomes.get(1).unwrap(), ) } else { - if matches!(bet.status, BetStatus::Active) { - bet.status = BetStatus::Lost; - let _ = bets::BetStorage::store_bet(&env, &bet); - } + ( + market.outcomes.get(1).unwrap(), + market.outcomes.get(0).unwrap(), + ) + }; + + // Compare oracle price with threshold + if callback_data.price > 0 { + Ok(yes_outcome.clone()) + } else { + Ok(no_outcome.clone()) } + } else { + // For multi-outcome markets, use price modulo number of outcomes + let outcome_index = ((callback_data.price.abs() % (market.outcomes.len() as i128)) as u32); + Ok(market.outcomes.get(outcome_index).unwrap().clone()) } + } - bettor_count += 1; - if bettor_count % 10 == 0 { - budget_guard.check()?; + /// Validate oracle callback authorization for market resolution + pub fn validate_oracle_authorization_for_market( + env: &Env, + caller: &Address, + market_id: &Symbol, + ) -> Result<(), Error> { + // Check if caller is authorized oracle + if !crate::oracles::OracleWhitelist::validate_oracle_contract(env, caller)? { + return Err(Error::OracleCallbackUnauthorized); } - } - budget_guard.check()?; - env.storage().persistent().set(&market_id, &market); + // Check if market exists and is ready for oracle resolution + let market = MarketStateManager::get_market(env, market_id)?; + OracleResolutionValidator::validate_market_for_oracle_resolution(env, &market)?; - Ok(total_distributed) + Ok(()) + } } \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..c181d5a1 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -43,7 +43,7 @@ pub fn check_market_creation_rent(env: &Env) -> Result<(), Error> { let current_seq = env.ledger().sequence(); if current_seq.checked_add(effective_ttl).is_none() { - return Err(Error::InsufficientStorageRent); + return Err(Error::InsufficientStorageRentBudget); } Ok(()) @@ -71,7 +71,6 @@ enum StorageTtlTier { pub enum DataKey { Whitelisted(Address), Blacklisted(Address), - AdminOverrideNonce(Address), ArchivedMarket(Symbol, u64), /// Cumulative days extended for a given market (u32). MarketExtensionTotal(Symbol), @@ -87,6 +86,7 @@ pub enum DataKey { MarketCache(Symbol), /// Nonce for admin override replay protection. AdminOverrideNonce(Address), + PlaceBetsIdem(Address, soroban_sdk::BytesN<32>), } /// Storage format version for migration tracking