From 559ed51eec67fdacedf95849db232e83880feffc Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Tue, 18 Aug 2026 11:13:14 +0100 Subject: [PATCH 1/2] Fix #3: Prevent principal-sweeping on zero-side market resolution --- prediction_market/src/lib.rs | 204 +- prediction_market/src/tests.rs | 208 +- ..._empty_side_resolution_pool_to_fees.1.json | 1991 ----------------- 3 files changed, 378 insertions(+), 2025 deletions(-) delete mode 100644 prediction_market/test_snapshots/tests/test_empty_side_resolution_pool_to_fees.1.json diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 420d2cb..830ed4d 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, IntoVal, - String, Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, token, vec, Address, BytesN, + Env, IntoVal, String, Symbol, Val, Vec, }; // ── Constants ───────────────────────────────────────────────────────────────── @@ -30,6 +30,10 @@ const LOSE_TOKENS: i128 = 2_0000000; const WITHDRAW_DELAY_SECS: u64 = 86_400; // 24h timelock between request and payout const MAX_WITHDRAWAL_BPS: i128 = 2_000; // per-request cap: 20% of accumulated fees +// Issue #3: zero-side resolutions get a short challenge window before +// principal is paid out and this market's fees become withdrawable. +const DISPUTE_WINDOW_SECS: u64 = 86_400; + // TTL: ~1yr threshold, ~2yr extend (mainnet: ~1 ledger/5s) const TTL_BUMP: u32 = 3_153_600; const TTL_HIGH: u32 = 6_307_200; @@ -65,6 +69,9 @@ pub enum MarketError { WithdrawalRequestExists = 23, NoWithdrawalRequest = 24, WithdrawalTooSoon = 25, + DisputePending = 26, + NoZeroSideResolution = 27, + DisputeWindowClosed = 28, } // ── Storage Keys ────────────────────────────────────────────────────────────── @@ -88,6 +95,8 @@ pub enum DataKey { Payout(u64, Address), // i128 — exact payout computed at resolve time // ── Timelocked withdrawal requests (issue #12) ─────────────────────── PendingWithdrawal(Address), // caller -> WithdrawalRequest + // ── Zero-side provenance (issue #3) ────────────────────────────────── + ForfeitedPool(u64), // per-market ForfeitedPool; never mixed into fees } // ── Config packed into one instance storage slot ─────────────────────────── @@ -120,6 +129,17 @@ pub struct WithdrawalRequest { pub requested_at: u64, } +// Per-market swept principal + sequestered fees (issue #3). Kept out of +// AccumulatedFees so a resolver/fee-recipient cannot withdraw user funds. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForfeitedPool { + pub amount: i128, // net principal owed back to the losing side + pub locked_fees: i128, // this market's fees, held until dispute ends + pub resolved_at: u64, + pub frozen: bool, +} + // ── Domain Structs ──────────────────────────────────────────────────────────── #[contracttype] @@ -536,11 +556,60 @@ impl PredictionMarketContract { .unwrap_or(0); if winning_side == 0 { - // No contest on the winning side — the whole pool is swept to - // accumulated fees (protocol-defined behavior, kept from prior - // design). Bettors still earn tokens/points via claim(). + // Issue #3: do not sweep user principal into AccumulatedFees. + // Credit each bettor their net back via the payout ledger, lock + // this market's fees out of the withdrawable accumulator, and + // open a dispute window before any of that money can move. if total_pool > 0 { - acc_fees += total_pool; + let fees_in_pool = total_pool * TOTAL_FEE_BPS / (BPS_DENOM - TOTAL_FEE_BPS); + let locked = if fees_in_pool < acc_fees { + fees_in_pool + } else { + acc_fees + }; + acc_fees -= locked; + + let bettors: u32 = env + .storage() + .persistent() + .get(&DataKey::BettorCount(market_id)) + .unwrap_or(0); + for i in 0..bettors { + let slot_key = DataKey::BettorAt(market_id, i); + let bettor: Address = + if let Some(a) = env.storage().persistent().get(&slot_key) { + a + } else { + continue; + }; + let bet_key = DataKey::Bet(market_id, bettor.clone()); + if let Some(entry) = + env.storage().persistent().get::(&bet_key) + { + let payout_key = DataKey::Payout(market_id, bettor.clone()); + env.storage().persistent().set(&payout_key, &entry.net); + env.storage() + .persistent() + .extend_ttl(&payout_key, TTL_BUMP, TTL_HIGH); + } + } + + let fp_key = DataKey::ForfeitedPool(market_id); + env.storage().persistent().set( + &fp_key, + &ForfeitedPool { + amount: total_pool, + locked_fees: locked, + resolved_at: env.ledger().timestamp(), + frozen: false, + }, + ); + env.storage() + .persistent() + .extend_ttl(&fp_key, TTL_BUMP, TTL_HIGH); + + env.events() + .publish((symbol_short!("zero_side"), market_id), total_pool); } } else { // Settlement-time payouts (issue #2): compute EXACT per-winner @@ -598,6 +667,78 @@ impl PredictionMarketContract { Ok(()) } + // ── Zero-side dispute (issue #3) ────────────────────────────────────── + // A griefing empty-side resolution can be frozen by admin during the + // challenge window. After the window, anyone can finalize so this + // market's genuine platform fees rejoin AccumulatedFees. + + pub fn freeze_market(env: Env, admin: Address, market_id: u64) -> Result<(), MarketError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + + let mut market = Self::load_market(&env, market_id)?; + if market.cancelled { + return Err(MarketError::MarketCancelled); + } + if !market.resolved { + return Err(MarketError::MarketNotResolved); + } + + let fp_key = DataKey::ForfeitedPool(market_id); + let mut pool: ForfeitedPool = env + .storage() + .persistent() + .get(&fp_key) + .ok_or(MarketError::NoZeroSideResolution)?; + if pool.frozen { + return Err(MarketError::MarketCancelled); + } + if Self::dispute_window_closed(&env, pool.resolved_at) { + return Err(MarketError::DisputeWindowClosed); + } + + pool.frozen = true; + env.storage().persistent().set(&fp_key, &pool); + env.storage() + .persistent() + .extend_ttl(&fp_key, TTL_BUMP, TTL_HIGH); + + // Route to the existing refund path: users pull GROSS via + // cancel_refund. Locked fees stay out of AccumulatedFees so the + // contract remains solvent for those refunds. + market.cancelled = true; + let mkt_key = DataKey::Market(market_id); + env.storage().persistent().set(&mkt_key, &market); + env.storage() + .persistent() + .extend_ttl(&mkt_key, TTL_BUMP, TTL_HIGH); + Ok(()) + } + + pub fn finalize_zero_side(env: Env, market_id: u64) -> Result<(), MarketError> { + Self::load_market(&env, market_id)?; + let fp_key = DataKey::ForfeitedPool(market_id); + let mut pool: ForfeitedPool = env + .storage() + .persistent() + .get(&fp_key) + .ok_or(MarketError::NoZeroSideResolution)?; + if pool.frozen { + return Err(MarketError::MarketCancelled); + } + if !Self::dispute_window_closed(&env, pool.resolved_at) { + return Err(MarketError::DisputePending); + } + Self::release_locked_fees(&env, market_id, &mut pool); + Ok(()) + } + + pub fn get_forfeited_pool(env: Env, market_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::ForfeitedPool(market_id)) + } + // ── Cancellation ────────────────────────────────────────────────────── pub fn cancel_market(env: Env, admin: Address, market_id: u64) -> Result<(), MarketError> { @@ -695,6 +836,9 @@ impl PredictionMarketContract { if !market.resolved { return Err(MarketError::MarketNotResolved); } + // Issue #3: zero-side principal cannot move until the dispute window + // closes (or the market is frozen into cancel_refund). + Self::enforce_zero_side_claim_window(&env, market_id)?; let bet_key = DataKey::Bet(market_id, user.clone()); let mut entry: BetEntry = env @@ -732,8 +876,8 @@ impl PredictionMarketContract { let this = env.current_contract_address(); // XLM payout straight from the settlement-time payout ledger. - // Winners are exactly the bettors who own a Payout entry; everyone - // else (losers, empty winning side) has no payout key at all. + // Two-sided winners (and zero-side refunds) own a Payout entry; + // two-sided losers have no key at all. let payout: i128 = if let Some(p) = env .storage() .persistent() @@ -743,7 +887,7 @@ impl PredictionMarketContract { } else { 0 }; - if is_winner && payout > 0 { + if payout > 0 { token::Client::new(&env, &cfg.xlm_sac).transfer(&this, &user, &payout); } @@ -1038,6 +1182,48 @@ impl PredictionMarketContract { .ok_or(MarketError::MarketNotFound) } + fn dispute_window_closed(env: &Env, resolved_at: u64) -> bool { + let now = env.ledger().timestamp(); + now >= resolved_at && now - resolved_at >= DISPUTE_WINDOW_SECS + } + + fn release_locked_fees(env: &Env, market_id: u64, pool: &mut ForfeitedPool) { + if pool.frozen || pool.locked_fees <= 0 { + return; + } + let mut acc_fees: i128 = env + .storage() + .instance() + .get(&DataKey::AccumulatedFees) + .unwrap_or(0); + acc_fees += pool.locked_fees; + pool.locked_fees = 0; + env.storage() + .instance() + .set(&DataKey::AccumulatedFees, &acc_fees); + let fp_key = DataKey::ForfeitedPool(market_id); + env.storage().persistent().set(&fp_key, pool); + env.storage() + .persistent() + .extend_ttl(&fp_key, TTL_BUMP, TTL_HIGH); + } + + fn enforce_zero_side_claim_window(env: &Env, market_id: u64) -> Result<(), MarketError> { + let fp_key = DataKey::ForfeitedPool(market_id); + let mut pool: ForfeitedPool = match env.storage().persistent().get(&fp_key) { + Some(p) => p, + None => return Ok(()), + }; + if pool.frozen { + return Err(MarketError::MarketCancelled); + } + if !Self::dispute_window_closed(env, pool.resolved_at) { + return Err(MarketError::DisputePending); + } + Self::release_locked_fees(env, market_id, &mut pool); + Ok(()) + } + #[inline] fn require_admin(env: &Env, caller: &Address) -> Result<(), MarketError> { let admin: Address = env diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 062792f..24f8aab 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1,8 +1,9 @@ use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger, LedgerInfo}, + symbol_short, + testutils::{storage::Persistent as _, Address as _, Events, Ledger, LedgerInfo}, token::{Client as TokenClient, StellarAssetClient}, - Env, String, + Env, IntoVal, String, }; use leaderboard::LeaderboardContract; @@ -1103,7 +1104,7 @@ fn test_create_multiple_markets() { assert_eq!(t.client.get_market(&id2).category, Category::Sports); } -// ── 41. Empty-side resolution: pool goes to AccumulatedFees, admin can withdraw ─ +// ── 41. Empty-side resolution: principal is NOT swept into AccumulatedFees ─ #[test] fn test_empty_side_resolution_pool_to_fees() { @@ -1121,30 +1122,25 @@ fn test_empty_side_resolution_pool_to_fees() { advance_time(&t.env, 3601); t.client.resolve_market(&t.admin, &id, &false); // total_no == 0 - // The entire pool (total_yes net = 98 XLM) must be swept into AccumulatedFees + // Principal stays in a per-market ForfeitedPool, not AccumulatedFees. + // This market's fees are locked for the dispute window. let fees_after = t.client.get_accumulated_fees(); - assert_eq!( - fees_after, - fees_before + 98_0000000, - "entire YES pool should sweep to fees when NO side is empty" + assert_eq!(fees_after, 0, "zero-side pool must not mix into withdrawable fees"); + let fp = t.client.get_forfeited_pool(&id).expect("forfeited pool"); + assert_eq!(fp.amount, 98_0000000); + assert_eq!(fp.locked_fees, 2_0000000); + assert!(!fp.frozen); + assert_eq!(t.client.get_payout(&id, &alice), 98_0000000); + + let topics = soroban_sdk::vec![ + &t.env, + symbol_short!("zero_side").into_val(&t.env), + id.into_val(&t.env), + ]; + assert!( + t.env.events().all().iter().any(|e| e.1 == topics), + "zero-side resolution must emit a monitorable event" ); - - // Admin can withdraw the swept pool to a registered treasury - let treasury = Address::generate(&t.env); - t.client.add_fee_recipient(&t.admin, &treasury); - let before = t.xlm.balance(&treasury); - let withdrawn = t.client.withdraw_fees(&t.admin, &treasury); - assert_eq!(withdrawn, fees_after); - assert_eq!(t.xlm.balance(&treasury), before + fees_after); - assert_eq!(t.client.get_accumulated_fees(), 0); - - // Alice (was YES, losing side) can still claim — gets PULSE tokens + points - t.client.claim(&alice, &id); - let bet = t.client.get_bet(&id, &alice); - assert!(bet.claimed); - // Gets lose-tier rewards because winning_side == 0 - assert_eq!(t.token_client.balance(&alice), 2_0000000); // LOSE_TOKENS - assert_eq!(t.leaderboard_client.get_points(&alice), 10); // LOSE_POINTS } // ── 42. Cancel accumulates fees on multiple bets correctly ──────────────────── @@ -1456,3 +1452,165 @@ fn test_cancel_refund_rebumps_ttl_entries() { assert!(ttl(&bet_key) > bet_before); assert!(ttl(&market_key) > market_before); } + +// ═══════════════════════════════════════════════════════════════════════════ +// SECURITY REGRESSION SUITE — issue #3 (zero-side sweep / fund theft) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_two_sided_resolution_unaffected_by_zero_side_guards() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + let bob = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + fund_user(&t, &bob, 200_0000000); + + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + t.client.place_bet(&bob, &id, &false, &100_0000000_i128); + let fees = t.client.get_accumulated_fees(); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + assert!(t.client.get_forfeited_pool(&id).is_none()); + assert_eq!(t.client.get_accumulated_fees(), fees); + + let alice_pre = t.xlm.balance(&alice); + t.client.claim(&alice, &id); + assert_eq!(t.xlm.balance(&alice) - alice_pre, 196_0000000); +} + +#[test] +fn test_one_sided_win_returns_net_immediately() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + assert!(t.client.get_forfeited_pool(&id).is_none()); + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); + + let alice_pre = t.xlm.balance(&alice); + t.client.claim(&alice, &id); + assert_eq!(t.xlm.balance(&alice) - alice_pre, 98_0000000); + assert_eq!(t.token_client.balance(&alice), 10_0000000); // WIN_TOKENS +} + +#[test] +#[should_panic(expected = "Error(Contract, #26)")] +fn test_zero_side_claim_blocked_during_dispute_window() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + t.client.claim(&alice, &id); +} + +#[test] +fn test_zero_side_claim_refunds_principal_after_dispute_window() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + + advance_time(&t.env, DISPUTE_WINDOW_SECS); + let alice_pre = t.xlm.balance(&alice); + t.client.claim(&alice, &id); + assert_eq!(t.xlm.balance(&alice) - alice_pre, 98_0000000); + assert_eq!(t.token_client.balance(&alice), 2_0000000); // LOSE_TOKENS + assert_eq!(t.leaderboard_client.get_points(&alice), 10); + // First claim after the window releases locked platform fees. + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); + assert_eq!(t.client.get_forfeited_pool(&id).unwrap().locked_fees, 0); +} + +#[test] +fn test_zero_side_withdraw_fees_cannot_drain_principal() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + + // During the window the market's fees are locked, so there is nothing + // to withdraw — and the 98 XLM principal is not in the accumulator. + assert_eq!(t.client.get_accumulated_fees(), 0); + + advance_time(&t.env, DISPUTE_WINDOW_SECS); + t.client.finalize_zero_side(&id); + let fees = t.client.get_accumulated_fees(); + assert_eq!(fees, 2_0000000, "only the genuine 2% fee is withdrawable"); + + let treasury = Address::generate(&t.env); + t.client.add_fee_recipient(&t.admin, &treasury); + let withdrawn = t.client.withdraw_fees(&t.admin, &treasury); + assert_eq!(withdrawn, 2_0000000); + assert_eq!(t.xlm.balance(&treasury), 2_0000000); + assert_eq!(t.client.get_accumulated_fees(), 0); +} + +#[test] +fn test_freeze_zero_side_during_dispute_refunds_gross() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + let alice_before = t.xlm.balance(&alice); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + + t.client.freeze_market(&t.admin, &id); + let fp = t.client.get_forfeited_pool(&id).unwrap(); + assert!(fp.frozen); + assert!(t.client.get_market(&id).cancelled); + + let refunded = t.client.cancel_refund(&alice, &id); + assert_eq!(refunded, 100_0000000); + assert_eq!(t.xlm.balance(&alice), alice_before); + // Frozen fees never rejoin AccumulatedFees. + assert_eq!(t.client.get_accumulated_fees(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #28)")] +fn test_reject_freeze_after_dispute_window() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + advance_time(&t.env, DISPUTE_WINDOW_SECS); + t.client.freeze_market(&t.admin, &id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn test_reject_freeze_two_sided_market() { + let t = setup(); + let id = create_test_market(&t); + let alice = Address::generate(&t.env); + let bob = Address::generate(&t.env); + fund_user(&t, &alice, 200_0000000); + fund_user(&t, &bob, 200_0000000); + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); + t.client.place_bet(&bob, &id, &false, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + t.client.freeze_market(&t.admin, &id); +} diff --git a/prediction_market/test_snapshots/tests/test_empty_side_resolution_pool_to_fees.1.json b/prediction_market/test_snapshots/tests/test_empty_side_resolution_pool_to_fees.1.json deleted file mode 100644 index 168b312..0000000 --- a/prediction_market/test_snapshots/tests/test_empty_side_resolution_pool_to_fees.1.json +++ /dev/null @@ -1,1991 +0,0 @@ -{ - "generators": { - "address": 8, - "nonce": 0, - "mux_id": 0 - }, - "auth": [ - [], - [ - [ - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", - { - "function": { - "contract_fn": { - "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "function_name": "set_admin", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "function_name": "initialize", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "string": "PULSE" - }, - { - "string": "PLSE" - }, - { - "u32": 7 - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [], - [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "initialize", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - }, - { - "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "function_name": "initialize", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", - "function_name": "initialize", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - }, - { - "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "function_name": "set_token", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "function_name": "set_minter", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "function_name": "set_minter", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "function_name": "set_minter", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "create_market", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "string": "Will BTC hit 100k?" - }, - { - "string": "https://example.com/btc.png" - }, - { - "vec": [ - { - "symbol": "Crypto" - } - ] - }, - { - "u64": "3600" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "function_name": "mint", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - }, - { - "i128": "2000000000" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "place_bet", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - }, - { - "u64": "1" - }, - { - "bool": true - }, - { - "i128": "1000000000" - } - ] - } - }, - "sub_invocations": [ - { - "function": { - "contract_fn": { - "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "function_name": "transfer", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - }, - { - "i128": "1000000000" - } - ] - } - }, - "sub_invocations": [] - } - ] - } - ] - ], - [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "resolve_market", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "u64": "1" - }, - { - "bool": false - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "withdraw_fees", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "function_name": "claim", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - }, - { - "u64": "1" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [], - [] - ], - "ledger": { - "protocol_version": 26, - "sequence_number": 101, - "timestamp": 1003601, - "network_id": "0000000000000000000000000000000000000000000000000000000000000000", - "base_reserve": 10, - "min_persistent_entry_ttl": 100, - "min_temp_entry_ttl": 100, - "max_entry_ttl": 10000000, - "ledger_entries": [ - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "account": { - "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", - "balance": "0", - "seq_num": "0", - "num_sub_entries": 0, - "inflation_dest": null, - "flags": 0, - "home_domain": "", - "thresholds": "01010101", - "signers": [], - "ext": "v0" - } - }, - "ext": "v0" - }, - "live_until": null - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", - "key": { - "ledger_key_nonce": { - "nonce": "801925984706572462" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "115220454072064130" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "1033654523790656264" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "1194852393571756375" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "1301173170172112462" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000100 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "2032731177588607455" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "4270020994084947596" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "4837995959683129791" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "5541220902715666415" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "5806905060045992000" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "6277191135259896685" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "6517132746326325848" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000100 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "key": { - "ledger_key_nonce": { - "nonce": "8370022561469687789" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "key": { - "vec": [ - { - "symbol": "AuthorizedMinter" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "key": { - "vec": [ - { - "symbol": "AuthorizedMinter" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "key": { - "vec": [ - { - "symbol": "AuthorizedMinter" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "key": { - "vec": [ - { - "symbol": "Balance" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "i128": "20000000" - } - } - }, - "ext": "v0" - }, - "live_until": 200 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", - "key": "ledger_key_contract_instance", - "durability": "persistent", - "val": { - "contract_instance": { - "executable": { - "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - "storage": [ - { - "key": { - "vec": [ - { - "symbol": "Admin" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "Decimals" - } - ] - }, - "val": { - "u32": 7 - } - }, - { - "key": { - "vec": [ - { - "symbol": "Name" - } - ] - }, - "val": { - "string": "PULSE" - } - }, - { - "key": { - "vec": [ - { - "symbol": "Symbol" - } - ] - }, - "val": { - "string": "PLSE" - } - }, - { - "key": { - "vec": [ - { - "symbol": "TotalSupply" - } - ] - }, - "val": { - "i128": "20000000" - } - } - ] - } - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "key": { - "vec": [ - { - "symbol": "Stats" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "bonus_bets" - }, - "val": { - "u32": 0 - } - }, - { - "key": { - "symbol": "lost_bets" - }, - "val": { - "u32": 1 - } - }, - { - "key": { - "symbol": "points" - }, - "val": { - "u64": "10" - } - }, - { - "key": { - "symbol": "won_bets" - }, - "val": { - "u32": 0 - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 6307301 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "key": { - "vec": [ - { - "symbol": "TopPlayerAt" - }, - { - "u32": 0 - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "address" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - }, - { - "key": { - "symbol": "points" - }, - "val": { - "u64": "10" - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 6307301 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "key": { - "vec": [ - { - "symbol": "TopPlayerSlot" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "u32": 0 - } - } - }, - "ext": "v0" - }, - "live_until": 6307301 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", - "key": "ledger_key_contract_instance", - "durability": "persistent", - "val": { - "contract_instance": { - "executable": { - "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - "storage": [ - { - "key": { - "vec": [ - { - "symbol": "Admin" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "MarketContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - }, - { - "key": { - "vec": [ - { - "symbol": "MinPoints" - } - ] - }, - "val": { - "u64": "10" - } - }, - { - "key": { - "vec": [ - { - "symbol": "MinSlot" - } - ] - }, - "val": { - "u32": 0 - } - }, - { - "key": { - "vec": [ - { - "symbol": "ReferralContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "TokenContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - } - }, - { - "key": { - "vec": [ - { - "symbol": "TopPlayerCount" - } - ] - }, - "val": { - "u32": 1 - } - } - ] - } - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", - "key": "ledger_key_contract_instance", - "durability": "persistent", - "val": { - "contract_instance": { - "executable": { - "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - "storage": [ - { - "key": { - "vec": [ - { - "symbol": "Admin" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "LeaderboardContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - }, - { - "key": { - "vec": [ - { - "symbol": "MarketContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - }, - { - "key": { - "vec": [ - { - "symbol": "TokenContract" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - } - }, - { - "key": { - "vec": [ - { - "symbol": "XlmSacContract" - } - ] - }, - "val": { - "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" - } - } - ] - } - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": { - "vec": [ - { - "symbol": "Bet" - }, - { - "u64": "1" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "claimed" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "count" - }, - "val": { - "u32": 1 - } - }, - { - "key": { - "symbol": "gross" - }, - "val": { - "i128": "1000000000" - } - }, - { - "key": { - "symbol": "is_yes" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "net" - }, - "val": { - "i128": "980000000" - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 6307300 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": { - "vec": [ - { - "symbol": "BettorAt" - }, - { - "u64": "1" - }, - { - "u32": 0 - } - ] - }, - "durability": "persistent", - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - } - }, - "ext": "v0" - }, - "live_until": 6307300 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": { - "vec": [ - { - "symbol": "BettorCount" - }, - { - "u64": "1" - } - ] - }, - "durability": "persistent", - "val": { - "u32": 1 - } - } - }, - "ext": "v0" - }, - "live_until": 6307300 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": { - "vec": [ - { - "symbol": "HasReferrer" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "bool": false - } - } - }, - "ext": "v0" - }, - "live_until": 6307300 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": { - "vec": [ - { - "symbol": "Market" - }, - { - "u64": "1" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "bet_count" - }, - "val": { - "u32": 1 - } - }, - { - "key": { - "symbol": "cancelled" - }, - "val": { - "bool": false - } - }, - { - "key": { - "symbol": "category" - }, - "val": { - "vec": [ - { - "symbol": "Crypto" - } - ] - } - }, - { - "key": { - "symbol": "creator" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "symbol": "end_time" - }, - "val": { - "u64": "1003600" - } - }, - { - "key": { - "symbol": "id" - }, - "val": { - "u64": "1" - } - }, - { - "key": { - "symbol": "image_url" - }, - "val": { - "string": "https://example.com/btc.png" - } - }, - { - "key": { - "symbol": "outcome" - }, - "val": { - "bool": false - } - }, - { - "key": { - "symbol": "question" - }, - "val": { - "string": "Will BTC hit 100k?" - } - }, - { - "key": { - "symbol": "resolved" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "total_no" - }, - "val": { - "i128": "0" - } - }, - { - "key": { - "symbol": "total_yes" - }, - "val": { - "i128": "980000000" - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 6307300 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", - "key": "ledger_key_contract_instance", - "durability": "persistent", - "val": { - "contract_instance": { - "executable": { - "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - "storage": [ - { - "key": { - "vec": [ - { - "symbol": "AccumulatedFees" - } - ] - }, - "val": { - "i128": "0" - } - }, - { - "key": { - "vec": [ - { - "symbol": "Admin" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "Cfg" - } - ] - }, - "val": { - "map": [ - { - "key": { - "symbol": "leaderboard" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - }, - { - "key": { - "symbol": "referral" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - }, - { - "key": { - "symbol": "token" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" - } - }, - { - "key": { - "symbol": "xlm_sac" - }, - "val": { - "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" - } - } - ] - } - }, - { - "key": { - "vec": [ - { - "symbol": "MarketCount" - } - ] - }, - "val": { - "u64": "1" - } - }, - { - "key": { - "vec": [ - { - "symbol": "RateWindow" - } - ] - }, - "val": { - "vec": [ - { - "u64": "1000000" - }, - { - "u32": 1 - } - ] - } - } - ] - } - } - } - }, - "ext": "v0" - }, - "live_until": 199 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", - "key": { - "ledger_key_nonce": { - "nonce": "3126073502131104533" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000099 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", - "key": { - "ledger_key_nonce": { - "nonce": "7270604957039011794" - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - "live_until": 10000100 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "key": { - "vec": [ - { - "symbol": "Balance" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "amount" - }, - "val": { - "i128": "0" - } - }, - { - "key": { - "symbol": "authorized" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "clawback" - }, - "val": { - "bool": false - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 518500 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "key": { - "vec": [ - { - "symbol": "Balance" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "amount" - }, - "val": { - "i128": "0" - } - }, - { - "key": { - "symbol": "authorized" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "clawback" - }, - "val": { - "bool": false - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 518500 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "key": { - "vec": [ - { - "symbol": "Balance" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "amount" - }, - "val": { - "i128": "1000000000" - } - }, - { - "key": { - "symbol": "authorized" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "clawback" - }, - "val": { - "bool": false - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 518500 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "key": { - "vec": [ - { - "symbol": "Balance" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" - } - ] - }, - "durability": "persistent", - "val": { - "map": [ - { - "key": { - "symbol": "amount" - }, - "val": { - "i128": "1000000000" - } - }, - { - "key": { - "symbol": "authorized" - }, - "val": { - "bool": true - } - }, - { - "key": { - "symbol": "clawback" - }, - "val": { - "bool": false - } - } - ] - } - } - }, - "ext": "v0" - }, - "live_until": 518501 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", - "key": "ledger_key_contract_instance", - "durability": "persistent", - "val": { - "contract_instance": { - "executable": "stellar_asset", - "storage": [ - { - "key": { - "symbol": "METADATA" - }, - "val": { - "map": [ - { - "key": { - "symbol": "decimal" - }, - "val": { - "u32": 7 - } - }, - { - "key": { - "symbol": "name" - }, - "val": { - "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" - } - }, - { - "key": { - "symbol": "symbol" - }, - "val": { - "string": "aaa" - } - } - ] - } - }, - { - "key": { - "vec": [ - { - "symbol": "Admin" - } - ] - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" - } - }, - { - "key": { - "vec": [ - { - "symbol": "AssetInfo" - } - ] - }, - "val": { - "vec": [ - { - "symbol": "AlphaNum4" - }, - { - "map": [ - { - "key": { - "symbol": "asset_code" - }, - "val": { - "string": "aaa\\0" - } - }, - { - "key": { - "symbol": "issuer" - }, - "val": { - "bytes": "0000000000000000000000000000000000000000000000000000000000000002" - } - } - ] - } - ] - } - } - ] - } - } - } - }, - "ext": "v0" - }, - "live_until": 121060 - }, - { - "entry": { - "last_modified_ledger_seq": 0, - "data": { - "contract_code": { - "ext": "v0", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "code": "" - } - }, - "ext": "v0" - }, - "live_until": 199 - } - ] - }, - "events": [] -} From 0dab0bf8a579f29aa2293686fb827ae4b2fd57b6 Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Tue, 18 Aug 2026 11:42:28 +0100 Subject: [PATCH 2/2] Fix #4: Replace global AccumulatedFees with a per-market fee ledger Co-authored-by: Cursor --- prediction_market/src/lib.rs | 265 +++++++++++++++++++++++---------- prediction_market/src/tests.rs | 152 ++++++++++++++++--- 2 files changed, 316 insertions(+), 101 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 830ed4d..a396a28 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -28,7 +28,11 @@ const LOSE_TOKENS: i128 = 2_0000000; // path is timelocked, so a compromised fee recipient cannot drain the whole // accumulator to an arbitrary address in one call. const WITHDRAW_DELAY_SECS: u64 = 86_400; // 24h timelock between request and payout -const MAX_WITHDRAWAL_BPS: i128 = 2_000; // per-request cap: 20% of accumulated fees +const MAX_WITHDRAWAL_BPS: i128 = 2_000; // per-request cap: 20% of that market's fees + +// Issue #4: market ids start at 1. Id 0 is the unattributed pre-migration bucket +// (existing AccumulatedFees that cannot be split retroactively with certainty). +const LEGACY_MARKET_ID: u64 = 0; // Issue #3: zero-side resolutions get a short challenge window before // principal is paid out and this market's fees become withdrawable. @@ -72,6 +76,7 @@ pub enum MarketError { DisputePending = 26, NoZeroSideResolution = 27, DisputeWindowClosed = 28, + FeesNotSettled = 29, } // ── Storage Keys ────────────────────────────────────────────────────────────── @@ -82,6 +87,8 @@ pub enum DataKey { // Config addresses — all in instance storage (shared, cheap) Cfg, // single packed Config struct — 1 read instead of 5 MarketCount, + // Global settlement view (issue #4): cached Σ MarketFees + LegacyFees. + // Never the source of truth for a cancel/withdraw — those use the ledger. AccumulatedFees, Market(u64), Bet(u64, Address), // net + gross + count packed; see BetEntry @@ -97,6 +104,10 @@ pub enum DataKey { PendingWithdrawal(Address), // caller -> WithdrawalRequest // ── Zero-side provenance (issue #3) ────────────────────────────────── ForfeitedPool(u64), // per-market ForfeitedPool; never mixed into fees + // ── Per-market fee ledger (issue #4) ───────────────────────────────── + MarketFees(u64), // i128 — genuine earned fees for this market + LegacyFees, // i128 — unattributed pre-migration balance + FeeLedgerMigrated, // bool — one-shot migration of the old global scalar } // ── Config packed into one instance storage slot ─────────────────────────── @@ -127,6 +138,7 @@ pub struct WithdrawalRequest { pub recipient: Address, pub amount: i128, pub requested_at: u64, + pub market_id: u64, // issue #4: a request only ever debits this ledger entry } // Per-market swept principal + sequestered fees (issue #3). Kept out of @@ -214,6 +226,10 @@ impl PredictionMarketContract { env.storage() .instance() .set(&DataKey::AccumulatedFees, &0_i128); + env.storage().instance().set(&DataKey::LegacyFees, &0_i128); + env.storage() + .instance() + .set(&DataKey::FeeLedgerMigrated, &true); Ok(()) } @@ -425,13 +441,8 @@ impl PredictionMarketContract { let this = env.current_contract_address(); xlm.transfer(&user, &this, &amount); - // ── Accumulated fees ────────────────────────────────────────────── - let mut acc_fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); - acc_fees += platform_fee; + // ── Per-market fee credit (issue #4) ─────────────────────────────── + let mut credited = platform_fee; // ── Referral (skip if cached no-referrer) ───────────────────────── let hr_key = DataKey::HasReferrer(user.clone()); @@ -461,11 +472,9 @@ impl PredictionMarketContract { }; if !paid_referrer { - acc_fees += referral_fee; + credited += referral_fee; } - env.storage() - .instance() - .set(&DataKey::AccumulatedFees, &acc_fees); + Self::credit_market_fees(&env, market_id, credited); // ── Write BetEntry (net + gross + count in one write) ───────────── let new_entry = match existing { @@ -549,25 +558,13 @@ impl PredictionMarketContract { market.total_no }; - let mut acc_fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); - if winning_side == 0 { // Issue #3: do not sweep user principal into AccumulatedFees. // Credit each bettor their net back via the payout ledger, lock - // this market's fees out of the withdrawable accumulator, and + // this market's fees out of its own ledger (issue #4), and // open a dispute window before any of that money can move. if total_pool > 0 { - let fees_in_pool = total_pool * TOTAL_FEE_BPS / (BPS_DENOM - TOTAL_FEE_BPS); - let locked = if fees_in_pool < acc_fees { - fees_in_pool - } else { - acc_fees - }; - acc_fees -= locked; + let locked = Self::take_market_fees(&env, market_id); let bettors: u32 = env .storage() @@ -649,14 +646,10 @@ impl PredictionMarketContract { let dust: i128 = total_pool - payout_sum; debug_assert!(dust >= 0, "payouts must never exceed the pool"); if dust > 0 { - acc_fees += dust; + Self::credit_market_fees(&env, market_id, dust); } } - env.storage() - .instance() - .set(&DataKey::AccumulatedFees, &acc_fees); - market.resolved = true; market.outcome = outcome; let mkt_key = DataKey::Market(market_id); @@ -760,22 +753,10 @@ impl PredictionMarketContract { .persistent() .extend_ttl(&mkt_key, TTL_BUMP, TTL_HIGH); - // Reclaim fees — net * fee_rate / (1 - fee_rate) - let net_pool = market.total_yes + market.total_no; - let fees_in_pool = net_pool * TOTAL_FEE_BPS / (BPS_DENOM - TOTAL_FEE_BPS); - let mut acc_fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); - acc_fees = if fees_in_pool < acc_fees { - acc_fees - fees_in_pool - } else { - 0 - }; - env.storage() - .instance() - .set(&DataKey::AccumulatedFees, &acc_fees); + // Issue #4: reclaim only THIS market's ledger entry. Estimated + // fees_in_pool against the global scalar could zero out (or reduce) + // fees legitimately earned by unrelated markets. + Self::take_market_fees(&env, market_id); Ok(()) } @@ -922,25 +903,29 @@ impl PredictionMarketContract { // caller, the admin, or a registered fee recipient. Fee recipients must // use the timelocked request_withdraw_fees -> execute_withdraw_fees flow, // which is also capped so the accumulator can never be drained at once. + // + // Issue #4: every withdrawal is scoped to one ledger entry (`market_id`). + // `market_id == 0` is the unattributed legacy bucket. Fees belonging to + // other markets (or still backing an open market) are never touched. pub fn withdraw_fees( env: Env, caller: Address, recipient: Address, + market_id: u64, ) -> Result { caller.require_auth(); Self::require_admin(&env, &caller)?; Self::require_valid_fee_recipient(&env, &caller, &recipient)?; + Self::require_fees_withdrawable(&env, market_id)?; - let fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); + let fees = Self::market_fee_balance(&env, market_id); if fees == 0 { return Err(MarketError::NoFeesToWithdraw); } + Self::debit_market_fees(&env, market_id, fees); + let cfg: Config = env.storage().instance().get(&DataKey::Cfg).unwrap(); token::Client::new(&env, &cfg.xlm_sac).transfer( &env.current_contract_address(), @@ -948,24 +933,25 @@ impl PredictionMarketContract { &fees, ); - env.storage() - .instance() - .set(&DataKey::AccumulatedFees, &0_i128); Ok(fees) } /// Issue #12: request a capped, timelocked withdrawal. The payout lands /// only after WITHDRAW_DELAY_SECS via execute_withdraw_fees, and the admin /// can cancel the request before then (see cancel_withdrawal_request). + /// Issue #4: `market_id` selects the ledger entry; the cap is 20% of + /// that entry, not of the global settlement view. pub fn request_withdraw_fees( env: Env, caller: Address, recipient: Address, amount: i128, + market_id: u64, ) -> Result<(), MarketError> { caller.require_auth(); Self::require_admin_or_fee_recipient(&env, &caller)?; Self::require_valid_fee_recipient(&env, &caller, &recipient)?; + Self::require_fees_withdrawable(&env, market_id)?; if amount <= 0 { return Err(MarketError::InvalidAmount); @@ -975,16 +961,12 @@ impl PredictionMarketContract { return Err(MarketError::WithdrawalRequestExists); } - let fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); + let fees = Self::market_fee_balance(&env, market_id); if amount > fees { return Err(MarketError::WithdrawalTooLarge); } - // Cap: a single request may take at most MAX_WITHDRAWAL_BPS of the - // accumulator, so even a compromised recipient cannot drain it fully. + // Cap: a single request may take at most MAX_WITHDRAWAL_BPS of this + // market's ledger, so even a compromised recipient cannot drain it fully. let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; if amount > cap { return Err(MarketError::WithdrawalTooLarge); @@ -996,6 +978,7 @@ impl PredictionMarketContract { recipient, amount, requested_at: env.ledger().timestamp(), + market_id, }, ); env.storage() @@ -1020,21 +1003,14 @@ impl PredictionMarketContract { return Err(MarketError::WithdrawalTooSoon); } - let mut acc_fees: i128 = env - .storage() - .instance() - .get(&DataKey::AccumulatedFees) - .unwrap_or(0); - if acc_fees < req.amount { + let fees = Self::market_fee_balance(&env, req.market_id); + if fees < req.amount { return Err(MarketError::WithdrawalTooLarge); } - acc_fees -= req.amount; // Effects before interaction so a reentrant recipient cannot re-read - // stale accumulator state. - env.storage() - .instance() - .set(&DataKey::AccumulatedFees, &acc_fees); + // stale ledger state. + Self::debit_market_fees(&env, req.market_id, req.amount); env.storage().persistent().remove(&key); let cfg: Config = env.storage().instance().get(&DataKey::Cfg).unwrap(); @@ -1130,12 +1106,36 @@ impl PredictionMarketContract { } pub fn get_accumulated_fees(env: Env) -> i128 { + Self::ensure_fee_ledger_migrated(&env); env.storage() .instance() .get(&DataKey::AccumulatedFees) .unwrap_or(0) } + /// Issue #4: genuine earned fees attributed to `market_id`. + /// `market_id == 0` returns the unattributed legacy bucket. + pub fn get_market_fees(env: Env, market_id: u64) -> i128 { + Self::market_fee_balance(&env, market_id) + } + + /// Issue #4: unattributed pre-migration balance. Same as `get_market_fees(0)`. + pub fn get_legacy_fees(env: Env) -> i128 { + Self::market_fee_balance(&env, LEGACY_MARKET_ID) + } + + /// Issue #4: one-shot upgrade helper. Credits the pre-existing global + /// `AccumulatedFees` scalar to the legacy/unattributed ledger instead of + /// guessing a per-market split. Idempotent; also runs lazily on the first + /// fee mutation after an upgrade so the contract cannot be bricked if this + /// is never called explicitly. + pub fn migrate_fee_ledger(env: Env, admin: Address) -> Result { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + Self::ensure_fee_ledger_migrated(&env); + Ok(Self::market_fee_balance(&env, LEGACY_MARKET_ID)) + } + pub fn is_fee_recipient(env: Env, recipient: Address) -> bool { env.storage() .persistent() @@ -1182,25 +1182,126 @@ impl PredictionMarketContract { .ok_or(MarketError::MarketNotFound) } - fn dispute_window_closed(env: &Env, resolved_at: u64) -> bool { - let now = env.ledger().timestamp(); - now >= resolved_at && now - resolved_at >= DISPUTE_WINDOW_SECS + // ── Issue #4 fee ledger ─────────────────────────────────────────────── + // + // AccumulatedFees is a derived global view: + // AccumulatedFees == Σ MarketFees(i) + LegacyFees + // Cancel/withdraw/lock only ever mutate one ledger entry, then adjust + // the cached sum by the same amount. The pre-existing global scalar is + // credited to LegacyFees on first access after upgrade (no retroactive + // split is possible without historical per-market state). + + fn ensure_fee_ledger_migrated(env: &Env) { + if env.storage().instance().has(&DataKey::FeeLedgerMigrated) { + return; + } + let acc: i128 = env + .storage() + .instance() + .get(&DataKey::AccumulatedFees) + .unwrap_or(0); + env.storage().instance().set(&DataKey::LegacyFees, &acc); + env.storage() + .instance() + .set(&DataKey::FeeLedgerMigrated, &true); } - fn release_locked_fees(env: &Env, market_id: u64, pool: &mut ForfeitedPool) { - if pool.frozen || pool.locked_fees <= 0 { + fn market_fee_balance(env: &Env, market_id: u64) -> i128 { + Self::ensure_fee_ledger_migrated(env); + if market_id == LEGACY_MARKET_ID { + env.storage() + .instance() + .get(&DataKey::LegacyFees) + .unwrap_or(0) + } else { + env.storage() + .persistent() + .get(&DataKey::MarketFees(market_id)) + .unwrap_or(0) + } + } + + fn set_market_fee_balance(env: &Env, market_id: u64, amount: i128) { + if market_id == LEGACY_MARKET_ID { + env.storage().instance().set(&DataKey::LegacyFees, &amount); + } else { + let key = DataKey::MarketFees(market_id); + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + } + + fn credit_market_fees(env: &Env, market_id: u64, amount: i128) { + if amount <= 0 { return; } - let mut acc_fees: i128 = env + Self::ensure_fee_ledger_migrated(env); + let next = Self::market_fee_balance(env, market_id) + amount; + Self::set_market_fee_balance(env, market_id, next); + let acc: i128 = env .storage() .instance() .get(&DataKey::AccumulatedFees) .unwrap_or(0); - acc_fees += pool.locked_fees; - pool.locked_fees = 0; env.storage() .instance() - .set(&DataKey::AccumulatedFees, &acc_fees); + .set(&DataKey::AccumulatedFees, &(acc + amount)); + } + + fn debit_market_fees(env: &Env, market_id: u64, amount: i128) { + if amount <= 0 { + return; + } + Self::ensure_fee_ledger_migrated(env); + let cur = Self::market_fee_balance(env, market_id); + let next = if amount < cur { cur - amount } else { 0 }; + Self::set_market_fee_balance(env, market_id, next); + let acc: i128 = env + .storage() + .instance() + .get(&DataKey::AccumulatedFees) + .unwrap_or(0); + let new_acc = if amount < acc { acc - amount } else { 0 }; + env.storage() + .instance() + .set(&DataKey::AccumulatedFees, &new_acc); + } + + fn take_market_fees(env: &Env, market_id: u64) -> i128 { + let amount = Self::market_fee_balance(env, market_id); + Self::debit_market_fees(env, market_id, amount); + amount + } + + fn require_fees_withdrawable(env: &Env, market_id: u64) -> Result<(), MarketError> { + Self::ensure_fee_ledger_migrated(env); + if market_id == LEGACY_MARKET_ID { + return Ok(()); + } + let market = Self::load_market(env, market_id)?; + if market.cancelled { + return Err(MarketError::MarketCancelled); + } + if !market.resolved { + return Err(MarketError::FeesNotSettled); + } + Ok(()) + } + + fn dispute_window_closed(env: &Env, resolved_at: u64) -> bool { + let now = env.ledger().timestamp(); + now >= resolved_at && now - resolved_at >= DISPUTE_WINDOW_SECS + } + + fn release_locked_fees(env: &Env, market_id: u64, pool: &mut ForfeitedPool) { + if pool.frozen || pool.locked_fees <= 0 { + return; + } + let locked = pool.locked_fees; + pool.locked_fees = 0; + Self::credit_market_fees(env, market_id, locked); let fp_key = DataKey::ForfeitedPool(market_id); env.storage().persistent().set(&fp_key, pool); env.storage() diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 24f8aab..ddb3208 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -125,6 +125,11 @@ fn advance_time(env: &Env, secs: u64) { }); } +fn settle(t: &TestSetup, id: u64, outcome: bool) { + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &outcome); +} + fn rewind_time(env: &Env, secs: u64) { let current = env.ledger().timestamp(); env.ledger().set(LedgerInfo { @@ -146,6 +151,7 @@ fn test_initialize() { let t = setup(); assert_eq!(t.client.get_market_count(), 0); assert_eq!(t.client.get_accumulated_fees(), 0); + assert_eq!(t.client.get_legacy_fees(), 0); } // ── 2. Create market ───────────────────────────────────────────────────────── @@ -598,14 +604,17 @@ fn test_withdraw_fees() { let user = Address::generate(&t.env); fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); + settle(&t, id, true); let fees_before = t.client.get_accumulated_fees(); assert!(fees_before > 0); + assert_eq!(t.client.get_market_fees(&id), fees_before); let admin_xlm_before = t.xlm.balance(&t.admin); - let withdrawn = t.client.withdraw_fees(&t.admin, &t.admin); + let withdrawn = t.client.withdraw_fees(&t.admin, &t.admin, &id); assert_eq!(withdrawn, fees_before); assert_eq!(t.client.get_accumulated_fees(), 0); + assert_eq!(t.client.get_market_fees(&id), 0); assert_eq!(t.xlm.balance(&t.admin), admin_xlm_before + fees_before); } @@ -618,23 +627,25 @@ fn test_fee_recipient_withdraw() { let user = Address::generate(&t.env); fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); + settle(&t, id, true); let recipient = Address::generate(&t.env); let treasury = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); t.client.add_fee_recipient(&t.admin, &treasury); - let fees = t.client.get_accumulated_fees(); + let fees = t.client.get_market_fees(&id); let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; let treasury_before = t.xlm.balance(&treasury); // Fee recipient requests a capped withdrawal to the registered treasury. - t.client.request_withdraw_fees(&recipient, &treasury, &cap); + t.client.request_withdraw_fees(&recipient, &treasury, &cap, &id); // Payout is NOT immediate: timelocked for WITHDRAW_DELAY_SECS. let pending = t.client.get_pending_withdrawal(&recipient).unwrap(); assert_eq!(pending.recipient, treasury); assert_eq!(pending.amount, cap); + assert_eq!(pending.market_id, id); assert_eq!(t.xlm.balance(&treasury), treasury_before); // After the delay the payout executes. @@ -658,7 +669,7 @@ fn test_reject_fee_recipient_immediate_withdraw() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); - t.client.withdraw_fees(&recipient, &recipient); + t.client.withdraw_fees(&recipient, &recipient, &id); } // ── 27c. Withdraw to an arbitrary address is rejected (issue #12) ───────────── @@ -673,7 +684,7 @@ fn test_reject_withdraw_fees_to_arbitrary_recipient() { t.client.place_bet(&user, &id, &true, &100_0000000_i128); let rando = Address::generate(&t.env); - t.client.withdraw_fees(&t.admin, &rando); + t.client.withdraw_fees(&t.admin, &rando, &id); } #[test] @@ -688,7 +699,7 @@ fn test_reject_fee_recipient_request_arbitrary_recipient() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); let rando = Address::generate(&t.env); - t.client.request_withdraw_fees(&recipient, &rando, &1_i128); + t.client.request_withdraw_fees(&recipient, &rando, &1_i128, &id); } // ── 27d. Cannot drain the whole accumulator in one request (issue #12) ──────── @@ -705,8 +716,9 @@ fn test_reject_drain_entire_accumulator_in_one_request() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); - let fees = t.client.get_accumulated_fees(); - t.client.request_withdraw_fees(&recipient, &recipient, &fees); + settle(&t, id, true); + let fees = t.client.get_market_fees(&id); + t.client.request_withdraw_fees(&recipient, &recipient, &fees, &id); } // ── 27e. Payout is locked until the timelock elapses (issue #12) ────────────── @@ -723,9 +735,10 @@ fn test_withdrawal_execute_before_delay() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); - let fees = t.client.get_accumulated_fees(); + settle(&t, id, true); + let fees = t.client.get_market_fees(&id); let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; - t.client.request_withdraw_fees(&recipient, &recipient, &cap); + t.client.request_withdraw_fees(&recipient, &recipient, &cap, &id); t.client.execute_withdraw_fees(&recipient); } @@ -742,9 +755,10 @@ fn test_admin_cancel_pending_withdrawal() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); - let fees = t.client.get_accumulated_fees(); + settle(&t, id, true); + let fees = t.client.get_market_fees(&id); let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; - t.client.request_withdraw_fees(&recipient, &recipient, &cap); + t.client.request_withdraw_fees(&recipient, &recipient, &cap, &id); assert!(t.client.get_pending_withdrawal(&recipient).is_some()); t.client.cancel_withdrawal_request(&t.admin, &recipient); @@ -776,10 +790,11 @@ fn test_reject_duplicate_withdrawal_request() { let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); - let fees = t.client.get_accumulated_fees(); + settle(&t, id, true); + let fees = t.client.get_market_fees(&id); let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; - t.client.request_withdraw_fees(&recipient, &recipient, &cap); - t.client.request_withdraw_fees(&recipient, &recipient, &cap); + t.client.request_withdraw_fees(&recipient, &recipient, &cap, &id); + t.client.request_withdraw_fees(&recipient, &recipient, &cap, &id); } // ── 28. Non-authorized cannot withdraw fees ─────────────────────────────────── @@ -793,7 +808,7 @@ fn test_reject_withdraw_fees_non_admin() { fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); let rando = Address::generate(&t.env); - t.client.withdraw_fees(&rando, &rando); + t.client.withdraw_fees(&rando, &rando, &id); } // ── 29. Bettor index enumeration ───────────────────────────────────────────── @@ -1024,7 +1039,7 @@ fn test_reject_resolve_before_deadline() { #[should_panic(expected = "Error(Contract, #15)")] fn test_withdraw_fees_zero() { let t = setup(); - t.client.withdraw_fees(&t.admin, &t.admin); + t.client.withdraw_fees(&t.admin, &t.admin, &0_u64); } // ── 36. Claim with no bet → NoBetFound ─────────────────────────────────────── @@ -1256,7 +1271,7 @@ fn test_e2e_full_inter_contract_flow() { let fees_total = t.client.get_accumulated_fees(); assert!(fees_total > 0); let treasury_before = t.xlm.balance(&treasury); - let withdrawn = t.client.withdraw_fees(&t.admin, &treasury); + let withdrawn = t.client.withdraw_fees(&t.admin, &treasury, &market_id); assert_eq!(withdrawn, fees_total); assert_eq!(t.client.get_accumulated_fees(), 0); assert_eq!(t.xlm.balance(&treasury), treasury_before + fees_total); @@ -1556,7 +1571,7 @@ fn test_zero_side_withdraw_fees_cannot_drain_principal() { let treasury = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &treasury); - let withdrawn = t.client.withdraw_fees(&t.admin, &treasury); + let withdrawn = t.client.withdraw_fees(&t.admin, &treasury, &id); assert_eq!(withdrawn, 2_0000000); assert_eq!(t.xlm.balance(&treasury), 2_0000000); assert_eq!(t.client.get_accumulated_fees(), 0); @@ -1614,3 +1629,102 @@ fn test_reject_freeze_two_sided_market() { t.client.resolve_market(&t.admin, &id, &true); t.client.freeze_market(&t.admin, &id); } + +// ═══════════════════════════════════════════════════════════════════════════ +// SECURITY REGRESSION SUITE — issue #4 (per-market fee provenance) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_cancel_preserves_unrelated_market_fees() { + let t = setup(); + let a = create_test_market(&t); + let b = create_test_market(&t); + let ua = Address::generate(&t.env); + let ub = Address::generate(&t.env); + fund_user(&t, &ua, 200_0000000); + fund_user(&t, &ub, 200_0000000); + + t.client.place_bet(&ua, &a, &true, &100_0000000_i128); + t.client.place_bet(&ub, &b, &true, &50_0000000_i128); + assert_eq!(t.client.get_market_fees(&a), 2_0000000); + assert_eq!(t.client.get_market_fees(&b), 1_0000000); + assert_eq!(t.client.get_accumulated_fees(), 3_0000000); + assert_eq!( + t.client.get_accumulated_fees(), + t.client.get_market_fees(&a) + t.client.get_market_fees(&b) + t.client.get_legacy_fees() + ); + + t.client.cancel_market(&t.admin, &a); + assert_eq!(t.client.get_market_fees(&a), 0); + assert_eq!(t.client.get_market_fees(&b), 1_0000000); + assert_eq!(t.client.get_accumulated_fees(), 1_0000000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] +fn test_reject_withdraw_fees_from_open_market() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 200_0000000); + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + t.client.withdraw_fees(&t.admin, &t.admin, &id); +} + +#[test] +fn test_withdraw_fees_scoped_to_one_market() { + let t = setup(); + let a = create_test_market(&t); + let b = create_test_market(&t); + let ua = Address::generate(&t.env); + let ub = Address::generate(&t.env); + fund_user(&t, &ua, 200_0000000); + fund_user(&t, &ub, 200_0000000); + + t.client.place_bet(&ua, &a, &true, &100_0000000_i128); + t.client.place_bet(&ub, &b, &true, &100_0000000_i128); + settle(&t, a, true); + settle(&t, b, true); + + let withdrawn = t.client.withdraw_fees(&t.admin, &t.admin, &a); + assert_eq!(withdrawn, 2_0000000); + assert_eq!(t.client.get_market_fees(&a), 0); + assert_eq!(t.client.get_market_fees(&b), 2_0000000); + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); +} + +#[test] +fn test_legacy_fee_migration_does_not_double_count() { + let t = setup(); + let contract = t.client.address.clone(); + let unattributed = 50_0000000_i128; + + // Simulate an upgraded instance: the old global scalar is populated and + // the new ledger keys do not exist yet. + t.env.as_contract(&contract, || { + t.env.storage().instance().remove(&DataKey::FeeLedgerMigrated); + t.env.storage().instance().remove(&DataKey::LegacyFees); + t.env + .storage() + .instance() + .set(&DataKey::AccumulatedFees, &unattributed); + }); + + assert_eq!(t.client.get_legacy_fees(), unattributed); + assert_eq!(t.client.get_accumulated_fees(), unattributed); + assert_eq!(t.client.migrate_fee_ledger(&t.admin), unattributed); + + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 200_0000000); + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + + assert_eq!(t.client.get_legacy_fees(), unattributed); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); + assert_eq!(t.client.get_accumulated_fees(), unattributed + 2_0000000); + + t.client.cancel_market(&t.admin, &id); + assert_eq!(t.client.get_market_fees(&id), 0); + assert_eq!(t.client.get_legacy_fees(), unattributed); + assert_eq!(t.client.get_accumulated_fees(), unattributed); +}