Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -1313,6 +1330,11 @@ impl ErrorHandler {
ErrorCategory::Financial,
RecoveryStrategy::Retry,
),
Error::InsufficientStorageRent => (
ErrorSeverity::Medium,
ErrorCategory::System,
RecoveryStrategy::Retry,
),
_ => (
ErrorSeverity::Medium,
ErrorCategory::Unknown,
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
}
}
}
Expand Down Expand Up @@ -1697,7 +1724,7 @@ mod tests {
Error::CumulativeExtensionCapHit,
Error::DuplicateMarketId,
Error::IllegalMarketStateTransition,
Error::InsufficientStorageRentBudget,
Error::InsufficientStorageRent,
Error::DisputeStakeCapExceeded,
Error::UpgradeChainMismatch,
Error::ReplayedOverride,
Expand Down
8 changes: 7 additions & 1 deletion contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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?")
);
}
11 changes: 4 additions & 7 deletions contracts/predictify-hybrid/src/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 28 additions & 1 deletion contracts/predictify-hybrid/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down
Loading