From cf50bea1fb0d8ad80e0e944b6e5847e06c1959cb Mon Sep 17 00:00:00 2001 From: Wraith Protocol Dev Date: Mon, 24 Aug 2026 11:45:11 +0100 Subject: [PATCH] Fix #57: emit fee provenance in withdraw events Track which markets contribute to each fee withdrawal so indexers can detect cross-market contamination. The fees_withdrawn event now includes a Vec with (market_id, amount) pairs in drain order (legacy first, then newest-to-oldest). Changes: - Add FeeProvenance struct (market_id, amount) - debit_proven_fees now returns the provenance it debits - Add preview_fee_provenance helper for non-mutating reads - Add get_withdrawal_provenance view function for integrators - Update withdraw_fees and execute_withdraw_fees events to include provenance - Update event schema comment to document new format Existing safeguards (20% cap, 24h timelock, per-market ledger, admin cancellation) remain unchanged. --- prediction_market/src/lib.rs | 102 ++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 036f7db..973f0f5 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -15,8 +15,8 @@ use soroban_sdk::{ // market_cancelled (admin, id) net_pool // cancel_refund (user, id) gross // claim_processed (user, id) (is_winner, payout) -// fees_withdrawn (caller) (recipient, amount) -// withdraw_requested (caller) (recipient, amount) +// fees_withdrawn (caller, recipient) (amount, Vec) +// withdraw_requested (caller, recipient) amount // withdraw_cancelled (admin) caller // config_changed (admin) Config // paused / unpaused (admin) () @@ -232,6 +232,15 @@ pub struct ForfeitedPool { pub frozen: bool, } +/// Provenance of a fee withdrawal: how much was drawn from each market. +/// Legacy fees (pre-upgrade) are reported with market_id == 0. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeProvenance { + pub market_id: u64, + pub amount: i128, +} + // ── Domain Structs ──────────────────────────────────────────────────────────── #[contracttype] @@ -1393,7 +1402,7 @@ impl PredictionMarketContract { return Err(MarketError::NoFeesToWithdraw); } let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; - Self::debit_proven_fees(&env, cap)?; + let provenance = Self::debit_proven_fees(&env, cap)?; let cfg: Config = env.storage().instance().get(&DataKey::Cfg).unwrap(); token::Client::new(&env, &cfg.xlm_sac).transfer( @@ -1404,7 +1413,7 @@ impl PredictionMarketContract { env.events().publish( (Symbol::new(&env, "fees_withdrawn"), caller, recipient.clone()), - cap, + (cap, provenance), ); Ok(cap) } @@ -1485,7 +1494,7 @@ impl PredictionMarketContract { // Debit the per-market / legacy ledger so the cached sum stays in // lockstep. Effects before interaction so a reentrant recipient // cannot re-read stale accumulator state. - Self::debit_proven_fees(&env, req.amount)?; + let provenance = Self::debit_proven_fees(&env, req.amount)?; env.storage().persistent().remove(&key); let cfg: Config = env.storage().instance().get(&DataKey::Cfg).unwrap(); @@ -1497,7 +1506,7 @@ impl PredictionMarketContract { env.events().publish( (Symbol::new(&env, "fees_withdrawn"), caller, req.recipient.clone()), - req.amount, + (req.amount, provenance), ); Ok(req.amount) } @@ -1612,6 +1621,17 @@ impl PredictionMarketContract { .unwrap_or(0) } + /// Preview which markets a withdrawal of `amount` would draw from, without + /// debiting. Returns a vec of (market_id, amount) pairs in drain order + /// (legacy first, then newest-to-oldest). Useful for indexers and UIs + /// that want to show fee provenance before a withdrawal executes. + pub fn get_withdrawal_provenance( + env: Env, + amount: i128, + ) -> Result, MarketError> { + Self::preview_fee_provenance(&env, amount) + } + /// Permissionless one-shot: snapshot the pre-upgrade global scalar into /// LegacyFees. Fresh deploys already set FeeLedgerMigrated at initialize. pub fn migrate_fee_ledger(env: Env) { @@ -1780,7 +1800,11 @@ impl PredictionMarketContract { /// Drain LegacyFees first, then per-market balances from newest to oldest, /// keeping AccumulatedFees in lockstep. Used by withdraw paths. - fn debit_proven_fees(env: &Env, amount: i128) -> Result<(), MarketError> { + /// Returns the provenance: which markets were debited and how much. + fn debit_proven_fees( + env: &Env, + amount: i128, + ) -> Result, MarketError> { Self::ensure_fee_ledger_migrated(env); if amount <= 0 { return Err(MarketError::InvalidAmount); @@ -1794,11 +1818,16 @@ impl PredictionMarketContract { return Err(MarketError::WithdrawalTooLarge); } + let mut provenance: Vec = Vec::new(env); let mut remaining = amount; let legacy = Self::market_fee_balance(env, LEGACY_MARKET_ID); let take_legacy = if remaining < legacy { remaining } else { legacy }; if take_legacy > 0 { Self::debit_market_fees(env, LEGACY_MARKET_ID, take_legacy); + provenance.push_back(FeeProvenance { + market_id: LEGACY_MARKET_ID, + amount: take_legacy, + }); remaining -= take_legacy; } if remaining > 0 { @@ -1813,6 +1842,10 @@ impl PredictionMarketContract { if mf > 0 { let take = if remaining < mf { remaining } else { mf }; Self::debit_market_fees(env, id, take); + provenance.push_back(FeeProvenance { + market_id: id, + amount: take, + }); remaining -= take; } id -= 1; @@ -1821,7 +1854,60 @@ impl PredictionMarketContract { if remaining > 0 { return Err(MarketError::WithdrawalTooLarge); } - Ok(()) + Ok(provenance) + } + + /// Preview the provenance of a withdrawal without debiting. + /// Returns which markets would be debited and how much. + fn preview_fee_provenance(env: &Env, amount: i128) -> Result, MarketError> { + Self::ensure_fee_ledger_migrated(env); + if amount <= 0 { + return Err(MarketError::InvalidAmount); + } + let acc: i128 = env + .storage() + .instance() + .get(&DataKey::AccumulatedFees) + .unwrap_or(0); + if amount > acc { + return Err(MarketError::WithdrawalTooLarge); + } + + let mut provenance: Vec = Vec::new(env); + let mut remaining = amount; + let legacy = Self::market_fee_balance(env, LEGACY_MARKET_ID); + let take_legacy = if remaining < legacy { remaining } else { legacy }; + if take_legacy > 0 { + provenance.push_back(FeeProvenance { + market_id: LEGACY_MARKET_ID, + amount: take_legacy, + }); + remaining -= take_legacy; + } + if remaining > 0 { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::MarketCount) + .unwrap_or(0); + let mut id = count; + while remaining > 0 && id > 0 { + let mf = Self::market_fee_balance(env, id); + if mf > 0 { + let take = if remaining < mf { remaining } else { mf }; + provenance.push_back(FeeProvenance { + market_id: id, + amount: take, + }); + remaining -= take; + } + id -= 1; + } + } + if remaining > 0 { + return Err(MarketError::WithdrawalTooLarge); + } + Ok(provenance) } fn sac_sentinel(env: &Env) -> BytesN<32> {