diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..a5f38a10 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -153,7 +153,9 @@ use admin::{ }; pub use admin::Severity; pub use err::Error; -use crate::storage::{check_market_creation_rent, DataKey, MARKET_TTL_LEDGERS}; +use crate::storage::{ + check_market_creation_rent, check_market_creation_rent_budget, DataKey, MARKET_TTL_LEDGERS, +}; // Backwards-compatible re-export for existing module paths. pub mod errors { pub use crate::err::*; @@ -409,10 +411,23 @@ impl PredictifyHybrid { winnings_swept: false, }; - // Pre-flight check: ensure sufficient storage rent budget + // Pre-flight checks: ensure sufficient storage rent budget. + // + // This entrypoint returns `Symbol` rather than `Result`, so a failed + // check is surfaced as a panic. Callers using `try_create_market` + // observe `Error::InsufficientStorageRent` or + // `Error::InsufficientStorageRentBudget` respectively. + // + // The aggregate check runs second and covers all three persistent + // entries this entrypoint writes: the market record below, the + // platform statistics record via `record_market_created`, and the + // audit trail record via `append_record`. if let Err(e) = check_market_creation_rent(&env) { panic_with_error!(env, e); } + if let Err(e) = check_market_creation_rent_budget(&env) { + panic_with_error!(env, e); + } // Store the market env.storage().persistent().set(&market_id, &market); @@ -7697,4 +7712,4 @@ mod tests { assert!(guard.consumed() == 0); // No instructions consumed yet in test host }); } -}mod dispute_multisig; +}mod dispute_multisig; \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index ab5db911..20af25f2 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -4,7 +4,10 @@ use soroban_sdk::{contracttype, token, vec, Address, Env, Map, String, Symbol, V // use crate::config; // Unused import use crate::err::Error; -use crate::storage::{check_market_creation_rent, DataKey, MARKET_CACHE_TTL_LEDGERS, MARKET_TTL_LEDGERS}; +use crate::storage::{ + check_market_creation_rent, check_market_creation_rent_budget, DataKey, + MARKET_CACHE_TTL_LEDGERS, MARKET_TTL_LEDGERS, +}; use crate::types::*; // Oracle imports removed - not currently used @@ -128,8 +131,13 @@ impl MarketCreator { // Use the generated id after creation in higher-level flows when event metadata is required. let _ = MarketUtils::process_creation_fee(env, &admin)?; - // Pre-flight check: ensure sufficient storage rent budget + // Pre-flight checks: ensure sufficient storage rent budget. + // The single-key check guards the market record itself; the aggregate + // check additionally covers every persistent entry a full creation + // flow writes, so a caller cannot get partway through and strand a + // market record whose companion entries could not be given a TTL. check_market_creation_rent(env)?; + check_market_creation_rent_budget(env)?; // Store market env.storage().persistent().set(&market_id, &market); diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..cd84eac1 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -3,7 +3,7 @@ use super::*; use crate::markets::{MarketStateLogic, MarketStateManager}; use crate::types::{Balance, ReflectorAsset, Market, MarketState, OracleConfig}; -use soroban_sdk::{contracttype, Address, Env, IntoVal, Map, Symbol, Val, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Map, Symbol, Val, Vec}; const STORAGE_CONFIG_KEY: &str = "storage_config"; const LEDGERS_PER_DAY: u32 = 17_280; @@ -22,8 +22,22 @@ pub const PLACE_BETS_IDEM_TTL_LEDGERS: u32 = 7 * LEDGERS_PER_DAY; /// Increase for longer-lived deployments; decrease to reduce ledger rent costs. pub const MARKET_CACHE_TTL_LEDGERS: u32 = 100; -/// Number of persistent storage keys allocated during a single `create_market` call. -pub const MARKET_CREATION_PERSISTENT_KEYS: u32 = 1; +/// Number of persistent storage keys allocated during a single `create_market` +/// call on the contract entrypoint path. +/// +/// The entrypoint writes three persistent entries per creation: +/// +/// 1. the market record itself, keyed by `market_id`; +/// 2. the platform statistics record, via +/// [`crate::statistics::StatisticsManager::record_market_created`]; +/// 3. the audit trail record, via +/// [`crate::audit_trail::AuditTrailManager::append_record`]. +/// +/// The internal `MarketCreator::create_market` helper writes only entry 1. +/// The aggregate preflight uses this constant as an upper bound so that a +/// creation which succeeds on the helper path cannot fail partway through the +/// entrypoint path. +pub const MARKET_CREATION_PERSISTENT_KEYS: u32 = 3; /// Pre-flight storage-rent check for market creation. /// @@ -49,6 +63,48 @@ pub fn check_market_creation_rent(env: &Env) -> Result<(), Error> { Ok(()) } +/// Pre-flight aggregate storage-rent budget check for market creation. +/// +/// Where [`check_market_creation_rent`] validates headroom for a single +/// persistent entry, this check validates headroom for *every* persistent entry +/// written during one `create_market` call, as counted by +/// [`MARKET_CREATION_PERSISTENT_KEYS`]. +/// +/// This is the stricter of the two checks: any ledger state rejected by +/// [`check_market_creation_rent`] is also rejected here, but not the reverse. +/// Calling it before the first persistent write prevents a partially-written +/// market, where the market record is stored but the statistics or audit entry +/// cannot be given its full TTL. +/// +/// # Formula +/// +/// 1. `effective_ttl = MIN(MARKET_TTL_LEDGERS, env.storage().max_ttl())` +/// 2. `required = effective_ttl * MARKET_CREATION_PERSISTENT_KEYS` +/// 3. The current ledger sequence plus `required` must not overflow `u32`. +/// +/// Step 2 is itself checked: `MARKET_CREATION_PERSISTENT_KEYS` is public, so a +/// future value large enough to overflow the multiplication is treated as an +/// exhausted budget rather than wrapping. +/// +/// # Errors +/// +/// Returns [`Error::InsufficientStorageRentBudget`] if the aggregate budget +/// would overflow `u32`. +pub fn check_market_creation_rent_budget(env: &Env) -> Result<(), Error> { + let effective_ttl = MARKET_TTL_LEDGERS.min(env.storage().max_ttl()); + + let required = effective_ttl + .checked_mul(MARKET_CREATION_PERSISTENT_KEYS) + .ok_or(Error::InsufficientStorageRentBudget)?; + + env.ledger() + .sequence() + .checked_add(required) + .ok_or(Error::InsufficientStorageRentBudget)?; + + Ok(()) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum StorageTtlTier { Balance, @@ -85,8 +141,12 @@ pub enum DataKey { /// Instance storage cache key for Market structs, keyed by market_id. /// Used by MarketReadCache in markets.rs. MarketCache(Symbol), - /// Nonce for admin override replay protection. - AdminOverrideNonce(Address), + /// Minimum anti-grief stake floor for disputes. + AntiGriefFloor, + /// Global protocol configuration record. + GlobalConfig, + /// Consumed `place_bets` idempotency key, scoped per user. + PlaceBetsIdem(Address, BytesN<32>), } /// Storage format version for migration tracking @@ -719,8 +779,16 @@ impl BalanceStorage { let key = Self::get_key(env, &balance.user, &balance.asset); env.storage().persistent().set(&key, balance); - // Extend TTL to ensure balance persists (approx 30 days) - env.storage().persistent().extend_ttl(&key, 535680, 535680); + // Extend TTL via the Balance tier so the write honours any + // `StorageConfig` override and the `max_ttl()` clamp. + StorageOptimizer::extend_persistent_ttl( + env, + &key, + StorageOptimizer::ttl_for_tier( + &StorageOptimizer::get_storage_config(env), + StorageTtlTier::Balance, + ), + ); Ok(()) } @@ -1319,6 +1387,113 @@ mod tests { }); } + // ── Storage Rent Aggregate Budget Pre-flight Tests ─────────────────────── + + #[test] + fn test_budget_check_accepts_normal_ledger_sequence() { + let (env, contract_id) = create_contract_env(); + env.ledger().with_mut(|li| { + li.sequence_number = 1_000_000; + }); + + env.as_contract(&contract_id, || { + assert_eq!(check_market_creation_rent_budget(&env), Ok(())); + }); + } + + #[test] + fn test_budget_check_rejects_aggregate_overflow() { + let (env, contract_id) = create_contract_env(); + + env.as_contract(&contract_id, || { + let effective_ttl = MARKET_TTL_LEDGERS.min(env.storage().max_ttl()); + let required = effective_ttl * MARKET_CREATION_PERSISTENT_KEYS; + + // Sequence chosen so a single key still fits but the aggregate does not. + let sequence = u32::MAX - required + 1; + env.ledger().with_mut(|li| { + li.sequence_number = sequence; + }); + + assert_eq!( + check_market_creation_rent_budget(&env), + Err(Error::InsufficientStorageRentBudget) + ); + }); + } + + /// The aggregate check must be strictly stronger than the single-key check. + /// + /// At a sequence where one key fits but three do not, the original preflight + /// returns `Ok` while the budget check rejects — this is the behaviour the + /// single-key check could not provide. + #[test] + fn test_budget_check_is_stricter_than_single_key_check() { + let (env, contract_id) = create_contract_env(); + + env.as_contract(&contract_id, || { + let effective_ttl = MARKET_TTL_LEDGERS.min(env.storage().max_ttl()); + assert!( + MARKET_CREATION_PERSISTENT_KEYS > 1, + "test is meaningless if creation writes a single key" + ); + + // Fits one key with room to spare, but not the full aggregate. + let sequence = u32::MAX - effective_ttl - 1; + env.ledger().with_mut(|li| { + li.sequence_number = sequence; + }); + + assert_eq!(check_market_creation_rent(&env), Ok(())); + assert_eq!( + check_market_creation_rent_budget(&env), + Err(Error::InsufficientStorageRentBudget) + ); + }); + } + + #[test] + fn test_budget_check_rejects_at_u32_max_sequence() { + let (env, contract_id) = create_contract_env(); + env.ledger().with_mut(|li| { + li.sequence_number = u32::MAX; + }); + + env.as_contract(&contract_id, || { + assert_eq!( + check_market_creation_rent_budget(&env), + Err(Error::InsufficientStorageRentBudget) + ); + }); + } + + #[test] + fn test_balance_ttl_honours_storage_config_override() { + let (env, contract_id) = create_contract_env(); + let user = ::generate(&env); + let asset = ReflectorAsset::BTC; + + env.as_contract(&contract_id, || { + let mut config = StorageOptimizer::get_storage_config(&env); + config.balance_ttl_ledgers = 10_000; + StorageOptimizer::update_storage_config(&env, &config).unwrap(); + + BalanceStorage::set_balance( + &env, + &Balance { + user: user.clone(), + asset: asset.clone(), + amount: 10, + }, + ) + .unwrap(); + + let key = BalanceStorage::get_key(&env, &user, &asset); + let expected = StorageOptimizer::clamp_persistent_ttl(&env, 10_000); + assert_eq!(env.storage().persistent().get_ttl(&key), expected); + }); + } + #[test] fn test_storage_utils() { let env = Env::default(); @@ -1332,4 +1507,4 @@ mod tests { // Recommendations may be empty for small markets, so we just check it doesn't panic // len() is always >= 0 for Vec } -} +} \ No newline at end of file