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: 25 additions & 4 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,11 @@ pub enum Error {
ExtensionDenied = 416,
/// Gas budget cap has been exceeded for the operation.
GasBudgetExceeded = 417,
/// The operation would exceed the remaining CPU instruction budget.
/// This is a pre-emptive guard that aborts before the host runs out of resources.
OperationWouldExceedBudget = 418,
/// Admin address has not been set. Contract initialization is incomplete.
AdminNotSet = 418,
AdminNotSet = 419,
/// Asset decimals mismatch. Stored decimals differ from the live SAC decimals.
/// This prevents silently inflated or deflated stakes via normalize_amount.
AssetDecimalsMismatch = 439,
Expand Down Expand Up @@ -185,7 +188,7 @@ pub enum Error {
/// Market ID already exists in the registry. Cannot create duplicate market IDs.
DuplicateMarketId = 441,

// ===== CIRCUIT BREAKER ERRORS ====="
// ===== CIRCUIT BREAKER ERRORS =====
/// Circuit breaker has not been initialized. Initialize before use.
CBNotInitialized = 500,
/// Circuit breaker is already open (active). Cannot open again.
Expand Down Expand Up @@ -612,6 +615,9 @@ impl ErrorHandler {
Error::InvalidState => {
"Invalid system state. The contract may be in an unexpected condition."
}
Error::OperationWouldExceedBudget => {
"Operation would exceed CPU instruction budget. The market has too many participants to process in this transaction."
}
_ => "An error occurred. Please verify your parameters and try again.",
};
String::from_str(env, msg)
Expand Down Expand Up @@ -740,6 +746,7 @@ impl ErrorHandler {
Error::AdminNotSet | Error::DisputeFeeFailed => RecoveryStrategy::ManualIntervention,
Error::InvalidState | Error::InvalidOracleConfig => RecoveryStrategy::NoRecovery,
Error::FeeExceedsMax => RecoveryStrategy::Retry,
Error::OperationWouldExceedBudget => RecoveryStrategy::NoRecovery,
_ => RecoveryStrategy::Abort,
}
}
Expand Down Expand Up @@ -1141,7 +1148,8 @@ impl ErrorHandler {
| Error::AdminNotSet
| Error::DisputeFeeFailed
| Error::InvalidState
| Error::InvalidOracleConfig => 0,
| Error::InvalidOracleConfig
| Error::OperationWouldExceedBudget => 0,
_ => 1,
}
}
Expand Down Expand Up @@ -1313,6 +1321,11 @@ impl ErrorHandler {
ErrorCategory::Financial,
RecoveryStrategy::Retry,
),
Error::OperationWouldExceedBudget => (
ErrorSeverity::Critical,
ErrorCategory::System,
RecoveryStrategy::NoRecovery,
),
_ => (
ErrorSeverity::Medium,
ErrorCategory::Unknown,
Expand Down Expand Up @@ -1350,6 +1363,9 @@ impl ErrorHandler {
"The oracle is temporarily unavailable. Please try again later."
}
(Error::InvalidInput, _) => "Check your input parameters and try again.",
(Error::OperationWouldExceedBudget, _) => {
"The operation requires too much CPU time. Try with fewer winners or split across multiple transactions."
}
(_, ErrorCategory::Validation) => "Review and correct the input data.",
(_, ErrorCategory::System) => {
"A system error occurred. Contact support if the issue persists."
Expand Down Expand Up @@ -1491,6 +1507,9 @@ impl Error {
Error::NoPendingFeeCommit => "No pending fee config commit found",
Error::FeeRevealTooEarly => "Fee config reveal attempted too early",
Error::FeePreimageMismatch => "Preimage does not match the committed hash",
Error::OperationWouldExceedBudget => {
"Operation would exceed CPU instruction budget"
}
}
}

Expand Down Expand Up @@ -1590,6 +1609,7 @@ impl Error {
Error::NoPendingFeeCommit => "NO_PENDING_FEE_COMMIT",
Error::FeeRevealTooEarly => "FEE_REVEAL_TOO_EARLY",
Error::FeePreimageMismatch => "FEE_PREIMAGE_MISMATCH",
Error::OperationWouldExceedBudget => "OPERATION_WOULD_EXCEED_BUDGET",
}
}
}
Expand Down Expand Up @@ -1701,6 +1721,7 @@ mod tests {
Error::DisputeStakeCapExceeded,
Error::UpgradeChainMismatch,
Error::ReplayedOverride,
Error::OperationWouldExceedBudget,
]
}

Expand Down Expand Up @@ -2244,4 +2265,4 @@ mod tests {
assert_eq!(recovery.max_recovery_attempts, 2);
assert!(recovery.recovery_success_timestamp.is_some());
}
}
}
95 changes: 95 additions & 0 deletions contracts/predictify-hybrid/src/gas.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#![allow(dead_code)]
use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol};

use crate::err::Error;

/// Stores the gas limit configured by an admin for a specific operation.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -131,3 +133,96 @@ impl GasTracker {
}
}
}

/// BudgetGuard provides CPU instruction budget monitoring at checkpoints.
///
/// It records the CPU instruction cost at creation and checks remaining budget
/// at each checkpoint, returning `Error::OperationWouldExceedBudget` if the
/// remaining budget falls below the configured threshold.
///
/// This guard is designed to be used in hot-path loops (e.g., resolution and
/// payout distribution) to abort gracefully before the host runs out of resources.
///
/// # Example
///
/// ```rust,ignore
/// let budget_guard = BudgetGuard::new(env, 50000);
///
/// // At each checkpoint:
/// budget_guard.check()?;
/// ```
///
/// # Usage Guidelines
///
/// - Create the guard once at the start of the operation
/// - Call `check()` at strategic checkpoints (every 10-50 iterations)
/// - Use a threshold of 50,000-100,000 instructions for safe abort
#[derive(Clone)]
pub struct BudgetGuard {
env: Env,
start_instructions: u64,
threshold_remaining: u64,
}

impl BudgetGuard {
/// Create a new BudgetGuard with the current CPU instruction cost.
///
/// # Arguments
/// * `env` - Soroban environment
/// * `threshold_remaining` - Minimum remaining instructions required
/// (recommended: 50,000 for resolution, 100,000 for payout loops)
///
/// # Returns
/// A new BudgetGuard instance
///
/// # Note
/// The threshold should be high enough to complete the current iteration
/// plus any post-loop cleanup operations.
pub fn new(env: &Env, threshold_remaining: u64) -> Self {
let start_instructions = env.budget().cpu_instruction_cost();
BudgetGuard {
env: env.clone(),
start_instructions,
threshold_remaining,
}
}

/// Check if enough budget remains to continue the operation.
///
/// This method reads the current CPU instruction cost from the environment
/// and compares the consumed amount against the threshold.
///
/// # Returns
/// * `Ok(())` - Enough budget remains
/// * `Err(Error::OperationWouldExceedBudget)` - Budget would be exceeded
///
/// # Performance
/// This is a lightweight call that reads a single value from the host.
/// It should be called at regular intervals, not on every iteration.
pub fn check(&self) -> Result<(), Error> {
let current = self.env.budget().cpu_instruction_cost();
let consumed = current.saturating_sub(self.start_instructions);

if consumed >= self.threshold_remaining {
return Err(Error::OperationWouldExceedBudget);
}

Ok(())
}

/// Get the current remaining budget consumed so far.
///
/// # Returns
/// The number of CPU instructions consumed since the guard was created.
pub fn consumed(&self) -> u64 {
let current = self.env.budget().cpu_instruction_cost();
current.saturating_sub(self.start_instructions)
}

/// Get the configured threshold.
pub fn threshold(&self) -> u64 {
self.threshold_remaining
}
}
#[cfg(test)]
mod gas_test;
145 changes: 29 additions & 116 deletions contracts/predictify-hybrid/src/gas_test.rs
Original file line number Diff line number Diff line change
@@ -1,117 +1,30 @@
#![cfg(test)]

use crate::gas::{GasTracker, GasUsage};
use crate::PredictifyHybrid;
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Events, Ledger},
token::StellarAssetClient,
vec, Address, Env, String, Symbol, TryIntoVal, Val,
};

#[test]
fn test_gas_limit_storage() {
let env = Env::default();
let contract_id = env.register(PredictifyHybrid, ());
let operation = symbol_short!("test_op");

env.as_contract(&contract_id, || {
// Default should be None
let (cpu, mem) = GasTracker::get_limits(&env, operation.clone());
assert_eq!(cpu, None);
assert_eq!(mem, None);

// Set limits
GasTracker::set_limit(&env, operation.clone(), 5000, 1000);
let (cpu, mem) = GasTracker::get_limits(&env, operation);
assert_eq!(cpu, Some(5000));
assert_eq!(mem, Some(1000));
});
}

#[test]
fn test_gas_tracking_observability() {
let env = Env::default();
let contract_id = env.register(PredictifyHybrid, ());
let operation = symbol_short!("test_op");

env.as_contract(&contract_id, || {
// Set mock cost
GasTracker::set_test_cost(&env, operation.clone(), 1234, 567);

let marker = GasTracker::start_tracking(&env);
GasTracker::end_tracking(&env, operation.clone(), marker);
});

// Verify event emission
let events = env.events().all();
let last_event = events.last().expect("Event should have been published");

// Event structure: (ContractAddress, Topics, Data)
let topics = &last_event.1;
let topic_0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();
let topic_1: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap();

assert_eq!(topic_0, symbol_short!("gas_used"));
assert_eq!(topic_1, operation);

let cost: GasUsage = last_event.2.try_into_val(&env).unwrap();
assert_eq!(cost.cpu, 1234);
assert_eq!(cost.mem, 567);
}

#[test]
#[should_panic(expected = "Gas budget cap exceeded")]
fn test_gas_limit_enforcement_cpu() {
let env = Env::default();
let contract_id = env.register(PredictifyHybrid, ());
let operation = symbol_short!("test_op");

env.as_contract(&contract_id, || {
// Set CPU limit to 500
GasTracker::set_limit(&env, operation.clone(), 500, 2000);

// Mock the cost to 1000 (exceeds CPU limit)
GasTracker::set_test_cost(&env, operation.clone(), 1000, 1000);

let marker = GasTracker::start_tracking(&env);
GasTracker::end_tracking(&env, operation, marker);
});
}

#[test]
#[should_panic(expected = "Gas budget cap exceeded")]
fn test_gas_limit_enforcement_mem() {
let env = Env::default();
let contract_id = env.register(PredictifyHybrid, ());
let operation = symbol_short!("test_op");

env.as_contract(&contract_id, || {
// Set Mem limit to 500
GasTracker::set_limit(&env, operation.clone(), 2000, 500);

// Mock the cost to 1000 (exceeds Mem limit)
GasTracker::set_test_cost(&env, operation.clone(), 1000, 1000);

let marker = GasTracker::start_tracking(&env);
GasTracker::end_tracking(&env, operation, marker);
});
}

#[test]
fn test_gas_limit_not_exceeded() {
let env = Env::default();
let contract_id = env.register(PredictifyHybrid, ());
let operation = symbol_short!("test_op");

env.as_contract(&contract_id, || {
// Set limits
GasTracker::set_limit(&env, operation.clone(), 1500, 1500);

// Mock the cost to 1000 (within limits)
GasTracker::set_test_cost(&env, operation.clone(), 1000, 1000);

let marker = GasTracker::start_tracking(&env);
GasTracker::end_tracking(&env, operation, marker);
});
#[cfg(test)]
mod budget_guard_tests {
use super::*;
use soroban_sdk::Env;
use crate::err::Error;

#[test]
fn test_budget_guard_aborts_when_budget_exceeds_threshold() {
let env = Env::default();
let guard = BudgetGuard::new(&env, u64::MAX);
let result = guard.check();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), Error::OperationWouldExceedBudget);
}

#[test]
fn test_budget_guard_passes_with_low_threshold() {
let env = Env::default();
let guard = BudgetGuard::new(&env, 0);
assert!(guard.check().is_ok());
}

#[test]
fn test_budget_guard_tracks_consumed_instructions() {
let env = Env::default();
let guard = BudgetGuard::new(&env, 0);
let consumed = guard.consumed();
assert!(consumed < 1000);
}
}
Loading
Loading