From eec3826121c64c838bbaae83ac8d1d6bc32ae921 Mon Sep 17 00:00:00 2001 From: Songu3020 Date: Sun, 28 Jun 2026 21:44:03 +0100 Subject: [PATCH] feat: add storage-rent budget pre-flight check on create_market to prevent under-funded archives --- contracts/predictify-hybrid/src/err.rs | 29 +++++++- contracts/predictify-hybrid/src/lib.rs | 8 ++- .../src/market_creation_validation_tests.rs | 69 ++++++++++++++++++- contracts/predictify-hybrid/src/markets.rs | 11 ++- contracts/predictify-hybrid/src/storage.rs | 29 +++++++- 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index d6c9d9dc..cc140862 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -216,6 +216,19 @@ pub enum Error { /// The effective fee (in basis points) exceeds the maximum the caller is willing to accept. /// The bet is rejected to protect the caller from unexpected fee changes. FeeExceedsMax = 508, + /// Storage rent is insufficient for the requested persistent key allocation. + /// The caller must extend the contract instance TTL or provide a larger + /// transaction inclusion fee to cover the storage rent for new market entries. + /// + /// # Formula + /// + /// `rent_needed = persistent_keys_count * MARKET_TTL_LEDGERS` + /// + /// This estimate uses the market-tier TTL (≈365 days) for each new persistent + /// entry. The TTL per key is clamped to `env.storage().max_ttl()` to respect + /// the network's maximum. The instance TTL headroom is also checked to ensure + /// the contract is not on the verge of expiry. + InsufficientStorageRent = 509, } // ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM ===== @@ -612,6 +625,9 @@ impl ErrorHandler { Error::InvalidState => { "Invalid system state. The contract may be in an unexpected condition." } + Error::InsufficientStorageRent => { + "Insufficient storage rent. Extend the contract instance TTL or increase the transaction fee to cover persistent storage rent." + } _ => "An error occurred. Please verify your parameters and try again.", }; String::from_str(env, msg) @@ -740,6 +756,7 @@ impl ErrorHandler { Error::AdminNotSet | Error::DisputeFeeFailed => RecoveryStrategy::ManualIntervention, Error::InvalidState | Error::InvalidOracleConfig => RecoveryStrategy::NoRecovery, Error::FeeExceedsMax => RecoveryStrategy::Retry, + Error::InsufficientStorageRent => RecoveryStrategy::Retry, _ => RecoveryStrategy::Abort, } } @@ -1313,6 +1330,11 @@ impl ErrorHandler { ErrorCategory::Financial, RecoveryStrategy::Retry, ), + Error::InsufficientStorageRent => ( + ErrorSeverity::Medium, + ErrorCategory::System, + RecoveryStrategy::Retry, + ), _ => ( ErrorSeverity::Medium, ErrorCategory::Unknown, @@ -1350,6 +1372,9 @@ impl ErrorHandler { "The oracle is temporarily unavailable. Please try again later." } (Error::InvalidInput, _) => "Check your input parameters and try again.", + (Error::InsufficientStorageRent, _) => { + "Increase the transaction inclusion fee or extend the contract instance TTL to cover storage rent." + } (_, ErrorCategory::Validation) => "Review and correct the input data.", (_, ErrorCategory::System) => { "A system error occurred. Contact support if the issue persists." @@ -1419,6 +1444,7 @@ impl Error { "Bets have already been placed on this market (cannot update)" } Error::InsufficientBalance => "Insufficient balance for operation", + Error::InsufficientStorageRent => "Insufficient storage rent for persistent key allocation", Error::OracleUnavailable => "Oracle is unavailable", Error::InvalidOracleConfig => "Invalid oracle configuration", Error::GasBudgetExceeded => "Gas budget exceeded", @@ -1590,6 +1616,7 @@ impl Error { Error::NoPendingFeeCommit => "NO_PENDING_FEE_COMMIT", Error::FeeRevealTooEarly => "FEE_REVEAL_TOO_EARLY", Error::FeePreimageMismatch => "FEE_PREIMAGE_MISMATCH", + Error::InsufficientStorageRent => "INSUFFICIENT_STORAGE_RENT", } } } @@ -1697,7 +1724,7 @@ mod tests { Error::CumulativeExtensionCapHit, Error::DuplicateMarketId, Error::IllegalMarketStateTransition, - Error::InsufficientStorageRentBudget, + Error::InsufficientStorageRent, Error::DisputeStakeCapExceeded, Error::UpgradeChainMismatch, Error::ReplayedOverride, diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b9962bee..deba1e8a 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -181,7 +181,7 @@ use admin::{ }; pub use admin::Severity; pub use err::Error; -use crate::storage::DataKey; +use crate::storage::{check_market_creation_rent, DataKey, MARKET_TTL_LEDGERS}; // Backwards-compatible re-export for existing module paths. pub mod errors { pub use crate::err::*; @@ -779,8 +779,14 @@ impl PredictifyHybrid { winnings_swept: false, }; + // Pre-flight check: ensure sufficient storage rent budget + if let Err(e) = check_market_creation_rent(&env) { + panic_with_error!(env, e); + } + // Store the market env.storage().persistent().set(&market_id, &market); + env.storage().persistent().extend_ttl(&market_id, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); // Emit events EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, end_time); diff --git a/contracts/predictify-hybrid/src/market_creation_validation_tests.rs b/contracts/predictify-hybrid/src/market_creation_validation_tests.rs index daded0b4..3a651539 100644 --- a/contracts/predictify-hybrid/src/market_creation_validation_tests.rs +++ b/contracts/predictify-hybrid/src/market_creation_validation_tests.rs @@ -1,7 +1,7 @@ use crate::err::Error; use crate::types::{OracleConfig, OracleProvider}; use crate::{PredictifyHybrid, PredictifyHybridClient}; -use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; use soroban_sdk::{ vec, Address, ConversionError, Env, Error as HostError, InvokeError, String, Symbol, Vec, }; @@ -302,3 +302,70 @@ fn create_event_rejects_past_end_time() { assert_contract_error(result, Error::InvalidDuration); } + +// ── Storage Rent Pre-flight Tests ───────────────────────────────────────────── + +#[test] +fn create_market_accepts_sufficient_storage_rent() { + let setup = TestSetup::new(); + let market_id = setup.client().create_market( + &setup.admin, + &String::from_str(&setup.env, "Will sufficient storage rent be accepted?"), + &setup.valid_outcomes(), + &30u32, + &setup.valid_oracle_config(), + &None, + &86_400u64, + ); + let market = setup.client().get_market(&market_id).unwrap(); + assert_eq!( + market.question, + String::from_str(&setup.env, "Will sufficient storage rent be accepted?") + ); +} + +#[test] +fn create_market_rejects_overflow_ledger_sequence() { + let setup = TestSetup::new(); + // Set ledger sequence near u32::MAX so that current_seq + effective_ttl overflows. + setup.env.ledger().set(LedgerInfo { + sequence_number: u32::MAX - 1_000, + timestamp: 1_000_000_000, + ..Default::default() + }); + let result = setup.client().try_create_market( + &setup.admin, + &String::from_str(&setup.env, "Will overflow be rejected?"), + &setup.valid_outcomes(), + &30u32, + &setup.valid_oracle_config(), + &None, + &86_400u64, + ); + assert_contract_error(result, Error::InsufficientStorageRent); +} + +#[test] +fn create_market_accepts_normal_ledger_sequence() { + let setup = TestSetup::new(); + // Set ledger to a realistic sequence to confirm normal-path passes. + setup.env.ledger().set(LedgerInfo { + sequence_number: 1_000_000, + timestamp: 1_000_000_000, + ..Default::default() + }); + let market_id = setup.client().create_market( + &setup.admin, + &String::from_str(&setup.env, "Will normal sequence be accepted?"), + &setup.valid_outcomes(), + &30u32, + &setup.valid_oracle_config(), + &None, + &86_400u64, + ); + let market = setup.client().get_market(&market_id).unwrap(); + assert_eq!( + market.question, + String::from_str(&setup.env, "Will normal sequence be accepted?") + ); +} diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 42b96f8a..45894437 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contracttype, token, vec, Address, Env, Map, String, Symbol, V // use crate::config; // Unused import use crate::err::Error; -use crate::storage::{DataKey, MARKET_CACHE_TTL_LEDGERS}; +use crate::storage::{check_market_creation_rent, DataKey, MARKET_CACHE_TTL_LEDGERS, MARKET_TTL_LEDGERS}; use crate::types::*; // Oracle imports removed - not currently used @@ -128,15 +128,12 @@ 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 to prevent under-funded archives - let min_rent_budget = env.storage().max_ttl(); - if env.ledger().sequence() + min_rent_budget > u32::MAX { - return Err(Error::InsufficientStorageRentBudget); - } + // Pre-flight check: ensure sufficient storage rent budget + check_market_creation_rent(env)?; // Store market env.storage().persistent().set(&market_id, &market); - env.storage().persistent().extend_ttl(&market_id, min_rent_budget, min_rent_budget); + env.storage().persistent().extend_ttl(&market_id, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); // CACHE INVALIDATION: ensure cache is empty for new market MarketReadCache::new(env).invalidate(&market_id); diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 200b4462..9afcb03c 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -8,7 +8,7 @@ use soroban_sdk::{contracttype, Address, Env, IntoVal, Map, Symbol, Val, Vec}; const STORAGE_CONFIG_KEY: &str = "storage_config"; const LEDGERS_PER_DAY: u32 = 17_280; const BALANCE_TTL_LEDGERS: u32 = 31 * LEDGERS_PER_DAY; -const MARKET_TTL_LEDGERS: u32 = 365 * LEDGERS_PER_DAY; +pub const MARKET_TTL_LEDGERS: u32 = 365 * LEDGERS_PER_DAY; const EVENT_TTL_LEDGERS: u32 = 90 * LEDGERS_PER_DAY; const ARCHIVE_TTL_LEDGERS: u32 = 365 * LEDGERS_PER_DAY; @@ -18,6 +18,33 @@ const ARCHIVE_TTL_LEDGERS: u32 = 365 * 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; + +/// Pre-flight storage-rent check for market creation. +/// +/// Verifies that the ledger has enough sequence headroom so the new persistent +/// entry's `live_until_ledger` does not overflow `u32`. +/// +/// # Formula +/// +/// 1. `effective_ttl = MIN(MARKET_TTL_LEDGERS, env.storage().max_ttl())` +/// 2. The current ledger sequence plus `effective_ttl` must not overflow `u32`. +/// +/// # Errors +/// +/// Returns [`Error::InsufficientStorageRent`] if the sequence would overflow. +pub fn check_market_creation_rent(env: &Env) -> Result<(), Error> { + let effective_ttl = MARKET_TTL_LEDGERS.min(env.storage().max_ttl()); + let current_seq = env.ledger().sequence(); + + if current_seq.checked_add(effective_ttl).is_none() { + return Err(Error::InsufficientStorageRent); + } + + Ok(()) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum StorageTtlTier { Balance,