From ea89ad06b28611699afb1e9b431390d74f9dc5ac Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Mon, 24 Aug 2026 06:32:39 +0100 Subject: [PATCH 1/5] fix(prediction_market): extend TTL on read paths and add bump_ttl Extend TTL in every view/read path that touches user-claimable state: - get_payout, get_market, get_bet, get_user_bet_count, get_bet_gross, get_pending_withdrawal now rebump their storage keys on read. Add public bump_ttl(market_id, user) so anyone can refresh a near-expiry BetEntry before it becomes unrecoverable. Emit claim_window_expired event from claim and cancel_refund when the BetEntry is missing so off-chain indexers can alert users. Closes #174 --- prediction_market/src/lib.rs | 111 +++++++++++++--- prediction_market/src/tests.rs | 233 +++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 19 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 036f7db..4e33669 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -20,6 +20,7 @@ use soroban_sdk::{ // withdraw_cancelled (admin) caller // config_changed (admin) Config // paused / unpaused (admin) () +// claim_window_expired (user, id) () // ──────────────────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────── @@ -1225,11 +1226,20 @@ impl PredictionMarketContract { // OPT: read BetEntry (which now contains gross) — was a separate BetGross key let bet_key = DataKey::Bet(market_id, user.clone()); - let mut entry: BetEntry = env + let mut entry: BetEntry = match env .storage() .persistent() .get(&bet_key) - .ok_or(MarketError::NoBetFound)?; + { + Some(e) => e, + None => { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user.clone(), market_id), + (), + ); + return Err(MarketError::NoBetFound); + } + }; if entry.gross == 0 { return Err(MarketError::NoBetFound); @@ -1282,11 +1292,20 @@ impl PredictionMarketContract { Self::enforce_zero_side_claim_window(&env, market_id)?; let bet_key = DataKey::Bet(market_id, user.clone()); - let mut entry: BetEntry = env + let mut entry: BetEntry = match env .storage() .persistent() .get(&bet_key) - .ok_or(MarketError::NoBetFound)?; + { + Some(e) => e, + None => { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user.clone(), market_id), + (), + ); + return Err(MarketError::NoBetFound); + } + }; if entry.claimed { return Err(MarketError::AlreadyClaimed); @@ -1526,16 +1545,29 @@ impl PredictionMarketContract { // ── View Functions ──────────────────────────────────────────────────── pub fn get_market(env: Env, market_id: u64) -> Result { - Self::load_market(&env, market_id) + let mkt_key = DataKey::Market(market_id); + let market = env + .storage() + .persistent() + .get(&mkt_key) + .ok_or(MarketError::MarketNotFound)?; + env.storage() + .persistent() + .extend_ttl(&mkt_key, TTL_BUMP, TTL_HIGH); + Ok(market) } // OPT: returns Bet (ABI-compatible) derived from BetEntry pub fn get_bet(env: Env, market_id: u64, user: Address) -> Result { + let bet_key = DataKey::Bet(market_id, user.clone()); let e: BetEntry = env .storage() .persistent() - .get(&DataKey::Bet(market_id, user)) + .get(&bet_key) .ok_or(MarketError::NoBetFound)?; + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); Ok(Bet { amount: e.net, is_yes: e.is_yes, @@ -1661,6 +1693,22 @@ impl PredictionMarketContract { Ok(bumped) } + pub fn bump_ttl(env: Env, market_id: u64, user: Address) -> Result<(), MarketError> { + let bet_key = DataKey::Bet(market_id, user.clone()); + if env.storage().persistent().has(&bet_key) { + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + Ok(()) + } else { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user, market_id), + (), + ); + Err(MarketError::NoBetFound) + } + } + pub fn is_fee_recipient(env: Env, recipient: Address) -> bool { env.storage() .persistent() @@ -1669,32 +1717,57 @@ impl PredictionMarketContract { } pub fn get_pending_withdrawal(env: Env, caller: Address) -> Option { - env.storage() - .persistent() - .get(&DataKey::PendingWithdrawal(caller)) + let key = DataKey::PendingWithdrawal(caller.clone()); + let req = env.storage().persistent().get(&key); + if req.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + req } pub fn get_payout(env: Env, market_id: u64, user: Address) -> i128 { - env.storage() - .persistent() - .get(&DataKey::Payout(market_id, user)) - .unwrap_or(0) + let key = DataKey::Payout(market_id, user.clone()); + let payout: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + payout } pub fn get_user_bet_count(env: Env, market_id: u64, user: Address) -> u32 { - env.storage() + let bet_key = DataKey::Bet(market_id, user.clone()); + let count = env + .storage() .persistent() - .get::(&DataKey::Bet(market_id, user)) + .get::(&bet_key) .map(|e| e.count) - .unwrap_or(0) + .unwrap_or(0); + if env.storage().persistent().has(&bet_key) { + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + } + count } pub fn get_bet_gross(env: Env, market_id: u64, user: Address) -> i128 { - env.storage() + let bet_key = DataKey::Bet(market_id, user.clone()); + let gross = env + .storage() .persistent() - .get::(&DataKey::Bet(market_id, user)) + .get::(&bet_key) .map(|e| e.gross) - .unwrap_or(0) + .unwrap_or(0); + if env.storage().persistent().has(&bet_key) { + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + } + gross } // ── Internal Helpers ────────────────────────────────────────────────── diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 8cc0965..8dd9de7 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1626,6 +1626,239 @@ fn test_resolve_market_rebumps_payout_entry() { assert!(payout_ttl >= TTL_BUMP); } +// ── #174: read-path TTL rebump + bump_ttl + claim_window_expired event ───────── + +#[test] +fn test_get_payout_rebumps_ttl() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let payout_key = DataKey::Payout(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&payout_key); + + t.client.get_payout(&id, &user); + + assert!(ttl(&payout_key) > before); +} + +#[test] +fn test_get_market_rebumps_ttl() { + let t = setup(); + let id = create_test_market(&t); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let market_key = DataKey::Market(id); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&market_key); + + t.client.get_market(&id); + + assert!(ttl(&market_key) > before); +} + +#[test] +fn test_get_bet_rebumps_ttl() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&bet_key); + + t.client.get_bet(&id, &user); + + assert!(ttl(&bet_key) > before); +} + +#[test] +fn test_get_user_bet_count_rebumps_ttl() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&bet_key); + + t.client.get_user_bet_count(&id, &user); + + assert!(ttl(&bet_key) > before); +} + +#[test] +fn test_get_bet_gross_rebumps_ttl() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&bet_key); + + t.client.get_bet_gross(&id, &user); + + assert!(ttl(&bet_key) > before); +} + +#[test] +fn test_get_pending_withdrawal_rebumps_ttl() { + 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); + + let recipient = Address::generate(&t.env); + t.client.add_fee_recipient(&t.admin, &recipient); + let fees = t.client.get_accumulated_fees(); + let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; + t.client.request_withdraw_fees(&recipient, &recipient, &cap); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let wd_key = DataKey::PendingWithdrawal(recipient.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&wd_key); + + t.client.get_pending_withdrawal(&recipient); + + assert!(ttl(&wd_key) > before); +} + +#[test] +fn test_bump_ttl_extends_bet_ttl() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&bet_key); + + assert_eq!(t.client.bump_ttl(&id, &user), Ok(())); + assert!(ttl(&bet_key) > before); +} + +#[test] +fn test_bump_ttl_emits_event_when_expired() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + + let result = t.client.bump_ttl(&id, &user); + assert!(result.is_err()); + + let events = t.env.events().all(); + let last = events.get(events.len() - 1).unwrap(); + let topic0: Val = last.1.get_unchecked(0); + let topic1: Val = last.1.get_unchecked(1); + let topic2: Val = last.1.get_unchecked(2); + assert_eq!(Symbol::try_from_val(&t.env, &topic0).unwrap(), Symbol::new(&t.env, "claim_window_expired")); + assert_eq!(Address::try_from_val(&t.env, &topic1).unwrap(), user); + assert_eq!(u64::try_from_val(&t.env, &topic2).unwrap(), id); +} + +#[test] +fn test_claim_emits_event_when_bet_expired() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + // Simulate bet entry expiry by removing it directly. + t.env.as_contract(&t.client.address, || { + t.env.storage().persistent().remove(&DataKey::Bet(id, user.clone())); + }); + + let result = t.client.claim(&user, &id); + assert!(result.is_err()); + + let events = t.env.events().all(); + let last = events.get(events.len() - 1).unwrap(); + let topic0: Val = last.1.get_unchecked(0); + assert_eq!(Symbol::try_from_val(&t.env, &topic0).unwrap(), Symbol::new(&t.env, "claim_window_expired")); +} + +#[test] +fn test_cancel_refund_emits_event_when_bet_expired() { + 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.cancel_market(&t.admin, &id); + + // Simulate bet entry expiry by removing it directly. + t.env.as_contract(&t.client.address, || { + t.env.storage().persistent().remove(&DataKey::Bet(id, user.clone())); + }); + + let result = t.client.cancel_refund(&user, &id); + assert!(result.is_err()); + + let events = t.env.events().all(); + let last = events.get(events.len() - 1).unwrap(); + let topic0: Val = last.1.get_unchecked(0); + assert_eq!(Symbol::try_from_val(&t.env, &topic0).unwrap(), Symbol::new(&t.env, "claim_window_expired")); +} + // ── Cross-contract interface versioning (issue #84) ─────────────────────────── // Stands in for a referral_registry/leaderboard deployment upgraded to an From 017b2178d7b12fb009d250d446065d0eaa9b7458 Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Mon, 24 Aug 2026 06:56:03 +0100 Subject: [PATCH 2/5] fix(prediction_market): extend TTL on read paths and add bump_ttl Extend TTL in every view/read path that touches user-claimable state: - get_payout, get_market, get_bet, get_user_bet_count, get_bet_gross, get_pending_withdrawal now rebump their storage keys on read. - get_payout also rebumps the BetEntry TTL. Add public bump_ttl(market_id, user) so anyone can refresh a near-expiry BetEntry before it becomes unrecoverable. Emit claim_window_expired event from claim and cancel_refund when the BetEntry is missing so off-chain indexers can alert users. Set bet/payout TTL relative to claim window at resolve time via CLAIM_WINDOW_TTL constant. Closes #174 --- prediction_market/src/lib.rs | 27 +++++++++++------- prediction_market/src/tests.rs | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 4e33669..d4f3135 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -58,6 +58,8 @@ const DISPUTE_WINDOW_SECS: u64 = 604_800; // 7 days const TTL_BUMP: u32 = 3_153_600; const TTL_HIGH: u32 = 6_307_200; const MAX_TTL_REFRESH_PAGE: u32 = 20; +// Claim window: after resolution, users have 30 days to claim winnings. +const CLAIM_WINDOW_TTL: u32 = 2_592_000; // ~30 days in ledgers // Issue #84: bump whenever a function signature, argument order, or return // type that a caller relies on changes. @@ -1327,7 +1329,7 @@ impl PredictionMarketContract { env.storage() .persistent() .extend_ttl(&DataKey::Market(market_id), TTL_BUMP, TTL_HIGH); - Self::bump_if_present(&env, &DataKey::Payout(market_id, user.clone())); + Self::bump_if_present(&env, &DataKey::Payout(market_id, user.clone()), None); let cfg: Config = env.storage().instance().get(&DataKey::Cfg).unwrap(); let this = env.current_contract_address(); @@ -1734,6 +1736,10 @@ impl PredictionMarketContract { env.storage() .persistent() .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + let bet_key = DataKey::Bet(market_id, user); + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); } payout } @@ -1966,11 +1972,12 @@ impl PredictionMarketContract { .ok_or(MarketError::MarketNotFound) } - fn bump_if_present(env: &Env, key: &DataKey) { + fn bump_if_present(env: &Env, key: &DataKey, ttl: Option) { if env.storage().persistent().has(key) { + let ttl = ttl.unwrap_or(TTL_BUMP); env.storage() .persistent() - .extend_ttl(key, TTL_BUMP, TTL_HIGH); + .extend_ttl(key, ttl, TTL_HIGH); } } @@ -1981,14 +1988,14 @@ impl PredictionMarketContract { if !env.storage().persistent().has(&mkt_key) { return Err(MarketError::MarketNotFound); } - Self::bump_if_present(env, &mkt_key); + Self::bump_if_present(env, &mkt_key, None); let bettors: u32 = env .storage() .persistent() .get(&DataKey::BettorCount(market_id)) .unwrap_or(0); - Self::bump_if_present(env, &DataKey::BettorCount(market_id)); + Self::bump_if_present(env, &DataKey::BettorCount(market_id), None); for i in 0..bettors { let slot_key = DataKey::BettorAt(market_id, i); if let Some(addr) = env @@ -1996,13 +2003,13 @@ impl PredictionMarketContract { .persistent() .get::(&slot_key) { - Self::bump_if_present(env, &slot_key); - Self::bump_if_present(env, &DataKey::Bet(market_id, addr.clone())); - Self::bump_if_present(env, &DataKey::Payout(market_id, addr)); + Self::bump_if_present(env, &slot_key, None); + Self::bump_if_present(env, &DataKey::Bet(market_id, addr.clone()), Some(CLAIM_WINDOW_TTL)); + Self::bump_if_present(env, &DataKey::Payout(market_id, addr), Some(CLAIM_WINDOW_TTL)); } } - Self::bump_if_present(env, &DataKey::MarketFees(market_id)); - Self::bump_if_present(env, &DataKey::ForfeitedPool(market_id)); + Self::bump_if_present(env, &DataKey::MarketFees(market_id), None); + Self::bump_if_present(env, &DataKey::ForfeitedPool(market_id), None); env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH); Ok(bettors) } diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 8dd9de7..41c82c3 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1771,6 +1771,58 @@ fn test_get_pending_withdrawal_rebumps_ttl() { assert!(ttl(&wd_key) > before); } +#[test] +fn test_get_payout_rebumps_bet_ttl() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let before = ttl(&bet_key); + + t.client.get_payout(&id, &user); + + assert!(ttl(&bet_key) > before); +} + +#[test] +fn test_resolve_market_sets_claim_window_ttl() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let payout_key = DataKey::Payout(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let bet_before = ttl(&bet_key); + let payout_before = ttl(&payout_key); + + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + assert!(ttl(&bet_key) > bet_before); + assert!(ttl(&payout_key) > payout_before); +} + #[test] fn test_bump_ttl_extends_bet_ttl() { let t = setup(); From b6e61b51ba3de6cd328e3085553b7129fc26075b Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Mon, 24 Aug 2026 07:23:22 +0100 Subject: [PATCH 3/5] fix(prediction_market): set claim-window TTL on write paths and add expiry tests - Use TTL_HIGH for BetEntry at placement so it survives market life + claim window - Use CLAIM_WINDOW_TTL for Payout entries at resolution - Use CLAIM_WINDOW_TTL for BetEntry updates in claim and cancel_refund - Fix get_payout to only extend BetEntry TTL when payout is non-zero - Add tests that simulate actual expiry by advancing ledgers beyond TTL - Add tests for get_payout extending TTL only when payout > 0 - Keep manual-removal tests for missing-entry edge cases Closes #174 --- prediction_market/src/lib.rs | 18 ++-- prediction_market/src/tests.rs | 151 +++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 8 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index d4f3135..db6fda3 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -862,7 +862,7 @@ impl PredictionMarketContract { env.storage().persistent().set(&bet_key, &new_entry); env.storage() .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + .extend_ttl(&bet_key, TTL_HIGH, TTL_HIGH); // ── Bettor index (first bet only) ───────────────────────────────── if !is_increase { @@ -1058,7 +1058,7 @@ impl PredictionMarketContract { env.storage().persistent().set(&payout_key, &payout); env.storage() .persistent() - .extend_ttl(&payout_key, TTL_BUMP, TTL_HIGH); + .extend_ttl(&payout_key, CLAIM_WINDOW_TTL, TTL_HIGH); payout_sum += payout; } } @@ -1256,7 +1256,7 @@ impl PredictionMarketContract { // late to a cancelled market can still pull their refund. env.storage() .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + .extend_ttl(&bet_key, CLAIM_WINDOW_TTL, TTL_HIGH); env.storage() .persistent() .extend_ttl(&DataKey::Market(market_id), TTL_BUMP, TTL_HIGH); @@ -1325,7 +1325,7 @@ impl PredictionMarketContract { env.storage().persistent().set(&bet_key, &entry); env.storage() .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + .extend_ttl(&bet_key, CLAIM_WINDOW_TTL, TTL_HIGH); env.storage() .persistent() .extend_ttl(&DataKey::Market(market_id), TTL_BUMP, TTL_HIGH); @@ -1736,10 +1736,12 @@ impl PredictionMarketContract { env.storage() .persistent() .extend_ttl(&key, TTL_BUMP, TTL_HIGH); - let bet_key = DataKey::Bet(market_id, user); - env.storage() - .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + if payout > 0 { + let bet_key = DataKey::Bet(market_id, user); + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + } } payout } diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 41c82c3..e04c558 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1911,6 +1911,157 @@ fn test_cancel_refund_emits_event_when_bet_expired() { assert_eq!(Symbol::try_from_val(&t.env, &topic0).unwrap(), Symbol::new(&t.env, "claim_window_expired")); } +#[test] +fn test_bet_expires_after_ledgers() { + 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); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert!(ttl(&bet_key) >= TTL_HIGH); + + advance_ledgers(&t.env, 7_500_000); + + assert_eq!(ttl(&bet_key), 0); +} + +#[test] +fn test_get_bet_extends_ttl_when_expired() { + 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); + + advance_ledgers(&t.env, 7_500_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert_eq!(ttl(&bet_key), 0); + + t.client.get_bet(&id, &user); + + assert!(ttl(&bet_key) > 0); +} + +#[test] +fn test_bump_ttl_extends_ttl_when_expired() { + 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); + + advance_ledgers(&t.env, 7_500_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert_eq!(ttl(&bet_key), 0); + + assert_eq!(t.client.bump_ttl(&id, &user), Ok(())); + assert!(ttl(&bet_key) > 0); +} + +#[test] +fn test_payout_expires_after_ledgers() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + let market_contract = t.client.address.clone(); + let payout_key = DataKey::Payout(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert!(ttl(&payout_key) >= CLAIM_WINDOW_TTL); + + advance_ledgers(&t.env, 3_000_000); + + assert_eq!(ttl(&payout_key), 0); +} + +#[test] +fn test_get_payout_extends_ttl_when_expired() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 3_000_000); + + let market_contract = t.client.address.clone(); + let payout_key = DataKey::Payout(id, user.clone()); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert_eq!(ttl(&payout_key), 0); + assert_eq!(ttl(&bet_key), 0); + + t.client.get_payout(&id, &user); + + assert!(ttl(&payout_key) > 0); + assert!(ttl(&bet_key) > 0); +} + +#[test] +fn test_get_payout_does_not_extend_bet_ttl_when_zero() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 3_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + assert_eq!(ttl(&bet_key), 0); + + // Loser's payout key does not exist, so get_payout returns 0 and does not touch BetEntry. + let loser = Address::generate(&t.env); + let loser_bet_key = DataKey::Bet(id, loser.clone()); + let loser_ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let loser_before = loser_ttl(&loser_bet_key); + + t.client.get_payout(&id, &loser); + + assert_eq!(loser_ttl(&loser_bet_key), loser_before); +} + // ── Cross-contract interface versioning (issue #84) ─────────────────────────── // Stands in for a referral_registry/leaderboard deployment upgraded to an From 53f80db366c826123d99eebd766704147221f9a4 Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Tue, 25 Aug 2026 12:41:56 +0100 Subject: [PATCH 4/5] fix(prediction_market): emit claim_window_expired in read paths Emit claim_window_expired event when user-claimable entries are missing in: - get_payout (when Payout key is missing) - get_bet (when BetEntry is missing) - get_user_bet_count (when BetEntry is missing) - get_bet_gross (when BetEntry is missing) This allows off-chain indexers to detect expired claim/refund windows from view calls, not just claim/cancel_refund. Note: get_market has no user parameter, so it cannot emit a meaningful claim_window_expired event. The event is scoped to user-specific read paths where a user address is available. Closes #174 --- prediction_market/src/lib.rs | 62 +++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index db6fda3..d6e6384 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -1562,19 +1562,22 @@ impl PredictionMarketContract { // OPT: returns Bet (ABI-compatible) derived from BetEntry pub fn get_bet(env: Env, market_id: u64, user: Address) -> Result { let bet_key = DataKey::Bet(market_id, user.clone()); - let e: BetEntry = env - .storage() - .persistent() - .get(&bet_key) - .ok_or(MarketError::NoBetFound)?; - env.storage() - .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); - Ok(Bet { - amount: e.net, - is_yes: e.is_yes, - claimed: e.claimed, - }) + if let Some(e) = env.storage().persistent().get(&bet_key) { + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + Ok(Bet { + amount: e.net, + is_yes: e.is_yes, + claimed: e.claimed, + }) + } else { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user, market_id), + (), + ); + Err(MarketError::NoBetFound) + } } pub fn get_market_count(env: Env) -> u64 { @@ -1742,40 +1745,53 @@ impl PredictionMarketContract { .persistent() .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); } + } else { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user, market_id), + (), + ); } payout } pub fn get_user_bet_count(env: Env, market_id: u64, user: Address) -> u32 { let bet_key = DataKey::Bet(market_id, user.clone()); - let count = env + if let Some(e) = env .storage() .persistent() .get::(&bet_key) - .map(|e| e.count) - .unwrap_or(0); - if env.storage().persistent().has(&bet_key) { + { env.storage() .persistent() .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + e.count + } else { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user, market_id), + (), + ); + 0 } - count } pub fn get_bet_gross(env: Env, market_id: u64, user: Address) -> i128 { let bet_key = DataKey::Bet(market_id, user.clone()); - let gross = env + if let Some(e) = env .storage() .persistent() .get::(&bet_key) - .map(|e| e.gross) - .unwrap_or(0); - if env.storage().persistent().has(&bet_key) { + { env.storage() .persistent() .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + e.gross + } else { + env.events().publish( + (Symbol::new(&env, "claim_window_expired"), user, market_id), + (), + ); + 0 } - gross } // ── Internal Helpers ────────────────────────────────────────────────── From c48f8a335171c22a837bbcb23a9130c88945dc28 Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Tue, 25 Aug 2026 13:00:00 +0100 Subject: [PATCH 5/5] fix(prediction_market): cover full claim window for BetEntry and extend bump_ttl - In resolve_market, extend BetEntry TTL with CLAIM_WINDOW_TTL alongside Payout - In get_payout, always extend BetEntry TTL when Payout key exists - In bump_ttl, extend Payout and Market TTLs in addition to BetEntry - Add tests for resolve_market BetEntry TTL, get_payout unconditional BetEntry extension, and bump_ttl covering Payout+Market Closes #174 --- prediction_market/src/lib.rs | 25 ++++++---- prediction_market/src/tests.rs | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index d6e6384..8cd5266 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -1059,6 +1059,9 @@ impl PredictionMarketContract { env.storage() .persistent() .extend_ttl(&payout_key, CLAIM_WINDOW_TTL, TTL_HIGH); + env.storage() + .persistent() + .extend_ttl(&bet_key, CLAIM_WINDOW_TTL, TTL_HIGH); payout_sum += payout; } } @@ -1700,10 +1703,14 @@ impl PredictionMarketContract { pub fn bump_ttl(env: Env, market_id: u64, user: Address) -> Result<(), MarketError> { let bet_key = DataKey::Bet(market_id, user.clone()); - if env.storage().persistent().has(&bet_key) { - env.storage() - .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); + let payout_key = DataKey::Payout(market_id, user.clone()); + let mkt_key = DataKey::Market(market_id); + if env.storage().persistent().has(&bet_key) + || env.storage().persistent().has(&payout_key) + { + Self::bump_if_present(&env, &bet_key, None); + Self::bump_if_present(&env, &payout_key, None); + Self::bump_if_present(&env, &mkt_key, None); Ok(()) } else { env.events().publish( @@ -1739,12 +1746,10 @@ impl PredictionMarketContract { env.storage() .persistent() .extend_ttl(&key, TTL_BUMP, TTL_HIGH); - if payout > 0 { - let bet_key = DataKey::Bet(market_id, user); - env.storage() - .persistent() - .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); - } + let bet_key = DataKey::Bet(market_id, user); + env.storage() + .persistent() + .extend_ttl(&bet_key, TTL_BUMP, TTL_HIGH); } else { env.events().publish( (Symbol::new(&env, "claim_window_expired"), user, market_id), diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index e04c558..11da461 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1845,6 +1845,90 @@ fn test_bump_ttl_extends_bet_ttl() { assert!(ttl(&bet_key) > before); } +#[test] +fn test_resolve_market_extends_bet_ttl_with_claim_window() { + 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); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let payout_key = DataKey::Payout(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let bet_before = ttl(&bet_key); + + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + assert!(ttl(&bet_key) > bet_before); + assert!(ttl(&payout_key) >= CLAIM_WINDOW_TTL); +} + +#[test] +fn test_get_payout_extends_bet_ttl_when_payout_key_exists() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let payout_key = DataKey::Payout(id, user.clone()); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let bet_before = ttl(&bet_key); + let payout_before = ttl(&payout_key); + + t.client.get_payout(&id, &user); + + assert!(ttl(&payout_key) > payout_before); + assert!(ttl(&bet_key) > bet_before); +} + +#[test] +fn test_bump_ttl_extends_payout_and_market_ttl() { + 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); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + + advance_ledgers(&t.env, 6_000_000); + + let market_contract = t.client.address.clone(); + let bet_key = DataKey::Bet(id, user.clone()); + let payout_key = DataKey::Payout(id, user.clone()); + let market_key = DataKey::Market(id); + let ttl = |key: &DataKey| -> u32 { + t.env + .as_contract(&market_contract, || t.env.storage().persistent().get_ttl(key)) + }; + let bet_before = ttl(&bet_key); + let payout_before = ttl(&payout_key); + let market_before = ttl(&market_key); + + assert_eq!(t.client.bump_ttl(&id, &user), Ok(())); + assert!(ttl(&bet_key) > bet_before); + assert!(ttl(&payout_key) > payout_before); + assert!(ttl(&market_key) > market_before); +} + #[test] fn test_bump_ttl_emits_event_when_expired() { let t = setup();