diff --git a/contracts/predictify-hybrid/PAYOUT_SPECIFICATION.md b/contracts/predictify-hybrid/PAYOUT_SPECIFICATION.md index 35ce4eef..9e236748 100644 --- a/contracts/predictify-hybrid/PAYOUT_SPECIFICATION.md +++ b/contracts/predictify-hybrid/PAYOUT_SPECIFICATION.md @@ -4,6 +4,52 @@ This document provides a comprehensive formal specification for the payout calculation and distribution mechanism in the Predictify Hybrid prediction market smart contract. +## 0. Resolution and Payout Timeline + +Resolution and payouts are deliberately separated so that no winner can be paid +before the result has been challengeable for a full dispute window. + +```text + market ends result proposed payouts open + | | | + |<--- resolution may ---->| | + | happen at any |<--- dispute window --------->| + | time after end | (dispute_window_seconds) | + | | | + end_time resolved_at resolved_at + window +``` + +Rules: + +1. **Resolution is one-way.** `resolve_market_manual`, `resolve_market_with_ties` + and `force_resolve_market` reject a market whose `winning_outcomes` is set or + whose state is `Resolved` with `Error::MarketResolved`. A `Cancelled` market + is rejected with `Error::InvalidState`. Force-resolve may bypass the market's + end time, but it cannot re-settle a resolved market or revive a cancelled one. +2. **Resolution time is recorded.** Every successful resolution stores the + ledger timestamp as the market's claim-window start (the existing + `UnclaimedWinningsPolicy::set_claim_window_start_if_missing` key). This value + is the `resolved_at` used by the dispute and payout gates. Resolved markets + created before this field existed fall back to `end_time`. +3. **The dispute window runs from `resolved_at`, not `end_time`.** A result + proposed long after `end_time` still gets a full `dispute_window_seconds` + during which it can be disputed. Once that window closes the result is final. +4. **Payouts are a separate step.** Resolution never calls `distribute_payouts`. + `claim_winnings`, `distribute_payouts` and `sweep_unclaimed_winnings` all use + the shared `require_payout_allowed` gate, which requires: + - a final outcome (`winning_outcomes` is set; otherwise + `Error::MarketNotResolved`), + - `now >= resolved_at + dispute_window_seconds` (otherwise + `Error::InvalidState`), + - no open (`Active`) dispute (otherwise `Error::InvalidState`). +5. **Claims stay idempotent.** Once a winner is credited through either + `claim_winnings` or `distribute_payouts`, the market's `claimed` map prevents + any other payout path from crediting the same winner again. + +Error precedence for payout paths is `MarketNotResolved`, then +`InvalidState` (window still open), then `InvalidState` (open dispute), then +`AlreadyClaimed`. + ## 1. Fee Source and Payout Calculation The `ConfigManager` platform fee is the canonical source. The fee applies at diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 840ff643..2fd7cd39 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -947,7 +947,7 @@ impl DisputeManager { // Get and validate market let mut market = MarketStateManager::get_market(env, &market_id)?; - DisputeValidator::validate_market_for_dispute(env, &market)?; + DisputeValidator::validate_market_for_dispute(env, &market_id, &market)?; // Enforce anti-grief floor (market specific or global) let global_anti_grief_floor = Self::get_anti_grief_floor(env).unwrap_or(0); @@ -1232,7 +1232,7 @@ impl DisputeManager { env.storage().persistent().extend_ttl(&DataKey::DisputeHistory(market_id.clone()), 535680, 535680); } - let _ = crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id, &market); + crate::resolution::ResolutionOutcomeCache::refresh(env, &market_id, &market)?; crate::monitoring::ContractMonitor::emit_dispute_transition_hook( env, &market_id, @@ -2496,35 +2496,55 @@ impl DisputeValidator { /// /// A dispute is only valid when: /// 1. The market has ended (`current_time >= end_time`). - /// 2. The dispute window is still open (`current_time < end_time + dispute_window_seconds`). - /// Allowing disputes after the window closes would create an ambiguous overlap with - /// the payout phase and could re-open markets that users already consider settled. - /// 3. The market has not already been resolved. - /// 4. An oracle result is available to dispute. - pub fn validate_market_for_dispute(env: &Env, market: &Market) -> Result<(), Error> { + /// 2. The dispute window is still open. For a resolved market the window is + /// measured from the recorded resolution time (`resolved_at`), so a result + /// proposed long after `end_time` still gets a full dispute window. + /// 3. An oracle result (unresolved markets) or a proposed resolution + /// (resolved markets) is available to dispute. + /// + /// Resolved markets may be disputed only while their post-resolution window + /// is open. Once it closes the market is final and payouts are allowed. + pub fn validate_market_for_dispute( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result<(), Error> { let current_time = env.ledger().timestamp(); + if market.state == crate::types::MarketState::Cancelled { + return Err(Error::InvalidState); + } + // Market must have ended before a dispute can be filed. if current_time < market.end_time { return Err(Error::MarketClosed); } - // Disputes must be filed within the dispute window. - // After `end_time + dispute_window_seconds` the window is closed and payouts - // are unambiguously allowed, so late disputes are rejected. + // The dispute window starts when a result is proposed. Resolved markets + // use the recorded resolution time (stored as the claim-window start by + // the resolution entrypoints); unresolved markets, and legacy resolved + // markets without a recorded time, fall back to `end_time`. + let window_start = if market.winning_outcomes.is_some() { + crate::recovery::UnclaimedWinningsPolicy::get_claim_window_start( + env, + market_id, + market.end_time, + ) + } else { + market.end_time + }; + if market.dispute_window_seconds > 0 - && current_time >= market.end_time + market.dispute_window_seconds + && current_time + >= window_start.saturating_add(market.dispute_window_seconds) { return Err(Error::MarketResolved); } - // Check if market is already resolved. - if market.winning_outcomes.is_some() { - return Err(Error::MarketResolved); - } - - // Check if oracle result is available to dispute. - if market.oracle_result.is_none() { + // An unresolved market needs an oracle result to dispute. A resolved + // market already has a proposed outcome, so manual resolutions (which + // do not carry an oracle result) remain disputable too. + if market.winning_outcomes.is_none() && market.oracle_result.is_none() { return Err(Error::OracleUnavailable); } @@ -3640,20 +3660,41 @@ mod tests { let mut market = create_test_market(&env, env.ledger().timestamp() + 86400); // Market not ended - should fail - assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_err()); + assert!( + DisputeValidator::validate_market_for_dispute( + &env, + &Symbol::new(&env, "market_1"), + &market, + ) + .is_err() + ); // Set market as ended market.end_time = env.ledger().timestamp().saturating_sub(1); // No oracle result - should fail - assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_err()); + assert!( + DisputeValidator::validate_market_for_dispute( + &env, + &Symbol::new(&env, "market_1"), + &market, + ) + .is_err() + ); // Add oracle result market.oracle_result = Some(String::from_str(&env, "yes")); // Should pass - assert!(DisputeValidator::validate_market_for_dispute(&env, &market).is_ok()); + assert!( + DisputeValidator::validate_market_for_dispute( + &env, + &Symbol::new(&env, "market_1"), + &market, + ) + .is_ok() + ); } #[test] diff --git a/contracts/predictify-hybrid/src/force_resolve_tests.rs b/contracts/predictify-hybrid/src/force_resolve_tests.rs index 7576e282..6fe2b384 100644 --- a/contracts/predictify-hybrid/src/force_resolve_tests.rs +++ b/contracts/predictify-hybrid/src/force_resolve_tests.rs @@ -248,7 +248,7 @@ fn test_force_resolve_idempotency_key_replay() { #[test] #[ignore = "known failing: pre-existing test/API drift, see docs/TESTING.md"] -fn test_force_resolve_different_keys_same_market() { +fn test_force_resolve_different_keys_same_market_is_rejected() { let ctx = Ctx::new(); let market_id = ctx.create_market(); @@ -262,18 +262,18 @@ fn test_force_resolve_different_keys_same_market() { ) .unwrap(); - ctx.client() - .try_force_resolve_market( - &ctx.admin, - &market_id, - &outcomes(&ctx.env, &["no"]), - &reason(&ctx.env, "Second"), - &key(&ctx.env, "key-b"), - ) - .unwrap(); + // Force-resolve bypasses time constraints, not the one-way resolution rule. + let result = ctx.client().try_force_resolve_market( + &ctx.admin, + &market_id, + &outcomes(&ctx.env, &["no"]), + &reason(&ctx.env, "Second"), + &key(&ctx.env, "key-b"), + ); + assert_eq!(result, Err(Ok(Error::MarketResolved))); let market = ctx.client().get_market(&market_id).unwrap(); - assert_eq!(market.winning_outcomes, Some(outcomes(&ctx.env, &["no"]))); + assert_eq!(market.winning_outcomes, Some(outcomes(&ctx.env, &["yes"]))); } #[test] diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 965fb809..6359d5e8 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -184,12 +184,16 @@ mod category_tags_tests; #[cfg(test)] mod tie_resolution_tests; #[cfg(test)] +mod payout_gating_tests; +#[cfg(test)] mod edge_case_surface_tests; #[cfg(test)] mod force_resolve_tests; // #[cfg(any())] // mod statistics_tests; +// Replaced by `payout_gating_tests`, which covers the post-resolution +// dispute window, one-way resolution, and all payout gates end to end. // #[cfg(any())] // mod resolution_delay_dispute_window_tests; @@ -1766,6 +1770,49 @@ impl PredictifyHybrid { bets::BetAnalytics::calculate_payout_multiplier(&env, &market_id, &outcome) } + /// Shared payout gate for every value-moving exit path. + /// + /// A payout is allowed only when the market has a final outcome, its + /// post-resolution dispute window has closed, and no dispute is still open. + /// The window starts at the recorded resolution time (the claim-window + /// start written by the resolution entrypoints); legacy resolved markets + /// without a recorded time fall back to `end_time`. + pub(crate) fn require_payout_allowed( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result<(), Error> { + // MarketNotResolved has precedence over every other payout error. + if market.winning_outcomes.is_none() { + return Err(Error::MarketNotResolved); + } + + if market.state == MarketState::Cancelled { + return Err(Error::InvalidState); + } + + // The dispute window is measured from resolution time, not end time. + let resolved_at = recovery::UnclaimedWinningsPolicy::get_claim_window_start( + env, + market_id, + market.end_time, + ); + if market.dispute_window_seconds > 0 + && env.ledger().timestamp() + < resolved_at.saturating_add(market.dispute_window_seconds) + { + return Err(Error::InvalidState); + } + + // An open dispute keeps payouts frozen even after the window would + // otherwise have closed. + if disputes::DisputeValidator::verify_has_active_dispute(env, market_id, market)? { + return Err(Error::InvalidState); + } + + Ok(()) + } + /// Allows users to claim their winnings from resolved prediction markets. /// /// This function enables users who voted for the winning outcome to claim @@ -1782,8 +1829,9 @@ impl PredictifyHybrid { /// /// This function will panic with specific errors if: /// - `Error::MarketNotFound` - Market with given ID doesn't exist - /// - `Error::AlreadyClaimed` - User has already claimed winnings from this market /// - `Error::MarketNotResolved` - Market hasn't been resolved yet + /// - `Error::InvalidState` - Post-resolution dispute window is still open or a dispute is open + /// - `Error::AlreadyClaimed` - User has already claimed winnings from this market /// - `Error::NothingToClaim` - User didn't vote or voted for losing outcome /// /// # Example @@ -1857,13 +1905,6 @@ impl PredictifyHybrid { user.require_auth(); let gas_marker = GasTracker::start_tracking(&env); - // ===== REPLAY PROTECTION: VALIDATE NONCE ===== - // Ensure the provided nonce matches the expected nonce to prevent replays. - // Each successful claim increments the stored nonce by 1. - if let Err(e) = storage::ClaimNonceManager::validate_nonce(&env, &user, &market_id, claim_nonce) { - panic_with_error!(env, e); - } - let mut market: Market = env .storage() .persistent() @@ -1872,6 +1913,19 @@ impl PredictifyHybrid { panic_with_error!(env, Error::MarketNotFound); }); + // Payout error precedence: not resolved, window open, open dispute, + // then already claimed. + if let Err(e) = Self::require_payout_allowed(&env, &market_id, &market) { + panic_with_error!(env, e); + } + + // ===== REPLAY PROTECTION: VALIDATE NONCE ===== + // Ensure the provided nonce matches the expected nonce to prevent replays. + // Each successful claim increments the stored nonce by 1. + if let Err(e) = storage::ClaimNonceManager::validate_nonce(&env, &user, &market_id, claim_nonce) { + panic_with_error!(env, e); + } + // Check if user has claimed already (redundant safety check; nonce validation should prevent) if market .claimed @@ -1888,13 +1942,6 @@ impl PredictifyHybrid { None => panic_with_error!(env, Error::MarketNotResolved), }; - // Enforce dispute window: payouts only after end_time + dispute_window_seconds - if market.dispute_window_seconds > 0 - && env.ledger().timestamp() < market.end_time + market.dispute_window_seconds - { - panic_with_error!(env, Error::InvalidState); - } - // Get user's vote let user_outcome = market .votes @@ -2208,6 +2255,8 @@ impl PredictifyHybrid { .get(&market_id) .ok_or(Error::MarketNotFound)?; + Self::require_payout_allowed(&env, &market_id, &market)?; + let winning_outcomes = market .winning_outcomes .clone() @@ -2560,6 +2609,15 @@ impl PredictifyHybrid { panic_with_error!(env, Error::MarketNotFound); }); + // Resolution is a one-way transition: a settled or cancelled market can + // never be resolved again through this entrypoint. + if market.state == MarketState::Cancelled { + panic_with_error!(env, Error::InvalidState); + } + if market.winning_outcomes.is_some() || market.state == MarketState::Resolved { + panic_with_error!(env, Error::MarketResolved); + } + // Check if market has ended if env.ledger().timestamp() < market.end_time { panic_with_error!(env, Error::MarketClosed); @@ -2584,12 +2642,17 @@ impl PredictifyHybrid { &market_id, env.ledger().timestamp(), ); + // Drop any stale read-cache entry so dispute/claim paths see the + // resolved market immediately. + markets::MarketReadCache::new(&env).invalidate(&market_id); env.storage().persistent().set(&market_id, &market); // Resolve bets to mark them as won/lost - let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes_vec); + bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes_vec) + .unwrap_or_else(|e| panic_with_error!(env, e)); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); + resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market) + .unwrap_or_else(|e| panic_with_error!(env, e)); // Emit market resolved event (simplified to avoid segfaults) let oracle_result_str = market @@ -2620,8 +2683,9 @@ impl PredictifyHybrid { &reason, ); - // Automatically distribute payouts to winners after resolution - let _ = Self::distribute_payouts(env.clone(), market_id.clone()); + // Payouts are intentionally NOT distributed here. Distribution is a + // separate call that only succeeds after the post-resolution dispute + // window has closed (see `require_payout_allowed`). // Invalidate analytics cache — market state and winning_outcomes have changed. analytics::AnalyticsCache::new(&env).invalidate(&market_id); @@ -2732,6 +2796,14 @@ impl PredictifyHybrid { panic_with_error!(env, Error::MarketNotFound); }); + // Resolution is a one-way transition. + if market.state == MarketState::Cancelled { + panic_with_error!(env, Error::InvalidState); + } + if market.winning_outcomes.is_some() || market.state == MarketState::Resolved { + panic_with_error!(env, Error::MarketResolved); + } + // Check if market has ended if env.ledger().timestamp() < market.end_time { panic_with_error!(env, Error::MarketClosed); @@ -2756,12 +2828,17 @@ impl PredictifyHybrid { &market_id, env.ledger().timestamp(), ); + // Drop any stale read-cache entry so dispute/claim paths see the + // resolved market immediately. + markets::MarketReadCache::new(&env).invalidate(&market_id); env.storage().persistent().set(&market_id, &market); // Resolve bets to mark them as won/lost - let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes); + bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes) + .unwrap_or_else(|e| panic_with_error!(env, e)); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); + resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market) + .unwrap_or_else(|e| panic_with_error!(env, e)); // Emit market resolved event let primary_outcome = winning_outcomes.get(0).unwrap().clone(); @@ -2792,8 +2869,8 @@ impl PredictifyHybrid { &reason, ); - // Automatically distribute payouts (handles split pool for ties) - let _ = Self::distribute_payouts(env.clone(), market_id.clone()); + // Payouts are intentionally NOT distributed here; see + // `require_payout_allowed` and the separate `distribute_payouts` call. // Invalidate analytics cache — market state and winning_outcomes have changed. analytics::AnalyticsCache::new(&env).invalidate(&market_id); @@ -2819,11 +2896,13 @@ impl PredictifyHybrid { } } - /// Force-resolves a market bypassing time/state constraints, with idempotency-key - /// replay protection and audit trail. + /// Force-resolves an unresolved market bypassing time constraints, with + /// idempotency-key replay protection and audit trail. /// - /// This admin-only entrypoint resolves a market **regardless** of its current state - /// or whether `end_time` has been reached. Every call must supply a non-empty `reason` + /// This admin-only entrypoint can resolve a market **before** its `end_time` + /// (for example, an active, ended, or disputed market). Resolution remains a + /// one-way transition: a market that is already resolved cannot be re-settled + /// and a cancelled market cannot be revived. Every call must supply a non-empty `reason` /// and a unique `idempotency_key` (a string, e.g. a UUID) scoped to the market. /// /// # Parameters @@ -2840,6 +2919,8 @@ impl PredictifyHybrid { /// * `Ok(())` - Force-resolve succeeded /// * `Err(Error::Unauthorized)` - Caller is not the contract primary admin /// * `Err(Error::MarketNotFound)` - Market does not exist + /// * `Err(Error::MarketResolved)` - Market has already been resolved + /// * `Err(Error::InvalidState)` - Market was cancelled /// * `Err(Error::InvalidOutcome)` - One or more outcomes are invalid for this market /// * `Err(Error::InvalidInput)` - Empty outcomes vector /// * `Err(Error::ForceResolveReasonEmpty)` - Reason string is empty @@ -2891,6 +2972,16 @@ impl PredictifyHybrid { return Err(Error::ForceResolveReplayed); } + // Force-resolve bypasses time constraints, but it is still a one-way + // transition. It cannot re-settle a resolved market or revive a + // cancelled one. + if market.state == MarketState::Cancelled { + return Err(Error::InvalidState); + } + if market.winning_outcomes.is_some() || market.state == MarketState::Resolved { + return Err(Error::MarketResolved); + } + let old_state = market.state.clone(); market.winning_outcomes = Some(winning_outcomes.clone()); @@ -2901,6 +2992,9 @@ impl PredictifyHybrid { &market_id, env.ledger().timestamp(), ); + // Drop any stale read-cache entry so dispute/claim paths see the + // resolved market immediately. + markets::MarketReadCache::new(&env).invalidate(&market_id); env.storage().persistent().set(&market_id, &market); @@ -2912,8 +3006,8 @@ impl PredictifyHybrid { &winning_outcomes, ); - let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes); - let _ = resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market); + bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes)?; + resolution::ResolutionOutcomeCache::refresh(&env, &market_id, &market)?; let primary_outcome = winning_outcomes.get(0).unwrap().clone(); @@ -2980,8 +3074,8 @@ impl PredictifyHybrid { ); } - // Auto-distribute payouts - let _ = Self::distribute_payouts(env.clone(), market_id); + // Payouts are intentionally NOT distributed here; callers must wait for + // the post-resolution dispute window and call `distribute_payouts`. Ok(()) } @@ -4424,11 +4518,12 @@ impl PredictifyHybrid { panic_with_error!(env, Error::MarketNotFound); }); - // ── Require resolved ─────────────────────────────────────────────────── - let winning_outcomes = match &market.winning_outcomes { - Some(outcomes) => outcomes, - None => return Err(Error::MarketNotResolved), - }; + // ── Payout gate: resolved + post-resolution window closed + no dispute ─ + Self::require_payout_allowed(&env, &market_id, &market)?; + let winning_outcomes = market + .winning_outcomes + .as_ref() + .ok_or(Error::MarketNotResolved)?; // ── Load bettor registry ─────────────────────────────────────────────── let bettors = BetStorage::get_all_bets_for_market(&env, &market_id); @@ -4568,7 +4663,7 @@ impl PredictifyHybrid { .unwrap_or(false) { bet.status = BetStatus::Won; - let _ = BetStorage::store_bet(&env, &bet); + BetStorage::store_bet(&env, &bet)?; } else if bet.amount > 0 { let user_share = (bet.amount .checked_mul(fee_denominator - fee_percent) @@ -4590,7 +4685,7 @@ impl PredictifyHybrid { .ok_or(Error::Overflow)?; bet.status = BetStatus::Won; - let _ = BetStorage::store_bet(&env, &bet); + BetStorage::store_bet(&env, &bet)?; match BalanceStorage::add_balance( &env, @@ -4614,7 +4709,7 @@ impl PredictifyHybrid { // Losing bet — mark as Lost if matches!(bet.status, BetStatus::Active) { bet.status = BetStatus::Lost; - let _ = BetStorage::store_bet(&env, &bet); + BetStorage::store_bet(&env, &bet)?; } } } diff --git a/contracts/predictify-hybrid/src/payout_gating_tests.rs b/contracts/predictify-hybrid/src/payout_gating_tests.rs new file mode 100644 index 00000000..5c23483a --- /dev/null +++ b/contracts/predictify-hybrid/src/payout_gating_tests.rs @@ -0,0 +1,409 @@ +#![cfg(test)] + +//! Regression tests for issue #1456: resolution is one-way and every payout +//! path is gated on the post-resolution dispute window. +//! +//! Timeline enforced by these tests: +//! +//! ```text +//! end_time resolved_at resolved_at + dispute_window +//! | | | +//! |<-- can resolve -->|<-- disputes open ----------->|<-- payouts open +//! ``` +//! +//! The window is measured from `resolved_at` (persisted as the claim-window +//! start), not from `end_time`. + +use crate::config::ConfigManager; +use crate::err::Error; +use crate::recovery::UnclaimedWinningsPolicy; +use crate::storage::BalanceStorage; +use crate::types::{Market, MarketState, OracleConfig, OracleProvider, ReflectorAsset}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{vec, Address, Env, String, Symbol}; + +const DAY: u64 = 86_400; +const WINDOW: u64 = 3_600; + +struct Setup { + env: Env, + contract_id: Address, + admin: Address, + token_id: Address, +} + +impl Setup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + let token_contract = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token_id = token_contract.address(); + + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + env.storage() + .persistent() + .set(&Symbol::new(&env, "platform_fee"), &200i128); + crate::circuit_breaker::CircuitBreaker::initialize(&env).unwrap(); + let cfg = ConfigManager::get_development_config(&env); + ConfigManager::store_config(&env, &cfg).unwrap(); + crate::admin::AdminInitializer::initialize(&env, &admin).unwrap(); + }); + + Self { + env, + contract_id, + admin, + token_id, + } + } + + fn client(&self) -> PredictifyHybridClient<'_> { + PredictifyHybridClient::new(&self.env, &self.contract_id) + } + + fn user(&self) -> Address { + let user = Address::generate(&self.env); + soroban_sdk::token::StellarAssetClient::new(&self.env, &self.token_id) + .mint(&user, &100_000_000_000i128); + user + } + + fn create_market(&self, dispute_window_seconds: u64) -> Symbol { + let outcomes = vec![ + &self.env, + String::from_str(&self.env, "yes"), + String::from_str(&self.env, "no"), + ]; + self.client().create_market( + &self.admin, + &String::from_str(&self.env, "Payout gating market"), + &outcomes, + &1u32, + &OracleConfig::new( + OracleProvider::reflector(), + Address::from_str( + &self.env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + String::from_str(&self.env, "BTC/USD"), + 5_000_000, + String::from_str(&self.env, "gt"), + ), + &None, + &86_400u64, + &None, + &None, + &Some(dispute_window_seconds), + &None, + &None, + ) + } + + fn advance(&self, seconds: u64) { + self.env.ledger().with_mut(|li| li.timestamp += seconds); + } + + fn stake_on(&self, user: &Address, market_id: &Symbol, outcome: &str, amount: i128) { + self.client().place_bet( + user, + market_id, + &String::from_str(&self.env, outcome), + &amount, + &250, + ); + } + + fn resolve(&self, market_id: &Symbol, outcome: &str) { + self.client().resolve_market_manual( + &self.admin, + market_id, + &String::from_str(&self.env, outcome), + ); + } + + fn balance(&self, user: &Address) -> i128 { + self.env.as_contract(&self.contract_id, || { + BalanceStorage::get_balance(&self.env, user, &ReflectorAsset::Stellar).amount + }) + } + + fn market(&self, market_id: &Symbol) -> Market { + self.client().get_market(market_id).unwrap() + } + + fn set_state(&self, market_id: &Symbol, state: MarketState) { + self.env.as_contract(&self.contract_id, || { + let mut market: Market = self.env.storage().persistent().get(market_id).unwrap(); + market.state = state; + self.env.storage().persistent().set(market_id, &market); + }); + } +} + +fn panic_market_error(error: Error) -> soroban_sdk::Error { + soroban_sdk::Error::from_contract_error(error as u32) +} + +// Row 1: resolution alone must not credit anybody. +#[test] +fn resolution_does_not_pay_immediately() { + let s = Setup::new(); + let winner = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + + assert_eq!(s.balance(&winner), 0, "resolution must not pay instantly"); + let market = s.market(&id); + assert!(market.claimed.get(winner.clone()).is_none()); +} + +// Row 2: distribute_payouts is rejected while the window is open. +#[test] +fn distribute_blocked_inside_window() { + let s = Setup::new(); + let winner = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + + let result = s.client().try_distribute_payouts(&id); + assert_eq!(result, Err(Ok(Error::InvalidState))); + assert_eq!( + s.balance(&winner), + 0, + "blocked distribution must not credit" + ); +} + +// Row 3: distribution after the window credits winners exactly once. +#[test] +fn distribute_after_window_credits_once() { + let s = Setup::new(); + let winner = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + s.advance(WINDOW + 1); + + let distributed = s.client().distribute_payouts(&id); + assert!(distributed > 0); + let paid = s.balance(&winner); + assert_eq!(paid, distributed); + + let second = s.client().distribute_payouts(&id); + assert_eq!(second, 0, "a second distribution must not pay again"); + assert_eq!(s.balance(&winner), paid); +} + +// Row 4: a settled market cannot be re-resolved with a different outcome. +#[test] +fn second_resolution_is_rejected_and_outcome_unchanged() { + let s = Setup::new(); + let id = s.create_market(WINDOW); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + + let result = + s.client() + .try_resolve_market_manual(&s.admin, &id, &String::from_str(&s.env, "no")); + assert_eq!(result, Err(Ok(panic_market_error(Error::MarketResolved)))); + + let market = s.market(&id); + assert_eq!( + market.get_winning_outcome(), + Some(String::from_str(&s.env, "yes")) + ); +} + +// Row 5: a cancelled market cannot be resolved. +#[test] +fn resolving_cancelled_market_is_invalid_state() { + let s = Setup::new(); + let id = s.create_market(WINDOW); + + s.advance(DAY + 1); + s.set_state(&id, MarketState::Cancelled); + + let result = + s.client() + .try_resolve_market_manual(&s.admin, &id, &String::from_str(&s.env, "yes")); + assert_eq!(result, Err(Ok(panic_market_error(Error::InvalidState)))); +} + +// Row 6: a late resolution gets a fresh dispute window measured from +// resolution time, and the window closes one window later. +#[test] +fn late_resolution_starts_a_fresh_dispute_window() { + let s = Setup::new(); + let disputer = s.user(); + + let late = s.create_market(DAY); + s.advance(2 * DAY + 1); + s.resolve(&late, "yes"); + + // Old code measured from end_time and would reject this dispute because + // end_time + DAY is already in the past. + s.client().dispute_market( + &disputer, + &late, + &10_000_000i128, + &Some(String::from_str(&s.env, "late resolution challenged")), + ); + + let on_time = s.create_market(DAY); + s.advance(2 * DAY + 1); + s.resolve(&on_time, "yes"); + s.advance(DAY + 1); + + let result = s + .client() + .try_dispute_market(&s.user(), &on_time, &10_000_000i128, &None); + assert_eq!(result, Err(Ok(Error::MarketResolved))); +} + +// Row 7: an open dispute keeps payouts frozen after the window closes. +#[test] +fn open_dispute_blocks_payouts_after_window() { + let s = Setup::new(); + let winner = s.user(); + let disputer = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + s.advance(10); + s.client() + .dispute_market(&disputer, &id, &10_000_000i128, &None); + + s.advance(WINDOW + 1); + + let distribute = s.client().try_distribute_payouts(&id); + assert_eq!(distribute, Err(Ok(Error::InvalidState))); + + let claim = s.client().try_claim_winnings(&winner, &id, &0u64); + assert_eq!(claim, Err(Ok(panic_market_error(Error::InvalidState)))); + assert_eq!(s.balance(&winner), 0); +} + +// Row 8: claim_winnings and distribute_payouts cannot double credit. +#[test] +fn claim_and_distribute_do_not_double_credit() { + let s = Setup::new(); + let winner = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + s.advance(WINDOW + 1); + + s.client().claim_winnings(&winner, &id, &0u64); + let after_claim = s.balance(&winner); + assert!(after_claim > 0); + + let distributed = s.client().distribute_payouts(&id); + assert_eq!(distributed, 0); + assert_eq!(s.balance(&winner), after_claim); +} + +// Row 9: a legacy resolved market without a recorded resolution time loads +// and is gated using `end_time` as the documented fallback. +#[test] +fn legacy_market_without_resolved_at_uses_end_time_fallback() { + let s = Setup::new(); + let id = Symbol::new(&s.env, "legacy_market"); + let end_time = s.env.ledger().timestamp().saturating_sub(WINDOW / 2); + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "yes"), + String::from_str(&s.env, "no"), + ]; + let mut market = Market::new( + &s.env, + s.admin.clone(), + String::from_str(&s.env, "Legacy resolved market"), + outcomes, + end_time, + OracleConfig::new( + OracleProvider::reflector(), + Address::from_str( + &s.env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + String::from_str(&s.env, "BTC/USD"), + 5_000_000, + String::from_str(&s.env, "gt"), + ), + None, + 0, + MarketState::Resolved, + ); + market.dispute_window_seconds = WINDOW; + let mut winning = soroban_sdk::Vec::new(&s.env); + winning.push_back(String::from_str(&s.env, "yes")); + market.winning_outcomes = Some(winning); + + s.env.as_contract(&s.contract_id, || { + s.env.storage().persistent().set(&id, &market); + // Explicitly ensure no resolution timestamp was recorded. + assert_eq!( + UnclaimedWinningsPolicy::get_claim_window_start(&s.env, &id, end_time), + end_time + ); + assert_eq!( + PredictifyHybrid::require_payout_allowed(&s.env, &id, &market), + Err(Error::InvalidState) + ); + }); + + s.advance(WINDOW); + + s.env.as_contract(&s.contract_id, || { + assert_eq!( + PredictifyHybrid::require_payout_allowed(&s.env, &id, &market), + Ok(()) + ); + }); +} + +// Row 10: conservation — the credited total never exceeds the pool. +#[test] +fn total_credited_never_exceeds_pool() { + let s = Setup::new(); + let winner = s.user(); + let loser = s.user(); + let id = s.create_market(WINDOW); + s.stake_on(&winner, &id, "yes", 10_000_000); + s.stake_on(&loser, &id, "no", 5_000_000); + + s.advance(DAY + 1); + s.resolve(&id, "yes"); + s.advance(WINDOW + 1); + + let distributed = s.client().distribute_payouts(&id); + let credited = s.balance(&winner) + s.balance(&loser); + let market = s.market(&id); + + assert!(distributed > 0); + assert_eq!(credited, distributed); + assert!( + credited <= market.total_staked, + "payouts must not exceed the pool" + ); +}