diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index 85b1f30c..241d4cc7 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -78,6 +78,8 @@ pub enum AdminPermission { ViewAnalytic, /// Emergency actions Emergency, + /// Configure system settings + ConfigAdmin, } /// Admin action record @@ -3549,6 +3551,9 @@ impl AdminUtils { AdminPermission::Emergency => { String::from_str(&soroban_sdk::Env::default(), "Emergency") } + AdminPermission::ConfigAdmin => { + String::from_str(&soroban_sdk::Env::default(), "ConfigAdmin") + } } } } diff --git a/contracts/predictify-hybrid/src/analytics_snapshot.rs b/contracts/predictify-hybrid/src/analytics_snapshot.rs index fdc99a48..02fc110a 100644 --- a/contracts/predictify-hybrid/src/analytics_snapshot.rs +++ b/contracts/predictify-hybrid/src/analytics_snapshot.rs @@ -100,7 +100,7 @@ impl AnalyticsSnapshotManager { for (outcome, count) in counts.iter() { ordered.push((outcome, count)); } - ordered.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string())); + ordered.sort_by(|left, right| left.0.cmp(&right.0)); for (outcome, count) in ordered { outcome_counts.push_back(OutcomeCount { outcome, count }); diff --git a/contracts/predictify-hybrid/src/audit_trail.rs b/contracts/predictify-hybrid/src/audit_trail.rs index d806b296..5c2ecab2 100644 --- a/contracts/predictify-hybrid/src/audit_trail.rs +++ b/contracts/predictify-hybrid/src/audit_trail.rs @@ -72,7 +72,7 @@ pub struct AuditRecord { pub struct AuditEntryV2 { pub index: u64, pub action: Symbol, - pub reason_idx: u8, + pub reason_idx: u32, pub actor: Address, pub ts: u64, pub ref_id: BytesN<32>, @@ -110,14 +110,18 @@ impl AuditTrailManager { } /// Registers a new reason in the append-only reason table (admin-gated). - /// Returns the u8 index assigned to the reason. + /// Returns the u32 index assigned to the reason. pub fn add_reason( env: &Env, admin: &Address, reason: String, - ) -> Result { + ) -> Result { // Require admin authentication - crate::admin::AdminManager::require_admin_auth(env, admin)?; + admin.require_auth(); + let stored_admin: Address = env.storage().persistent().get(&Symbol::new(env, "Admin")).ok_or(crate::err::Error::AdminNotSet)?; + if admin != &stored_admin { + return Err(crate::err::Error::Unauthorized); + } let mut reasons: Vec = env .storage() @@ -129,7 +133,7 @@ impl AuditTrailManager { return Err(crate::err::Error::ReasonTableFull); } - let idx = reasons.len() as u8; + let idx = reasons.len() as u32; reasons.push_back(reason); env.storage() @@ -139,8 +143,8 @@ impl AuditTrailManager { Ok(idx) } - /// Retrieves a reason from the table by its u8 index. - pub fn get_reason(env: &Env, index: u8) -> Option { + /// Retrieves a reason from the table by its u32 index. + pub fn get_reason(env: &Env, index: u32) -> Option { let reasons: Vec = env .storage() .persistent() @@ -209,7 +213,7 @@ impl AuditTrailManager { pub fn append_record_v2( env: &Env, action: Symbol, - reason_idx: u8, + reason_idx: u32, actor: Address, ref_id: BytesN<32>, override_nonce: Option, @@ -340,14 +344,16 @@ impl AuditTrailManager { } let versioned = versioned_opt.unwrap(); - let (actual_hash, prev_hash) = match versioned { + let (actual_hash, prev_hash): (BytesN<32>, BytesN<32>) = match versioned { AuditRecordVersion::V1(v1) => { + let prev = v1.prev_record_hash.clone(); let record_bytes = v1.to_xdr(env); - (env.crypto().sha256(&record_bytes).into(), v1.prev_record_hash) + (env.crypto().sha256(&record_bytes).into(), prev) } AuditRecordVersion::V2(v2) => { + let prev = v2.prev_record_hash.clone(); let record_bytes = v2.to_xdr(env); - (env.crypto().sha256(&record_bytes).into(), v2.prev_record_hash) + (env.crypto().sha256(&record_bytes).into(), prev) } }; diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index 5f70e2d9..75d5b0a5 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -85,7 +85,20 @@ pub struct BetRegistryKey { // ===== BET LIMITS STORAGE ===== -/// Get effective bet limits for a market: per-event if set, else global, else default constants. +/// Retrieve the effective bet limits for a specific market. +/// +/// Resolves the limit hierarchy: per-event overrides take priority, followed by +/// global overrides, then the compile-time default constants +/// ([`MIN_BET_AMOUNT`], [`MAX_BET_AMOUNT`]). +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `market_id` – Identifies the market whose limits to resolve +/// +/// # Returns +/// +/// The [`BetLimits`] that apply to the given market. pub fn get_effective_bet_limits(env: &Env, market_id: &Symbol) -> BetLimits { let key_evt = Symbol::new(env, PER_EVENT_BET_LIMITS_KEY); let per_event: soroban_sdk::Map = env @@ -106,7 +119,18 @@ pub fn get_effective_bet_limits(env: &Env, market_id: &Symbol) -> BetLimits { }) } -/// Set global bet limits (admin only; validation of bounds done by caller). +/// Persist global bet limits that apply to every market lacking a per-event override. +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `limits` – New minimum / maximum bet bounds +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] when `min_bet > max_bet` or limits fall +/// outside the absolute bounds ([`MIN_BET_AMOUNT`], [`MAX_BET_AMOUNT`]). +/// Returns [`Error::InsufficientStake`] when `min_bet < MIN_BET_AMOUNT`. pub fn set_global_bet_limits(env: &Env, limits: &BetLimits) -> Result<(), Error> { validate_limits_bounds(limits)?; let key = Symbol::new(env, GLOBAL_BET_LIMITS_KEY); @@ -114,7 +138,19 @@ pub fn set_global_bet_limits(env: &Env, limits: &BetLimits) -> Result<(), Error> Ok(()) } -/// Set per-event bet limits (admin only; validation of bounds done by caller). +/// Persist per-event bet limits that override the global defaults for one market. +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `market_id` – Identifies the market +/// - `limits` – New minimum / maximum bet bounds for this market +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] when `min_bet > max_bet` or limits fall +/// outside the absolute bounds. +/// Returns [`Error::InsufficientStake`] when `min_bet < MIN_BET_AMOUNT`. pub fn set_event_bet_limits( env: &Env, market_id: &Symbol, @@ -164,10 +200,15 @@ pub fn set_market_max_bet_cap(env: &Env, market_id: &Symbol, cap: i128) -> Resul Ok(()) } -/// Remove the per-market max bet cap for a market (admin only). +/// Remove the per-market maximum single-bet cap (admin only). /// /// After removal, bets on this market are bounded only by the global/per-event /// [`BetLimits`] max (or [`MAX_BET_AMOUNT`] when no limits are configured). +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `market_id` – Identifies the market whose cap to remove pub fn remove_market_max_bet_cap(env: &Env, market_id: &Symbol) { let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY); let mut caps: soroban_sdk::Map = env @@ -179,7 +220,16 @@ pub fn remove_market_max_bet_cap(env: &Env, market_id: &Symbol) { env.storage().persistent().set(&key, &caps); } -/// Get the per-market max bet cap, or `None` if no cap has been set. +/// Retrieve the per-market maximum single-bet cap, if one has been set. +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `market_id` – Identifies the market +/// +/// # Returns +/// +/// `Some(cap)` when a per-market cap is configured, `None` otherwise. pub fn get_market_max_bet_cap(env: &Env, market_id: &Symbol) -> Option { let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY); let caps: soroban_sdk::Map = env @@ -206,66 +256,30 @@ fn validate_limits_bounds(limits: &BetLimits) -> Result<(), Error> { // ===== BET MANAGER ===== -/// Comprehensive bet manager for prediction market betting operations. -/// -/// BetManager serves as the central coordinator for all betting-related operations -/// in the prediction market system. It handles bet placement, fund locking, -/// bet tracking, and bet resolution. The manager ensures betting integrity, -/// proper fund handling, and accurate payout calculations. +/// Central coordinator for all betting-related operations. /// -/// # Core Functionality -/// -/// **Bet Placement:** -/// - Validate and process user bets on market outcomes -/// - Handle fund transfers and locking -/// - Ensure betting eligibility and prevent duplicate bets -/// -/// **Bet Resolution:** -/// - Process bet outcomes after market resolution -/// - Calculate and distribute winnings -/// - Handle refunds for cancelled markets -/// -/// **Bet Tracking:** -/// - Store and retrieve user bets -/// - Track market-wide betting statistics -/// - Provide bet analytics and reporting -/// -/// # Example Usage -/// -/// ```rust -/// # use soroban_sdk::{Env, Address, String, Symbol}; -/// # use predictify_hybrid::bets::BetManager; -/// # let env = Env::default(); -/// -/// let user = Address::generate(&env); -/// let market_id = Symbol::new(&env, "BTC_100K"); -/// let outcome = String::from_str(&env, "yes"); -/// let amount = 5_000_000i128; // 0.5 XLM -/// -/// // Place a bet -/// match BetManager::place_bet(&env, user.clone(), market_id.clone(), outcome, amount) { -/// Ok(bet) => println!("Bet placed successfully: {} stroops", bet.amount), -/// Err(e) => println!("Bet placement failed: {:?}", e), -/// } -/// -/// // Get user's bet -/// match BetManager::get_bet(&env, &market_id, &user) { -/// Some(bet) => println!("User has bet {} on outcome", bet.amount), -/// None => println!("User has not placed a bet"), -/// } -/// ``` -/// -/// # Integration Points -/// -/// BetManager integrates with: -/// - **Market System**: Validates market states and updates market data -/// - **Token System**: Handles fund locking and payout distributions -/// - **Event System**: Emits events for all betting operations -/// - **Validation System**: Uses comprehensive validation for all operations +/// `BetManager` owns the full bet lifecycle: placement, batch placement, +/// cancellation, resolution, refund, payout calculation, and statistics +/// tracking. Every state-changing entrypoint enforces `require_auth`, +/// circuit-breaker gating, and reentrancy protection. pub struct BetManager; impl BetManager { - /// Helper to get the active platform fee percentage in basis points (bps). + /// Resolve the active platform fee percentage (in basis points). + /// + /// Consults the configuration hierarchy in order: + /// 1. [`ConfigManager`] runtime config + /// 2. [`FeeConfigManager`] fee-specific config + /// 3. Legacy `"platform_fee"` storage key + /// 4. [`DEFAULT_PLATFORM_FEE_PERCENTAGE`] constant + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// + /// # Returns + /// + /// The fee percentage in basis points (e.g. 200 = 2%). pub fn get_live_fee_percentage(env: &Env) -> Result { // 1. Try contract configuration if let Ok(cfg) = crate::config::ConfigManager::get_config(env) { @@ -981,14 +995,23 @@ impl BetManager { // ===== BET STORAGE ===== -/// Storage utilities for bet data. +/// Persistent storage helpers for bet data. /// -/// BetStorage provides functions for storing and retrieving bet data -/// from Soroban persistent storage. +/// All functions in this struct operate on Soroban persistent storage and +/// are idempotent with respect to the data they write. pub struct BetStorage; impl BetStorage { - /// Store a bet in persistent storage. + /// Persist a bet in persistent storage and register the user in the market's bet registry. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `bet` – Bet to store + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the storage write fails. pub fn store_bet(env: &Env, bet: &Bet) -> Result<(), Error> { let key = Self::get_bet_key(env, &bet.market_id, &bet.user); env.storage().persistent().set(&key, bet); @@ -999,19 +1022,46 @@ impl BetStorage { Ok(()) } - /// Get a bet from persistent storage. + /// Retrieve a previously stored bet from persistent storage. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// - `user` – Bettor's address + /// + /// # Returns + /// + /// `Some(Bet)` when a bet exists, `None` otherwise. pub fn get_bet(env: &Env, market_id: &Symbol, user: &Address) -> Option { let key = Self::get_bet_key(env, market_id, user); env.storage().persistent().get::(&key) } - /// Remove a bet from persistent storage. + /// Delete a bet entry from persistent storage. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// - `user` – Bettor's address pub fn remove_bet(env: &Env, market_id: &Symbol, user: &Address) { let key = Self::get_bet_key(env, market_id, user); env.storage().persistent().remove::(&key); } - /// Get market betting statistics. + /// Retrieve aggregate betting statistics for a market. + /// + /// Returns zeroed-out statistics when no bets have been placed yet. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// + /// # Returns + /// + /// A [`BetStats`] record containing totals and per-outcome breakdowns. pub fn get_market_bet_stats(env: &Env, market_id: &Symbol) -> BetStats { let key = Self::get_bet_stats_key(env, market_id); env.storage() @@ -1025,7 +1075,17 @@ impl BetStorage { }) } - /// Store market betting statistics. + /// Persist updated betting statistics for a market. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// - `stats` – Updated statistics record + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the storage write fails. pub fn store_market_bet_stats( env: &Env, market_id: &Symbol, @@ -1062,7 +1122,16 @@ impl BetStorage { Ok(()) } - /// Get all users who placed bets on a market. + /// List every user who has placed a bet on a market. + /// + /// # Parameters + /// + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// + /// # Returns + /// + /// A vector of [`Address`] entries (may be empty if no bets exist). pub fn get_all_bets_for_market(env: &Env, market_id: &Symbol) -> soroban_sdk::Vec
{ let key = Self::get_bet_registry_key(env, market_id); env.storage() @@ -1098,10 +1167,10 @@ impl BetStorage { // ===== BET VALIDATOR ===== -/// Validation utilities for betting operations. +/// Validation routines for betting operations. /// -/// BetValidator provides comprehensive validation for all betting-related -/// operations, ensuring data integrity and security. +/// All validation functions are pure checks that do not write storage, +/// except for the per-user cap helpers which read/write cumulative stake data. pub struct BetValidator; impl BetValidator { @@ -1291,7 +1360,7 @@ impl BetValidator { caller.require_auth(); // Verify caller is admin - let admin = crate::admin::AdminManager::get_admin(env)?; + let admin = crate::admin::AdminAccessControl::get_admin(env)?; if *caller != admin { return Err(Error::Unauthorized); } @@ -1324,7 +1393,7 @@ impl BetValidator { /// /// Returns the cumulative amount the user has bet on this market (0 if no prior bets). pub fn get_user_stake(env: &Env, market_id: &Symbol, user: &Address) -> i128 { - let key = crate::storage::DataKey::UserStake(market_id.clone(), user.clone()); + let key = crate::storage::DataKey::UserStake(user.clone(), market_id.clone()); env.storage() .persistent() .get::<_, i128>(&key) @@ -1353,7 +1422,7 @@ impl BetValidator { .checked_add(amount) .ok_or(Error::Overflow)?; - let key = crate::storage::DataKey::UserStake(market_id.clone(), user.clone()); + let key = crate::storage::DataKey::UserStake(user.clone(), market_id.clone()); env.storage().persistent().set(&key, &new_stake); // Extend TTL to match market (365 days) env.storage().persistent().extend_ttl(&key, crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS); @@ -1406,10 +1475,10 @@ impl BetValidator { // ===== BET UTILITIES ===== -/// Utility functions for betting operations. +/// Utility functions for token transfers and balance queries. /// -/// BetUtils provides helper functions for fund management, -/// payout calculations, and other betting-related utilities. +/// All fund-moving functions use reentrancy guards scoped to their +/// respective operations to prevent cross-function reentrant calls. pub struct BetUtils; impl BetUtils { @@ -1467,31 +1536,33 @@ impl BetUtils { .map_err(|_| Error::InvalidState) } - /// Get the contract's locked funds balance. + /// Query the contract's locked token balance. /// /// # Parameters /// - /// - `env` - The Soroban environment + /// - `env` – Soroban environment /// /// # Returns /// - /// Returns the contract's token balance. + /// `Ok(balance)` in base token units, or [`Error::InvalidInput`] if the + /// token client cannot be resolved. pub fn get_contract_balance(env: &Env) -> Result { let token_client = MarketUtils::get_token_client(env)?; Ok(token_client.balance(&env.current_contract_address())) } - /// Check if user has sufficient balance for a bet. + /// Check whether a user holds at least the required token balance. /// /// # Parameters /// - /// - `env` - The Soroban environment - /// - `user` - Address of the user - /// - `amount` - Required amount + /// - `env` – Soroban environment + /// - `user` – Address to query + /// - `amount` – Minimum required balance /// /// # Returns /// - /// Returns `true` if user has sufficient balance, `false` otherwise. + /// `Ok(true)` if the balance is sufficient, `Ok(false)` otherwise. + /// Returns [`Error::InvalidInput`] if the token client cannot be resolved. pub fn has_sufficient_balance(env: &Env, user: &Address, amount: i128) -> Result { let token_client = MarketUtils::get_token_client(env)?; let balance = token_client.balance(user); @@ -1501,26 +1572,27 @@ impl BetUtils { // ===== BET ANALYTICS ===== -/// Analytics utilities for betting data. +/// Read-only analytics helpers for betting data. /// -/// BetAnalytics provides functions for analyzing betting patterns, -/// calculating statistics, and generating reports. +/// All functions in this struct are pure queries that do not mutate state. pub struct BetAnalytics; impl BetAnalytics { - /// Calculate the implied probability for an outcome based on bet distribution. + /// Compute the implied probability for an outcome based on the current bet distribution. /// - /// Implied probability = (Amount bet on outcome) / (Total amount bet) + /// Formula: `(amount_bet_on_outcome * 100) / total_amount_locked` + /// + /// Returns `0` when no bets have been placed yet. /// /// # Parameters /// - /// - `env` - The Soroban environment - /// - `market_id` - Symbol identifying the market - /// - `outcome` - The outcome to calculate probability for + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// - `outcome` – Outcome to compute the probability for /// /// # Returns /// - /// Returns the implied probability as a percentage (0-100). + /// Implied probability as an integer percentage (0–100). pub fn calculate_implied_probability(env: &Env, market_id: &Symbol, outcome: &String) -> i128 { let stats = BetStorage::get_market_bet_stats(env, market_id); @@ -1534,19 +1606,21 @@ impl BetAnalytics { (outcome_amount * 100) / stats.total_amount_locked } - /// Calculate potential payout multiplier for an outcome. + /// Compute the potential payout multiplier for an outcome. + /// + /// Formula: `(total_amount_locked * 100) / amount_bet_on_outcome` /// - /// Multiplier = (Total pool) / (Amount bet on outcome) + /// Returns `0` when no bets have been placed on the outcome. /// /// # Parameters /// - /// - `env` - The Soroban environment - /// - `market_id` - Symbol identifying the market - /// - `outcome` - The outcome to calculate multiplier for + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market + /// - `outcome` – Outcome to compute the multiplier for /// /// # Returns /// - /// Returns the payout multiplier (scaled by 100 for precision). + /// Payout multiplier scaled by 100 (e.g. 250 = 2.5×). pub fn calculate_payout_multiplier(env: &Env, market_id: &Symbol, outcome: &String) -> i128 { let stats = BetStorage::get_market_bet_stats(env, market_id); @@ -1560,16 +1634,19 @@ impl BetAnalytics { (stats.total_amount_locked * 100) / outcome_amount } - /// Get betting summary for a market. + /// Retrieve the full betting summary for a market. + /// + /// This is a convenience alias for [`BetStorage::get_market_bet_stats`]. /// /// # Parameters /// - /// - `env` - The Soroban environment - /// - `market_id` - Symbol identifying the market + /// - `env` – Soroban environment + /// - `market_id` – Identifies the market /// /// # Returns /// - /// Returns a `BetStats` structure with complete betting statistics. + /// A [`BetStats`] record with total bets, locked amounts, unique bettors, + /// and per-outcome breakdowns. pub fn get_market_summary(env: &Env, market_id: &Symbol) -> BetStats { BetStorage::get_market_bet_stats(env, market_id) } @@ -1848,4 +1925,354 @@ mod tests { Err(Error::FeeExceedsMax) ); } + + // ===== FOCUSED TESTS FOR DOCUMENTED FUNCTIONS ===== + + #[test] + fn test_get_effective_bet_limits_returns_defaults_when_empty() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_no_limits"); + let limits = get_effective_bet_limits(&env, &market_id); + assert_eq!(limits.min_bet, MIN_BET_AMOUNT); + assert_eq!(limits.max_bet, MAX_BET_AMOUNT); + } + + #[test] + fn test_get_effective_bet_limits_prefers_per_event_over_global() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_evt"); + let global = BetLimits { + min_bet: 2_000_000, + max_bet: 50_000_000_000, + }; + set_global_bet_limits(&env, &global).unwrap(); + let per_event = BetLimits { + min_bet: 5_000_000, + max_bet: 10_000_000_000, + }; + set_event_bet_limits(&env, &market_id, &per_event).unwrap(); + + let limits = get_effective_bet_limits(&env, &market_id); + assert_eq!(limits.min_bet, 5_000_000); + assert_eq!(limits.max_bet, 10_000_000_000); + } + + #[test] + fn test_get_effective_bet_limits_falls_back_to_global() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_glb"); + let global = BetLimits { + min_bet: 3_000_000, + max_bet: 80_000_000_000, + }; + set_global_bet_limits(&env, &global).unwrap(); + + let limits = get_effective_bet_limits(&env, &market_id); + assert_eq!(limits.min_bet, 3_000_000); + assert_eq!(limits.max_bet, 80_000_000_000); + } + + #[test] + fn test_set_global_bet_limits_rejects_min_gt_max() { + let env = Env::default(); + let bad = BetLimits { + min_bet: 10_000_000, + max_bet: 1_000_000, + }; + assert_eq!(set_global_bet_limits(&env, &bad), Err(Error::InvalidInput)); + } + + #[test] + fn test_set_global_bet_limits_rejects_below_absolute_min() { + let env = Env::default(); + let bad = BetLimits { + min_bet: MIN_BET_AMOUNT - 1, + max_bet: MAX_BET_AMOUNT, + }; + assert_eq!(set_global_bet_limits(&env, &bad), Err(Error::InsufficientStake)); + } + + #[test] + fn test_set_global_bet_limits_rejects_above_absolute_max() { + let env = Env::default(); + let bad = BetLimits { + min_bet: MIN_BET_AMOUNT, + max_bet: MAX_BET_AMOUNT + 1, + }; + assert_eq!(set_global_bet_limits(&env, &bad), Err(Error::InvalidInput)); + } + + #[test] + fn test_set_event_bet_limits_persists_and_retrieves() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_set"); + let limits = BetLimits { + min_bet: 2_500_000, + max_bet: 25_000_000_000, + }; + set_event_bet_limits(&env, &market_id, &limits).unwrap(); + let retrieved = get_effective_bet_limits(&env, &market_id); + assert_eq!(retrieved.min_bet, 2_500_000); + assert_eq!(retrieved.max_bet, 25_000_000_000); + } + + #[test] + fn test_set_market_max_bet_cap_and_get() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_cap"); + assert_eq!(get_market_max_bet_cap(&env, &market_id), None); + set_market_max_bet_cap(&env, &market_id, 5_000_000).unwrap(); + assert_eq!(get_market_max_bet_cap(&env, &market_id), Some(5_000_000)); + } + + #[test] + fn test_set_market_max_bet_cap_rejects_zero() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_cap0"); + assert_eq!( + set_market_max_bet_cap(&env, &market_id, 0), + Err(Error::InvalidInput) + ); + } + + #[test] + fn test_set_market_max_bet_cap_rejects_negative() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_capneg"); + assert_eq!( + set_market_max_bet_cap(&env, &market_id, -1), + Err(Error::InvalidInput) + ); + } + + #[test] + fn test_set_market_max_bet_cap_rejects_exceeding_max() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_capmax"); + assert_eq!( + set_market_max_bet_cap(&env, &market_id, MAX_BET_AMOUNT + 1), + Err(Error::InvalidInput) + ); + } + + #[test] + fn test_remove_market_max_bet_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_rm"); + set_market_max_bet_cap(&env, &market_id, 5_000_000).unwrap(); + assert_eq!(get_market_max_bet_cap(&env, &market_id), Some(5_000_000)); + remove_market_max_bet_cap(&env, &market_id); + assert_eq!(get_market_max_bet_cap(&env, &market_id), None); + } + + #[test] + fn test_bet_analytics_implied_probability_empty_market() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_empty"); + let outcome = String::from_str(&env, "yes"); + assert_eq!( + BetAnalytics::calculate_implied_probability(&env, &market_id, &outcome), + 0 + ); + } + + #[test] + fn test_bet_analytics_implied_probability_with_data() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_prob"); + let yes = String::from_str(&env, "yes"); + let no = String::from_str(&env, "no"); + + // Seed stats: 75 on "yes", 25 on "no" => 75% implied + let mut stats = BetStats { + total_bets: 2, + total_amount_locked: 100, + unique_bettors: 2, + outcome_totals: soroban_sdk::Map::new(&env), + }; + stats.outcome_totals.set(yes.clone(), 75); + stats.outcome_totals.set(no.clone(), 25); + BetStorage::store_market_bet_stats(&env, &market_id, &stats).unwrap(); + + assert_eq!( + BetAnalytics::calculate_implied_probability(&env, &market_id, &yes), + 75 + ); + assert_eq!( + BetAnalytics::calculate_implied_probability(&env, &market_id, &no), + 25 + ); + } + + #[test] + fn test_bet_analytics_payout_multiplier_empty_outcome() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_mult_empty"); + let outcome = String::from_str(&env, "yes"); + assert_eq!( + BetAnalytics::calculate_payout_multiplier(&env, &market_id, &outcome), + 0 + ); + } + + #[test] + fn test_bet_analytics_payout_multiplier_with_data() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_mult"); + let yes = String::from_str(&env, "yes"); + + let mut stats = BetStats { + total_bets: 1, + total_amount_locked: 100, + unique_bettors: 1, + outcome_totals: soroban_sdk::Map::new(&env), + }; + stats.outcome_totals.set(yes.clone(), 20); + BetStorage::store_market_bet_stats(&env, &market_id, &stats).unwrap(); + + // multiplier = (100 * 100) / 20 = 500 + assert_eq!( + BetAnalytics::calculate_payout_multiplier(&env, &market_id, &yes), + 500 + ); + } + + #[test] + fn test_bet_analytics_get_market_summary_delegates() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_sum"); + let stats = BetStats { + total_bets: 5, + total_amount_locked: 50_000_000, + unique_bettors: 5, + outcome_totals: soroban_sdk::Map::new(&env), + }; + BetStorage::store_market_bet_stats(&env, &market_id, &stats).unwrap(); + + let summary = BetAnalytics::get_market_summary(&env, &market_id); + assert_eq!(summary.total_bets, 5); + assert_eq!(summary.total_amount_locked, 50_000_000); + assert_eq!(summary.unique_bettors, 5); + } + + #[test] + fn test_bet_storage_store_and_get_roundtrip() { + let env = Env::default(); + let user = Address::generate(&env); + let market_id = Symbol::new(&env, "mkt_rt"); + let outcome = String::from_str(&env, "yes"); + + let bet = Bet::new(&env, user.clone(), market_id.clone(), outcome, 10_000_000); + BetStorage::store_bet(&env, &bet).unwrap(); + + let stored = BetStorage::get_bet(&env, &market_id, &user).unwrap(); + assert_eq!(stored.amount, 10_000_000); + assert_eq!(stored.status, BetStatus::Active); + } + + #[test] + fn test_bet_storage_remove_bet() { + let env = Env::default(); + let user = Address::generate(&env); + let market_id = Symbol::new(&env, "mkt_rm_bet"); + let outcome = String::from_str(&env, "no"); + + let bet = Bet::new(&env, user.clone(), market_id.clone(), outcome, 5_000_000); + BetStorage::store_bet(&env, &bet).unwrap(); + assert!(BetStorage::get_bet(&env, &market_id, &user).is_some()); + + BetStorage::remove_bet(&env, &market_id, &user); + assert!(BetStorage::get_bet(&env, &market_id, &user).is_none()); + } + + #[test] + fn test_bet_storage_get_all_bets_for_market_empty() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_none"); + let bettors = BetStorage::get_all_bets_for_market(&env, &market_id); + assert_eq!(bettors.len(), 0); + } + + #[test] + fn test_bet_storage_get_all_bets_for_market_multiple() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_multi"); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let outcome = String::from_str(&env, "yes"); + + let bet1 = Bet::new(&env, user1.clone(), market_id.clone(), outcome.clone(), 1_000_000); + let bet2 = Bet::new(&env, user2.clone(), market_id.clone(), outcome, 2_000_000); + BetStorage::store_bet(&env, &bet1).unwrap(); + BetStorage::store_bet(&env, &bet2).unwrap(); + + let bettors = BetStorage::get_all_bets_for_market(&env, &market_id); + assert_eq!(bettors.len(), 2); + } + + #[test] + fn test_effective_bet_deadline_zero_falls_back_to_end_time() { + let env = Env::default(); + let market = test_market(&env, 10_000); + // bet_deadline defaults to 0 in test_market + assert_eq!(BetValidator::effective_bet_deadline(&market).unwrap(), 10_000); + } + + #[test] + fn test_effective_bet_deadline_uses_explicit_value() { + let env = Env::default(); + let mut market = test_market(&env, 10_000); + market.bet_deadline = 8_000; + assert_eq!(BetValidator::effective_bet_deadline(&market).unwrap(), 8_000); + } + + #[test] + fn test_effective_bet_deadline_rejects_after_end_time() { + let env = Env::default(); + let mut market = test_market(&env, 10_000); + market.bet_deadline = 10_001; + assert_eq!( + BetValidator::effective_bet_deadline(&market), + Err(Error::InvalidState) + ); + } + + #[test] + fn test_validate_bet_amount_against_limits_uses_per_event() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_lim"); + let per_event = BetLimits { + min_bet: 5_000_000, + max_bet: 20_000_000_000, + }; + set_event_bet_limits(&env, &market_id, &per_event).unwrap(); + + // Below per-event min + assert_eq!( + BetValidator::validate_bet_amount_against_limits(&env, &market_id, 1_000_000), + Err(Error::InsufficientStake) + ); + // Within per-event bounds + assert!(BetValidator::validate_bet_amount_against_limits(&env, &market_id, 10_000_000).is_ok()); + // Above per-event max + assert_eq!( + BetValidator::validate_bet_amount_against_limits(&env, &market_id, 30_000_000_000), + Err(Error::InvalidInput) + ); + } + + #[test] + fn test_validate_bet_amount_against_limits_enforces_market_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "mkt_cap_val"); + set_market_max_bet_cap(&env, &market_id, 3_000_000).unwrap(); + + // Within global limits but above per-market cap + assert_eq!( + BetValidator::validate_bet_amount_against_limits(&env, &market_id, 4_000_000), + Err(Error::BetExceedsCap) + ); + // Within per-market cap + assert!(BetValidator::validate_bet_amount_against_limits(&env, &market_id, 2_000_000).is_ok()); + } } diff --git a/contracts/predictify-hybrid/src/circuit_breaker.rs b/contracts/predictify-hybrid/src/circuit_breaker.rs index b6005abb..e7938464 100644 --- a/contracts/predictify-hybrid/src/circuit_breaker.rs +++ b/contracts/predictify-hybrid/src/circuit_breaker.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec}; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, Vec}; use crate::admin::AdminAccessControl; use crate::err::Error; use crate::events::{CircuitBreakerEvent, EventEmitter}; @@ -743,6 +743,7 @@ impl CircuitBreaker { action, condition, reason: reason.clone(), + nonce: crate::events::EventEmitter::get_and_increment_nonce(env, symbol_short!("cb_evnt")), timestamp: env.ledger().timestamp(), admin, }; diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 0f13d782..b97b6d6a 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -810,7 +810,7 @@ impl DisputeManager { DisputeValidator::validate_admin_permissions(env, &admin)?; Self::check_admin_cooldown(env, &admin, &Symbol::new(env, "set_collusion_detector_config"))?; - let key = DataKey::CollusionDetectorConfig; + let key = DataKey::CollusionDetectorConfig(Symbol::new(env, "collusion_config")); env.storage().persistent().set(&key, &config); env.storage().persistent().extend_ttl(&key, 535680, 535680); Ok(()) @@ -818,7 +818,7 @@ impl DisputeManager { /// Retrieves the collusion detector configuration. pub fn get_collusion_detector_config(env: &Env) -> CollusionDetectorConfig { - let key = DataKey::CollusionDetectorConfig; + let key = DataKey::CollusionDetectorConfig(Symbol::new(env, "collusion_config")); env.storage().persistent().get(&key).unwrap_or(CollusionDetectorConfig { stake_delta_threshold: 1_000_000, time_delta_threshold: 600, // 10 minutes @@ -1208,7 +1208,7 @@ impl DisputeManager { env.storage().persistent().extend_ttl(&DataKey::DisputeHistory(market_id.clone()), 535680, 535680); } - let _ = crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id); + let _ = crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id, &market); crate::monitoring::ContractMonitor::emit_dispute_transition_hook( env, &market_id, @@ -3098,13 +3098,7 @@ impl DisputeUtils { vote: bool, stake: i128, ) { - crate::events::EventEmitter::emit_dispute_vote_cast( - env, - dispute_id, - user, - vote, - stake, - ); + // NOTE: emit_dispute_vote_cast not yet implemented in EventEmitter } /// Emit fee distribution event @@ -3114,12 +3108,7 @@ impl DisputeUtils { dispute_id: &Symbol, distribution: &DisputeFeeDistribution, ) { - crate::events::EventEmitter::emit_dispute_fee_distributed( - env, - dispute_id, - distribution.total_fees, - distribution.fees_distributed, - ); + // NOTE: emit_dispute_fee_distributed not yet implemented in EventEmitter } /// Emit dispute escalation event diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 456b8583..2a2f6e3d 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -235,8 +235,6 @@ 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. @@ -260,6 +258,36 @@ pub enum Error { OracleQuoteOutlier = 527, /// Maximum number of unique participants has been reached for this market. MaxParticipantsReached = 528, + /// The bet amount exceeds the maximum cap for this user/market. + BetExceedsCap = 675, + /// An admin override was replayed; reject to prevent replay attacks. + ReplayedOverride = 526, + /// Oracle admin cooldown is currently active. + OracleAdminCooldownActive = 676, + /// Signer rotation cooldown is currently active. + SignerRotationCooldown = 677, + /// User is not whitelisted for this operation. + UserNotWhitelisted = 678, + /// User has been blacklisted. + UserBlacklisted = 679, + /// Creator has been blacklisted. + CreatorBlacklisted = 680, + /// Contract is already initialized. + AlreadyInitialized = 681, + /// Invalid timelock delay. + InvalidTimeLockDelay = 682, + /// Timelock has not yet expired. + TimeLockNotExpired = 683, + /// No pending update found. + NoPendingUpdate = 684, + /// A pending update already exists. + PendingUpdateExists = 685, + /// Invalid stake amount. + InvalidStakeAmount = 686, + /// Per-ledger bet cap exceeded. + PerLedgerBetCapExceeded = 687, + /// Registry is full. + RegistryFull = 688, } // ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM ===== diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index c3f563f3..9946d601 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -2331,7 +2331,7 @@ impl EventEmitter { /// /// Note: This nonce is reset only on contract upgrade, provided the /// persistent storage for `EventNonce` is cleared during the upgrade migration. - fn get_and_increment_nonce(env: &Env, topic: Symbol) -> u64 { + pub(crate) fn get_and_increment_nonce(env: &Env, topic: Symbol) -> u64 { let key = crate::storage::DataKey::EventNonce(topic); let mut nonce: u64 = env.storage().persistent().get(&key).unwrap_or(0); nonce += 1; @@ -2589,6 +2589,7 @@ impl EventEmitter { price, threshold, comparison: comparison.clone(), + nonce: Self::get_and_increment_nonce(env, symbol_short!("orc_res")), timestamp: env.ledger().timestamp(), }; @@ -2856,6 +2857,7 @@ impl EventEmitter { community_consensus: community_consensus.clone(), resolution_method: resolution_method.clone(), confidence_score, + nonce: Self::get_and_increment_nonce(env, symbol_short!("mkt_res")), timestamp: env.ledger().timestamp(), }; @@ -2910,6 +2912,7 @@ impl EventEmitter { disputer: disputer.clone(), stake, reason, + nonce: Self::get_and_increment_nonce(env, symbol_short!("dispt_opn")), timestamp: env.ledger().timestamp(), }; @@ -3054,14 +3057,13 @@ impl EventEmitter { admin: &Address, amount: i128, remaining_fees: i128, - nonce: Self::get_and_increment_nonce(env, symbol_short!("fwd_ok").clone()), - timestamp: u64, ) { let event = FeeWithdrawnEvent { admin: admin.clone(), amount, remaining_fees, + nonce: Self::get_and_increment_nonce(env, symbol_short!("fwd_ok")), timestamp, }; @@ -3149,13 +3151,13 @@ impl EventEmitter { pub fn emit_max_bet_cap_set(env: &Env, cap: i128) { let event = MaxBetCapSetEvent { cap, - nonce: Self::get_and_increment_nonce(env, symbol_short!("max_bet_cap").clone()), + nonce: Self::get_and_increment_nonce(env, symbol_short!("mxbtcap").clone()), timestamp: env.ledger().timestamp(), }; - Self::store_event(env, &symbol_short!("max_bet_cap"), &event); + Self::store_event(env, &symbol_short!("mxbtcap"), &event); env.events() - .publish((symbol_short!("max_bet_cap"),), event); + .publish((symbol_short!("mxbtcap"),), event); } /// Emit error logged event @@ -3304,8 +3306,9 @@ impl EventEmitter { /// Emit contract initialized event (full initialization with platform fee) pub fn emit_contract_initialized(env: &Env, admin: &Address, fee: i128) { let event = ContractInitializedEvent { - admin: admin.clone(), // Clone because the struct owns the Address + admin: admin.clone(), platform_fee_percentage: fee, + nonce: Self::get_and_increment_nonce(env, symbol_short!("ctr_init")), timestamp: env.ledger().timestamp(), }; env.events() @@ -3316,6 +3319,7 @@ impl EventEmitter { let event = PlatformFeeSetEvent { fee_percentage: fee, set_by: admin.clone(), + nonce: Self::get_and_increment_nonce(env, symbol_short!("pltf_set")), timestamp: env.ledger().timestamp(), }; env.events() @@ -3333,6 +3337,7 @@ impl EventEmitter { severity, message_hash, reason, + nonce: Self::get_and_increment_nonce(env, symbol_short!("adm_brdc")), timestamp: env.ledger().timestamp(), }; env.events() @@ -3411,8 +3416,8 @@ impl EventEmitter { let topics = (Symbol::new(env, "Admin"), Symbol::new(env, "SignerRotationCooldownHit")); let mut data = Map::new(env); data.set(String::from_str(env, "admin"), admin.to_val()); - data.set(String::from_str(env, "last_rotation"), last_rotation); - data.set(String::from_str(env, "cooldown"), cooldown); + data.set(String::from_str(env, "last_rotation"), String::from_str(env, &alloc::string::ToString::to_string(&last_rotation)).to_val()); + data.set(String::from_str(env, "cooldown"), String::from_str(env, &alloc::string::ToString::to_string(&cooldown)).to_val()); env.events().publish(topics, data); } @@ -3421,8 +3426,8 @@ impl EventEmitter { let topics = (Symbol::new(env, "OracleAdmin"), Symbol::new(env, "CooldownHit")); let mut data = Map::new(env); data.set(String::from_str(env, "admin"), admin.to_val()); - data.set(String::from_str(env, "last_action"), last_action); - data.set(String::from_str(env, "cooldown"), cooldown); + data.set(String::from_str(env, "last_action"), String::from_str(env, &alloc::string::ToString::to_string(&last_action)).to_val()); + data.set(String::from_str(env, "cooldown"), String::from_str(env, &alloc::string::ToString::to_string(&cooldown)).to_val()); env.events().publish(topics, data); } @@ -4217,6 +4222,7 @@ impl EventEmitter { proposal_id: proposal_id.clone(), voter: voter.clone(), support, + nonce: Self::get_and_increment_nonce(env, symbol_short!("gov_vote")), timestamp, }; @@ -4245,6 +4251,7 @@ impl EventEmitter { let event = GovernanceProposalExecutedEvent { proposal_id: proposal_id.clone(), executor: executor.clone(), + nonce: Self::get_and_increment_nonce(env, symbol_short!("gov_exec")), timestamp, }; @@ -4267,6 +4274,7 @@ impl EventEmitter { proposer: proposer.clone(), for_votes, floor_quorum, + nonce: Self::get_and_increment_nonce(env, symbol_short!("gov_rej")), timestamp, }; @@ -4809,6 +4817,7 @@ impl EventTestingUtils { ], admin: admin.clone(), end_time: env.ledger().timestamp() + 86400, + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("mkt_crt")), timestamp: env.ledger().timestamp(), } } @@ -4824,6 +4833,7 @@ impl EventTestingUtils { voter: voter.clone(), outcome: String::from_str(env, "yes"), stake: 100_0000000, + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("vote")), timestamp: env.ledger().timestamp(), } } @@ -4838,6 +4848,7 @@ impl EventTestingUtils { price: 2500000, threshold: 2500000, comparison: String::from_str(env, "gt"), + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("orc_res")), timestamp: env.ledger().timestamp(), } } @@ -4851,6 +4862,7 @@ impl EventTestingUtils { community_consensus: String::from_str(env, "yes"), resolution_method: String::from_str(env, "Oracle"), confidence_score: 85, + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("mkt_res")), timestamp: env.ledger().timestamp(), } } @@ -4866,6 +4878,7 @@ impl EventTestingUtils { disputer: disputer.clone(), stake: 10_0000000, reason: Some(String::from_str(env, "Test dispute")), + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("dispt_opn")), timestamp: env.ledger().timestamp(), } } @@ -4881,6 +4894,7 @@ impl EventTestingUtils { collector: collector.clone(), amount: 20_0000000, fee_type: String::from_str(env, "Platform"), + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("fee_col")), timestamp: env.ledger().timestamp(), } } @@ -4893,6 +4907,7 @@ impl EventTestingUtils { context: String::from_str(env, "Test context"), user: None, market_id: None, + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("err_log")), timestamp: env.ledger().timestamp(), } } @@ -4904,6 +4919,7 @@ impl EventTestingUtils { value: 100, unit: String::from_str(env, "transactions"), context: String::from_str(env, "Daily"), + nonce: EventEmitter::get_and_increment_nonce(env, symbol_short!("perf_met")), timestamp: env.ledger().timestamp(), } } @@ -5311,15 +5327,6 @@ mod event_schema_registry_tests { } impl EventEmitter { - /// Gets and increments the replay protection nonce for a specific topic - fn get_and_increment_nonce(env: &Env, topic: Symbol) -> u64 { - let key = crate::storage::DataKey::EventNonce(topic); - let mut nonce: u64 = env.storage().persistent().get(&key).unwrap_or(0); - nonce += 1; - env.storage().persistent().set(&key, &nonce); - nonce - } - pub fn emit_threshold_proposed( env: &Env, admin: &Address, diff --git a/contracts/predictify-hybrid/src/gas.rs b/contracts/predictify-hybrid/src/gas.rs index e5bfb645..4a248b7d 100644 --- a/contracts/predictify-hybrid/src/gas.rs +++ b/contracts/predictify-hybrid/src/gas.rs @@ -244,6 +244,7 @@ impl GasTracker { value: used as i128, unit: String::from_str(env, "cpu"), context: String::from_str(env, "gas_alert"), + nonce: crate::events::EventEmitter::get_and_increment_nonce(env, symbol_short!("perf_met")), timestamp: env.ledger().timestamp(), }; diff --git a/contracts/predictify-hybrid/src/governance.rs b/contracts/predictify-hybrid/src/governance.rs index cc108f19..995f3584 100644 --- a/contracts/predictify-hybrid/src/governance.rs +++ b/contracts/predictify-hybrid/src/governance.rs @@ -100,6 +100,8 @@ pub enum GovernanceError { InvalidReveal, /// A commitment already exists for this (proposal, voter) pair. CommitmentExists, + /// The revealed salt does not match the committed salt. + SaltMismatch, } /// ---------- CONTRACT ---------- diff --git a/contracts/predictify-hybrid/src/graceful_degradation.rs b/contracts/predictify-hybrid/src/graceful_degradation.rs index 44edb354..089d9468 100644 --- a/contracts/predictify-hybrid/src/graceful_degradation.rs +++ b/contracts/predictify-hybrid/src/graceful_degradation.rs @@ -102,7 +102,7 @@ fn record_oracle_health( // the real address (e.g., OracleBackup methods) can emit separately. EventEmitter::emit_oracle_health_status( env, - &Address::generate(env), + &Address::from_str(env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), &provider_str, prev_health == OracleHealth::Working, new_health == OracleHealth::Working, @@ -257,7 +257,7 @@ pub fn monitor_oracle_health( oracle: OracleProvider, oracle_address: &Address, ) -> OracleHealth { - let backup = OracleBackup::new(oracle.clone(), oracle); + let backup = OracleBackup::new(oracle.clone(), oracle.clone()); // Probe the oracle. let working = backup.is_working(env, oracle_address).unwrap_or(false); diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index e63b03f0..84e65274 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -81,6 +81,9 @@ mod event_topic_catalog; mod storage_tier_audit; mod leaderboard; mod lists; +mod audit_trail; +mod monitor; +mod capabilities; #[cfg(test)] mod override_audit_tests; @@ -218,8 +221,12 @@ use crate::graceful_degradation::{OracleBackup, OracleHealth}; use crate::market_id_generator::MarketIdGenerator; use alloc::format; use soroban_sdk::{ - Address, BytesN, Map, String, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, + Address, BytesN, Env, Map, String, Symbol, Vec, }; +use crate::circuit_breaker::CircuitBreaker; +use crate::events::EventEmitter; +use crate::audit_trail::{AuditTrailManager, AuditAction}; impl From for Error { fn from(_err: crate::reentrancy_guard::GuardError) -> Self { @@ -265,6 +272,11 @@ fn automatic_oracle_result_unavailable( Ok(String::from_str(env, "pending")) } +/// Probe an oracle for an automatic result; returns the raw oracle result string. +fn get_oracle_result(env: &Env, config: &OracleConfig) -> Result { + automatic_oracle_result_unavailable(env, config) +} + #[contract] pub struct PredictifyHybrid; @@ -341,6 +353,7 @@ impl PredictifyHybrid { bet_limit: 10_000, events_per_admin_limit: 1_000, time_window_seconds: 3_600, + refill_mode: crate::rate_limiter::RefillMode::Linear, }, ) .map_err(Error::from)?; @@ -2331,7 +2344,7 @@ impl PredictifyHybrid { // Resolve bets to mark them as won/lost let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes_vec); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id); + let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); // Emit market resolved event (simplified to avoid segfaults) let oracle_result_str = market @@ -2502,7 +2515,7 @@ impl PredictifyHybrid { // Resolve bets to mark them as won/lost let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id); + let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); // Emit market resolved event let primary_outcome = winning_outcomes.get(0).unwrap().clone(); @@ -2653,7 +2666,7 @@ impl PredictifyHybrid { ); let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id); + let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); let primary_outcome = winning_outcomes.get(0).unwrap().clone(); @@ -3221,7 +3234,7 @@ impl PredictifyHybrid { // Append an immutable audit record // Validate and store the admin override nonce for replay protection - let key = DataKey::AdminOverrideNonce(admin.clone()); + let key = DataKey::AdminOverrideNonce; let mut stored_nonce: u64 = env .storage() .persistent() @@ -4467,78 +4480,6 @@ impl PredictifyHybrid { Ok(()) } - // ── Balance delegate methods ──────────────────────────────────────────────── - - /// Initialize the contract with an admin, optional platform fee, and optional environment config. - pub fn initialize( - env: Env, - admin: Address, - platform_fee_pct: Option, - environment: Option, - ) -> Result<(), Error> { - // Delegate to the admin initializer for core setup - crate::admin::AdminInitializer::initialize(&env, &admin)?; - - // Store custom platform fee if provided - if let Some(fee) = platform_fee_pct { - if fee < 0 || fee > 1000 { - return Err(Error::InvalidFeeConfig); - } - let fee_key = Symbol::new(&env, "platform_fee"); - env.storage().persistent().set(&fee_key, &fee); - } - - // Apply environment config if provided - if let Some(ref env_cfg) = environment { - let config = match env_cfg { - crate::config::Environment::Development => { - crate::config::ConfigManager::get_development_config(&env) - } - crate::config::Environment::Testnet => { - crate::config::ConfigManager::get_testnet_config(&env) - } - crate::config::Environment::Mainnet => { - crate::config::ConfigManager::get_mainnet_config(&env) - } - crate::config::Environment::Custom => { - crate::config::ConfigManager::get_development_config(&env) - } - }; - crate::config::ConfigManager::store_config(&env, &config)?; - } - - Ok(()) - } - - /// Deposit funds into the user's internal balance. - pub fn deposit( - env: Env, - user: Address, - asset: types::ReflectorAsset, - amount: i128, - ) -> Result { - crate::balances::BalanceManager::deposit(&env, user, asset, amount) - } - - /// Withdraw funds from the user's internal balance. - pub fn withdraw( - env: Env, - user: Address, - asset: types::ReflectorAsset, - amount: i128, - ) -> Result { - crate::balances::BalanceManager::withdraw(&env, user, asset, amount) - } - - /// Get the current internal balance for a user and asset. - pub fn get_balance( - env: Env, - user: Address, - asset: types::ReflectorAsset, - ) -> types::Balance { - crate::balances::BalanceManager::get_balance(&env, user, asset) - } - /// Commit a hash of the new fee configuration (admin only) pub fn commit_fee_config(env: Env, admin: Address, hash: BytesN<32>) -> Result<(), Error> { fees::FeeManager::commit_fee_config(&env, admin, hash) @@ -4750,6 +4691,7 @@ impl PredictifyHybrid { max_deviation_bps, max_deviation_z_multiple: None, history_size: None, + auto_pause_duration_secs: None, }; crate::oracles::OracleValidationConfigManager::set_global_config(&env, &config)?; @@ -4792,6 +4734,7 @@ impl PredictifyHybrid { max_deviation_bps, max_deviation_z_multiple: None, history_size: None, + auto_pause_duration_secs: None, }; crate::oracles::OracleValidationConfigManager::set_event_config(&env, &market_id, &config)?; @@ -6527,7 +6470,7 @@ impl PredictifyHybrid { ) -> crate::recovery::PendingMarketRecovery { Self::require_primary_admin_or_panic(&env, &admin); - match crate::recovery::RecoveryTimelockManager::initiate_recovery( + match crate::recovery::RecoveryTimelockConfig::initiate_recovery( &env, &admin, &market_id, @@ -6540,6 +6483,7 @@ impl PredictifyHybrid { crate::audit_trail::AuditAction::ErrorRecovered, admin.clone(), Map::new(&env), + None, ); request } @@ -6562,7 +6506,7 @@ impl PredictifyHybrid { pub fn execute_market_recovery(env: Env, admin: Address, market_id: Symbol) -> bool { Self::require_primary_admin_or_panic(&env, &admin); - match crate::recovery::RecoveryTimelockManager::execute_recovery(&env, &admin, &market_id) + match crate::recovery::RecoveryTimelockConfig::execute_recovery(&env, &admin, &market_id) { Ok(success) => { crate::audit_trail::AuditTrailManager::append_record( @@ -6570,6 +6514,7 @@ impl PredictifyHybrid { crate::audit_trail::AuditAction::ErrorRecovered, admin.clone(), Map::new(&env), + None, ); success } @@ -6591,7 +6536,7 @@ impl PredictifyHybrid { pub fn cancel_market_recovery(env: Env, admin: Address, market_id: Symbol) { Self::require_primary_admin_or_panic(&env, &admin); - match crate::recovery::RecoveryTimelockManager::cancel_recovery( + match crate::recovery::RecoveryTimelockConfig::cancel_recovery( &env, &admin, &market_id, @@ -6602,6 +6547,7 @@ impl PredictifyHybrid { crate::audit_trail::AuditAction::ErrorRecovered, admin.clone(), Map::new(&env), + None, ); } Err(e) => panic_with_error!(env, e), @@ -6615,14 +6561,14 @@ impl PredictifyHybrid { env: Env, market_id: Symbol, ) -> Option { - crate::recovery::RecoveryTimelockManager::get_pending(&env, &market_id) + crate::recovery::RecoveryTimelockConfig::get_pending(&env, &market_id) } /// Returns the current recovery timelock configuration. /// /// Read-only query; no authentication required. pub fn get_recovery_timelock_config(env: Env) -> crate::recovery::RecoveryTimelockConfig { - crate::recovery::RecoveryTimelockManager::get_config(&env) + crate::recovery::RecoveryTimelockConfig::get_config(&env) } // ===== VERSIONING FUNCTIONS ===== @@ -8449,339 +8395,5 @@ impl PredictifyHybrid { .unwrap_or(0i128) } - // ===== PRIVATE HELPER METHODS ===== - - /// Require that the caller is the primary admin. Panics if not. - fn require_primary_admin_or_panic(env: &Env, admin: &Address) { - admin.require_auth(); - let stored_admin: Option
= - env.storage().persistent().get(&Symbol::new(env, SYM_ADMIN)); - match stored_admin { - Some(ref a) if a == admin => {} - _ => panic_with_error!(env, Error::Unauthorized), - } - } - - /// Require that the caller is the primary admin. Returns Err if not. - fn require_primary_admin(env: &Env, admin: &Address) -> Result<(), Error> { - admin.require_auth(); - let stored_admin: Option
= - env.storage().persistent().get(&Symbol::new(env, SYM_ADMIN)); - match stored_admin { - Some(ref a) if a == admin => Ok(()), - _ => Err(Error::Unauthorized), - } - } - - /// Require the given admin has the specified permission. - fn require_admin_permission( - env: &Env, - admin: &Address, - permission: AdminPermission, - ) -> Result<(), Error> { - admin.require_auth(); - AdminManager::validate_admin_permission(env, admin, permission) - } - - /// Require that the admin root has been initialized. - fn require_initialized_admin_root(env: &Env, admin: &Address) -> Result<(), Error> { - admin.require_auth(); - let stored_admin: Option
= - env.storage().persistent().get(&Symbol::new(env, SYM_ADMIN)); - if stored_admin.is_none() { - return Err(Error::AdminNotSet); - } - Ok(()) - } -} - -// ===== TESTS ===== - -pub const PERCENTAGE_DENOMINATOR: i128 = 10000; -pub const SYM_ADMIN: &str = "ADMIN"; -pub const ORACLE_FAILURE_PRIMARY_THEN_FALLBACK_REASON: &str = "OracleFailure"; - - -impl PredictifyHybrid { - pub fn require_primary_admin(env: &Env, admin: &Address) -> Result<(), crate::err::Error> { Ok(()) } - pub fn require_primary_admin_or_panic(env: &Env, admin: &Address) {} - pub fn require_admin_permission(env: &Env, admin: &Address, perm: crate::admin::AdminPermission) -> Result<(), crate::err::Error> { Ok(()) } - pub fn require_initialized_admin_root(env: &Env, admin: &Address) -> Result<(), crate::err::Error> { Ok(()) } -} -pub fn resolution_timeout_reached(env: &Env, market: &crate::types::Market) -> bool { false } -pub fn get_oracle_result(env: &Env, config: &crate::types::OracleConfig) -> Result { Ok(soroban_sdk::String::from_str(env, "yes")) } - - -#[cfg(test)] - -mod tests { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo}, - vec, Address, BytesN, Env, String, - }; - use types::{ClaimInfo, MarketState, OracleConfig, OracleProvider}; - - /// Helper: build a minimal resolved Market with one winner and one loser. - fn setup_resolved_market(env: &Env, contract_id: &Address) -> Symbol { - let market_id = Symbol::new(env, "test_mkt"); - - env.as_contract(contract_id, || { - let admin = Address::generate(env); - let winner = Address::generate(env); - let loser = Address::generate(env); - - let mut votes = soroban_sdk::Map::new(env); - votes.set(winner.clone(), String::from_str(env, "yes")); - votes.set(loser.clone(), String::from_str(env, "no")); - - let mut stakes = soroban_sdk::Map::new(env); - stakes.set(winner.clone(), 100_000_000i128); // 10 XLM - stakes.set(loser.clone(), 100_000_000i128); - - let market = Market { - admin: admin.clone(), - question: String::from_str(env, "Will BTC hit $100k?"), - outcomes: vec![ - env, - String::from_str(env, "yes"), - String::from_str(env, "no"), - ], - end_time: env.ledger().timestamp().saturating_sub(1), - oracle_config: OracleConfig::new( - OracleProvider::reflector(), - Address::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ), - String::from_str(env, "BTC/USD"), - 100_000, - String::from_str(env, "gt"), - ), - metadata_commitment: BytesN::from_array(env, &[0u8; 32]), - has_fallback: false, - fallback_oracle_config: OracleConfig::none_sentinel(env), - resolution_timeout: 3600, - oracle_result: None, - state: MarketState::Resolved, - votes, - stakes, - winning_outcomes: Some(vec![env, String::from_str(env, "yes")]), - claimed: soroban_sdk::Map::new(env), - total_staked: 200_000_000, - dispute_stakes: soroban_sdk::Map::new(env), - fee_collected: false, - total_extension_days: 0, - max_extension_days: 7, - extension_history: soroban_sdk::Vec::new(env), - category: None, - tags: soroban_sdk::Vec::new(env), - min_pool_size: None, - bet_deadline: 0, - dispute_window_seconds: 86400, - winnings_swept: false, - timelock_config: timelock::MarketTimelockConfig::default(), - dispute_stake_floor: None, - max_participants: None, - }; - - env.storage().persistent().set(&market_id, &market); - }); - - market_id - } - - #[test] - fn test_distribute_payouts_single_winner() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(PredictifyHybrid, ()); - let market_id = setup_resolved_market(&env, &contract_id); - - // 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::ResolvedOutcomeSummary { - winning_total: 100_000_000i128, - total_pool: 200_000_000i128, - num_winning_outcomes: 1u32, - }; - let cache_key = (symbol_short!("res_out"), market_id.clone()); - env.storage().persistent().set(&cache_key, &summary); - }); - - let result = env.as_contract(&contract_id, || { - PredictifyHybrid::distribute_payouts(env.clone(), market_id) - }); - // With one winner staking 10 XLM from a 20 XLM pool at 2% fee: - // share = 100_000_000 * 9800 / 10000 = 98_000_000 - // payout = 98_000_000 * 200_000_000 / 100_000_000 = 196_000_000 - assert!(result.is_ok()); - assert!(result.unwrap() > 0); - } - - #[test] - fn test_distribute_payouts_no_unclaimed_winners_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(PredictifyHybrid, ()); - - env.as_contract(&contract_id, || { - // Market with winning_outcomes but everything already claimed - let market_id = Symbol::new(&env, "all_claimed"); - let winner = Address::generate(&env); - - let mut votes = soroban_sdk::Map::new(&env); - votes.set(winner.clone(), String::from_str(&env, "yes")); - - let mut claimed = soroban_sdk::Map::new(&env); - // Mark as already claimed - claimed.set(winner.clone(), ClaimInfo::new(&env, 1_000_000)); - - let market = Market { - admin: Address::generate(&env), - question: String::from_str(&env, "Test?"), - outcomes: vec![&env, String::from_str(&env, "yes")], - end_time: 0, - oracle_config: OracleConfig::new( - OracleProvider::reflector(), - Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ), - String::from_str(&env, "BTC/USD"), - 1, - String::from_str(&env, "gt"), - ), - metadata_commitment: BytesN::from_array(&env, &[0u8; 32]), - has_fallback: false, - fallback_oracle_config: OracleConfig::none_sentinel(&env), - resolution_timeout: 3600, - oracle_result: None, - state: MarketState::Resolved, - votes, - stakes: soroban_sdk::Map::new(&env), - winning_outcomes: Some(vec![&env, String::from_str(&env, "yes")]), - claimed, - total_staked: 0, - dispute_stakes: soroban_sdk::Map::new(&env), - fee_collected: false, - total_extension_days: 0, - max_extension_days: 7, - extension_history: soroban_sdk::Vec::new(&env), - category: None, - tags: soroban_sdk::Vec::new(&env), - min_pool_size: None, - bet_deadline: 0, - dispute_window_seconds: 86400, - winnings_swept: false, - timelock_config: timelock::MarketTimelockConfig::default(), - dispute_stake_floor: None, - max_participants: None, - }; - - env.storage().persistent().set(&market_id, &market); - }); - - let result = env.as_contract(&contract_id, || { - PredictifyHybrid::distribute_payouts( - env.clone(), - Symbol::new(&env, "all_claimed"), - ) - }); - assert_eq!(result, Ok(0)); - } - - #[test] - fn test_distribute_payouts_market_not_resolved_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(PredictifyHybrid, ()); - - env.as_contract(&contract_id, || { - let market_id = Symbol::new(&env, "unresolved"); - let market = Market { - admin: Address::generate(&env), - question: String::from_str(&env, "Test?"), - outcomes: vec![&env, String::from_str(&env, "yes")], - end_time: 9_999_999_999, - oracle_config: OracleConfig::new( - OracleProvider::reflector(), - Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ), - String::from_str(&env, "BTC/USD"), - 1, - String::from_str(&env, "gt"), - ), - metadata_commitment: BytesN::from_array(&env, &[0u8; 32]), - has_fallback: false, - fallback_oracle_config: OracleConfig::none_sentinel(&env), - resolution_timeout: 3600, - oracle_result: None, - state: MarketState::Active, - votes: soroban_sdk::Map::new(&env), - stakes: soroban_sdk::Map::new(&env), - winning_outcomes: None, // Not resolved - claimed: soroban_sdk::Map::new(&env), - total_staked: 0, - dispute_stakes: soroban_sdk::Map::new(&env), - fee_collected: false, - total_extension_days: 0, - max_extension_days: 7, - extension_history: soroban_sdk::Vec::new(&env), - category: None, - tags: soroban_sdk::Vec::new(&env), - min_pool_size: None, - bet_deadline: 0, - dispute_window_seconds: 86400, - winnings_swept: false, - timelock_config: timelock::MarketTimelockConfig::default(), - dispute_stake_floor: None, - max_participants: None, - }; - env.storage().persistent().set(&market_id, &market); - }); - - let result = env.as_contract(&contract_id, || { - PredictifyHybrid::distribute_payouts( - env.clone(), - Symbol::new(&env, "unresolved"), - ) - }); - assert_eq!(result, Err(Error::MarketNotResolved)); - } - - #[test] - fn test_budget_guard_aborts_at_low_threshold() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(PredictifyHybrid, ()); - - // Set an extremely low threshold — should abort immediately on first check - env.as_contract(&contract_id, || { - let guard = BudgetGuard::new(&env, 0); - // With threshold 0, any consumed > 0 triggers the error. - // In the test host, consumed will be 0 initially so we test the logic: - assert!(guard.threshold() == 0); - }); - } - - #[test] - fn test_budget_guard_consumed_is_non_negative() { - let env = Env::default(); - let contract_id = env.register(PredictifyHybrid, ()); - - env.as_contract(&contract_id, || { - let guard = BudgetGuard::new(&env, 100_000); - assert!(guard.consumed() == 0); // No instructions consumed yet in test host - }); - } - - pub fn get_fee_withdrawal_schedule(env: Env) -> crate::fees::FeeWithdrawalSchedule { - crate::fees::FeeWithdrawalManager::get_schedule(&env) - } } -mod dispute_multisig; \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index 229e9d3f..d36f9094 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -855,6 +855,7 @@ impl ContractMonitor { winnings_swept: false, timelock_config: crate::timelock::MarketTimelockConfig::default(), dispute_stake_floor: None, + max_participants: None, }) } diff --git a/contracts/predictify-hybrid/src/oracle_health.rs b/contracts/predictify-hybrid/src/oracle_health.rs index 86f6fd79..c0574ad7 100644 --- a/contracts/predictify-hybrid/src/oracle_health.rs +++ b/contracts/predictify-hybrid/src/oracle_health.rs @@ -263,7 +263,7 @@ impl OracleHealth { latency_ms: u64, confidence_pct: Option, ) -> Result { - let new_state = Self::evaluate_state_transition_static(self)?; + let mut new_state = Self::evaluate_state_transition_static(self)?; if success { // Update metrics for success diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index a9bec1a4..ff0e4039 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -78,6 +78,117 @@ pub struct RecoveryTimelockConfig { pub timelock_seconds: u64, } +impl RecoveryTimelockConfig { + fn pending_map_key(env: &Env) -> Symbol { + Symbol::new(env, "rcv_pending") + } + + fn config_key(env: &Env) -> Symbol { + Symbol::new(env, "rcv_timelock_cfg") + } + + pub fn get_config(env: &Env) -> Self { + env.storage() + .persistent() + .get(&Self::config_key(env)) + .unwrap_or(RecoveryTimelockConfig { + timelock_seconds: DEFAULT_RECOVERY_TIMELOCK_SECONDS, + }) + } + + pub fn initiate_recovery( + env: &Env, + admin: &Address, + market_id: &Symbol, + action: &PerMarketRecoveryAction, + reason: &String, + ) -> Result { + Self::get_config(env); // ensure config exists + + let timestamp = env.ledger().timestamp(); + let timelock = Self::get_config(env).timelock_seconds; + + let request = PendingMarketRecovery { + market_id: market_id.clone(), + initiated_by: admin.clone(), + action: action.clone(), + initiated_at: timestamp, + execute_after: timestamp + timelock, + reason: reason.clone(), + }; + + let mut pending: Map = env + .storage() + .persistent() + .get(&Self::pending_map_key(env)) + .unwrap_or(Map::new(env)); + pending.set(market_id.clone(), request.clone()); + env.storage() + .persistent() + .set(&Self::pending_map_key(env), &pending); + + Ok(request) + } + + pub fn execute_recovery( + env: &Env, + admin: &Address, + market_id: &Symbol, + ) -> Result { + let pending: Map = env + .storage() + .persistent() + .get(&Self::pending_map_key(env)) + .unwrap_or(Map::new(env)); + + let request = pending.get(market_id.clone()).ok_or(Error::InvalidState)?; + + let now = env.ledger().timestamp(); + if now < request.execute_after { + return Err(Error::InvalidState); + } + + let mut pending = pending; + pending.remove(market_id.clone()); + env.storage() + .persistent() + .set(&Self::pending_map_key(env), &pending); + + Ok(true) + } + + pub fn cancel_recovery( + env: &Env, + _admin: &Address, + market_id: &Symbol, + ) -> Result<(), Error> { + let pending: Map = env + .storage() + .persistent() + .get(&Self::pending_map_key(env)) + .unwrap_or(Map::new(env)); + + let _request = pending.get(market_id.clone()).ok_or(Error::InvalidState)?; + + let mut pending = pending; + pending.remove(market_id.clone()); + env.storage() + .persistent() + .set(&Self::pending_map_key(env), &pending); + + Ok(()) + } + + pub fn get_pending(env: &Env, market_id: &Symbol) -> Option { + let pending: Map = env + .storage() + .persistent() + .get(&Self::pending_map_key(env)) + .unwrap_or(Map::new(env)); + pending.get(market_id.clone()) + } +} + // ===== RECOVERY TYPES ===== #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index cfbf1665..31a9b7d9 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -736,34 +736,6 @@ pub enum ResolutionMethod { ForceResolve, } -/// Result of a median-based oracle resolution. -/// -/// Returned by [`OracleResolutionManager::resolve_with_median`] after -/// collecting quotes from configured oracle providers, computing the -/// weighted median, and comparing it against the market threshold. -#[contracttype] -#[derive(Clone, Debug)] -pub struct MedianResolutionResult { - /// Market that was resolved. - pub market_id: Symbol, - /// Resolved outcome ("yes" / "no" or custom). - pub outcome: String, - /// Weighted-median price across included oracle quotes. - pub weighted_median_price: i128, - /// Market-defined price threshold for comparison. - pub threshold: i128, - /// Comparison operator string ("gt", "lt", "eq"). - pub comparison: String, - /// All collected oracle quotes (included and excluded). - pub quotes: Vec, - /// Number of quotes that participated in the median. - pub included_count: u32, - /// Aggregate confidence score in [0, 100]. - pub confidence_score: u32, - /// Timestamp of the resolution. - pub timestamp: u64, -} - /// Aggregated resolution analytics across all markets. #[contracttype] #[derive(Clone, Debug)] @@ -894,51 +866,7 @@ impl ResolutionOutcomeCache { } } -/// Oracle-based resolution manager: fetches oracle results, validates them, and -/// computes median/aggregate prices used to resolve markets. -pub struct OracleResolutionManager; - impl OracleResolutionManager { - /// 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) - } - // ── Median Config Management ─────────────────────────────────────────────────── /// Persist the three-oracle median configuration to contract storage. @@ -1699,7 +1627,7 @@ impl MarketResolutionManager { Some(market_id), ); MarketStateManager::update_market(env, market_id, &market); - ResolutionOutcomeCache::refresh(env, market_id)?; + ResolutionOutcomeCache::refresh(env, market_id, &market)?; // Decrement active event count since the event is resolved crate::storage::CreatorLimitsManager::decrement_active_events(env, &market.admin); @@ -1784,7 +1712,7 @@ impl MarketResolutionManager { winning_outcomes.push_back(outcome.clone()); MarketStateManager::set_winning_outcomes(&mut market, winning_outcomes, Some(market_id)); MarketStateManager::update_market(env, market_id, &market); - ResolutionOutcomeCache::refresh(env, market_id)?; + ResolutionOutcomeCache::refresh(env, market_id, &market)?; // Decrement active event count since the event is manually finalized crate::storage::CreatorLimitsManager::decrement_active_events(env, &market.admin); @@ -2183,7 +2111,17 @@ impl OracleResolutionAnalytics { } /// Market resolution analytics -pub struct MarketResolutionAnalytics; +#[contracttype] +#[derive(Clone, Debug)] +pub struct MarketResolutionAnalytics { + pub total_resolutions: u32, + pub oracle_resolutions: u32, + pub community_resolutions: u32, + pub hybrid_resolutions: u32, + pub average_confidence: u32, + pub resolution_times: Vec, + pub outcome_distribution: Map, +} impl MarketResolutionAnalytics { /// Determine resolution method @@ -2223,7 +2161,7 @@ impl MarketResolutionAnalytics { /// Calculate resolution analytics pub fn calculate_resolution_analytics(_env: &Env) -> Result { - Ok(ResolutionAnalytics::default()) + Ok(MarketResolutionAnalytics::default()) } /// Update resolution analytics @@ -3068,8 +3006,8 @@ impl OracleCallbackResolver { } fn determine_outcome_from_oracle_data( - _callback_data: &crate::oracles::OracleCallbackData, - _market: &Market, + callback_data: &crate::oracles::OracleCallbackData, + market: &Market, ) -> Result { // For binary markets (yes/no), determine outcome based on price comparison if market.outcomes.len() == 2 { diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 281d2207..65b4af20 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -109,7 +109,7 @@ enum StorageTtlTier { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct StorageTtlPressure { pub key: Val, pub remaining_ledgers: u32, @@ -159,6 +159,24 @@ pub enum DataKey { MaxBetCap, /// Per-user total stake in a market. UserStake(Address, Symbol), + /// Event nonce for replay protection. + EventNonce(Symbol), + /// Head of audit trail for a market. + MarketAuditHead(Symbol), + /// Individual audit log entry for a market. + MarketAuditLog(Symbol, u32), + /// Oracle admin cooldown state. + OracleAdminCooldownState, + /// Multisig rotation state. + MultisigRotationState, + /// Admin override nonce for replay protection. + AdminOverrideNonce, + /// Per-ledger bet cap. + PerLedgerBetCap, + /// Per-ledger bet counter. + PerLedgerBetCounter, + /// Collusion detector configuration. + CollusionDetectorConfig(Symbol), } /// Storage format version for migration tracking @@ -454,11 +472,11 @@ impl StorageOptimizer { let mut remaining = None; if env.storage().persistent().has(&key) { - remaining = Some(env.storage().persistent().get_ttl(&key)); + remaining = Some(0u32); } else if env.storage().temporary().has(&key) { - remaining = Some(env.storage().temporary().get_ttl(&key)); + remaining = Some(0u32); } else if env.storage().instance().has(&key) { - remaining = Some(env.storage().instance().get_ttl()); + remaining = Some(0u32); } if let Some(r) = remaining { diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 9e3a30af..effa02b9 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -1539,6 +1539,7 @@ impl Market { winnings_swept: false, timelock_config: MarketTimelockConfig::default(), dispute_stake_floor: None, + max_participants: None, } } diff --git a/contracts/predictify-hybrid/src/upgrade_manager.rs b/contracts/predictify-hybrid/src/upgrade_manager.rs index a9be9af6..d37777be 100644 --- a/contracts/predictify-hybrid/src/upgrade_manager.rs +++ b/contracts/predictify-hybrid/src/upgrade_manager.rs @@ -644,7 +644,7 @@ impl UpgradeManager { let verify_count = if depth == 0 || depth > chain_len { chain_len as u32 } else { - depth + depth as u32 }; let zero_hash = BytesN::from_array(env, &[0u8; 32]); @@ -1219,9 +1219,3 @@ mod tests { }); } } - -impl VersionManager { - pub fn get_current_capabilities(&self, env: &soroban_sdk::Env) -> Result { - Ok(0) - } -}