diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 65e343c..d6c0163 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -2353,7 +2353,7 @@ pub struct DisputeUtils; impl DisputeUtils { /// Add dispute to market - /// Records `dispute.stake` in `market.dispute_stakes` for the disputing user. + pub fn add_dispute_to_market(market: &mut Market, dispute: Dispute) -> Result<(), Error> { // Add dispute stake to market let current_stake = market.dispute_stakes.get(dispute.user.clone()).unwrap_or(0); market @@ -2367,14 +2367,14 @@ impl DisputeUtils { } /// Extend market for dispute period - /// Extends `market.end_time` by [`DISPUTE_EXTENSION_HOURS`] to allow voting. + pub fn extend_market_for_dispute(market: &mut Market, _env: &Env) -> Result<(), Error> { let extension_seconds = (DISPUTE_EXTENSION_HOURS as u64) * 3600; market.end_time += extension_seconds; Ok(()) } /// Determine final outcome considering disputes - /// Picks the final outcome, deferring to community consensus when dispute impact > 30%. + pub fn determine_final_outcome_with_disputes( env: &Env, market: &Market, ) -> Result { @@ -2401,7 +2401,7 @@ impl DisputeUtils { } /// Finalize market with resolution - /// Sets `market.winning_outcomes` to `[final_outcome]` after validating it is a known outcome. + pub fn finalize_market_with_resolution( market: &mut Market, final_outcome: String, ) -> Result<(), Error> { @@ -2417,7 +2417,7 @@ impl DisputeUtils { } /// Extract disputes from market - /// Builds a `Vec` from `market.dispute_stakes` entries with stake > 0. + pub fn extract_disputes_from_market( env: &Env, market: &Market, market_id: Symbol, @@ -2442,17 +2442,17 @@ impl DisputeUtils { } /// Check if user has disputed - /// Returns `true` if `user` has a non-zero stake in `market.dispute_stakes`. + pub fn has_user_disputed(market: &Market, user: &Address) -> bool { market.dispute_stakes.get(user.clone()).unwrap_or(0) > 0 } /// Get user's dispute stake - /// Returns the dispute stake for `user`, or `0` if they have not disputed. + pub fn get_user_dispute_stake(market: &Market, user: &Address) -> i128 { market.dispute_stakes.get(user.clone()).unwrap_or(0) } /// Calculate dispute impact on market resolution - /// Returns `total_dispute_stakes / total_staked` as a float, or `0.0` when `total_staked == 0`. + pub fn calculate_dispute_impact(market: &Market) -> f64 { let total_staked = market.total_staked; let total_disputes = market.total_dispute_stakes(); @@ -2464,7 +2464,7 @@ impl DisputeUtils { } /// Add vote to dispute - /// Appends `vote` to the dispute's voting record and updates aggregate stake counters. + pub fn add_vote_to_dispute( env: &Env, dispute_id: &Symbol, vote: DisputeVote, @@ -2492,7 +2492,7 @@ impl DisputeUtils { } /// Get dispute voting data - /// Loads the [`DisputeVoting`] record for `dispute_id`, creating a default if absent. + pub fn get_dispute_voting(env: &Env, dispute_id: &Symbol) -> Result { let key = (symbol_short!("dispute_v"), dispute_id.clone()); Ok(env .storage() @@ -2512,7 +2512,7 @@ impl DisputeUtils { } /// Store dispute voting data - /// Persists `voting` under the `dispute_v` storage key for `dispute_id`. + pub fn store_dispute_voting( env: &Env, dispute_id: &Symbol, voting: &DisputeVoting, @@ -2523,7 +2523,7 @@ impl DisputeUtils { } /// Store dispute vote - /// Persists an individual `vote` keyed by `(dispute_id, user)`. + pub fn store_dispute_vote( env: &Env, dispute_id: &Symbol, vote: &DisputeVote, @@ -2539,13 +2539,11 @@ impl DisputeUtils { env.storage().persistent().get(&key) } - /// Returns `true` if `user` has already claimed winnings for `dispute_id`. pub fn has_user_claimed_dispute(env: &Env, dispute_id: &Symbol, user: &Address) -> bool { let key = (symbol_short!("d_clm"), dispute_id.clone(), user.clone()); env.storage().persistent().get(&key).unwrap_or(false) } - /// Marks `user` as having claimed winnings for `dispute_id` to prevent double-claims. pub fn set_user_claimed_dispute(env: &Env, dispute_id: &Symbol, user: &Address) { let key = (symbol_short!("d_clm"), dispute_id.clone(), user.clone()); env.storage().persistent().set(&key, &true); @@ -2573,7 +2571,7 @@ impl DisputeUtils { } /// Distribute fees based on outcome - /// Builds and stores a [`DisputeFeeDistribution`] record based on `outcome`. + pub fn distribute_fees_based_on_outcome( env: &Env, dispute_id: &Symbol, voting_data: &DisputeVoting, @@ -2609,7 +2607,7 @@ impl DisputeUtils { } /// Store dispute fee distribution - /// Persists `distribution` under the `dispute_f` storage key for `dispute_id`. + pub fn store_dispute_fee_distribution( env: &Env, dispute_id: &Symbol, distribution: &DisputeFeeDistribution, @@ -2620,7 +2618,7 @@ impl DisputeUtils { } /// Get dispute fee distribution - /// Loads the [`DisputeFeeDistribution`] for `dispute_id`, returning a zeroed default if absent. + pub fn get_dispute_fee_distribution( env: &Env, dispute_id: &Symbol, ) -> Result { @@ -2641,7 +2639,7 @@ impl DisputeUtils { } /// Store dispute escalation - /// Persists `escalation` under the `dispute_e` storage key for `dispute_id`. + pub fn store_dispute_escalation( env: &Env, dispute_id: &Symbol, escalation: &DisputeEscalation, @@ -2652,14 +2650,13 @@ impl DisputeUtils { } /// Get dispute escalation - /// Returns the [`DisputeEscalation`] for `dispute_id`, or `None` if not escalated. + pub fn get_dispute_escalation(env: &Env, dispute_id: &Symbol) -> Option { let key = (symbol_short!("dispute_e"), dispute_id.clone()); env.storage().persistent().get(&key) } /// Emit dispute vote event - /// Records a vote event for `dispute_id` in persistent storage. pub fn emit_dispute_vote_event( env: &Env, _dispute_id: &Symbol, @@ -2676,7 +2673,6 @@ impl DisputeUtils { /// Emit fee distribution event - /// Records a fee distribution event for `dispute_id` in persistent storage. pub fn emit_fee_distribution_event( env: &Env, _dispute_id: &Symbol, @@ -2689,7 +2685,6 @@ impl DisputeUtils { } /// Emit dispute escalation event - /// Records an escalation event for `dispute_id` in persistent storage. pub fn emit_dispute_escalation_event( env: &Env, _dispute_id: &Symbol, @@ -2708,7 +2703,7 @@ impl DisputeUtils { } /// Store dispute timeout - /// Persists `timeout` under the `timeout` storage key for `dispute_id`. + pub fn store_dispute_timeout( env: &Env, dispute_id: &Symbol, timeout: &DisputeTimeout, @@ -2719,7 +2714,7 @@ impl DisputeUtils { } /// Get dispute timeout - /// Loads the [`DisputeTimeout`] for `dispute_id`. + pub fn get_dispute_timeout(env: &Env, dispute_id: &Symbol) -> Result { let key = (symbol_short!("timeout"), dispute_id.clone()); env.storage() .persistent() @@ -2728,27 +2723,27 @@ impl DisputeUtils { } /// Check if dispute timeout exists - /// Returns `true` if a timeout has been configured for `dispute_id`. + pub fn has_dispute_timeout(env: &Env, dispute_id: &Symbol) -> bool { let key = (symbol_short!("timeout"), dispute_id.clone()); env.storage().persistent().has(&key) } /// Remove dispute timeout - /// Removes the timeout record for `dispute_id` from persistent storage. + pub fn remove_dispute_timeout(env: &Env, dispute_id: &Symbol) -> Result<(), Error> { let key = (symbol_short!("timeout"), dispute_id.clone()); env.storage().persistent().remove(&key); Ok(()) } /// Get all active timeouts - /// Returns all active [`DisputeTimeout`] records (currently returns empty — index not yet implemented). + pub fn get_active_timeouts(env: &Env) -> Vec { // This is a simplified implementation // In a real system, you would maintain an index of active timeouts Vec::new(env) } /// Check for expired timeouts - /// Returns IDs of disputes whose timeout has expired (currently returns empty — index not yet implemented). + pub fn check_expired_timeouts(env: &Env) -> Vec { let _expired_disputes = Vec::new(env); let _current_time = env.ledger().timestamp(); diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 11615b5..0c366e5 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -123,6 +123,8 @@ pub enum Error { DisputeError = 410, /// Unclaimed winnings have already been swept for this market. Repeat sweeps are not allowed. SweepAlreadyDone = 411, + /// Fee arithmetic overflowed or otherwise failed during checked calculation. + FeeArithmeticOverflow = 412, /// Platform fee has already been collected from this market. FeeAlreadyCollected = 413, /// No fees are available to collect from this market. @@ -1378,6 +1380,9 @@ impl Error { Error::DisputeCondNotMet => "Dispute resolution conditions not met", Error::DisputeFeeFailed => "Dispute fee distribution failed", Error::DisputeError => "Generic dispute subsystem error", + Error::SweepAlreadyDone => { + "Unclaimed winnings have already been swept for this market" + } Error::FeeArithmeticOverflow => "Fee arithmetic overflowed", Error::FeeAlreadyCollected => "Platform fee already collected", Error::NoFeesToCollect => "No fees available to collect", @@ -1471,6 +1476,7 @@ impl Error { Error::DisputeCondNotMet => "DISPUTE_RESOLUTION_CONDITIONS_NOT_MET", Error::DisputeFeeFailed => "DISPUTE_FEE_DISTRIBUTION_FAILED", Error::DisputeError => "DISPUTE_ERROR", + Error::SweepAlreadyDone => "SWEEP_ALREADY_DONE", Error::FeeArithmeticOverflow => "FEE_ARITHMETIC_OVERFLOW", Error::FeeAlreadyCollected => "FEE_ALREADY_COLLECTED", Error::NoFeesToCollect => "NO_FEES_TO_COLLECT", diff --git a/contracts/predictify-hybrid/src/graceful_degradation.rs b/contracts/predictify-hybrid/src/graceful_degradation.rs index 788f66b..b6e76ae 100644 --- a/contracts/predictify-hybrid/src/graceful_degradation.rs +++ b/contracts/predictify-hybrid/src/graceful_degradation.rs @@ -91,11 +91,11 @@ impl OracleBackup { let msg = String::from_str(env, "Primary oracle failed"); EventEmitter::emit_oracle_degradation(env, &self.primary, &msg); - // capture backup result to ensure we don't fial silently if the fallback drops let backup_result = self.call_oracle(env, &self.backup, oracle_address, feed_id); if backup_result.is_err() { let backup_msg = String::from_str(env, "Backup oracle failed"); EventEmitter::emit_oracle_degradation(env, &self.backup, &backup_msg); + return Err(Error::FallbackOracleUnavailable); } backup_result } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 882f021..6f9c1fb 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -47,13 +47,6 @@ mod metadata_limits_tests; mod monitoring; #[cfg(test)] mod multi_admin_multisig_tests; -#[cfg(test)] -mod admin_auth_audit_tests; -#[cfg(any())] -mod metadata_limits_tests; -mod monitoring; -#[cfg(any())] -mod multi_admin_multisig_tests; mod oracles; mod performance_benchmarks; mod queries; @@ -130,6 +123,8 @@ mod circuit_breaker_tests; #[cfg(any())] mod category_tags_tests; +#[cfg(test)] +mod tie_resolution_tests; // #[cfg(any())] // mod statistics_tests; diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index bdf2fde..8fcc5b9 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -4,8 +4,6 @@ use alloc::format; use alloc::string::ToString; use crate::bandprotocol; use crate::errors::Error; -use alloc::format; -use alloc::string::ToString; use soroban_sdk::{ contracttype, symbol_short, vec, Address, Bytes, Env, IntoVal, String, Symbol, Vec, }; diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 3940084..7b865d3 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -32,12 +32,8 @@ use alloc::string::ToString; use crate::{ - errors::Error, - markets::{MarketAnalytics, MarketStateManager, MarketValidator}, - types::{Market, MarketState, PagedMarketIds, PagedUserBets}, - voting::VotingStats, admin::{AdminManager, AdminPermission, AdminRole, MultisigConfig}, - oracles::{OracleMetadata, OracleWhitelist}, + bets::BetManager, disputes::{Dispute, DisputeManager, DisputeStats, DisputeVote}, errors::Error, governance::{GovernanceContract, GovernanceProposal}, diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index 949fc89..70d09c9 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -903,6 +903,9 @@ mod tests { #[test] fn test_recovery_history_capped_per_market() { let (env, _admin, contract_id, market_id) = setup_admin_env(); + // Large history vectors exceed default test budgets at MAX=100; unlimited here + // only exercises cap logic, not production metering. + env.cost_estimate().budget().reset_unlimited(); let cap = MAX_RECOVERY_HISTORY_PER_MARKET as usize + 5; env.as_contract(&contract_id, || { for i in 0..cap { diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index c60ef13..c21b2fd 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -954,7 +954,9 @@ impl StorageUtils { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::{Address as _, EnvTestConfig}; + use soroban_sdk::testutils::{ + storage::Persistent as _, Address as _, EnvTestConfig, Ledger as _, + }; #[test] fn test_sub_balance_rejects_overdraw_without_mutation() { diff --git a/contracts/predictify-hybrid/src/tie_resolution_tests.rs b/contracts/predictify-hybrid/src/tie_resolution_tests.rs new file mode 100644 index 0000000..2db06a2 --- /dev/null +++ b/contracts/predictify-hybrid/src/tie_resolution_tests.rs @@ -0,0 +1,892 @@ + +//! Tie-resolution regression tests for `resolve_market_with_ties`. +//! +//! These tests lock the documented payout specification for equal-stake +//! multi-winner markets. Every scenario is self-contained: it creates a +//! fresh market, places bets (synced to votes/stakes), advances the ledger past +//! the market end-time +//! and dispute window, resolves with ties, then asserts payout correctness. +//! +//! # Payout formula (from PAYOUT_SPECIFICATION.md) +//! +//! ```text +//! user_share = user_stake * (10_000 - fee_bps) / 10_000 +//! payout = user_share * total_pool / winning_total +//! ``` +//! +//! Where `winning_total` is the sum of stakes on **all** winning outcomes. +//! For a perfect two-way tie with equal stakes the formula reduces to: +//! +//! ```text +//! payout ≈ user_stake * (1 - fee) * total_pool / (total_pool / 2) +//! = user_stake * (1 - fee) * 2 +//! ``` +//! +//! # Acceptance criteria verified +//! +//! - Two-way tie payouts are proportional to individual stakes. +//! - Three-way tie payouts are proportional to individual stakes. +//! - Sum of all payouts never exceeds `total_pool` minus fees (no dust leak). +//! - Single-winner path is unaffected by the tie code-path. +//! - Rounding dust (odd-stroop pools) never causes an over-payment. +//! - Recorded payouts match [`PayoutData`] / `MarketUtils::calculate_payout`. + +use crate::errors::Error; +use crate::markets::{MarketAnalytics, MarketUtils, WinningStats}; +use crate::types::{Market, MarketState, OracleConfig, OracleProvider}; +use crate::voting::PayoutData; +use crate::{PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{vec, Address, Env, String, Symbol}; + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +/// Shared setup for every tie-resolution test. +/// +/// Mirrors the pattern used in `voting_tests.rs` so the two suites stay +/// consistent. The contract is initialised with the default 2 % platform fee +/// (200 basis points) stored under the `"platform_fee"` key, which is what +/// `distribute_payouts` reads. +struct TieSetup { + env: Env, + contract_id: Address, + admin: Address, + token_id: Address, +} + +impl TieSetup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + + // Register a Stellar asset so the token client works. + let token_contract = + env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token_id = token_contract.address(); + + // Wire the token and circuit-breaker before initialising the contract. + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + crate::circuit_breaker::CircuitBreaker::initialize(&env).unwrap(); + }); + + PredictifyHybridClient::new(&env, &contract_id).initialize(&admin, &None, &None); + + // `initialize` stores DEFAULT_PLATFORM_FEE_PERCENTAGE (200 bps = 2 %) + // under "platform_fee". `distribute_payouts` reads that key directly, + // so no extra setup is needed. + + Self { env, contract_id, admin, token_id } + } + + /// Create and fund a fresh user with 100 000 XLM worth of stroops. + fn user(&self) -> Address { + let u = Address::generate(&self.env); + soroban_sdk::token::StellarAssetClient::new(&self.env, &self.token_id) + .mint(&u, &100_000_000_000i128); + u + } + + /// Create a market with the supplied `outcomes` and a 1-day duration. + /// The dispute window is set to 0 so tests can claim immediately after + /// resolution without advancing the ledger a second time. + fn create_market(&self, outcomes: soroban_sdk::Vec) -> Symbol { + PredictifyHybridClient::new(&self.env, &self.contract_id).create_market( + &self.admin, + &String::from_str(&self.env, "Tie regression market"), + &outcomes, + &1u32, // 1-day duration + &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, + &86400u64, + &None, + &None, + // dispute_window_seconds = 0 so claims are unblocked immediately + &Some(0u64), + ) + } + + /// Advance the ledger past the market end-time (and any dispute window). + fn advance_past_end(&self) { + // 1 day + 1 second is enough to pass a 1-day market. + self.env.ledger().with_mut(|li| li.timestamp += 86_401); + } + + /// Lock stake on an outcome via `place_bet` (same path `test.rs` uses for + /// tie-resolution scenarios). + fn stake_on(&self, user: &Address, market_id: &Symbol, outcome: &str, amount: i128) { + PredictifyHybridClient::new(&self.env, &self.contract_id).place_bet( + user, + market_id, + &String::from_str(&self.env, outcome), + &amount, + ); + } + + /// Resolve with ties via the admin endpoint. + fn resolve_with_ties(&self, market_id: &Symbol, winning: soroban_sdk::Vec) { + PredictifyHybridClient::new(&self.env, &self.contract_id) + .resolve_market_with_ties(&self.admin, market_id, &winning); + } + + /// Read the payout recorded in `market.claimed` for `user`. + /// Returns 0 if the user has no entry. + fn recorded_payout(&self, market_id: &Symbol, user: &Address) -> i128 { + self.env.as_contract(&self.contract_id, || { + let market: Market = self + .env + .storage() + .persistent() + .get(market_id) + .unwrap(); + market + .claimed + .get(user.clone()) + .map(|info| info.get_payout()) + .unwrap_or(0) + }) + } + + /// Platform fee in basis points (matches default `initialize` value). + const FEE_BPS: i128 = 200; + + /// Compute the expected payout using the documented formula. + fn expected_payout(user_stake: i128, winning_total: i128, total_pool: i128) -> i128 { + let fee_denom: i128 = 10_000; + let user_share = user_stake * (fee_denom - Self::FEE_BPS) / fee_denom; + user_share * total_pool / winning_total + } + + /// Upper bound on distributable winnings: total pool minus platform fee. + fn max_distributable(total_pool: i128) -> i128 { + total_pool * (10_000 - Self::FEE_BPS) / 10_000 + } + + /// Build a [`PayoutData`] snapshot and verify it against the recorded payout. + fn assert_payout_data_matches_spec( + user_stake: i128, + winning_total: i128, + total_pool: i128, + actual_payout: i128, + ) { + let expected = MarketUtils::calculate_payout(user_stake, winning_total, total_pool, 2) + .expect("calculate_payout must succeed for positive winning_total"); + + let data = PayoutData { + user_stake, + winning_total, + total_pool, + fee_percentage: 2, + payout_amount: expected, + }; + + assert_eq!( + actual_payout, data.payout_amount, + "recorded payout must match PayoutData spec snapshot" + ); + } +} + +// --------------------------------------------------------------------------- +// 1. Two-way tie — equal stakes +// --------------------------------------------------------------------------- + +/// Two users each stake the same amount on different outcomes. +/// Both outcomes are declared winners. Each user should receive a payout +/// equal to their proportional share of the pool after the 2 % fee. +/// +/// Pool = 200, winning_total = 200, user_stake = 100 +/// expected = 100 * 9800 / 10000 * 200 / 200 = 98 +#[test] +fn test_two_way_tie_equal_stakes_payout_correct() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + let mid = s.create_market(outcomes); + + let u1 = s.user(); + let u2 = s.user(); + let stake: i128 = 100_000_000; // 10 XLM in stroops + + s.stake_on(&u1, &mid, "Alpha", stake); + s.stake_on(&u2, &mid, "Beta", stake); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake * 2; + let winning_total = stake * 2; // both outcomes win + let expected = TieSetup::expected_payout(stake, winning_total, total_pool); + + let p1 = s.recorded_payout(&mid, &u1); + let p2 = s.recorded_payout(&mid, &u2); + + assert_eq!(p1, expected, "u1 payout mismatch in two-way equal-stake tie"); + assert_eq!(p2, expected, "u2 payout mismatch in two-way equal-stake tie"); + + TieSetup::assert_payout_data_matches_spec(stake, winning_total, total_pool, p1); + TieSetup::assert_payout_data_matches_spec(stake, winning_total, total_pool, p2); + + let cap = TieSetup::max_distributable(total_pool); + assert!( + p1 + p2 <= cap, + "payouts ({}) exceed pool minus fees ({}) — dust leak detected", + p1 + p2, + cap + ); +} + +// --------------------------------------------------------------------------- +// 2. Two-way tie — unequal stakes +// --------------------------------------------------------------------------- + +/// u1 stakes 3× more than u2 on different outcomes, both declared winners. +/// Payouts must be proportional: p1 / p2 ≈ 3. +#[test] +fn test_two_way_tie_unequal_stakes_proportional_payout() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + let mid = s.create_market(outcomes); + + let u1 = s.user(); + let u2 = s.user(); + let stake1: i128 = 300_000_000; // 30 XLM + let stake2: i128 = 100_000_000; // 10 XLM + + s.stake_on(&u1, &mid, "Alpha", stake1); + s.stake_on(&u2, &mid, "Beta", stake2); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake1 + stake2; + let winning_total = stake1 + stake2; + + let expected1 = TieSetup::expected_payout(stake1, winning_total, total_pool); + let expected2 = TieSetup::expected_payout(stake2, winning_total, total_pool); + + let p1 = s.recorded_payout(&mid, &u1); + let p2 = s.recorded_payout(&mid, &u2); + + assert_eq!(p1, expected1, "u1 payout mismatch"); + assert_eq!(p2, expected2, "u2 payout mismatch"); + + // Proportionality: p1 should be ~3× p2 (within 1 stroop of rounding). + let ratio = (p1 * 100) / p2; + assert!( + ratio >= 295 && ratio <= 305, + "expected ~3:1 payout ratio, got {ratio} (p1={p1}, p2={p2})" + ); + + // No dust leak: sum must stay within pool minus platform fee. + let cap = TieSetup::max_distributable(total_pool); + assert!( + p1 + p2 <= cap, + "payouts ({}) exceed pool minus fees ({})", + p1 + p2, + cap + ); +} + +// --------------------------------------------------------------------------- +// 3. Three-way tie — equal stakes +// --------------------------------------------------------------------------- + +/// Three users each stake the same amount on three different outcomes. +/// All three outcomes are declared winners. Each user should receive the +/// same payout (pool / 3 after fee, since winning_total == total_pool). +#[test] +fn test_three_way_tie_equal_stakes_payout_correct() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + String::from_str(&s.env, "Cc"), + ]; + let mid = s.create_market(outcomes); + + let u1 = s.user(); + let u2 = s.user(); + let u3 = s.user(); + let stake: i128 = 100_000_000; // 10 XLM each + + s.stake_on(&u1, &mid, "Aa", stake); + s.stake_on(&u2, &mid, "Bb", stake); + s.stake_on(&u3, &mid, "Cc", stake); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + String::from_str(&s.env, "Cc"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake * 3; + let winning_total = stake * 3; + let expected = TieSetup::expected_payout(stake, winning_total, total_pool); + + let p1 = s.recorded_payout(&mid, &u1); + let p2 = s.recorded_payout(&mid, &u2); + let p3 = s.recorded_payout(&mid, &u3); + + assert_eq!(p1, expected, "u1 payout mismatch in three-way tie"); + assert_eq!(p2, expected, "u2 payout mismatch in three-way tie"); + assert_eq!(p3, expected, "u3 payout mismatch in three-way tie"); + + TieSetup::assert_payout_data_matches_spec(stake, winning_total, total_pool, p1); + + let cap = TieSetup::max_distributable(total_pool); + assert!( + p1 + p2 + p3 <= cap, + "payouts ({}) exceed pool minus fees ({})", + p1 + p2 + p3, + cap + ); +} + +// --------------------------------------------------------------------------- +// 4. Three-way tie — mixed stakes, two winners one loser +// --------------------------------------------------------------------------- + +/// Three users stake on three outcomes; only two outcomes are declared +/// winners (partial tie). The loser's stake is absorbed into the pool +/// and split between the two winners proportionally. +#[test] +fn test_three_outcome_partial_tie_two_winners_one_loser() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + String::from_str(&s.env, "Cc"), + ]; + let mid = s.create_market(outcomes); + + let winner1 = s.user(); + let winner2 = s.user(); + let loser = s.user(); + + let stake_w1: i128 = 200_000_000; // 20 XLM + let stake_w2: i128 = 100_000_000; // 10 XLM + let stake_l: i128 = 150_000_000; // 15 XLM (lost) + + s.stake_on(&winner1, &mid, "Aa", stake_w1); + s.stake_on(&winner2, &mid, "Bb", stake_w2); + s.stake_on(&loser, &mid, "Cc", stake_l); + + s.advance_past_end(); + + // Only Aa and Bb win — Cc loses. + let winning = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake_w1 + stake_w2 + stake_l; + let winning_total = stake_w1 + stake_w2; + + let exp_w1 = TieSetup::expected_payout(stake_w1, winning_total, total_pool); + let exp_w2 = TieSetup::expected_payout(stake_w2, winning_total, total_pool); + + let p_w1 = s.recorded_payout(&mid, &winner1); + let p_w2 = s.recorded_payout(&mid, &winner2); + let p_l = s.recorded_payout(&mid, &loser); + + assert_eq!(p_w1, exp_w1, "winner1 payout mismatch"); + assert_eq!(p_w2, exp_w2, "winner2 payout mismatch"); + assert_eq!(p_l, 0, "loser must receive zero payout"); + + // Winners should receive more than their original stake (they absorbed the loser). + assert!(p_w1 > stake_w1, "winner1 should profit from loser's stake"); + assert!(p_w2 > stake_w2, "winner2 should profit from loser's stake"); + + // No dust leak. + let cap = TieSetup::max_distributable(total_pool); + assert!( + p_w1 + p_w2 <= cap, + "payouts ({}) exceed pool minus fees ({})", + p_w1 + p_w2, + cap + ); +} + +// --------------------------------------------------------------------------- +// 5. Single-winner path unaffected +// --------------------------------------------------------------------------- + +/// Resolving via `resolve_market_with_ties` with a single winning outcome +/// must behave identically to `resolve_market_manual`: the sole winner +/// receives the full pool minus the platform fee. +#[test] +fn test_single_winner_via_ties_endpoint_correct() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Yes"), + String::from_str(&s.env, "No"), + ]; + let mid = s.create_market(outcomes); + + let winner = s.user(); + let loser = s.user(); + let stake_w: i128 = 300_000_000; // 30 XLM + let stake_l: i128 = 100_000_000; // 10 XLM + + s.stake_on(&winner, &mid, "Yes", stake_w); + s.stake_on(&loser, &mid, "No", stake_l); + + s.advance_past_end(); + + // Single winner passed through the ties endpoint. + let winning = vec![&s.env, String::from_str(&s.env, "Yes")]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake_w + stake_l; + let winning_total = stake_w; + let expected = TieSetup::expected_payout(stake_w, winning_total, total_pool); + + let p_winner = s.recorded_payout(&mid, &winner); + let p_loser = s.recorded_payout(&mid, &loser); + + assert_eq!(p_winner, expected, "single-winner payout mismatch"); + assert_eq!(p_loser, 0, "loser must receive zero"); + + TieSetup::assert_payout_data_matches_spec(stake_w, winning_total, total_pool, p_winner); + + assert!(p_winner > stake_w, "winner should profit"); + + assert!( + p_winner <= TieSetup::max_distributable(total_pool), + "winner payout ({}) exceeds pool minus fees", + p_winner + ); +} + +/// Single-winner payout via `resolve_market_with_ties` must match +/// `resolve_market_manual` for identical stake layout. +#[test] +fn test_single_winner_ties_matches_manual_resolve_payout() { + let stake_w: i128 = 250_000_000; + let stake_l: i128 = 75_000_000; + let total_pool = stake_w + stake_l; + let winning_total = stake_w; + let expected = TieSetup::expected_payout(stake_w, winning_total, total_pool); + + // Market resolved through the ties endpoint (single outcome). + let ties = TieSetup::new(); + let mid_ties = ties.create_market(vec![ + &ties.env, + String::from_str(&ties.env, "Yes"), + String::from_str(&ties.env, "No"), + ]); + let w_ties = ties.user(); + let l_ties = ties.user(); + ties.stake_on(&w_ties, &mid_ties, "Yes", stake_w); + ties.stake_on(&l_ties, &mid_ties, "No", stake_l); + ties.advance_past_end(); + ties.resolve_with_ties( + &mid_ties, + vec![&ties.env, String::from_str(&ties.env, "Yes")], + ); + let p_ties = ties.recorded_payout(&mid_ties, &w_ties); + + // Market resolved through manual single-outcome endpoint. + let manual = TieSetup::new(); + let mid_manual = manual.create_market(vec![ + &manual.env, + String::from_str(&manual.env, "Yes"), + String::from_str(&manual.env, "No"), + ]); + let w_manual = manual.user(); + let l_manual = manual.user(); + manual.stake_on(&w_manual, &mid_manual, "Yes", stake_w); + manual.stake_on(&l_manual, &mid_manual, "No", stake_l); + manual.advance_past_end(); + PredictifyHybridClient::new(&manual.env, &manual.contract_id).resolve_market_manual( + &manual.admin, + &mid_manual, + &String::from_str(&manual.env, "Yes"), + ); + let p_manual = manual.recorded_payout(&mid_manual, &w_manual); + + assert_eq!(p_ties, expected, "ties endpoint payout mismatch"); + assert_eq!(p_manual, expected, "manual resolve payout mismatch"); + assert_eq!( + p_ties, p_manual, + "single-winner ties payout must equal manual resolve payout" + ); +} + +// --------------------------------------------------------------------------- +// 6. Rounding dust — odd-stroop pool +// --------------------------------------------------------------------------- + +/// Use a pool size that does not divide evenly to verify that integer +/// truncation never causes the sum of payouts to exceed the pool. +#[test] +fn test_rounding_dust_never_exceeds_pool() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Xx"), + String::from_str(&s.env, "Yy"), + ]; + let mid = s.create_market(outcomes); + + let u1 = s.user(); + let u2 = s.user(); + + // Minimum allowed bet is 1_000_000 stroops (0.1 XLM). + // Use the smallest equal stakes to maximise rounding effect. + let stake: i128 = 1_000_001; // odd number to force non-integer split + + s.stake_on(&u1, &mid, "Xx", stake); + s.stake_on(&u2, &mid, "Yy", stake); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Xx"), + String::from_str(&s.env, "Yy"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake * 2; + let p1 = s.recorded_payout(&mid, &u1); + let p2 = s.recorded_payout(&mid, &u2); + + let cap = TieSetup::max_distributable(total_pool); + assert!( + p1 + p2 <= cap, + "dust leak: payouts ({}) exceed pool minus fees ({})", + p1 + p2, + cap + ); + + // Both payouts must be non-negative. + assert!(p1 >= 0, "negative payout for u1"); + assert!(p2 >= 0, "negative payout for u2"); +} + +/// Near-tie: winning sides differ by 1 stroop; payouts stay proportional and +/// within pool minus fees (integer rounding must not leak dust). +#[test] +fn test_near_tie_one_stroop_difference_proportional() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + let mid = s.create_market(outcomes); + + let u1 = s.user(); + let u2 = s.user(); + let stake1: i128 = 100_000_001; + let stake2: i128 = 100_000_000; + + s.stake_on(&u1, &mid, "Alpha", stake1); + s.stake_on(&u2, &mid, "Beta", stake2); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Alpha"), + String::from_str(&s.env, "Beta"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake1 + stake2; + let winning_total = total_pool; + + let p1 = s.recorded_payout(&mid, &u1); + let p2 = s.recorded_payout(&mid, &u2); + + TieSetup::assert_payout_data_matches_spec(stake1, winning_total, total_pool, p1); + TieSetup::assert_payout_data_matches_spec(stake2, winning_total, total_pool, p2); + + assert!(p1 >= p2, "larger stake must receive >= payout in near-tie"); + assert!( + p1 + p2 <= TieSetup::max_distributable(total_pool), + "near-tie payouts must not exceed pool minus fees" + ); +} + +// --------------------------------------------------------------------------- +// 7. Asymmetric rounding — large pool, small winning side +// --------------------------------------------------------------------------- + +/// One winner with a tiny stake vs. a large loser pool. +/// Verifies the winner receives a large multiplied payout and the sum +/// still does not exceed the total pool. +#[test] +fn test_large_pool_small_winning_stake_no_overflow() { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Rare"), + String::from_str(&s.env, "Common"), + ]; + let mid = s.create_market(outcomes); + + let winner = s.user(); + let loser = s.user(); + + let stake_w: i128 = 1_000_000; // 0.1 XLM (minimum) + let stake_l: i128 = 90_000_000_000; // 9 000 XLM + + s.stake_on(&winner, &mid, "Rare", stake_w); + s.stake_on(&loser, &mid, "Common", stake_l); + + s.advance_past_end(); + + let winning = vec![&s.env, String::from_str(&s.env, "Rare")]; + s.resolve_with_ties(&mid, winning); + + let total_pool = stake_w + stake_l; + let p_winner = s.recorded_payout(&mid, &winner); + + // Winner should receive nearly the entire pool (minus 2 % fee). + let expected = TieSetup::expected_payout(stake_w, stake_w, total_pool); + assert_eq!(p_winner, expected, "large-pool single-winner payout mismatch"); + + assert!( + p_winner <= TieSetup::max_distributable(total_pool), + "winner payout ({}) exceeds pool minus fees", + p_winner + ); +} + +// --------------------------------------------------------------------------- +// 8. calculate_winning_stats reflects tie correctly +// --------------------------------------------------------------------------- + +/// Unit-test `MarketAnalytics::calculate_winning_stats` directly against a +/// crafted `Market` to verify it sums stakes for the requested outcome only. +#[test] +fn test_calculate_winning_stats_two_way_tie_each_outcome() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(PredictifyHybrid, ()); + let token_id = + env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + crate::circuit_breaker::CircuitBreaker::initialize(&env).unwrap(); + }); + + let admin = Address::generate(&env); + PredictifyHybridClient::new(&env, &contract_id).initialize(&admin, &None, &None); + + // Build a market in memory (no storage write needed for this unit test). + let mut market = Market::new( + &env, + admin.clone(), + String::from_str(&env, "Stats test?"), + vec![ + &env, + String::from_str(&env, "Alpha"), + String::from_str(&env, "Beta"), + ], + env.ledger().timestamp() + 86400, + OracleConfig::new( + OracleProvider::reflector(), + Address::generate(&env), + String::from_str(&env, "BTC/USD"), + 5_000_000, + String::from_str(&env, "gt"), + ), + None, + 0, + MarketState::Active, + ); + + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + let u3 = Address::generate(&env); + + // u1 and u3 vote Alpha; u2 votes Beta. + market.add_vote(u1.clone(), String::from_str(&env, "Alpha"), 300_000_000); + market.add_vote(u2.clone(), String::from_str(&env, "Beta"), 200_000_000); + market.add_vote(u3.clone(), String::from_str(&env, "Alpha"), 100_000_000); + + env.as_contract(&contract_id, || { + let stats_alpha: WinningStats = + MarketAnalytics::calculate_winning_stats(&market, &String::from_str(&env, "Alpha")); + assert_eq!(stats_alpha.winning_total, 400_000_000, "Alpha winning_total"); + assert_eq!(stats_alpha.winning_voters, 2, "Alpha winning_voters"); + assert_eq!(stats_alpha.total_pool, market.total_staked, "Alpha total_pool"); + + let stats_beta: WinningStats = + MarketAnalytics::calculate_winning_stats(&market, &String::from_str(&env, "Beta")); + assert_eq!(stats_beta.winning_total, 200_000_000, "Beta winning_total"); + assert_eq!(stats_beta.winning_voters, 1, "Beta winning_voters"); + }); +} + +// --------------------------------------------------------------------------- +// 9. calculate_payout formula matches spec +// --------------------------------------------------------------------------- + +/// Directly verify `MarketUtils::calculate_payout` against [`PayoutData`] +/// examples from PAYOUT_SPECIFICATION.md §10.2. +#[test] +fn test_calculate_payout_spec_examples() { + let data1 = PayoutData { + user_stake: 1_000, + winning_total: 5_000, + total_pool: 10_000, + fee_percentage: 2, + payout_amount: 1_960, + }; + let p1 = MarketUtils::calculate_payout( + data1.user_stake, + data1.winning_total, + data1.total_pool, + data1.fee_percentage, + ) + .unwrap(); + assert_eq!(p1, data1.payout_amount, "spec example 1 mismatch"); + + let data2 = PayoutData { + user_stake: 2_000, + winning_total: 7_000, + total_pool: 10_000, + fee_percentage: 2, + payout_amount: 2_800, + }; + let p2 = MarketUtils::calculate_payout( + data2.user_stake, + data2.winning_total, + data2.total_pool, + data2.fee_percentage, + ) + .unwrap(); + assert_eq!(p2, data2.payout_amount, "spec example 2 mismatch"); + + // Edge: winning_total == 0 must return NothingToClaim. + let err = MarketUtils::calculate_payout(1_000, 0, 10_000, 2); + assert_eq!(err, Err(Error::NothingToClaim), "zero winning_total must error"); +} + +// --------------------------------------------------------------------------- +// Pool-minus-fees invariant across three-way tie stake distributions +// --------------------------------------------------------------------------- + +/// For three-way ties with varying stake ratios, verify proportional payouts +/// and that the sum never exceeds pool minus fees. +#[test] +fn test_three_way_tie_sum_never_exceeds_pool_minus_fees() { + // Stake distributions to test: (stake_a, stake_b, stake_c) + let distributions: &[(i128, i128, i128)] = &[ + (100_000_000, 100_000_000, 100_000_000), // equal + (300_000_000, 200_000_000, 100_000_000), // 3:2:1 + (500_000_000, 300_000_000, 200_000_000), // 5:3:2 + ]; + + for &(sa, sb, sc) in distributions { + let s = TieSetup::new(); + + let outcomes = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + String::from_str(&s.env, "Cc"), + ]; + let mid = s.create_market(outcomes); + + let ua = s.user(); + let ub = s.user(); + let uc = s.user(); + + s.stake_on(&ua, &mid, "Aa", sa); + s.stake_on(&ub, &mid, "Bb", sb); + s.stake_on(&uc, &mid, "Cc", sc); + + s.advance_past_end(); + + let winning = vec![ + &s.env, + String::from_str(&s.env, "Aa"), + String::from_str(&s.env, "Bb"), + String::from_str(&s.env, "Cc"), + ]; + s.resolve_with_ties(&mid, winning); + + let total_pool = sa + sb + sc; + let winning_total = total_pool; + let cap = TieSetup::max_distributable(total_pool); + let pa = s.recorded_payout(&mid, &ua); + let pb = s.recorded_payout(&mid, &ub); + let pc = s.recorded_payout(&mid, &uc); + let sum = pa + pb + pc; + + TieSetup::assert_payout_data_matches_spec(sa, winning_total, total_pool, pa); + TieSetup::assert_payout_data_matches_spec(sb, winning_total, total_pool, pb); + TieSetup::assert_payout_data_matches_spec(sc, winning_total, total_pool, pc); + + assert!( + sum <= cap, + "distribution ({sa},{sb},{sc}): payouts {sum} exceed pool minus fees {cap}" + ); + + // Proportionality: payouts track stake ordering. + if sa >= sb && sb >= sc { + assert!(pa >= pb && pb >= pc, "payouts must follow stake order ({sa},{sb},{sc})"); + } + } +}