From 85804a9c16e430e894e7fc2ccd6c7c303e49190d Mon Sep 17 00:00:00 2001 From: ogaziedaniel80-droid Date: Sun, 26 Jul 2026 02:29:39 +0100 Subject: [PATCH] fix: TTL bump on markets storage reads Bump persistent storage TTL on hot read paths in markets to prevent frequently-accessed markets from expiring. - MarketStateManager::get_market(): extend TTL on cache miss (persistent read) - QueryManager::get_market_from_storage(): extend TTL on query reads - Added 4 focused tests covering cache miss bump, cache hit non-bump, max_ttl clamp - Changed get_market_from_storage visibility to pub(crate) for testability Closes #861 --- contracts/predictify-hybrid/src/markets.rs | 150 ++++++++++++++++++++- contracts/predictify-hybrid/src/queries.rs | 70 +++++++++- 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 57c360d7..890c0c31 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -858,6 +858,12 @@ impl MarketStateManager { match market { Some(m) => { + // TTL BUMP: extend persistent storage TTL on hot read to prevent + // frequently-accessed markets from expiring. + let effective_ttl = MARKET_TTL_LEDGERS.min(_env.storage().max_ttl()); + _env.storage() + .persistent() + .extend_ttl(market_id, effective_ttl, effective_ttl); // Populate cache for subsequent reads cache.set(market_id.clone(), &m); Ok(m) @@ -3173,7 +3179,8 @@ impl MarketStateLogic { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::storage::Persistent as _; + use soroban_sdk::testutils::{Address as _, EnvTestConfig, Ledger}; #[test] fn test_market_validation() { @@ -3296,6 +3303,147 @@ mod tests { assert_eq!(consensus.total_votes, 0); assert_eq!(consensus.percentage, 0); } + + #[test] + fn test_get_market_bumps_persistent_ttl_on_cache_miss() { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + let market = Market::new( + &env, + admin, + String::from_str(&env, "TTL test?"), + vec![&env, String::from_str(&env, "yes"), String::from_str(&env, "no")], + env.ledger().timestamp() + 86400, + OracleConfig::new( + OracleProvider::reflector(), + Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), + String::from_str(&env, "BTC"), + 2500000, + String::from_str(&env, "gt"), + ), + None, + 86400, + MarketState::Active, + ); + + let market_id = Symbol::new(&env, "ttl_test_market"); + env.storage().persistent().set(&market_id, &market); + env.storage().persistent().extend_ttl(&market_id, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); + let initial_ttl = env.storage().persistent().get_ttl(&market_id); + + // Advance ledger to consume some TTL + env.ledger().with_mut(|li| { + li.sequence_number += 500; + }); + let depleted_ttl = env.storage().persistent().get_ttl(&market_id); + assert!(depleted_ttl < initial_ttl); + + // Clear instance cache so get_market falls through to persistent read + MarketReadCache::new(&env).invalidate(&market_id); + + // get_market cache miss should bump persistent TTL back to full + let result = MarketStateManager::get_market(&env, &market_id); + assert!(result.is_ok()); + let refreshed_ttl = env.storage().persistent().get_ttl(&market_id); + assert_eq!(refreshed_ttl, initial_ttl); + }); + } + + #[test] + fn test_get_market_cache_hit_does_not_bump_persistent_ttl() { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + let market = Market::new( + &env, + admin, + String::from_str(&env, "Cache test?"), + vec![&env, String::from_str(&env, "yes"), String::from_str(&env, "no")], + env.ledger().timestamp() + 86400, + OracleConfig::new( + OracleProvider::reflector(), + Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), + String::from_str(&env, "BTC"), + 2500000, + String::from_str(&env, "gt"), + ), + None, + 86400, + MarketState::Active, + ); + + let market_id = Symbol::new(&env, "cache_test_market"); + env.storage().persistent().set(&market_id, &market); + env.storage().persistent().extend_ttl(&market_id, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); + + // First read: cache miss → bumps persistent TTL, populates cache + let _ = MarketStateManager::get_market(&env, &market_id); + let ttl_after_miss = env.storage().persistent().get_ttl(&market_id); + + env.ledger().with_mut(|li| { + li.sequence_number += 200; + }); + + // Second read: cache hit → should NOT bump persistent TTL + let _ = MarketStateManager::get_market(&env, &market_id); + let ttl_after_hit = env.storage().persistent().get_ttl(&market_id); + assert_eq!(ttl_after_hit, ttl_after_miss - 200); + }); + } + + #[test] + fn test_get_market_ttl_bump_respects_max_ttl() { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + let market = Market::new( + &env, + admin, + String::from_str(&env, "Max TTL test?"), + vec![&env, String::from_str(&env, "yes"), String::from_str(&env, "no")], + env.ledger().timestamp() + 86400, + OracleConfig::new( + OracleProvider::reflector(), + Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), + String::from_str(&env, "BTC"), + 2500000, + String::from_str(&env, "gt"), + ), + None, + 86400, + MarketState::Active, + ); + + let market_id = Symbol::new(&env, "max_ttl_market"); + let max_ttl = env.storage().max_ttl(); + env.storage().persistent().set(&market_id, &market); + env.storage().persistent().extend_ttl(&market_id, max_ttl, max_ttl); + + // Clear cache and read + MarketReadCache::new(&env).invalidate(&market_id); + let _ = MarketStateManager::get_market(&env, &market_id); + + let effective_ttl = env.storage().persistent().get_ttl(&market_id); + // TTL should never exceed max_ttl + assert!(effective_ttl <= max_ttl); + }); + } } ///////////////////////////////////////////////////////////////////////////////// diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 81e1c84b..f50689a0 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -89,7 +89,7 @@ use crate::{ markets::{MarketAnalytics, MarketStateManager, MarketValidator}, oracles::{OracleMetadata, OracleWhitelist}, statistics::StatisticsManager, - storage::EventManager, + storage::{EventManager, MARKET_TTL_LEDGERS}, types::{Market, MarketState, PagedMarketIds, PagedUserBets}, voting::VotingStats, }; @@ -820,11 +820,22 @@ impl QueryManager { /// Retrieve market from persistent storage. /// /// Internal helper to get market data from storage with error handling. - fn get_market_from_storage(env: &Env, market_id: &Symbol) -> Result { - env.storage() + /// Bumps persistent TTL on read to keep hot market data alive. + pub(crate) fn get_market_from_storage(env: &Env, market_id: &Symbol) -> Result { + let market: Market = env + .storage() .persistent() .get(market_id) - .ok_or(Error::MarketNotFound) + .ok_or(Error::MarketNotFound)?; + + // TTL BUMP: extend persistent storage TTL on hot read to prevent + // frequently-queried markets from expiring. + let effective_ttl = MARKET_TTL_LEDGERS.min(env.storage().max_ttl()); + env.storage() + .persistent() + .extend_ttl(market_id, effective_ttl, effective_ttl); + + Ok(market) } /// Calculate payout for a user based on stake and market outcome. @@ -1221,7 +1232,8 @@ impl QueryManager { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::storage::Persistent as _; + use soroban_sdk::testutils::{Address as _, EnvTestConfig, Ledger}; use soroban_sdk::Env; #[test] @@ -1345,4 +1357,52 @@ mod tests { assert!(pool.is_ok()); assert_eq!(pool.unwrap(), 125); } + + #[test] + fn test_get_market_from_storage_bumps_persistent_ttl() { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + let market = Market::new( + &env, + admin, + String::from_str(&env, "Query TTL test?"), + vec![&env, String::from_str(&env, "yes"), String::from_str(&env, "no")], + env.ledger().timestamp() + 86400, + crate::types::OracleConfig::new( + crate::types::OracleProvider::reflector(), + Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), + String::from_str(&env, "BTC"), + 2500000, + String::from_str(&env, "gt"), + ), + None, + 86400, + MarketState::Active, + ); + + let market_id = Symbol::new(&env, "query_ttl_market"); + env.storage().persistent().set(&market_id, &market); + env.storage().persistent().extend_ttl(&market_id, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); + let initial_ttl = env.storage().persistent().get_ttl(&market_id); + + // Advance ledger to consume some TTL + env.ledger().with_mut(|li| { + li.sequence_number += 500; + }); + let depleted_ttl = env.storage().persistent().get_ttl(&market_id); + assert!(depleted_ttl < initial_ttl); + + // get_market_from_storage should bump persistent TTL back to full + let result = QueryManager::get_market_from_storage(&env, &market_id); + assert!(result.is_ok()); + let refreshed_ttl = env.storage().persistent().get_ttl(&market_id); + assert_eq!(refreshed_ttl, initial_ttl); + }); + } }