diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index bf45d37a..4545608d 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -1414,7 +1414,6 @@ impl AdminManager { None } - /// Emits admin change events using existing AdminActionType pub fn emit_admin_change_event(env: &Env, admin: &Address, action: AdminActionType) { let action_str = match action { diff --git a/contracts/predictify-hybrid/src/balance_tests.rs b/contracts/predictify-hybrid/src/balance_tests.rs index b76964bb..eb503741 100644 --- a/contracts/predictify-hybrid/src/balance_tests.rs +++ b/contracts/predictify-hybrid/src/balance_tests.rs @@ -1,8 +1,8 @@ #![cfg(test)] +use crate::errors::Error; use crate::test::PredictifyTest; use crate::types::ReflectorAsset; -use crate::errors::Error; use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; #[test] @@ -11,34 +11,34 @@ fn test_deposit_and_withdrawal_flow() { let env = &test.env; let user = &test.user; let contract_address = &test.contract_id; - + // 1. Initial State: User has 1000 XLM (minted in setup), Contract has 0 let token_client = soroban_sdk::token::Client::new(env, &test.token_test.token_id); // Verify initial token balances // Note: token_test.token_client is associated with the token contract - // We need to use the client correctly. + // We need to use the client correctly. // In PredictifyTest::setup, we minted 1000_0000000 to user. - + assert_eq!(token_client.balance(user), 1000_0000000); assert_eq!(token_client.balance(contract_address), 0); // 2. Deposit Funds let deposit_amount = 500_0000000; // 500 XLM let client = crate::PredictifyHybridClient::new(env, contract_address); - + // We need to mock auth for the user env.mock_all_auths(); - + let balance = client.deposit(user, &ReflectorAsset::Stellar, &deposit_amount); - + // 3. Verify Deposit assert_eq!(balance.amount, deposit_amount); assert_eq!(balance.user, *user); - + // Verify stored balance matches let stored_balance = client.get_balance(user, &ReflectorAsset::Stellar); assert_eq!(stored_balance.amount, deposit_amount); - + // Verify token transfer happened assert_eq!(token_client.balance(user), 500_0000000); assert_eq!(token_client.balance(contract_address), 500_0000000); @@ -46,14 +46,14 @@ fn test_deposit_and_withdrawal_flow() { // 4. Withdraw Funds let withdraw_amount = 200_0000000; // 200 XLM let balance_after_withdraw = client.withdraw(user, &ReflectorAsset::Stellar, &withdraw_amount); - + // 5. Verify Withdrawal assert_eq!(balance_after_withdraw.amount, 300_0000000); // 500 - 200 = 300 - + // Verify stored balance updated let stored_balance_2 = client.get_balance(user, &ReflectorAsset::Stellar); assert_eq!(stored_balance_2.amount, 300_0000000); - + // Verify token transfer happened (Contract -> User) assert_eq!(token_client.balance(user), 700_0000000); // 500 + 200 = 700 assert_eq!(token_client.balance(contract_address), 300_0000000); // 500 - 200 = 300 @@ -66,17 +66,17 @@ fn test_insufficient_balance_withdrawal() { let user = &test.user; let contract_address = &test.contract_id; let client = crate::PredictifyHybridClient::new(env, contract_address); - + env.mock_all_auths(); - + // Deposit 100 let deposit_amount = 100_0000000; client.deposit(user, &ReflectorAsset::Stellar, &deposit_amount); - + // Try to withdraw 150 let withdraw_amount = 150_0000000; let result = client.try_withdraw(user, &ReflectorAsset::Stellar, &withdraw_amount); - + assert_eq!(result, Err(Ok(Error::InsufficientBalance))); } @@ -87,13 +87,13 @@ fn test_invalid_deposit_amount() { let user = &test.user; let contract_address = &test.contract_id; let client = crate::PredictifyHybridClient::new(env, contract_address); - + env.mock_all_auths(); - + // Try to deposit 0 let result = client.try_deposit(user, &ReflectorAsset::Stellar, &0); assert_eq!(result, Err(Ok(Error::InvalidInput))); - + // Try to deposit negative let result_neg = client.try_deposit(user, &ReflectorAsset::Stellar, &-100); assert_eq!(result_neg, Err(Ok(Error::InvalidInput))); @@ -106,15 +106,15 @@ fn test_invalid_withdraw_amount() { let user = &test.user; let contract_address = &test.contract_id; let client = crate::PredictifyHybridClient::new(env, contract_address); - + env.mock_all_auths(); - + client.deposit(user, &ReflectorAsset::Stellar, &1000); - + // Try to withdraw 0 let result = client.try_withdraw(user, &ReflectorAsset::Stellar, &0); assert_eq!(result, Err(Ok(Error::InvalidInput))); - + // Try to withdraw negative let result_neg = client.try_withdraw(user, &ReflectorAsset::Stellar, &-100); assert_eq!(result_neg, Err(Ok(Error::InvalidInput))); @@ -127,14 +127,14 @@ fn test_multiple_deposits() { let user = &test.user; let contract_address = &test.contract_id; let client = crate::PredictifyHybridClient::new(env, contract_address); - + env.mock_all_auths(); - + // Deposit 1 client.deposit(user, &ReflectorAsset::Stellar, &100); let b1 = client.get_balance(user, &ReflectorAsset::Stellar); assert_eq!(b1.amount, 100); - + // Deposit 2 client.deposit(user, &ReflectorAsset::Stellar, &200); let b2 = client.get_balance(user, &ReflectorAsset::Stellar); @@ -148,9 +148,9 @@ fn test_deposit_invalid_asset() { let user = &test.user; let contract_address = &test.contract_id; let client = crate::PredictifyHybridClient::new(env, contract_address); - + env.mock_all_auths(); - + // Try to deposit Bitcoin (not configured/supported yet in balances.rs match statement) // The current implementation in balances.rs returns Error::InvalidInput for non-Stellar assets // because it can't resolve the token client for them. diff --git a/contracts/predictify-hybrid/src/balances.rs b/contracts/predictify-hybrid/src/balances.rs index 22aa01dc..48e105ae 100644 --- a/contracts/predictify-hybrid/src/balances.rs +++ b/contracts/predictify-hybrid/src/balances.rs @@ -6,7 +6,7 @@ use crate::markets::MarketUtils; use crate::storage::BalanceStorage; use crate::types::{Balance, ReflectorAsset}; use crate::validation::InputValidator; -use soroban_sdk::{Env, Address, String}; +use soroban_sdk::{Address, Env, String}; /// Manages user balances for deposits and withdrawals. /// @@ -27,7 +27,12 @@ impl BalanceManager { /// /// # Returns /// * `Result` - The updated balance or an error. - pub fn deposit(env: &Env, user: Address, asset: ReflectorAsset, amount: i128) -> Result { + pub fn deposit( + env: &Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { user.require_auth(); // Validate amount @@ -42,7 +47,7 @@ impl BalanceManager { }; // Transfer funds from user to contract - // The user must have authorized this transfer (allowance) or we use transfer_from if supported, + // The user must have authorized this transfer (allowance) or we use transfer_from if supported, // but standard Soroban token interface uses transfer(from, to, amount) where 'from' must auth. // Since we called user.require_auth(), we can try to transfer. // Note: The token contract will check if 'user' signed the tx. @@ -74,7 +79,12 @@ impl BalanceManager { /// /// # Returns /// * `Result` - The updated balance or an error. - pub fn withdraw(env: &Env, user: Address, asset: ReflectorAsset, amount: i128) -> Result { + pub fn withdraw( + env: &Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { user.require_auth(); // Validate amount @@ -148,7 +158,7 @@ impl BalanceManager { // So `BalanceStorage` only tracks "Idle" funds. // // So for `withdraw`, if `BalanceStorage` is "Idle Funds", then checking `balance.amount >= amount` is sufficient. - + // Resolve token client let token_client = match asset { ReflectorAsset::Stellar => MarketUtils::get_token_client(env)?, diff --git a/contracts/predictify-hybrid/src/bet_tests.rs b/contracts/predictify-hybrid/src/bet_tests.rs index 2a0e3a3f..08ad12cc 100644 --- a/contracts/predictify-hybrid/src/bet_tests.rs +++ b/contracts/predictify-hybrid/src/bet_tests.rs @@ -591,7 +591,7 @@ fn test_market_validation_for_betting() { dispute_stakes: Map::new(&env), stakes: Map::new(&env), claimed: Map::new(&env), - winning_outcome: None, + winning_outcomes: None, fee_collected: false, state: MarketState::Active, total_extension_days: 0, @@ -611,7 +611,7 @@ fn test_market_validation_for_betting() { // Resolved market should fail let mut resolved_market = active_market.clone(); - resolved_market.winning_outcome = Some(String::from_str(&env, "yes")); + resolved_market.winning_outcomes = Some(vec![&env, String::from_str(&env, "yes")]); assert!(BetValidator::validate_market_for_betting(&env, &resolved_market).is_err()); // Closed market should fail @@ -1000,6 +1000,547 @@ fn test_set_global_bet_limits_above_absolute_max_rejects() { client.set_global_bet_limits(&setup.admin, &MIN_BET_AMOUNT, &(MAX_BET_AMOUNT + 1)); } +// ===== MULTI-OUTCOME TESTS ===== + +/// Test creating a market with 3 outcomes (Team A / Team B / Draw) +#[test] +fn test_create_market_with_three_outcomes() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + let question = String::from_str(&setup.env, "Who will win the match?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Verify market was created + let market = client.get_market(&market_id).unwrap(); + assert_eq!(market.outcomes.len(), 3); + assert_eq!( + market.outcomes.get(0).unwrap(), + String::from_str(&setup.env, "Team A") + ); + assert_eq!( + market.outcomes.get(1).unwrap(), + String::from_str(&setup.env, "Team B") + ); + assert_eq!( + market.outcomes.get(2).unwrap(), + String::from_str(&setup.env, "Draw") + ); +} + +/// Test creating a market with N outcomes (5 outcomes) +#[test] +fn test_create_market_with_n_outcomes() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + let question = String::from_str(&setup.env, "What will be the final score range?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "0-10"), + String::from_str(&setup.env, "11-20"), + String::from_str(&setup.env, "21-30"), + String::from_str(&setup.env, "31-40"), + String::from_str(&setup.env, "41+"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Verify market was created with 5 outcomes + let market = client.get_market(&market_id).unwrap(); + assert_eq!(market.outcomes.len(), 5); +} + +/// Test placing bets on a 3-outcome market +#[test] +fn test_place_bet_on_three_outcome_market() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets on different outcomes + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team A"), + &10_0000000, + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Team B"), + &20_0000000, + ); + + // Verify bets were placed + let user_bet = client.get_bet(&market_id, &setup.user); + assert!(user_bet.is_some()); + let bet = user_bet.unwrap(); + assert_eq!(bet.outcome, String::from_str(&setup.env, "Team A")); + assert_eq!(bet.amount, 10_0000000); + + let user2_bet = client.get_bet(&market_id, &setup.user2); + assert!(user2_bet.is_some()); + let bet2 = user2_bet.unwrap(); + assert_eq!(bet2.outcome, String::from_str(&setup.env, "Team B")); +} + +/// Test placing bet with invalid outcome on multi-outcome market +#[test] +#[should_panic] +fn test_place_bet_invalid_outcome_multi_outcome() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Try to place bet with invalid outcome + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Invalid Outcome"), // Not in market outcomes + &10_0000000, + ); +} + +/// Test resolving 3-outcome market with single winner +#[test] +fn test_resolve_three_outcome_market_single_winner() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team A"), + &10_0000000, + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Team B"), + &20_0000000, + ); + + // Advance time to end market + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with single winner + client.resolve_market_manual( + &setup.admin, + &market_id, + &String::from_str(&setup.env, "Team A"), + ); + + // Verify resolution + let market = client.get_market(&market_id).unwrap(); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!( + winners.get(0).unwrap(), + String::from_str(&setup.env, "Team A") + ); + + // Verify bet statuses + let user_bet = client.get_bet(&market_id, &setup.user).unwrap(); + assert_eq!(user_bet.status, BetStatus::Won); + + let user2_bet = client.get_bet(&market_id, &setup.user2).unwrap(); + assert_eq!(user2_bet.status, BetStatus::Lost); +} + +/// Test resolving 3-outcome market with tie (multiple winners - pool split) +#[test] +fn test_resolve_three_outcome_market_with_tie() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets on different outcomes + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team A"), + &10_0000000, // 10 XLM + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Team B"), + &10_0000000, // 10 XLM (same amount - tie scenario) + ); + + // Advance time to end market + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with tie (both Team A and Team B win - pool split) + let winning_outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + ]; + + client.resolve_market_with_ties(&setup.admin, &market_id, &winning_outcomes); + + // Verify resolution with multiple winners + let market = client.get_market(&market_id).unwrap(); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 2); + assert!(winners.contains(&String::from_str(&setup.env, "Team A"))); + assert!(winners.contains(&String::from_str(&setup.env, "Team B"))); + + // Verify both bets are marked as won + let user_bet = client.get_bet(&market_id, &setup.user).unwrap(); + assert_eq!(user_bet.status, BetStatus::Won); + + let user2_bet = client.get_bet(&market_id, &setup.user2).unwrap(); + assert_eq!(user2_bet.status, BetStatus::Won); + + // Verify payout calculation handles split pool + // Both users should get proportional share of total pool + let user_payout = client.calculate_bet_payout(&market_id, &setup.user); + let user2_payout = client.calculate_bet_payout(&market_id, &setup.user2); + + // Both should get equal payouts since they bet equal amounts + // Total pool = 20 XLM, both bet 10 XLM, so each gets ~50% of pool (minus fees) + assert!(user_payout > 0); + assert!(user2_payout > 0); + // Payouts should be approximately equal (within rounding) + let diff = if user_payout > user2_payout { + user_payout - user2_payout + } else { + user2_payout - user_payout + }; + assert!(diff < 1000); // Allow small rounding differences +} + +/// Test payout calculation for tie scenario with different stake amounts +#[test] +fn test_tie_payout_calculation_different_stakes() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets with different amounts + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team A"), + &10_0000000, // 10 XLM + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Team B"), + &30_0000000, // 30 XLM (3x more) + ); + + // Advance time to end market + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with tie + let winning_outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + ]; + + client.resolve_market_with_ties(&setup.admin, &market_id, &winning_outcomes); + + // Calculate payouts + let user_payout = client.calculate_bet_payout(&market_id, &setup.user); + let user2_payout = client.calculate_bet_payout(&market_id, &setup.user2); + + // User2 should get 3x more payout since they bet 3x more + // Total pool = 40 XLM, user1 stake = 10, user2 stake = 30 + // user1 share = 10/40 = 25%, user2 share = 30/40 = 75% + assert!(user2_payout > user_payout); + // Verify proportional split (within rounding) + let ratio = user2_payout * 100 / user_payout; + assert!(ratio >= 290 && ratio <= 310); // ~3x ratio (allowing rounding) +} + +/// Test edge case: resolving with all outcomes as winners (extreme tie) +#[test] +fn test_resolve_all_outcomes_as_winners() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets on all outcomes + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team A"), + &10_0000000, + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Team B"), + &10_0000000, + ); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with all outcomes as winners (extreme tie case) + let all_outcomes = outcomes.clone(); + client.resolve_market_with_ties(&setup.admin, &market_id, &all_outcomes); + + // Verify all outcomes are winners + let market = client.get_market(&market_id).unwrap(); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 3); +} + +/// Test that binary (yes/no) markets still work correctly +#[test] +fn test_binary_market_backward_compatibility() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create binary market (yes/no) + let question = String::from_str(&setup.env, "Will BTC reach $100k?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "yes"), + String::from_str(&setup.env, "no"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 100_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "yes"), + &10_0000000, + ); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with single winner (backward compatible) + client.resolve_market_manual( + &setup.admin, + &market_id, + &String::from_str(&setup.env, "yes"), + ); + + // Verify resolution works as before + let market = client.get_market(&market_id).unwrap(); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!(winners.get(0).unwrap(), String::from_str(&setup.env, "yes")); + + // Verify payout calculation works + let payout = client.calculate_bet_payout(&market_id, &setup.user); + assert!(payout > 0); +} + // ===== BATCH BET PLACEMENT TESTS ===== #[test] @@ -1008,8 +1549,10 @@ fn test_place_bets_success() { let client = setup.client(); // Create additional markets - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); - let market_id3 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id3 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Prepare batch bets let bets = vec![ @@ -1040,17 +1583,26 @@ fn test_place_bets_success() { // Verify first bet assert_eq!(placed_bets.get(0).unwrap().market_id, setup.market_id); assert_eq!(placed_bets.get(0).unwrap().amount, 10_0000000); - assert_eq!(placed_bets.get(0).unwrap().outcome, String::from_str(&setup.env, "yes")); + assert_eq!( + placed_bets.get(0).unwrap().outcome, + String::from_str(&setup.env, "yes") + ); // Verify second bet assert_eq!(placed_bets.get(1).unwrap().market_id, market_id2); assert_eq!(placed_bets.get(1).unwrap().amount, 20_0000000); - assert_eq!(placed_bets.get(1).unwrap().outcome, String::from_str(&setup.env, "no")); + assert_eq!( + placed_bets.get(1).unwrap().outcome, + String::from_str(&setup.env, "no") + ); // Verify third bet assert_eq!(placed_bets.get(2).unwrap().market_id, market_id3); assert_eq!(placed_bets.get(2).unwrap().amount, 15_0000000); - assert_eq!(placed_bets.get(2).unwrap().outcome, String::from_str(&setup.env, "yes")); + assert_eq!( + placed_bets.get(2).unwrap().outcome, + String::from_str(&setup.env, "yes") + ); // Verify all bets are recorded assert!(client.has_user_bet(&setup.market_id, &setup.user)); @@ -1091,7 +1643,8 @@ fn test_place_bets_maximum_batch_size() { // Create 50 markets (max batch size) let mut bets = Vec::new(&setup.env); for i in 0..50 { - let market_id = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); bets.push_back(( market_id, String::from_str(&setup.env, "yes"), @@ -1126,7 +1679,8 @@ fn test_place_bets_exceeds_max_batch_size() { // Create 51 bets (exceeds max of 50) let mut bets = Vec::new(&setup.env); for _ in 0..51 { - let market_id = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); bets.push_back(( market_id, String::from_str(&setup.env, "yes"), @@ -1143,7 +1697,8 @@ fn test_place_bets_atomic_revert_on_invalid_market() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); let invalid_market = Symbol::new(&setup.env, "nonexistent"); // Batch with one invalid market @@ -1176,7 +1731,8 @@ fn test_place_bets_atomic_revert_on_invalid_outcome() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Batch with one invalid outcome let bets = vec![ @@ -1203,7 +1759,8 @@ fn test_place_bets_atomic_revert_on_insufficient_stake() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Batch with one bet below minimum let bets = vec![ @@ -1230,7 +1787,350 @@ fn test_place_bets_atomic_revert_on_already_bet() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + + // Place a bet on first market + client.place_bet( + &setup.user, + &setup.market_id, + &String::from_str(&setup.env, "yes"), + &10_0000000, + ); + + // Try to place batch including the market already bet on + let bets = vec![ + &setup.env, + ( + setup.market_id.clone(), + String::from_str(&setup.env, "no"), + 15_0000000i128, + ), + ( + market_id2, + String::from_str(&setup.env, "yes"), + 20_0000000i128, + ), + ]; + + // Should panic and revert all bets + client.place_bets(&setup.user, &bets); +} + +/// Test N-outcome market (5 outcomes) with single winner +#[test] +fn test_resolve_n_outcome_market_single_winner() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 5-outcome market (e.g., election with 5 candidates) + let question = String::from_str(&setup.env, "Who will win the election?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Candidate A"), + String::from_str(&setup.env, "Candidate B"), + String::from_str(&setup.env, "Candidate C"), + String::from_str(&setup.env, "Candidate D"), + String::from_str(&setup.env, "Candidate E"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets on different outcomes + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Candidate A"), + &10_0000000, + ); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Candidate B"), + &20_0000000, + ); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with single winner + client.resolve_market_manual( + &setup.admin, + &market_id, + &String::from_str(&setup.env, "Candidate A"), + ); + + // Verify resolution + let market = client.get_market(&market_id).unwrap(); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!( + winners.get(0).unwrap(), + String::from_str(&setup.env, "Candidate A") + ); + + // Verify bet statuses + let user_bet = client.get_bet(&market_id, &setup.user).unwrap(); + assert_eq!(user_bet.status, BetStatus::Won); + + let user2_bet = client.get_bet(&market_id, &setup.user2).unwrap(); + assert_eq!(user2_bet.status, BetStatus::Lost); +} + +/// Test N-outcome market (4 outcomes) with 3-way tie +#[test] +fn test_resolve_n_outcome_market_three_way_tie() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 4-outcome market + let question = String::from_str(&setup.env, "Race result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Runner 1"), + String::from_str(&setup.env, "Runner 2"), + String::from_str(&setup.env, "Runner 3"), + String::from_str(&setup.env, "Runner 4"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Place bets on 3 different outcomes with equal amounts + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Runner 1"), + &10_0000000, + ); + + // Create additional users for testing + let user3 = Address::generate(&setup.env); + let user4 = Address::generate(&setup.env); + + // Fund additional users + let stellar_client = soroban_sdk::token::StellarAssetClient::new(&setup.env, &setup.token_id); + stellar_client.mint(&user3, &1000_0000000); + stellar_client.mint(&user4, &1000_0000000); + + // Approve tokens + let token_client = soroban_sdk::token::Client::new(&setup.env, &setup.token_id); + token_client.approve(&user3, &setup.contract_id, &i128::MAX, &1000000); + token_client.approve(&user4, &setup.contract_id, &i128::MAX, &1000000); + + client.place_bet( + &setup.user2, + &market_id, + &String::from_str(&setup.env, "Runner 2"), + &10_0000000, + ); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Resolve with 3-way tie + let winning_outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Runner 1"), + String::from_str(&setup.env, "Runner 2"), + String::from_str(&setup.env, "Runner 3"), + ]; + + client.resolve_market_with_ties(&setup.admin, &market_id, &winning_outcomes); + + // Verify resolution + let market = client.get_market(&market_id).unwrap(); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 3); + assert!(winners.contains(&String::from_str(&setup.env, "Runner 1"))); + assert!(winners.contains(&String::from_str(&setup.env, "Runner 2"))); + assert!(winners.contains(&String::from_str(&setup.env, "Runner 3"))); + + // Verify bet statuses + let user_bet = client.get_bet(&market_id, &setup.user).unwrap(); + assert_eq!(user_bet.status, BetStatus::Won); + + let user2_bet = client.get_bet(&market_id, &setup.user2).unwrap(); + assert_eq!(user2_bet.status, BetStatus::Won); +} + +/// Test invalid outcome validation in N-outcome market +#[test] +#[should_panic] +fn test_place_bet_invalid_outcome_n_outcome() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 5-outcome market + let question = String::from_str(&setup.env, "Tournament winner?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team 1"), + String::from_str(&setup.env, "Team 2"), + String::from_str(&setup.env, "Team 3"), + String::from_str(&setup.env, "Team 4"), + String::from_str(&setup.env, "Team 5"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Try to place bet with outcome not in market outcomes + client.place_bet( + &setup.user, + &market_id, + &String::from_str(&setup.env, "Team 99"), // Invalid - not in outcomes + &10_0000000, + ); +} + +/// Test resolving with invalid winning outcome (not in market outcomes) +#[test] +#[should_panic] +fn test_resolve_with_invalid_winning_outcome() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Try to resolve with invalid outcome + client.resolve_market_manual( + &setup.admin, + &market_id, + &String::from_str(&setup.env, "Invalid Team"), // Not in market outcomes + ); +} + +/// Test resolving with empty winning outcomes vector +#[test] +#[should_panic] +fn test_resolve_with_empty_winning_outcomes() { + let setup = BetTestSetup::new(); + let client = setup.client(); + setup.env.mock_all_auths(); + + // Create 3-outcome market + let question = String::from_str(&setup.env, "Match result?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Team A"), + String::from_str(&setup.env, "Team B"), + String::from_str(&setup.env, "Draw"), + ]; + + let oracle_config = OracleConfig::new( + OracleProvider::Reflector, + String::from_str(&setup.env, "BTC/USD"), + 50_000_00, + String::from_str(&setup.env, "gt"), + ); + + let market_id = + client.create_market(&setup.admin, &question, &outcomes, &30u32, &oracle_config); + + // Advance time + setup.env.ledger().set(LedgerInfo { + timestamp: (30 * 24 * 60 * 60) + 1, + protocol_version: 22, + sequence_number: 100, + network_id: Default::default(), + base_reserve: 10000000, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // Try to resolve with empty vector + let empty_outcomes = vec![&setup.env]; + client.resolve_market_with_ties(&setup.admin, &market_id, &empty_outcomes); +} + +// ===== BATCH BET PLACEMENT TESTS (continued) ===== + +#[test] +#[should_panic] +fn test_place_bets_atomic_revert_on_already_bet_continued() { + let setup = BetTestSetup::new(); + let client = setup.client(); + + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Place a bet on first market client.place_bet( @@ -1265,7 +2165,8 @@ fn test_place_bets_atomic_revert_on_closed_market() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Advance time past first market end setup.advance_past_market_end(); @@ -1295,8 +2196,10 @@ fn test_place_bets_insufficient_balance() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); - let market_id3 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id3 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Try to place bets totaling more than user balance let bets = vec![ @@ -1327,7 +2230,8 @@ fn test_place_bets_updates_market_stats() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Place batch bets let bets = vec![ @@ -1364,7 +2268,8 @@ fn test_place_bets_emits_events() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Place batch bets let bets = vec![ @@ -1395,7 +2300,8 @@ fn test_place_bets_gas_efficiency() { // Create 10 markets let mut markets = Vec::new(&setup.env); for _ in 0..10 { - let market_id = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); markets.push_back(market_id); } @@ -1452,7 +2358,8 @@ fn test_place_bets_multiple_users() { let setup = BetTestSetup::new(); let client = setup.client(); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // First user places batch bets let bets1 = vec![ @@ -1515,7 +2422,8 @@ fn test_place_bets_with_bet_limits() { setup.env.mock_all_auths(); client.set_global_bet_limits(&setup.admin, &min, &max); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Place batch bets within limits let bets = vec![ @@ -1525,11 +2433,7 @@ fn test_place_bets_with_bet_limits() { String::from_str(&setup.env, "yes"), min, ), - ( - market_id2, - String::from_str(&setup.env, "no"), - max, - ), + (market_id2, String::from_str(&setup.env, "no"), max), ]; setup.env.mock_all_auths(); @@ -1552,7 +2456,8 @@ fn test_place_bets_with_bet_limits_violation() { setup.env.mock_all_auths(); client.set_global_bet_limits(&setup.admin, &min, &max); - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Try to place batch with one bet exceeding max let bets = vec![ @@ -1562,11 +2467,7 @@ fn test_place_bets_with_bet_limits_violation() { String::from_str(&setup.env, "yes"), min, ), - ( - market_id2, - String::from_str(&setup.env, "no"), - max + 1, - ), + (market_id2, String::from_str(&setup.env, "no"), max + 1), ]; setup.env.mock_all_auths(); @@ -1581,7 +2482,8 @@ fn test_place_bets_total_amount_overflow_protection() { // This test verifies overflow protection in total amount calculation // The checked_add in place_bets prevents overflow - let market_id2 = BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); + let market_id2 = + BetTestSetup::create_test_market_static(&setup.env, &setup.contract_id, &setup.admin); // Place reasonable bets (overflow protection is in place) let bets = vec![ diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index d22b5613..a529ea50 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -19,7 +19,7 @@ //! - Balance validation before fund transfer //! - Market state validation before accepting bets -use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol}; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, Vec}; use crate::errors::Error; use crate::events::EventEmitter; @@ -98,7 +98,11 @@ pub fn set_global_bet_limits(env: &Env, limits: &BetLimits) -> Result<(), Error> } /// Set per-event bet limits (admin only; validation of bounds done by caller). -pub fn set_event_bet_limits(env: &Env, market_id: &Symbol, limits: &BetLimits) -> Result<(), Error> { +pub fn set_event_bet_limits( + env: &Env, + market_id: &Symbol, + limits: &BetLimits, +) -> Result<(), Error> { validate_limits_bounds(limits)?; let key = Symbol::new(env, PER_EVENT_BET_LIMITS_KEY); let mut per_event: soroban_sdk::Map = env @@ -349,7 +353,13 @@ impl BetManager { BetValidator::validate_market_for_betting(env, &market)?; // Validate bet parameters - BetValidator::validate_bet_parameters(env, &market_id, &outcome, &market.outcomes, amount)?; + BetValidator::validate_bet_parameters( + env, + &market_id, + &outcome, + &market.outcomes, + amount, + )?; // Check if user has already bet on this market if Self::has_user_bet(env, &market_id, &user) { @@ -391,7 +401,8 @@ impl BetManager { Self::update_market_bet_stats(env, &market_id, &outcome, amount)?; // Update market's total staked - market.total_staked = market.total_staked + market.total_staked = market + .total_staked .checked_add(amount) .ok_or(Error::InvalidInput)?; @@ -482,13 +493,14 @@ impl BetManager { /// Process bet resolution when a market is resolved. /// - /// This function updates all bets for a market based on the winning outcome. + /// This function updates all bets for a market based on the winning outcome(s). + /// Supports both single winner and multi-winner (tie) cases. /// /// # Parameters /// /// - `env` - The Soroban environment /// - `market_id` - Symbol identifying the market - /// - `winning_outcome` - The resolved winning outcome + /// - `winning_outcomes` - The resolved winning outcome(s) (single or multiple for ties) /// /// # Returns /// @@ -496,7 +508,7 @@ impl BetManager { pub fn resolve_market_bets( env: &Env, market_id: &Symbol, - winning_outcome: &String, + winning_outcomes: &Vec, ) -> Result<(), Error> { // Get all bets for this market from the bet registry let bets = BetStorage::get_all_bets_for_market(env, market_id); @@ -506,8 +518,8 @@ impl BetManager { for i in 0..bet_count { if let Some(bet_key) = bets.get(i) { if let Some(mut bet) = BetStorage::get_bet(env, market_id, &bet_key) { - // Determine if bet won or lost - if bet.outcome == *winning_outcome { + // Determine if bet won or lost (check if outcome is in winning outcomes) + if winning_outcomes.contains(&bet.outcome) { bet.mark_as_won(); } else { bet.mark_as_lost(); @@ -597,17 +609,27 @@ impl BetManager { // Get market bet stats let stats = BetStorage::get_market_bet_stats(env, market_id); - // Get total amount bet on the winning outcome - let winning_outcome = market.winning_outcome.ok_or(Error::MarketNotResolved)?; - let winning_total = stats.outcome_totals.get(winning_outcome).unwrap_or(0); + // Get total amount bet on all winning outcomes (handles ties - pool split) + let winning_outcomes = market.winning_outcomes.ok_or(Error::MarketNotResolved)?; + let mut winning_total = 0; + for outcome in winning_outcomes.iter() { + winning_total += stats.outcome_totals.get(outcome.clone()).unwrap_or(0); + } if winning_total == 0 { return Ok(0); } - // Get platform fee percentage from config - let cfg = crate::config::ConfigManager::get_config(env)?; - let fee_percentage = cfg.fees.platform_fee_percentage; + // Get platform fee percentage from config (with fallback to legacy storage) + let fee_percentage = crate::config::ConfigManager::get_config(env) + .map(|cfg| cfg.fees.platform_fee_percentage) + .unwrap_or_else(|_| { + // Fallback to legacy storage for backward compatibility + env.storage() + .persistent() + .get(&Symbol::new(env, "platform_fee")) + .unwrap_or(200) // Default 2% if not set + }); // Calculate payout let payout = MarketUtils::calculate_payout( @@ -777,7 +799,7 @@ impl BetValidator { } // Check if market is not already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 6d8ed548..1c00ea27 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -1894,7 +1894,7 @@ impl DisputeValidator { } // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } @@ -1909,7 +1909,7 @@ impl DisputeValidator { /// Validate market state for resolution pub fn validate_market_for_resolution(_env: &Env, market: &Market) -> Result<(), Error> { // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } @@ -2179,8 +2179,10 @@ impl DisputeUtils { // Validate the final outcome DisputeValidator::validate_resolution_parameters(market, &final_outcome)?; - // Set the winning outcome - market.winning_outcome = Some(final_outcome); + // Set the winning outcome(s) - convert single outcome to vector + let mut winning_outcomes = Vec::new(market.votes.env()); + winning_outcomes.push_back(final_outcome); + market.winning_outcomes = Some(winning_outcomes); Ok(()) } @@ -2508,7 +2510,7 @@ impl DisputeAnalytics { for (_, stake) in market.dispute_stakes.iter() { if stake > 0 { unique_disputers += 1; - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { active_disputes += 1; } else { resolved_disputes += 1; diff --git a/contracts/predictify-hybrid/src/edge_cases.rs b/contracts/predictify-hybrid/src/edge_cases.rs index 76de2a4b..63f12139 100644 --- a/contracts/predictify-hybrid/src/edge_cases.rs +++ b/contracts/predictify-hybrid/src/edge_cases.rs @@ -605,7 +605,7 @@ impl EdgeCaseHandler { config: &EdgeCaseConfig, ) -> Result { // Check if market has ended but not resolved - if current_time > market.end_time && market.winning_outcome.is_none() { + if current_time > market.end_time && market.winning_outcomes.is_none() { let time_since_end = current_time - market.end_time; if time_since_end > config.max_orphan_time { return Ok(true); diff --git a/contracts/predictify-hybrid/src/errors.rs b/contracts/predictify-hybrid/src/errors.rs index 5aa50300..5cc740c0 100644 --- a/contracts/predictify-hybrid/src/errors.rs +++ b/contracts/predictify-hybrid/src/errors.rs @@ -1087,7 +1087,9 @@ impl Error { Error::InvalidOutcome => "Invalid outcome choice", Error::AlreadyVoted => "User has already voted", Error::AlreadyBet => "User has already placed a bet on this market", - Error::BetsAlreadyPlaced => "Bets have already been placed on this market (cannot update)", + Error::BetsAlreadyPlaced => { + "Bets have already been placed on this market (cannot update)" + } Error::InsufficientBalance => "Insufficient balance for operation", Error::OracleUnavailable => "Oracle is unavailable", Error::InvalidOracleConfig => "Invalid oracle configuration", diff --git a/contracts/predictify-hybrid/src/event_archive.rs b/contracts/predictify-hybrid/src/event_archive.rs index 26f6b4b7..2603a1bb 100644 --- a/contracts/predictify-hybrid/src/event_archive.rs +++ b/contracts/predictify-hybrid/src/event_archive.rs @@ -112,7 +112,7 @@ impl EventArchive { end_time: market.end_time, created_at, state: market.state, - winning_outcome: market.winning_outcome.clone(), + winning_outcome: market.get_winning_outcome(), // Get first outcome for backward compatibility total_staked: market.total_staked, archived_at, category, @@ -151,7 +151,11 @@ impl EventArchive { scanned += 1; let created_at = entry.timestamp; if created_at >= from_ts && created_at <= to_ts { - if let Some(market) = env.storage().persistent().get::(&entry.market_id) { + if let Some(market) = env + .storage() + .persistent() + .get::(&entry.market_id) + { result.push_back(Self::market_to_history_entry( env, &entry.market_id, @@ -183,7 +187,11 @@ impl EventArchive { for i in 0..registry_page.len() { if let Some(entry) = registry_page.get(i) { scanned += 1; - if let Some(market) = env.storage().persistent().get::(&entry.market_id) { + if let Some(market) = env + .storage() + .persistent() + .get::(&entry.market_id) + { if market.state == status { result.push_back(Self::market_to_history_entry( env, diff --git a/contracts/predictify-hybrid/src/event_management_tests.rs b/contracts/predictify-hybrid/src/event_management_tests.rs index d63b8582..0973c57b 100644 --- a/contracts/predictify-hybrid/src/event_management_tests.rs +++ b/contracts/predictify-hybrid/src/event_management_tests.rs @@ -18,10 +18,10 @@ impl TestSetup { fn new() -> Self { let env = Env::default(); env.mock_all_auths(); - + let admin = Address::generate(&env); let contract_id = env.register(PredictifyHybrid, ()); - + // Setup Token let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); @@ -29,13 +29,15 @@ impl TestSetup { // Store TokenID in contract env.as_contract(&contract_id, || { - env.storage().persistent().set(&Symbol::new(&env, "TokenID"), &token_id); + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); }); // Initialize the contract let client = PredictifyHybridClient::new(&env, &contract_id); client.initialize(&admin, &None); - + Self { env, contract_id, @@ -43,7 +45,7 @@ impl TestSetup { token_id, } } - + fn create_user(&self) -> Address { let user = Address::generate(&self.env); // Mint tokens for user so they can vote/bet @@ -51,7 +53,7 @@ impl TestSetup { stellar_client.mint(&user, &10_000_000_000); // 1000 XLM user } - + fn create_market(&self, question: &str, outcomes: Vec, duration_days: u32) -> Symbol { let client = PredictifyHybridClient::new(&self.env, &self.contract_id); let oracle_config = OracleConfig::new( @@ -77,19 +79,19 @@ impl TestSetup { fn test_extend_deadline_success() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", outcomes, 30); - + // Get initial market state let market_before = client.get_market(&market_id).unwrap(); let initial_end_time = market_before.end_time; - + // Extend deadline by 7 days let result = client.try_extend_deadline( &setup.admin, @@ -97,9 +99,9 @@ fn test_extend_deadline_success() { &7u32, &String::from_str(&setup.env, "Low participation"), ); - + assert!(result.is_ok()); - + // Verify market was updated let market_after = client.get_market(&market_id).unwrap(); assert_eq!(market_after.end_time, initial_end_time + (7 * 24 * 60 * 60)); @@ -111,15 +113,15 @@ fn test_extend_deadline_success() { fn test_extend_deadline_exceeds_maximum() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", outcomes, 30); - + // Try to extend by more than max_extension_days (default 30) let result = client.try_extend_deadline( &setup.admin, @@ -127,7 +129,7 @@ fn test_extend_deadline_exceeds_maximum() { &31u32, &String::from_str(&setup.env, "Too long"), ); - + assert_eq!(result, Err(Ok(Error::InvalidDuration))); } @@ -135,27 +137,27 @@ fn test_extend_deadline_exceeds_maximum() { fn test_extend_deadline_resolved_market() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", outcomes, 30); - + // Move time forward past end time setup.env.ledger().with_mut(|li| { li.timestamp = li.timestamp + (31 * 24 * 60 * 60); }); - + // Resolve the market let _ = client.try_resolve_market_manual( &setup.admin, &market_id, &String::from_str(&setup.env, "Yes"), ); - + // Try to extend resolved market let result = client.try_extend_deadline( &setup.admin, @@ -163,7 +165,7 @@ fn test_extend_deadline_resolved_market() { &7u32, &String::from_str(&setup.env, "Extension after resolution"), ); - + assert_eq!(result, Err(Ok(Error::MarketAlreadyResolved))); } @@ -172,15 +174,15 @@ fn test_extend_deadline_unauthorized() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let unauthorized_user = setup.create_user(); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", outcomes, 30); - + // Try to extend as unauthorized user let result = client.try_extend_deadline( &unauthorized_user, @@ -188,7 +190,7 @@ fn test_extend_deadline_unauthorized() { &7u32, &String::from_str(&setup.env, "Unauthorized extension"), ); - + assert_eq!(result, Err(Ok(Error::Unauthorized))); } @@ -198,25 +200,21 @@ fn test_extend_deadline_unauthorized() { fn test_update_event_description_success() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Original question?", outcomes, 30); - + // Update description let new_description = String::from_str(&setup.env, "Updated question with more details?"); - let result = client.try_update_event_description( - &setup.admin, - &market_id, - &new_description, - ); - + let result = client.try_update_event_description(&setup.admin, &market_id, &new_description); + assert!(result.is_ok()); - + // Verify market was updated let market = client.get_market(&market_id).unwrap(); assert_eq!(market.question, new_description); @@ -226,22 +224,22 @@ fn test_update_event_description_success() { fn test_update_event_description_empty() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Original question?", outcomes, 30); - + // Try to update with empty description let result = client.try_update_event_description( &setup.admin, &market_id, &String::from_str(&setup.env, ""), ); - + assert_eq!(result, Err(Ok(Error::InvalidQuestion))); } @@ -250,15 +248,15 @@ fn test_update_event_description_after_votes() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let user = setup.create_user(); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Original question?", outcomes, 30); - + // Place a vote client.vote( &user, @@ -266,14 +264,14 @@ fn test_update_event_description_after_votes() { &String::from_str(&setup.env, "Yes"), &1000000i128, ); - + // Try to update description after vote let result = client.try_update_event_description( &setup.admin, &market_id, &String::from_str(&setup.env, "Updated question?"), ); - + assert_eq!(result, Err(Ok(Error::AlreadyVoted))); } @@ -284,15 +282,15 @@ fn test_update_event_description_after_activity() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let user = setup.create_user(); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Original question?", outcomes, 30); - + // Place a vote (testing that any activity prevents updates) client.vote( &user, @@ -300,14 +298,14 @@ fn test_update_event_description_after_activity() { &String::from_str(&setup.env, "Yes"), &1000000i128, ); - + // Try to update description after activity let result = client.try_update_event_description( &setup.admin, &market_id, &String::from_str(&setup.env, "Updated question?"), ); - + // Should fail because votes have been placed assert_eq!(result, Err(Ok(Error::AlreadyVoted))); } @@ -317,22 +315,22 @@ fn test_update_event_description_unauthorized() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let unauthorized_user = setup.create_user(); - + let outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Original question?", outcomes, 30); - + // Try to update as unauthorized user let result = client.try_update_event_description( &unauthorized_user, &market_id, &String::from_str(&setup.env, "Unauthorized update?"), ); - + assert_eq!(result, Err(Ok(Error::Unauthorized))); } @@ -342,15 +340,15 @@ fn test_update_event_description_unauthorized() { fn test_update_event_outcomes_success() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Update outcomes let new_outcomes = vec![ &setup.env, @@ -358,48 +356,46 @@ fn test_update_event_outcomes_success() { String::from_str(&setup.env, "No"), String::from_str(&setup.env, "Maybe"), ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + assert!(result.is_ok()); - + // Verify market was updated let market = client.get_market(&market_id).unwrap(); assert_eq!(market.outcomes.len(), 3); - assert_eq!(market.outcomes.get(0).unwrap(), String::from_str(&setup.env, "Yes")); - assert_eq!(market.outcomes.get(1).unwrap(), String::from_str(&setup.env, "No")); - assert_eq!(market.outcomes.get(2).unwrap(), String::from_str(&setup.env, "Maybe")); + assert_eq!( + market.outcomes.get(0).unwrap(), + String::from_str(&setup.env, "Yes") + ); + assert_eq!( + market.outcomes.get(1).unwrap(), + String::from_str(&setup.env, "No") + ); + assert_eq!( + market.outcomes.get(2).unwrap(), + String::from_str(&setup.env, "Maybe") + ); } #[test] fn test_update_event_outcomes_too_few() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Try to update with only one outcome - let new_outcomes = vec![ - &setup.env, - String::from_str(&setup.env, "Yes"), - ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + let new_outcomes = vec![&setup.env, String::from_str(&setup.env, "Yes")]; + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + assert_eq!(result, Err(Ok(Error::InvalidOutcomes))); } @@ -407,28 +403,24 @@ fn test_update_event_outcomes_too_few() { fn test_update_event_outcomes_empty_string() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Try to update with empty outcome string let new_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, ""), ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + assert_eq!(result, Err(Ok(Error::InvalidOutcome))); } @@ -437,15 +429,15 @@ fn test_update_event_outcomes_after_votes() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let user = setup.create_user(); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Place a vote client.vote( &user, @@ -453,7 +445,7 @@ fn test_update_event_outcomes_after_votes() { &String::from_str(&setup.env, "Yes"), &1000000i128, ); - + // Try to update outcomes after vote let new_outcomes = vec![ &setup.env, @@ -461,13 +453,9 @@ fn test_update_event_outcomes_after_votes() { String::from_str(&setup.env, "No"), String::from_str(&setup.env, "Maybe"), ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + assert_eq!(result, Err(Ok(Error::AlreadyVoted))); } @@ -478,15 +466,15 @@ fn test_update_event_outcomes_after_activity() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let user = setup.create_user(); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Place a vote (testing that any activity prevents updates) client.vote( &user, @@ -494,7 +482,7 @@ fn test_update_event_outcomes_after_activity() { &String::from_str(&setup.env, "Yes"), &1000000i128, ); - + // Try to update outcomes after activity let new_outcomes = vec![ &setup.env, @@ -502,13 +490,9 @@ fn test_update_event_outcomes_after_activity() { String::from_str(&setup.env, "No"), String::from_str(&setup.env, "Maybe"), ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + // Should fail because votes have been placed assert_eq!(result, Err(Ok(Error::AlreadyVoted))); } @@ -518,15 +502,15 @@ fn test_update_event_outcomes_unauthorized() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); let unauthorized_user = setup.create_user(); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Try to update as unauthorized user let new_outcomes = vec![ &setup.env, @@ -534,13 +518,9 @@ fn test_update_event_outcomes_unauthorized() { String::from_str(&setup.env, "No"), String::from_str(&setup.env, "Maybe"), ]; - - let result = client.try_update_event_outcomes( - &unauthorized_user, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&unauthorized_user, &market_id, &new_outcomes); + assert_eq!(result, Err(Ok(Error::Unauthorized))); } @@ -548,27 +528,27 @@ fn test_update_event_outcomes_unauthorized() { fn test_update_event_outcomes_resolved_market() { let setup = TestSetup::new(); let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let initial_outcomes = vec![ &setup.env, String::from_str(&setup.env, "Yes"), String::from_str(&setup.env, "No"), ]; - + let market_id = setup.create_market("Test question?", initial_outcomes, 30); - + // Move time forward past end time setup.env.ledger().with_mut(|li| { li.timestamp = li.timestamp + (31 * 24 * 60 * 60); }); - + // Resolve the market let _ = client.try_resolve_market_manual( &setup.admin, &market_id, &String::from_str(&setup.env, "Yes"), ); - + // Try to update outcomes on resolved market let new_outcomes = vec![ &setup.env, @@ -576,12 +556,8 @@ fn test_update_event_outcomes_resolved_market() { String::from_str(&setup.env, "No"), String::from_str(&setup.env, "Maybe"), ]; - - let result = client.try_update_event_outcomes( - &setup.admin, - &market_id, - &new_outcomes, - ); - + + let result = client.try_update_event_outcomes(&setup.admin, &market_id, &new_outcomes); + assert_eq!(result, Err(Ok(Error::MarketAlreadyResolved))); } diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 7dfdf0c7..50483087 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -1928,11 +1928,7 @@ impl EventEmitter { } /// Emit refund on oracle failure event (market cancelled, all bets refunded in full). - pub fn emit_refund_on_oracle_failure( - env: &Env, - market_id: &Symbol, - total_refunded: i128, - ) { + pub fn emit_refund_on_oracle_failure(env: &Env, market_id: &Symbol, total_refunded: i128) { let event = RefundOnOracleFailureEvent { market_id: market_id.clone(), total_refunded, @@ -2425,11 +2421,7 @@ impl EventEmitter { /// /// EventEmitter::emit_error_event(&env, Error::NothingToClaim, &context); /// ``` - pub fn emit_diagnostic_event( - env: &Env, - error: Error, - context: &crate::errors::ErrorContext, - ) { + pub fn emit_diagnostic_event(env: &Env, error: Error, context: &crate::errors::ErrorContext) { let error_code = error as u32; // Convert error enum to message string @@ -2567,11 +2559,15 @@ impl EventEmitter { ) { env.events().publish( (symbol_short!("bal_chg"), user, asset.clone()), - (operation.clone(), amount, new_balance, env.ledger().timestamp()), + ( + operation.clone(), + amount, + new_balance, + env.ledger().timestamp(), + ), ); } - /// Store event in persistent storage fn store_event(env: &Env, event_key: &Symbol, event_data: &T) where diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index f69bd2b1..76c15586 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -1153,7 +1153,7 @@ impl FeeValidator { /// Validate market for fee collection pub fn validate_market_for_fee_collection(market: &Market) -> Result<(), Error> { // Check if market is resolved - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return Err(Error::MarketNotResolved); } @@ -1270,14 +1270,14 @@ impl FeeUtils { /// Check if fees can be collected for a market pub fn can_collect_fees(market: &Market) -> bool { - market.winning_outcome.is_some() + market.winning_outcomes.is_some() && !market.fee_collected && market.total_staked >= FEE_COLLECTION_THRESHOLD } /// Get fee collection eligibility for a market pub fn get_fee_eligibility(market: &Market) -> (bool, String) { - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return ( false, String::from_str(&Env::default(), "Market not resolved"), @@ -1728,7 +1728,9 @@ mod tests { assert!(!FeeUtils::can_collect_fees(&market)); // Set winning outcome - market.winning_outcome = Some(String::from_str(&env, "yes")); + let mut winning_outcomes = Vec::new(&env); + winning_outcomes.push_back(String::from_str(&env, "yes")); + market.winning_outcomes = Some(winning_outcomes); // Insufficient stakes market.total_staked = FEE_COLLECTION_THRESHOLD - 1; diff --git a/contracts/predictify-hybrid/src/integration_test.rs b/contracts/predictify-hybrid/src/integration_test.rs index 68e6a807..41ab6c21 100644 --- a/contracts/predictify-hybrid/src/integration_test.rs +++ b/contracts/predictify-hybrid/src/integration_test.rs @@ -200,7 +200,7 @@ fn test_complete_market_lifecycle() { // Step 7: Verify market is resolved let market = test_suite.get_market(&market_id); assert_eq!(market.state, MarketState::Resolved); - assert!(market.winning_outcome.is_some()); + assert!(market.winning_outcomes.is_some()); } #[test] diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 1f58616a..385ce0b1 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -248,12 +248,21 @@ impl PredictifyHybrid { /// with custom questions, possible outcomes, duration, and oracle integration. /// Each market gets a unique identifier and is stored in persistent contract storage. /// + /// # Multi-Outcome Support + /// + /// Markets support 2 to N outcomes, enabling both binary (yes/no) and multi-outcome + /// markets (e.g., Team A / Team B / Draw). The contract handles: + /// - Single winner resolution (one outcome wins) + /// - Tie/multi-winner resolution (multiple outcomes win, pool split proportionally) + /// - Outcome validation during bet placement + /// - Proportional payout distribution for ties + /// /// # Parameters /// /// * `env` - The Soroban environment for blockchain operations /// * `admin` - The administrator address creating the market (must be authorized) /// * `question` - The prediction question (must be non-empty) - /// * `outcomes` - Vector of possible outcomes (minimum 2 required, all non-empty) + /// * `outcomes` - Vector of possible outcomes (minimum 2 required, all non-empty, no duplicates) /// * `duration_days` - Market duration in days (must be between 1-365 days) /// * `oracle_config` - Configuration for oracle integration (Reflector, Pyth, etc.) /// @@ -299,6 +308,39 @@ impl PredictifyHybrid { /// ); /// ``` /// + /// # Multi-Outcome Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, String, Vec}; + /// # use predictify_hybrid::{PredictifyHybrid, OracleConfig, OracleProvider}; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Create a 3-outcome market (e.g., match result) + /// let question = String::from_str(&env, "Match result?"); + /// let outcomes = vec![ + /// &env, + /// String::from_str(&env, "Team A"), + /// String::from_str(&env, "Team B"), + /// String::from_str(&env, "Draw"), + /// ]; + /// let oracle_config = OracleConfig::new( + /// OracleProvider::Reflector, + /// String::from_str(&env, "BTC/USD"), + /// 50_000_00, + /// String::from_str(&env, "gt"), + /// ); + /// + /// let market_id = PredictifyHybrid::create_market( + /// env.clone(), + /// admin, + /// question, + /// outcomes, + /// 30, + /// oracle_config + /// ); + /// ``` + /// /// # Market State /// /// New markets are created in `MarketState::Active` state, allowing immediate voting. @@ -357,7 +399,7 @@ impl PredictifyHybrid { dispute_stakes: Map::new(&env), stakes: Map::new(&env), claimed: Map::new(&env), - winning_outcome: None, + winning_outcomes: None, fee_collected: false, state: MarketState::Active, total_extension_days: 0, @@ -659,6 +701,58 @@ impl PredictifyHybrid { /// - Balance validation before fund transfer /// - Atomic fund locking with bet creation /// - Reentrancy protection via reentrancy guard (guard flag in storage) + /// Places a bet on a specific outcome in a prediction market. + /// + /// This function allows users to place bets on markets with 2 or more outcomes. + /// The outcome must be one of the valid outcomes defined when the market was created. + /// Users can only place one bet per market. + /// + /// # Multi-Outcome Support + /// + /// - Validates that the selected outcome exists in the market's outcome list + /// - Works with binary (2 outcomes) and multi-outcome (N outcomes) markets + /// - Rejects invalid outcomes that don't match any market outcome + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `user` - The address of the user placing the bet (must be authenticated) + /// * `market_id` - Unique identifier of the market to bet on + /// * `outcome` - The outcome to bet on (must match one of the market's outcomes) + /// * `amount` - Amount of tokens to bet (must meet minimum/maximum bet limits) + /// + /// # Returns + /// + /// Returns the created `Bet` struct containing bet details. + /// + /// # Panics + /// + /// This function will panic with specific errors if: + /// - `Error::MarketNotFound` - Market with given ID doesn't exist + /// - `Error::MarketClosed` - Market is not active or has ended + /// - `Error::InvalidOutcome` - Outcome doesn't match any market outcomes + /// - `Error::AlreadyBet` - User has already placed a bet on this market + /// - `Error::InsufficientStake` - Bet amount is below minimum + /// - `Error::InvalidInput` - Bet amount exceeds maximum + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, Symbol, String}; + /// # use predictify_hybrid::PredictifyHybrid; + /// # let env = Env::default(); + /// # let user = Address::generate(&env); + /// # let market_id = Symbol::new(&env, "market_1"); + /// + /// // Place bet on "Team A" outcome + /// let bet = PredictifyHybrid::place_bet( + /// env.clone(), + /// user, + /// market_id, + /// String::from_str(&env, "Team A"), + /// 10_0000000, // 10 XLM + /// ); + /// ``` pub fn place_bet( env: Env, user: Address, @@ -854,6 +948,102 @@ impl PredictifyHybrid { bets::BetManager::get_market_bet_stats(&env, &market_id) } + /// Calculate the payout amount for a user's bet on a resolved market. + /// + /// This function calculates how much a user will receive if they won their bet. + /// For multi-outcome markets with ties, the payout is calculated based on + /// the proportional share of the total pool split among all winners. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `market_id` - Unique identifier of the market + /// * `user` - Address of the user to calculate payout for + /// + /// # Returns + /// + /// Returns `Ok(i128)` with the payout amount in base token units, or `Err(Error)` if calculation fails. + /// Returns `Ok(0)` if the user didn't win or has no bet. + /// + /// # Errors + /// + /// - `Error::MarketNotFound` - Market doesn't exist + /// - `Error::MarketNotResolved` - Market hasn't been resolved yet + /// - `Error::NothingToClaim` - User has no bet on this market + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, Symbol}; + /// # use predictify_hybrid::PredictifyHybrid; + /// # let env = Env::default(); + /// # let market_id = Symbol::new(&env, "resolved_market"); + /// # let user = Address::generate(&env); + /// + /// match PredictifyHybrid::calculate_bet_payout(env.clone(), market_id, user) { + /// Ok(payout) => println!("User will receive {} stroops", payout), + /// Err(e) => println!("Calculation failed: {:?}", e), + /// } + /// ``` + /// + /// # Payout Calculation for Ties + /// + /// When multiple outcomes win (tie): + /// - Total pool is split proportionally among all winners + /// - Each winner's payout = (their_stake / total_winning_stakes) * total_pool * (1 - fee) + /// - This ensures fair distribution even when outcomes are tied + /// Calculates the payout amount for a user's bet on a resolved market. + /// + /// This function computes the payout based on: + /// - Whether the user's bet outcome is a winning outcome + /// - The user's stake relative to total winning stakes + /// - The total pool size + /// - Platform fees + /// + /// # Multi-Outcome Support + /// + /// For markets with multiple winning outcomes (ties): + /// - Payouts are calculated proportionally across all winning outcomes + /// - Total winning stakes = sum of all stakes on all winning outcomes + /// - User's share = (user_stake / total_winning_stakes) * total_pool * (1 - fee) + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `market_id` - Unique identifier of the market + /// * `user` - Address of the user whose payout to calculate + /// + /// # Returns + /// + /// Returns `Ok(i128)` with the payout amount in base token units if: + /// - Market is resolved + /// - User placed a bet + /// - User's outcome is a winning outcome + /// + /// Returns `Err(Error)` if: + /// - Market is not resolved + /// - User has no bet + /// - User's outcome did not win + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, Symbol}; + /// # use predictify_hybrid::PredictifyHybrid; + /// # let env = Env::default(); + /// # let user = Address::generate(&env); + /// # let market_id = Symbol::new(&env, "market_1"); + /// + /// // Calculate payout for user's winning bet + /// match PredictifyHybrid::calculate_bet_payout(env.clone(), market_id, user) { + /// Ok(payout) => println!("Payout: {}", payout), + /// Err(e) => println!("Error: {:?}", e), + /// } + /// ``` + pub fn calculate_bet_payout(env: Env, market_id: Symbol, user: Address) -> Result { + bets::BetManager::calculate_bet_payout(&env, &market_id, &user) + } + /// Calculates the implied probability for an outcome based on bet distribution. /// /// The implied probability indicates the market's collective prediction for @@ -998,8 +1188,8 @@ impl PredictifyHybrid { } // Check if market is resolved - let winning_outcome = match &market.winning_outcome { - Some(outcome) => outcome, + let winning_outcomes = match &market.winning_outcomes { + Some(outcomes) => outcomes, None => panic_with_error!(env, Error::MarketNotResolved), }; @@ -1011,12 +1201,12 @@ impl PredictifyHybrid { let user_stake = market.stakes.get(user.clone()).unwrap_or(0); - // Calculate payout if user won - if &user_outcome == winning_outcome { - // Calculate total winning stakes + // Calculate payout if user won (check if outcome is in winning outcomes) + if winning_outcomes.contains(&user_outcome) { + // Calculate total winning stakes across all winning outcomes let mut winning_total = 0; for (voter, outcome) in market.votes.iter() { - if &outcome == winning_outcome { + if winning_outcomes.contains(&outcome) { winning_total += market.stakes.get(voter.clone()).unwrap_or(0); } } @@ -1250,14 +1440,15 @@ impl PredictifyHybrid { // Capture old state for event let old_state = market.state.clone(); - // Set winning outcome and update state - market.winning_outcome = Some(winning_outcome.clone()); + // Set winning outcome(s) as a vector (single outcome for now, supports future multi-winner) + let mut winning_outcomes_vec = Vec::new(&env); + winning_outcomes_vec.push_back(winning_outcome.clone()); + market.winning_outcomes = Some(winning_outcomes_vec.clone()); market.state = MarketState::Resolved; env.storage().persistent().set(&market_id, &market); - // Note: Bet resolution is skipped to avoid segfaults. - // Since place_bet syncs votes/stakes, winners must call claim_winnings(user, market_id) - // to receive their share; no automatic distribution is performed here. + // Resolve bets to mark them as won/lost + let _ = bets::BetManager::resolve_market_bets(&env, &market_id, &winning_outcomes_vec); // Emit market resolved event (simplified to avoid segfaults) let oracle_result_str = market @@ -1289,6 +1480,149 @@ impl PredictifyHybrid { ); } + /// Resolves a market with multiple winning outcomes (for tie cases). + /// + /// This function allows authorized administrators to resolve a market with + /// multiple winners when there's a tie. The pool will be split proportionally + /// among all winning outcomes based on stake distribution. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The administrator address performing the resolution (must be authorized) + /// * `market_id` - Unique identifier of the market to resolve + /// * `winning_outcomes` - Vector of outcomes to be declared as winners (minimum 1, all must be valid) + /// + /// # Panics + /// + /// This function will panic with specific errors if: + /// - `Error::Unauthorized` - Caller is not the contract admin + /// - `Error::MarketNotFound` - Market with given ID doesn't exist + /// - `Error::MarketClosed` - Market hasn't ended yet + /// - `Error::InvalidOutcome` - One or more outcomes are not valid for this market + /// - `Error::InvalidInput` - Empty outcomes vector + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, Symbol, String, Vec}; + /// # use predictify_hybrid::PredictifyHybrid; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// # let market_id = Symbol::new(&env, "sports_match"); + /// + /// // Resolve with tie (Team A and Team B both win) + /// let winning_outcomes = vec![ + /// &env, + /// String::from_str(&env, "Team A"), + /// String::from_str(&env, "Team B"), + /// ]; + /// + /// PredictifyHybrid::resolve_market_with_ties( + /// env.clone(), + /// admin, + /// market_id, + /// winning_outcomes + /// ); + /// ``` + /// + /// # Pool Split Logic + /// + /// When multiple outcomes win: + /// - Total pool is split proportionally among all winners + /// - Each winner receives: (their_stake / total_winning_stakes) * total_pool * (1 - fee) + /// - This ensures fair distribution even when outcomes are tied + pub fn resolve_market_with_ties( + env: Env, + admin: Address, + market_id: Symbol, + winning_outcomes: Vec, + ) { + admin.require_auth(); + + // Verify admin + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(&env, "Admin")) + .unwrap_or_else(|| { + panic_with_error!(env, Error::Unauthorized); + }); + + if admin != stored_admin { + panic_with_error!(env, Error::Unauthorized); + } + + // Validate outcomes vector is not empty + if winning_outcomes.len() == 0 { + panic_with_error!(env, Error::InvalidInput); + } + + let mut market: Market = env + .storage() + .persistent() + .get(&market_id) + .unwrap_or_else(|| { + panic_with_error!(env, Error::MarketNotFound); + }); + + // Check if market has ended + if env.ledger().timestamp() < market.end_time { + panic_with_error!(env, Error::MarketClosed); + } + + // Validate all winning outcomes exist in market outcomes + for outcome in winning_outcomes.iter() { + let outcome_exists = market.outcomes.iter().any(|o| o == outcome); + if !outcome_exists { + panic_with_error!(env, Error::InvalidOutcome); + } + } + + // Capture old state for event + let old_state = market.state.clone(); + + // Set winning outcome(s) - supports multiple winners for ties + market.winning_outcomes = Some(winning_outcomes.clone()); + market.state = MarketState::Resolved; + 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); + + // Emit market resolved event + let primary_outcome = winning_outcomes.get(0).unwrap().clone(); + let oracle_result_str = market + .oracle_result + .clone() + .unwrap_or_else(|| String::from_str(&env, "N/A")); + let community_consensus_str = String::from_str(&env, "Manual"); + let resolution_method = String::from_str(&env, "Manual"); + + EventEmitter::emit_market_resolved( + &env, + &market_id, + &primary_outcome, + &oracle_result_str, + &community_consensus_str, + &resolution_method, + 100, // confidence score for manual resolution + ); + + // Emit state change event + let reason = String::from_str(&env, "Manual resolution with ties by admin"); + EventEmitter::emit_state_change_event( + &env, + &market_id, + &old_state, + &MarketState::Resolved, + &reason, + ); + + // Automatically distribute payouts (handles split pool for ties) + let _ = Self::distribute_payouts(env.clone(), market_id); + } + /// Fetches oracle result for a market from external oracle contracts. /// /// This function retrieves prediction results from configured oracle sources @@ -1781,8 +2115,8 @@ impl PredictifyHybrid { }); // Check if market is resolved - let winning_outcome = match &market.winning_outcome { - Some(outcome) => outcome, + let winning_outcomes = match &market.winning_outcomes { + Some(outcomes) => outcomes, None => return Err(Error::MarketNotResolved), }; @@ -1805,7 +2139,7 @@ impl PredictifyHybrid { // Check voters for (user, outcome) in market.votes.iter() { - if &outcome == winning_outcome { + if winning_outcomes.contains(&outcome) { if !market.claimed.get(user.clone()).unwrap_or(false) { has_unclaimed_winners = true; break; @@ -1817,7 +2151,7 @@ impl PredictifyHybrid { if !has_unclaimed_winners { for user in bettors.iter() { if let Some(bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { - if bet.outcome == *winning_outcome + if winning_outcomes.contains(&bet.outcome) && !market.claimed.get(user.clone()).unwrap_or(false) { has_unclaimed_winners = true; @@ -1831,20 +2165,21 @@ impl PredictifyHybrid { return Ok(0); } - // Calculate total winning stakes (voters + bettors) + // Calculate total winning stakes across all winning outcomes (for split pool calculation) + // Supports both single winner and multi-winner (tie) scenarios let mut winning_total = 0; // Sum voter stakes for (voter, outcome) in market.votes.iter() { - if outcome == *winning_outcome { + if winning_outcomes.contains(&outcome) { winning_total += market.stakes.get(voter.clone()).unwrap_or(0); } } - // Sum bet amounts + // Sum bet amounts (check if bet outcome is in winning outcomes for multi-outcome support) for user in bettors.iter() { if let Some(bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { - if bet.outcome == *winning_outcome { + if winning_outcomes.contains(&bet.outcome) { winning_total += bet.amount; } } @@ -1860,9 +2195,10 @@ impl PredictifyHybrid { let mut total_distributed: i128 = 0; // 1. Distribute to Voters - // Distribute payouts to all winners + // Distribute payouts to all winners (handles both single and multi-winner cases) + // For multi-winner (ties), pool is split proportionally among all winners for (user, outcome) in market.votes.iter() { - if outcome == *winning_outcome { + if winning_outcomes.contains(&outcome) { if market.claimed.get(user.clone()).unwrap_or(false) { continue; } @@ -1874,6 +2210,8 @@ impl PredictifyHybrid { .checked_mul(fee_denominator - fee_percent) .ok_or(Error::InvalidInput)?) / fee_denominator; + // Payout calculation: (user_stake / total_winning_stakes) * total_pool + // This automatically handles split pools for ties - each winner gets proportional share let payout = (user_share .checked_mul(total_pool) .ok_or(Error::InvalidInput)?) @@ -1894,9 +2232,10 @@ impl PredictifyHybrid { } // 2. Distribute to Bettors + // Check if bet outcome is in winning outcomes (supports multi-outcome/tie scenarios) for user in bettors.iter() { if let Some(mut bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { - if bet.outcome == *winning_outcome { + if winning_outcomes.contains(&bet.outcome) { if market.claimed.get(user.clone()).unwrap_or(false) { // Already claimed (perhaps as a voter or double check) bet.status = BetStatus::Won; @@ -3043,7 +3382,7 @@ impl PredictifyHybrid { if market.state == MarketState::Cancelled { return Ok(0); } - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } if market.oracle_result.is_some() { diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 429f1fa6..39d212a6 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -1134,10 +1134,20 @@ impl MarketStateManager { /// /// # Side Effects /// - /// * Sets `market.winning_outcome` to the specified outcome + /// * Sets `market.winning_outcomes` to the specified outcome(s) /// * Transitions market state to `Resolved` /// * Emits state change event /// + /// # Backward Compatibility + /// + /// For single winner, pass a vector with one outcome. + /// This function replaces the old `set_winning_outcome` for multi-outcome support. + pub fn set_winning_outcome(market: &mut Market, outcome: String, market_id: Option<&Symbol>) { + // Convert single outcome to vector for backward compatibility + let outcomes = vec![market.votes.env(), outcome]; + Self::set_winning_outcomes(market, outcomes, market_id); + } + /// /// # Example /// /// ```rust @@ -1161,14 +1171,23 @@ impl MarketStateManager { /// /// // Market should now be resolved /// assert_eq!(market.state, MarketState::Resolved); - /// assert!(market.winning_outcome.is_some()); + /// assert!(market.winning_outcomes.is_some()); /// /// MarketStateManager::update_market(&env, &market_id, &market); /// ``` - pub fn set_winning_outcome(market: &mut Market, outcome: String, market_id: Option<&Symbol>) { + /// Set winning outcome(s) for a market after resolution. + /// + /// Supports both single winner and multi-winner (tie) cases. + /// For single winner: pass a vector with one outcome. + /// For ties: pass a vector with multiple outcomes (pool will be split). + pub fn set_winning_outcomes( + market: &mut Market, + outcomes: Vec, + market_id: Option<&Symbol>, + ) { MarketStateLogic::check_function_access_for_state("resolve", market.state).unwrap(); let old_state = market.state; - market.winning_outcome = Some(outcome); + market.winning_outcomes = Some(outcomes); // State transition: Ended/Disputed -> Resolved if market.state == MarketState::Ended || market.state == MarketState::Disputed { MarketStateLogic::validate_state_transition(market.state, MarketState::Resolved) @@ -1979,6 +1998,108 @@ impl MarketUtils { } } } + + /// Determine winning outcome(s) for a multi-outcome market, handling ties. + /// + /// This function analyzes vote distribution and stake distribution to determine + /// the winning outcome(s). In case of ties (multiple outcomes with same vote count + /// or stake), all tied outcomes are returned as winners (pool will be split). + /// + /// # Parameters + /// + /// * `env` - The Soroban environment + /// * `market` - The market to analyze + /// * `oracle_result` - Oracle-determined outcome (if available) + /// * `community_consensus` - Community consensus data + /// * `tie_threshold` - Minimum vote/stake difference to break ties (default: 0 = exact tie) + /// + /// # Returns + /// + /// Vector of winning outcomes. For single winner: one outcome. + /// For ties: multiple outcomes (pool split among all winners). + /// + /// # Tie Detection Logic + /// + /// Ties are detected when: + /// - Multiple outcomes have the same vote count (vote-based tie) + /// - Multiple outcomes have the same total stake (stake-based tie) + /// - Oracle and community disagree and both have equal strength + /// + /// When a tie is detected, all tied outcomes are considered winners and + /// the pool is split proportionally among all winners. + pub fn determine_winning_outcomes( + env: &Env, + market: &Market, + oracle_result: &String, + community_consensus: &CommunityConsensus, + tie_threshold: u32, + ) -> Vec { + // First, get the primary result using existing logic + let primary_result = Self::determine_final_result(env, oracle_result, community_consensus); + + // Check for ties by analyzing vote distribution + let mut outcome_votes: Map = Map::new(env); + let mut outcome_stakes: Map = Map::new(env); + + // Count votes and stakes per outcome + for (user, outcome) in market.votes.iter() { + let vote_count = outcome_votes.get(outcome.clone()).unwrap_or(0); + outcome_votes.set(outcome.clone(), vote_count + 1); + + let stake = market.stakes.get(user.clone()).unwrap_or(0); + let current_stake = outcome_stakes.get(outcome.clone()).unwrap_or(0); + outcome_stakes.set(outcome.clone(), current_stake + stake); + } + + // Find outcomes with maximum votes + let mut max_votes = 0; + for (_, count) in outcome_votes.iter() { + if count > max_votes { + max_votes = count; + } + } + + // Find all outcomes with max_votes (within tie threshold) + let mut tied_outcomes = Vec::new(env); + for (outcome, count) in outcome_votes.iter() { + // Check if this outcome is within tie threshold of max + if count >= max_votes.saturating_sub(tie_threshold) { + tied_outcomes.push_back(outcome.clone()); + } + } + + // If primary result is in tied outcomes, use tied outcomes + // Otherwise, check if we should use stake-based tie detection + if tied_outcomes.contains(&primary_result) { + // Check for stake-based ties among the vote-tied outcomes + let mut max_stake = 0; + for outcome in tied_outcomes.iter() { + let stake = outcome_stakes.get(outcome.clone()).unwrap_or(0); + if stake > max_stake { + max_stake = stake; + } + } + + // Filter to outcomes with max stake (or within threshold) + let mut final_winners = Vec::new(env); + for outcome in tied_outcomes.iter() { + let stake = outcome_stakes.get(outcome.clone()).unwrap_or(0); + if stake >= max_stake.saturating_sub(tie_threshold as i128) { + final_winners.push_back(outcome.clone()); + } + } + + if final_winners.len() > 0 { + final_winners + } else { + // Fallback to primary result if no stake-based winners + vec![env, primary_result] + } + } else { + // Primary result is not in tied outcomes, use it as single winner + vec![env, primary_result] + } + } } // ===== MARKET STATISTICS TYPES ===== @@ -2105,7 +2226,7 @@ pub struct WinningStats { /// println!("Stake: {} stroops", user_stats.stake); /// } /// -/// if !user_stats.has_claimed && market.winning_outcome.is_some() { +/// if !user_stats.has_claimed && market.winning_outcomes.is_some() { /// println!("User may be eligible to claim winnings"); /// } /// ``` @@ -2469,8 +2590,9 @@ impl MarketTestHelpers { let final_result = MarketUtils::determine_final_result(env, &oracle_result, &community_consensus); - // Set winning outcome - MarketStateManager::set_winning_outcome(&mut market, final_result.clone(), None); + // Set winning outcome(s) - convert single outcome to vector + let winning_outcomes = vec![env, final_result.clone()]; + MarketStateManager::set_winning_outcomes(&mut market, winning_outcomes, None); MarketStateManager::update_market(env, market_id, &market); Ok(final_result) @@ -2723,7 +2845,7 @@ impl MarketStateLogic { if market.end_time <= now { return Err(Error::InvalidState); } - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::InvalidState); } } @@ -2731,7 +2853,7 @@ impl MarketStateLogic { if market.end_time > now { return Err(Error::InvalidState); } - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::InvalidState); } } @@ -2741,7 +2863,7 @@ impl MarketStateLogic { } } Resolved => { - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return Err(Error::InvalidState); } } diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index 3eca44f1..df0de5fa 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -451,7 +451,7 @@ impl ContractMonitor { claimed: Map::new(env), total_staked: 0, dispute_stakes: Map::new(env), - winning_outcome: None, + winning_outcomes: None, fee_collected: false, state: MarketState::Active, total_extension_days: 0, diff --git a/contracts/predictify-hybrid/src/property_based_tests.rs b/contracts/predictify-hybrid/src/property_based_tests.rs index 6dd5893f..c2633b39 100644 --- a/contracts/predictify-hybrid/src/property_based_tests.rs +++ b/contracts/predictify-hybrid/src/property_based_tests.rs @@ -49,7 +49,7 @@ impl PropertyBasedTestSuite { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let token_id = token_contract.address(); - + // Store TokenID env.as_contract(&contract_id, || { env.storage() @@ -63,7 +63,7 @@ impl PropertyBasedTestSuite { // Mint tokens to admin and users let stellar_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); stellar_client.mint(&admin, &1_000_000_000_000); // Mint ample funds - + for user in &users { stellar_client.mint(user, &1_000_000_000_000); } diff --git a/contracts/predictify-hybrid/src/resolution.rs b/contracts/predictify-hybrid/src/resolution.rs index f2ca2b93..8e22e0df 100644 --- a/contracts/predictify-hybrid/src/resolution.rs +++ b/contracts/predictify-hybrid/src/resolution.rs @@ -1249,9 +1249,22 @@ impl MarketResolutionManager { // Calculate community consensus let community_consensus = MarketAnalytics::calculate_community_consensus(&market); - // Determine final result using hybrid algorithm - let final_result = - MarketUtils::determine_final_result(env, &oracle_result, &community_consensus); + // Determine winning outcome(s) using multi-outcome resolution with tie detection + // This handles both single winner and tie cases (pool split) + let winning_outcomes = MarketUtils::determine_winning_outcomes( + env, + &market, + &oracle_result, + &community_consensus, + 0, // Tie threshold: 0 = exact ties only + ); + + // For resolution record, use first outcome (or comma-separated for display) + let final_result = if winning_outcomes.len() > 0 { + winning_outcomes.get(0).unwrap().clone() + } else { + oracle_result.clone() // Fallback + }; // Determine resolution method let resolution_method = MarketResolutionAnalytics::determine_resolution_method( @@ -1280,8 +1293,12 @@ impl MarketResolutionManager { // Capture old state for event let old_state = market.state.clone(); - // Set winning outcome - MarketStateManager::set_winning_outcome(&mut market, final_result.clone(), Some(market_id)); + // Set winning outcome(s) - supports both single winner and ties + MarketStateManager::set_winning_outcomes( + &mut market, + winning_outcomes.clone(), + Some(market_id), + ); MarketStateManager::update_market(env, market_id, &market); // Emit market resolved event @@ -1351,8 +1368,10 @@ impl MarketResolutionManager { confidence_score: 100, // Admin override has full confidence }; - // Set final outcome - MarketStateManager::set_winning_outcome(&mut market, outcome.clone(), Some(market_id)); + // Set final outcome(s) - convert single outcome to vector + let mut winning_outcomes = Vec::new(env); + winning_outcomes.push_back(outcome.clone()); + MarketStateManager::set_winning_outcomes(&mut market, winning_outcomes, Some(market_id)); MarketStateManager::update_market(env, market_id, &market); Ok(resolution) @@ -1432,7 +1451,7 @@ impl MarketResolutionValidator { /// Validate market for resolution pub fn validate_market_for_resolution(env: &Env, market: &Market) -> Result<(), Error> { // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } @@ -1597,7 +1616,7 @@ pub struct ResolutionUtils; impl ResolutionUtils { /// Get resolution state for a market pub fn get_resolution_state(_env: &Env, market: &Market) -> ResolutionState { - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { ResolutionState::MarketResolved } else if market.oracle_result.is_some() { ResolutionState::OracleResolved @@ -1612,7 +1631,7 @@ impl ResolutionUtils { pub fn can_resolve_market(env: &Env, market: &Market) -> bool { market.has_ended(env.ledger().timestamp()) && market.oracle_result.is_some() - && market.winning_outcome.is_none() + && market.winning_outcomes.is_none() } /// Get resolution eligibility @@ -1625,7 +1644,7 @@ impl ResolutionUtils { return (false, String::from_str(env, "Oracle result not available")); } - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return (false, String::from_str(env, "Market already resolved")); } @@ -1654,7 +1673,7 @@ impl ResolutionUtils { } // Validate market is not already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } diff --git a/contracts/predictify-hybrid/src/statistics.rs b/contracts/predictify-hybrid/src/statistics.rs index c6acedb9..920d70ed 100644 --- a/contracts/predictify-hybrid/src/statistics.rs +++ b/contracts/predictify-hybrid/src/statistics.rs @@ -58,10 +58,16 @@ impl StatisticsManager { /// Record a new market creation pub fn record_market_created(env: &Env) { let mut stats = Self::get_platform_stats(env); - stats.total_events_created = stats.total_events_created.checked_add(1).unwrap_or(stats.total_events_created); - stats.active_events_count = stats.active_events_count.checked_add(1).unwrap_or(stats.active_events_count); + stats.total_events_created = stats + .total_events_created + .checked_add(1) + .unwrap_or(stats.total_events_created); + stats.active_events_count = stats + .active_events_count + .checked_add(1) + .unwrap_or(stats.active_events_count); Self::set_platform_stats(env, &stats); - + Self::emit_update(env, &stats); } @@ -72,7 +78,7 @@ impl StatisticsManager { stats.active_events_count -= 1; } Self::set_platform_stats(env, &stats); - + Self::emit_update(env, &stats); } @@ -80,16 +86,28 @@ impl StatisticsManager { pub fn record_bet_placed(env: &Env, user: &Address, amount: i128) { // Update platform stats let mut p_stats = Self::get_platform_stats(env); - p_stats.total_bets_placed = p_stats.total_bets_placed.checked_add(1).unwrap_or(p_stats.total_bets_placed); - p_stats.total_volume = p_stats.total_volume.checked_add(amount).unwrap_or(p_stats.total_volume); + p_stats.total_bets_placed = p_stats + .total_bets_placed + .checked_add(1) + .unwrap_or(p_stats.total_bets_placed); + p_stats.total_volume = p_stats + .total_volume + .checked_add(amount) + .unwrap_or(p_stats.total_volume); Self::set_platform_stats(env, &p_stats); - + Self::emit_update(env, &p_stats); // Update user stats let mut u_stats = Self::get_user_stats(env, user); - u_stats.total_bets_placed = u_stats.total_bets_placed.checked_add(1).unwrap_or(u_stats.total_bets_placed); - u_stats.total_amount_wagered = u_stats.total_amount_wagered.checked_add(amount).unwrap_or(u_stats.total_amount_wagered); + u_stats.total_bets_placed = u_stats + .total_bets_placed + .checked_add(1) + .unwrap_or(u_stats.total_bets_placed); + u_stats.total_amount_wagered = u_stats + .total_amount_wagered + .checked_add(amount) + .unwrap_or(u_stats.total_amount_wagered); u_stats.last_activity_ts = env.ledger().timestamp(); // Win rate doesn't change on bet placement, only on resolution/claim Self::set_user_stats(env, user, &u_stats); @@ -97,37 +115,47 @@ impl StatisticsManager { /// Record winnings claimed pub fn record_winnings_claimed(env: &Env, user: &Address, amount: i128) { - // Note: fees are already deducted from 'amount' usually? - // Or do we track total fees collected separately? - // The implementation plan says "increments fees". + // Note: fees are already deducted from 'amount' usually? + // Or do we track total fees collected separately? + // The implementation plan says "increments fees". // But claim_winnings in lib.rs logic: // user_share = user_stake * (1 - fee) * total_pool / winning_total // The fee part stays in the contract or is sent to fee collector? - // lib.rs seems to deduct fee from user_share. + // lib.rs seems to deduct fee from user_share. // So the "fee collected" is the difference. // I need to update the hook to pass the fee amount. - + // Update user stats let mut u_stats = Self::get_user_stats(env, user); - u_stats.total_winnings = u_stats.total_winnings.checked_add(amount).unwrap_or(u_stats.total_winnings); - u_stats.total_bets_won = u_stats.total_bets_won.checked_add(1).unwrap_or(u_stats.total_bets_won); + u_stats.total_winnings = u_stats + .total_winnings + .checked_add(amount) + .unwrap_or(u_stats.total_winnings); + u_stats.total_bets_won = u_stats + .total_bets_won + .checked_add(1) + .unwrap_or(u_stats.total_bets_won); u_stats.last_activity_ts = env.ledger().timestamp(); - + // Recalculate win rate // Win rate = (bets_won / bets_placed) * 10000 if u_stats.total_bets_placed > 0 { - u_stats.win_rate = ((u_stats.total_bets_won as u128 * 10000) / u_stats.total_bets_placed as u128) as u32; + u_stats.win_rate = ((u_stats.total_bets_won as u128 * 10000) + / u_stats.total_bets_placed as u128) as u32; } - + Self::set_user_stats(env, user, &u_stats); } /// Record fees collected pub fn record_fees_collected(env: &Env, amount: i128) { let mut p_stats = Self::get_platform_stats(env); - p_stats.total_fees_collected = p_stats.total_fees_collected.checked_add(amount).unwrap_or(p_stats.total_fees_collected); + p_stats.total_fees_collected = p_stats + .total_fees_collected + .checked_add(amount) + .unwrap_or(p_stats.total_fees_collected); Self::set_platform_stats(env, &p_stats); - + // We might not want to emit full update on every fee collection if it's frequent, but for now consistent behavior is good. Self::emit_update(env, &p_stats); } diff --git a/contracts/predictify-hybrid/src/statistics_tests.rs b/contracts/predictify-hybrid/src/statistics_tests.rs index 962c9cdd..ba8a5359 100644 --- a/contracts/predictify-hybrid/src/statistics_tests.rs +++ b/contracts/predictify-hybrid/src/statistics_tests.rs @@ -13,7 +13,7 @@ fn setup_env() -> (Env, Address) { #[test] fn test_platform_stats_initialization() { let (env, contract_id) = setup_env(); - + env.as_contract(&contract_id, || { let stats = StatisticsManager::get_platform_stats(&env); assert_eq!(stats.total_events_created, 0); @@ -27,16 +27,16 @@ fn test_platform_stats_initialization() { #[test] fn test_record_market_created() { let (env, contract_id) = setup_env(); - + env.as_contract(&contract_id, || { StatisticsManager::record_market_created(&env); - + let stats = StatisticsManager::get_platform_stats(&env); assert_eq!(stats.total_events_created, 1); assert_eq!(stats.active_events_count, 1); - + StatisticsManager::record_market_created(&env); - + let stats2 = StatisticsManager::get_platform_stats(&env); assert_eq!(stats2.total_events_created, 2); assert_eq!(stats2.active_events_count, 2); @@ -46,16 +46,16 @@ fn test_record_market_created() { #[test] fn test_record_market_resolved() { let (env, contract_id) = setup_env(); - + env.as_contract(&contract_id, || { StatisticsManager::record_market_created(&env); StatisticsManager::record_market_created(&env); - + let before = StatisticsManager::get_platform_stats(&env); assert_eq!(before.active_events_count, 2); - + StatisticsManager::record_market_resolved(&env); - + let after = StatisticsManager::get_platform_stats(&env); assert_eq!(after.active_events_count, 1); assert_eq!(after.total_events_created, 2); @@ -67,14 +67,14 @@ fn test_record_bet_placed() { let (env, contract_id) = setup_env(); let user = Address::generate(&env); let amount = 100_000_000i128; - + env.as_contract(&contract_id, || { StatisticsManager::record_bet_placed(&env, &user, amount); - + let p_stats = StatisticsManager::get_platform_stats(&env); assert_eq!(p_stats.total_bets_placed, 1); assert_eq!(p_stats.total_volume, amount); - + let u_stats = StatisticsManager::get_user_stats(&env, &user); assert_eq!(u_stats.total_bets_placed, 1); assert_eq!(u_stats.total_amount_wagered, amount); @@ -85,7 +85,7 @@ fn test_record_bet_placed() { fn test_user_stats_initialization() { let (env, contract_id) = setup_env(); let user = Address::generate(&env); - + env.as_contract(&contract_id, || { let stats = StatisticsManager::get_user_stats(&env, &user); assert_eq!(stats.total_bets_placed, 0); @@ -100,13 +100,13 @@ fn test_user_stats_initialization() { fn test_record_winnings_claimed() { let (env, contract_id) = setup_env(); let user = Address::generate(&env); - + env.as_contract(&contract_id, || { StatisticsManager::record_bet_placed(&env, &user, 100); StatisticsManager::record_bet_placed(&env, &user, 100); - + StatisticsManager::record_winnings_claimed(&env, &user, 150); - + let u_stats = StatisticsManager::get_user_stats(&env, &user); assert_eq!(u_stats.total_winnings, 150); assert_eq!(u_stats.total_bets_won, 1); @@ -117,11 +117,11 @@ fn test_record_winnings_claimed() { #[test] fn test_record_fees_collected() { let (env, contract_id) = setup_env(); - + env.as_contract(&contract_id, || { StatisticsManager::record_fees_collected(&env, 500); StatisticsManager::record_fees_collected(&env, 300); - + let stats = StatisticsManager::get_platform_stats(&env); assert_eq!(stats.total_fees_collected, 800); }); diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 37a4673d..4bef196b 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -3,7 +3,7 @@ use super::*; use crate::markets::{MarketStateLogic, MarketStateManager}; use crate::types::{Balance, ReflectorAsset}; -use soroban_sdk::{contracttype, Env, Symbol, Vec, Val, IntoVal, Address}; +use soroban_sdk::{contracttype, Address, Env, IntoVal, Symbol, Val, Vec}; // ===== STORAGE OPTIMIZATION TYPES ===== @@ -468,22 +468,37 @@ impl BalanceStorage { env.storage().persistent().extend_ttl(&key, 535680, 535680); // ~30 days } - pub fn add_balance(env: &Env, user: &Address, asset: &ReflectorAsset, amount: i128) -> Result { + pub fn add_balance( + env: &Env, + user: &Address, + asset: &ReflectorAsset, + amount: i128, + ) -> Result { let mut balance = Self::get_balance(env, user, asset); - balance.amount = balance.amount.checked_add(amount).ok_or(Error::InvalidInput)?; + balance.amount = balance + .amount + .checked_add(amount) + .ok_or(Error::InvalidInput)?; Self::set_balance(env, &balance); Ok(balance) } - pub fn sub_balance(env: &Env, user: &Address, asset: &ReflectorAsset, amount: i128) -> Result { + pub fn sub_balance( + env: &Env, + user: &Address, + asset: &ReflectorAsset, + amount: i128, + ) -> Result { let mut balance = Self::get_balance(env, user, asset); - balance.amount = balance.amount.checked_sub(amount).ok_or(Error::InsufficientBalance)?; + balance.amount = balance + .amount + .checked_sub(amount) + .ok_or(Error::InsufficientBalance)?; Self::set_balance(env, &balance); Ok(balance) } } - // ===== PRIVATE HELPER METHODS ===== impl StorageOptimizer { diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs index a3c82f7c..a216681d 100644 --- a/contracts/predictify-hybrid/src/test.rs +++ b/contracts/predictify-hybrid/src/test.rs @@ -1127,13 +1127,13 @@ fn test_automatic_payout_distribution_unresolved_market() { .get::(&market_id) .unwrap() }); - assert!(market.winning_outcome.is_none()); + assert!(market.winning_outcomes.is_none()); // The distribute_payouts function would return MarketNotResolved (#104) error // for unresolved markets. Due to Soroban SDK limitations with should_panic tests // causing SIGSEGV, we verify the precondition is properly set up. // The actual error handling is verified through the function's implementation - // which checks for winning_outcome before distributing payouts. + // which checks for winning_outcomes before distributing payouts. } #[test] @@ -1398,7 +1398,7 @@ fn test_cancel_event_already_resolved() { .unwrap() }); assert_eq!(resolved_market.state, MarketState::Resolved); - assert!(resolved_market.winning_outcome.is_some()); + assert!(resolved_market.winning_outcomes.is_some()); // Note: Calling cancel_event on a resolved market would panic with MarketAlreadyResolved. // Due to Soroban SDK limitations with should_panic tests causing SIGSEGV, @@ -1523,8 +1523,18 @@ fn test_refund_on_oracle_failure_full_amount_per_user() { let amt1 = 10_000_000i128; let amt2 = 20_000_000i128; test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &amt1); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &amt2); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &amt1, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &amt2, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -1556,7 +1566,12 @@ fn test_refund_on_oracle_failure_no_double_refund() { let market_id = test.create_test_market(); let user1 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -1593,7 +1608,12 @@ fn test_refund_on_oracle_failure_after_timeout_any_caller() { let user1 = test.create_funded_user(); let any_caller = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let market = test.env.as_contract(&test.contract_id, || { test.env @@ -1684,10 +1704,10 @@ fn test_manual_dispute_resolution() { // Verify state and outcome assert_eq!(market_after.state, MarketState::Resolved); - assert_eq!( - market_after.winning_outcome, - Some(String::from_str(&test.env, "yes")) - ); + assert!(market_after.winning_outcomes.is_some()); + let winners = market_after.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!(winners.get(0).unwrap(), String::from_str(&test.env, "yes")); } #[test] @@ -2215,10 +2235,10 @@ fn test_proportional_payout_multiple_winners() { .unwrap() }); assert_eq!(market.state, MarketState::Resolved); - assert_eq!( - market.winning_outcome, - Some(String::from_str(&test.env, "yes")) - ); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!(winners.get(0).unwrap(), String::from_str(&test.env, "yes")); } #[test] @@ -2478,10 +2498,20 @@ fn test_integration_full_market_lifecycle_with_payouts() { .unwrap() }); assert_eq!(market.state, MarketState::Resolved); - assert_eq!( - market.winning_outcome, - Some(String::from_str(&test.env, "yes")) - ); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!(winners.get(0).unwrap(), String::from_str(&test.env, "yes")); + + // resolve_market_manual distributes payouts internally; verify market state and claimed flags + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + assert_eq!(market.state, MarketState::Resolved); assert!(market.claimed.get(user1.clone()).unwrap_or(false)); assert!(market.claimed.get(user2.clone()).unwrap_or(false)); assert!(!market.claimed.get(user3.clone()).unwrap_or(false)); // Loser hasn't claimed @@ -2867,11 +2897,20 @@ fn test_get_bet_after_claim() { let market_id = test.create_test_market(); test.env.mock_all_auths(); - client.place_bet(&test.user, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); // Advance time and resolve market let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -2902,7 +2941,12 @@ fn test_has_user_bet_returns_true_when_bet_exists() { let user = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + client.place_bet( + &user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); let has_bet = client.has_user_bet(&market_id, &user); assert!(has_bet); @@ -2958,9 +3002,24 @@ fn test_get_market_bet_stats_with_bets() { let user3 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &20_000_000); - client.place_bet(&user3, &market_id, &String::from_str(&test.env, "yes"), &15_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &20_000_000, + ); + client.place_bet( + &user3, + &market_id, + &String::from_str(&test.env, "yes"), + &15_000_000, + ); let stats = client.get_market_bet_stats(&market_id); @@ -2996,8 +3055,18 @@ fn test_get_implied_probability_balanced_market() { test.env.mock_all_auths(); // Equal bets on both sides - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &10_000_000, + ); let yes_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "yes")); let no_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "no")); @@ -3018,8 +3087,18 @@ fn test_get_implied_probability_skewed_market() { test.env.mock_all_auths(); // Skewed bets: 80% on yes, 20% on no - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &80_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &20_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &80_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &20_000_000, + ); let yes_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "yes")); let no_prob = client.get_implied_probability(&market_id, &String::from_str(&test.env, "no")); @@ -3074,8 +3153,18 @@ fn test_get_payout_multiplier_even_odds() { let user2 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &10_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &10_000_000, + ); let multiplier = client.get_payout_multiplier(&market_id, &String::from_str(&test.env, "yes")); @@ -3184,7 +3273,11 @@ fn test_get_market_after_resolution() { // Resolve the market let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -3205,7 +3298,10 @@ fn test_get_market_after_resolution() { assert!(market_result.is_some()); let market = market_result.unwrap(); assert_eq!(market.state, MarketState::Resolved); - assert_eq!(market.winning_outcome, Some(String::from_str(&test.env, "yes"))); + assert!(market.winning_outcomes.is_some()); + let winners = market.winning_outcomes.unwrap(); + assert_eq!(winners.len(), 1); + assert_eq!(winners.get(0).unwrap(), String::from_str(&test.env, "yes")); } // ===== Tests for get_market_analytics() ===== @@ -3323,7 +3419,12 @@ fn test_multiple_sequential_queries() { for i in 0..5 { let user = test.create_funded_user(); let amount = (i + 1) * 1_000_000; - client.place_bet(&user, &market_id, &String::from_str(&test.env, "yes"), &amount); + client.place_bet( + &user, + &market_id, + &String::from_str(&test.env, "yes"), + &amount, + ); } // Perform multiple queries @@ -3356,7 +3457,10 @@ fn test_query_with_very_long_outcome_name() { let client = PredictifyHybridClient::new(&test.env, &test.contract_id); let market_id = test.create_test_market(); - let long_outcome = String::from_str(&test.env, "this_is_a_very_long_outcome_name_that_probably_does_not_exist_in_the_market"); + let long_outcome = String::from_str( + &test.env, + "this_is_a_very_long_outcome_name_that_probably_does_not_exist_in_the_market", + ); let prob = client.get_implied_probability(&market_id, &long_outcome); @@ -3374,8 +3478,18 @@ fn test_get_market_bet_stats_consistency() { let user2 = test.create_funded_user(); test.env.mock_all_auths(); - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); - client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &15_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &15_000_000, + ); let stats1 = client.get_market_bet_stats(&market_id); let stats2 = client.get_market_bet_stats(&market_id); @@ -3417,7 +3531,6 @@ fn test_implied_probability_sum_equals_100() { assert!(total >= 95 && total <= 105); // Allow small variance } - // ===== CORE FEE CALCULATION TESTS ===== #[test] @@ -3471,7 +3584,10 @@ fn test_withdraw_collected_fee() { // Set collected fees directly in storage test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &50_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &50_000_000i128); }); test.env.mock_all_auths(); @@ -3481,7 +3597,11 @@ fn test_withdraw_collected_fee() { // Verify fees were withdrawn let remaining = test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().get::(&fees_key).unwrap_or(0) + test.env + .storage() + .persistent() + .get::(&fees_key) + .unwrap_or(0) }); assert_eq!(remaining, 0); } @@ -3495,7 +3615,10 @@ fn test_withdraw_fees_non_admin() { // Set some fees test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &50_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &50_000_000i128); }); test.env.mock_all_auths(); @@ -3510,7 +3633,10 @@ fn test_withdraw_partial_fees() { // Set collected fees test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().set(&fees_key, &100_000_000i128); + test.env + .storage() + .persistent() + .set(&fees_key, &100_000_000i128); }); test.env.mock_all_auths(); @@ -3520,7 +3646,11 @@ fn test_withdraw_partial_fees() { // Verify remaining fees let remaining = test.env.as_contract(&test.contract_id, || { let fees_key = Symbol::new(&test.env, "tot_fees"); - test.env.storage().persistent().get::(&fees_key).unwrap_or(0) + test.env + .storage() + .persistent() + .get::(&fees_key) + .unwrap_or(0) }); assert_eq!(remaining, 50_000_000); } @@ -3547,14 +3677,27 @@ fn test_fee_state_after_cancellation() { let stellar_client = StellarAssetClient::new(&test.env, &test.token_test.token_id); test.env.mock_all_auths(); stellar_client.mint(&test.user, &100_000_000); - client.place_bet(&test.user, &market_id, &String::from_str(&test.env, "yes"), &100_000_000); + client.place_bet( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_000_000, + ); // Cancel market - client.cancel_event(&test.admin, &market_id, &Some(String::from_str(&test.env, "Test"))); + client.cancel_event( + &test.admin, + &market_id, + &Some(String::from_str(&test.env, "Test")), + ); // Verify market is cancelled let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market.state, MarketState::Cancelled); } @@ -3575,11 +3718,20 @@ fn test_fee_complete_flow() { stellar_client.mint(&user1, &200_000_000); // Place bet - client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &200_000_000); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &200_000_000, + ); // Verify market has staked amount let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market.total_staked, 200_000_000); @@ -3603,7 +3755,11 @@ fn test_fee_complete_flow() { // Market should be resolved let market_resolved = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); assert_eq!(market_resolved.state, MarketState::Resolved); } @@ -3628,10 +3784,10 @@ fn test_fee_amount_boundaries() { fn test_percentage_calculations_accuracy() { // Test percentage calculation accuracy let test_amounts = [ - (100_000_000, 2, 2_000_000), // 10 XLM @ 2% = 0.2 XLM - (500_000_000, 2, 10_000_000), // 50 XLM @ 2% = 1 XLM + (100_000_000, 2, 2_000_000), // 10 XLM @ 2% = 0.2 XLM + (500_000_000, 2, 10_000_000), // 50 XLM @ 2% = 1 XLM (1_000_000_000, 2, 20_000_000), // 100 XLM @ 2% = 2 XLM - (100_000_000, 5, 5_000_000), // 10 XLM @ 5% = 0.5 XLM + (100_000_000, 5, 5_000_000), // 10 XLM @ 5% = 0.5 XLM (100_000_000, 10, 10_000_000), // 10 XLM @ 10% = 1 XLM ]; @@ -3661,12 +3817,18 @@ fn test_initialize_with_default_fees() { client.initialize(&admin, &None); let stored_admin: Address = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "Admin")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "Admin")) + .unwrap() }); assert_eq!(stored_admin, admin); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 2); } @@ -3683,7 +3845,10 @@ fn test_initialize_with_custom_fees() { client.initialize(&admin, &Some(5)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 5); } @@ -3701,7 +3866,10 @@ fn test_initialize_valid_fee_bound() { client.initialize(&admin, &Some(0)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 0); } @@ -3717,7 +3885,10 @@ fn test_initialize_valid_fee_bound() { client.initialize(&admin, &Some(10)); let stored_fee: i128 = env.as_contract(&contract_id, || { - env.storage().persistent().get(&Symbol::new(&env, "platform_fee")).unwrap() + env.storage() + .persistent() + .get(&Symbol::new(&env, "platform_fee")) + .unwrap() }); assert_eq!(stored_fee, 10); } @@ -3938,11 +4109,8 @@ fn test_query_events_by_category() { let client = PredictifyHybridClient::new(&test.env, &test.contract_id); let _market_id = test.create_test_market(); - let (entries, _) = client.query_events_by_category( - &String::from_str(&test.env, "BTC"), - &0u32, - &10u32, - ); + let (entries, _) = + client.query_events_by_category(&String::from_str(&test.env, "BTC"), &0u32, &10u32); assert!(!entries.is_empty()); let first = entries.get(0).unwrap(); assert_eq!(first.category, String::from_str(&test.env, "BTC")); @@ -4033,9 +4201,18 @@ fn test_archived_entry_has_archived_at_set() { let market_id = test.create_test_market(); test.env.mock_all_auths(); - client.vote(&test.user, &market_id, &String::from_str(&test.env, "yes"), &10_0000000); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &10_0000000, + ); let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -4180,7 +4357,11 @@ fn test_query_result_correctness_resolved_market() { &stake, ); let market = test.env.as_contract(&test.contract_id, || { - test.env.storage().persistent().get::(&market_id).unwrap() + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() }); test.env.ledger().set(LedgerInfo { timestamp: market.end_time + 1, @@ -4204,7 +4385,10 @@ fn test_query_result_correctness_resolved_market() { assert_eq!(e.state, MarketState::Resolved); assert_eq!(e.winning_outcome, Some(String::from_str(&test.env, "yes"))); assert_eq!(e.total_staked, stake); - assert_eq!(e.question, String::from_str(&test.env, "Will BTC go above $25,000 by December 31?")); + assert_eq!( + e.question, + String::from_str(&test.env, "Will BTC go above $25,000 by December 31?") + ); assert!(e.outcomes.len() >= 2); } diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index a28b44ba..58c3502c 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -722,8 +722,10 @@ pub struct Market { pub total_staked: i128, /// Dispute stakes mapping (address -> dispute stake) pub dispute_stakes: Map, - /// Winning outcome (set after resolution) - pub winning_outcome: Option, + /// Winning outcome(s) (set after resolution) + /// For single winner: contains one outcome + /// For ties/multi-winner: contains multiple outcomes (pool split among winners) + pub winning_outcomes: Option>, /// Whether fees have been collected pub fee_collected: bool, /// Current market state @@ -852,7 +854,7 @@ impl Market { claimed: Map::new(env), total_staked: 0, dispute_stakes: Map::new(env), - winning_outcome: None, + winning_outcomes: None, fee_collected: false, state, @@ -877,7 +879,26 @@ impl Market { /// Check if the market is resolved pub fn is_resolved(&self) -> bool { - self.winning_outcome.is_some() + self.winning_outcomes.is_some() + } + + /// Get the primary winning outcome (first outcome if multiple, for backward compatibility) + pub fn get_winning_outcome(&self) -> Option { + self.winning_outcomes.as_ref().and_then(|outcomes| { + if outcomes.len() > 0 { + Some(outcomes.get(0).unwrap().clone()) + } else { + None + } + }) + } + + /// Check if a specific outcome is a winner (handles both single and multi-winner cases) + pub fn is_winning_outcome(&self, outcome: &String) -> bool { + self.winning_outcomes + .as_ref() + .map(|outcomes| outcomes.contains(outcome)) + .unwrap_or(false) } /// Get total dispute stakes for the market diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 20e5bbba..084fe8c6 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -1299,7 +1299,10 @@ impl InputValidator { } /// Validate sufficient balance for withdrawal/transfer - pub fn validate_sufficient_balance(current: i128, required: i128) -> Result<(), ValidationError> { + pub fn validate_sufficient_balance( + current: i128, + required: i128, + ) -> Result<(), ValidationError> { if current < required { return Err(ValidationError::NumberOutOfRange); } @@ -1995,7 +1998,7 @@ impl MarketValidator { } // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(ValidationError::InvalidMarket); } @@ -2020,7 +2023,7 @@ impl MarketValidator { } // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(ValidationError::InvalidMarket); } @@ -2044,7 +2047,7 @@ impl MarketValidator { } // Check if market is resolved - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return Err(ValidationError::InvalidMarket); } @@ -2489,7 +2492,7 @@ impl DisputeValidator { return Err(ValidationError::InvalidMarket); } - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return Err(ValidationError::InvalidMarket); } @@ -2830,7 +2833,7 @@ impl ComprehensiveValidator { } // Check market resolution - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { result.add_warning(); } diff --git a/contracts/predictify-hybrid/src/voting.rs b/contracts/predictify-hybrid/src/voting.rs index 10746527..71e09b05 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -924,7 +924,7 @@ impl VotingValidator { } // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } @@ -940,7 +940,7 @@ impl VotingValidator { } // Check if market is already resolved - if market.winning_outcome.is_some() { + if market.winning_outcomes.is_some() { return Err(Error::MarketAlreadyResolved); } @@ -961,7 +961,7 @@ impl VotingValidator { } // Check if market is resolved - if market.winning_outcome.is_none() { + if market.winning_outcomes.is_none() { return Err(Error::MarketNotResolved); } @@ -1137,8 +1137,8 @@ impl VotingUtils { market: &Market, user: &Address, ) -> Result { - let winning_outcome = market - .winning_outcome + let winning_outcomes = market + .winning_outcomes .as_ref() .ok_or(Error::MarketNotResolved)?; @@ -1149,21 +1149,30 @@ impl VotingUtils { let user_stake = market.stakes.get(user.clone()).unwrap_or(0); - // Only pay if user voted for winning outcome - if user_outcome != *winning_outcome { + // Only pay if user voted for a winning outcome (handles ties - pool split) + if !winning_outcomes.contains(&user_outcome) { return Ok(0); } - // Calculate winning statistics - let winning_stats = MarketAnalytics::calculate_winning_stats(market, winning_outcome); + // Calculate winning statistics for payout calculation + // For multi-winner (ties), pool is split proportionally among all winners + // Get total stake across all winning outcomes + let mut winning_total = 0; + for outcome in winning_outcomes.iter() { + for (voter, voted_outcome) in market.votes.iter() { + if voted_outcome == outcome { + winning_total += market.stakes.get(voter.clone()).unwrap_or(0); + } + } + } - // Calculate payout + // Calculate payout using total across all winning outcomes (handles ties - pool split) // Use dynamic platform fee percentage from current configuration let cfg = crate::config::ConfigManager::get_config(env)?; let payout = MarketUtils::calculate_payout( user_stake, - winning_stats.winning_total, - winning_stats.total_pool, + winning_total, // Total stake across all winning outcomes (for tie handling) + market.total_staked, // Total pool cfg.fees.platform_fee_percentage, )?;