diff --git a/contracts/predict-iq/src/errors.rs b/contracts/predict-iq/src/errors.rs index a13fcbe..fe91a0d 100644 --- a/contracts/predict-iq/src/errors.rs +++ b/contracts/predict-iq/src/errors.rs @@ -54,6 +54,9 @@ pub enum ErrorCode { InvalidReferrer = 48, GracePeriodActive = 49, InvalidDeadline = 50, + /// Issue #63: Emitted when an admin attempts fallback resolution but the + /// voting period has not yet elapsed — the deadlock is not yet confirmed. + VotingPeriodNotElapsed = 51, AlreadyInitialized = 100, NotAuthorized = 101, MarketNotFound = 102, diff --git a/contracts/predict-iq/src/lib.rs b/contracts/predict-iq/src/lib.rs index 6735537..b5c1767 100644 --- a/contracts/predict-iq/src/lib.rs +++ b/contracts/predict-iq/src/lib.rs @@ -203,6 +203,24 @@ impl PredictIQ { crate::modules::resolution::finalize_resolution(&e, market_id) } + /// Issue #63: Administrative fallback for disputed markets that failed to + /// reach the 60% community majority threshold after the full voting period. + /// + /// Only callable by the master admin. Enforces that: + /// - The market is still Disputed (not already resolved). + /// - The 72-hour voting window has fully elapsed. + /// - Community voting genuinely deadlocked (NoMajorityReached). + /// + /// This ensures user capital is never permanently orphaned while keeping + /// the community-first resolution path intact. + pub fn admin_fallback_resolution( + e: Env, + market_id: u64, + winning_outcome: u32, + ) -> Result<(), ErrorCode> { + crate::modules::resolution::admin_fallback_resolution(&e, market_id, winning_outcome) + } + pub fn reset_monitoring(e: Env) -> Result<(), ErrorCode> { crate::modules::admin::require_admin(&e)?; crate::modules::monitoring::reset_monitoring(&e); diff --git a/contracts/predict-iq/src/modules/events.rs b/contracts/predict-iq/src/modules/events.rs index dfa8b2f..5ecd908 100644 --- a/contracts/predict-iq/src/modules/events.rs +++ b/contracts/predict-iq/src/modules/events.rs @@ -191,3 +191,21 @@ pub fn emit_fee_collected(e: &Env, _market_id: u64, contract_address: Address, a e.events() .publish((symbol_short!("fee_colct"), 0u64, contract_address), amount); } + +/// Issue #63: Emit AdminFallbackResolution event +/// Emitted when an admin resolves a market that reached a voting deadlock +/// (no 60% majority after the full voting period). +/// +/// Topics: [adm_fallbk, market_id, admin] +/// Data: (winning_outcome) +pub fn emit_admin_fallback_resolution( + e: &Env, + market_id: u64, + admin: Address, + winning_outcome: u32, +) { + e.events().publish( + (symbol_short!("adm_fallbk"), market_id, admin), + winning_outcome, + ); +} diff --git a/contracts/predict-iq/src/modules/resolution.rs b/contracts/predict-iq/src/modules/resolution.rs index d693fc1..a00c273 100644 --- a/contracts/predict-iq/src/modules/resolution.rs +++ b/contracts/predict-iq/src/modules/resolution.rs @@ -43,7 +43,7 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { match market.status { MarketStatus::PendingResolution => { - // Check if 24h dispute window has passed + // Check if 48h dispute window has passed let pending_ts = market .pending_resolution_timestamp .ok_or(ErrorCode::ResolutionNotReady)?; @@ -69,12 +69,12 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { let dispute_ts = market .dispute_timestamp .ok_or(ErrorCode::MarketNotDisputed)?; - let dispute_ts = market.pending_resolution_timestamp.ok_or(ErrorCode::MarketNotDisputed)?; if e.ledger().timestamp() < dispute_ts + VOTING_PERIOD_SECONDS { return Err(ErrorCode::TimelockActive); } - // Calculate voting outcome + // Calculate voting outcome — returns NoMajorityReached if < 60% consensus. + // In that case the market stays Disputed; admin_fallback_resolution must be used. let winning_outcome = calculate_voting_outcome(e, &market)?; market.status = MarketStatus::Resolved; @@ -94,6 +94,73 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { } } +/// Issue #63: Administrative fallback for disputed markets that failed to reach +/// the 60% majority threshold after the full voting period. +/// +/// Preconditions (all enforced on-chain): +/// 1. Caller must be the master admin. +/// 2. Market must still be in `Disputed` status (not already resolved/cancelled). +/// 3. The 72-hour community voting period must have fully elapsed. +/// 4. Community voting must have genuinely failed — `calculate_voting_outcome` +/// must return `NoMajorityReached` (prevents admin from bypassing a valid vote). +/// 5. `winning_outcome` must be a valid index into `market.options`. +/// +/// This guarantees that user capital is never permanently orphaned while +/// preserving the integrity of the community-first resolution path. +pub fn admin_fallback_resolution( + e: &Env, + market_id: u64, + winning_outcome: u32, +) -> Result<(), ErrorCode> { + // 1. Admin-only gate + crate::modules::admin::require_admin(e)?; + + let mut market = markets::get_market(e, market_id).ok_or(ErrorCode::MarketNotFound)?; + + // 2. Market must be stuck in Disputed — not already resolved or cancelled + if market.status != MarketStatus::Disputed { + return Err(ErrorCode::MarketNotDisputed); + } + + // 3. Voting period must have fully elapsed + let dispute_ts = market + .dispute_timestamp + .ok_or(ErrorCode::MarketNotDisputed)?; + if e.ledger().timestamp() < dispute_ts + VOTING_PERIOD_SECONDS { + return Err(ErrorCode::VotingPeriodNotElapsed); + } + + // 4. Community vote must have genuinely deadlocked — only allow fallback when + // calculate_voting_outcome returns NoMajorityReached. Any other error + // (e.g. TooManyOutcomes) is surfaced directly so it can be fixed separately. + match calculate_voting_outcome(e, &market) { + Ok(_) => { + // A clear majority exists — admin must not override it; use finalize_resolution instead. + return Err(ErrorCode::CannotChangeOutcome); + } + Err(ErrorCode::NoMajorityReached) => { + // Confirmed deadlock — proceed with admin fallback. + } + Err(other) => return Err(other), + } + + // 5. Validate the admin-chosen outcome index + if winning_outcome >= market.options.len() { + return Err(ErrorCode::InvalidOutcome); + } + + // Resolve the market with the admin-chosen outcome + market.status = MarketStatus::Resolved; + market.winning_outcome = Some(winning_outcome); + market.resolved_at = Some(e.ledger().timestamp()); + markets::update_market(e, market); + + let admin = crate::modules::admin::get_admin(e).unwrap_or(e.current_contract_address()); + crate::modules::events::emit_admin_fallback_resolution(e, market_id, admin, winning_outcome); + + Ok(()) +} + /// Single-pass O(n) tally. n is bounded by MAX_OUTCOMES_PER_MARKET (32). fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result { let num_outcomes = market.options.len(); @@ -103,9 +170,6 @@ fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result = soroban_sdk::Vec::new(e); - - for outcome in 0..market.options.len() { let mut max_outcome = 0u32; let mut max_votes = 0i128; @@ -122,20 +186,8 @@ fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result max_votes { - max_votes = votes; - max_outcome = outcome; - } - } - - // Check if majority exceeds 60% - let majority_pct = (max_votes * 10000) / total_votes; + // Check if the leading outcome exceeds the 60% supermajority threshold + let majority_pct = (max_votes * 10_000) / total_votes; if majority_pct >= MAJORITY_THRESHOLD_BPS { Ok(max_outcome) } else {