diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 036f7db..8cd5266 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 ───────────────────────────────────────────────────────────────── @@ -57,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. @@ -859,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 { @@ -1055,7 +1058,10 @@ 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); + env.storage() + .persistent() + .extend_ttl(&bet_key, CLAIM_WINDOW_TTL, TTL_HIGH); payout_sum += payout; } } @@ -1225,11 +1231,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); @@ -1244,7 +1259,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); @@ -1282,11 +1297,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); @@ -1304,11 +1328,11 @@ 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); - 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(); @@ -1526,21 +1550,37 @@ 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 e: BetEntry = env - .storage() - .persistent() - .get(&DataKey::Bet(market_id, user)) - .ok_or(MarketError::NoBetFound)?; - Ok(Bet { - amount: e.net, - is_yes: e.is_yes, - claimed: e.claimed, - }) + let bet_key = DataKey::Bet(market_id, user.clone()); + 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 { @@ -1661,6 +1701,26 @@ 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()); + 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( + (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 +1729,74 @@ 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); + 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), + (), + ); + } + 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()); + if let Some(e) = env + .storage() .persistent() - .get::(&DataKey::Bet(market_id, user)) - .map(|e| e.count) - .unwrap_or(0) + .get::(&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 + } } pub fn get_bet_gross(env: Env, market_id: u64, user: Address) -> i128 { - env.storage() + let bet_key = DataKey::Bet(market_id, user.clone()); + if let Some(e) = env + .storage() .persistent() - .get::(&DataKey::Bet(market_id, user)) - .map(|e| e.gross) - .unwrap_or(0) + .get::(&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 + } } // ── Internal Helpers ────────────────────────────────────────────────── @@ -1893,11 +1995,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); } } @@ -1908,14 +2011,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 @@ -1923,13 +2026,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 8cc0965..11da461 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -1626,6 +1626,526 @@ 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_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(); + 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_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(); + 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")); +} + +#[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