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
59 changes: 42 additions & 17 deletions contracts/predictify-hybrid/src/gas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,26 @@ use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol};
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GasConfigKey {
/// Global or operation-specific gas limit (CPU instructions)
GasLimit(Symbol),
/// Operation-specific memory limit (bytes)
MemLimit(Symbol),
/// Mock cost for tests: (symbol_short!("t_cpu") | symbol_short!("t_mem"), operation)
TestCost(Symbol, Symbol),
}

/// Represents consumed resources for an operation.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct GasUsage {
pub cpu: u64,
pub mem: u64,
}

/// GasTracker provides observability hooks and optimization limits.
///
/// It allows tracking CPU and memory usage in tests via mocks and provides
/// an administrative interface to set limits on production operations.
pub struct GasTracker;

impl GasTracker {
Expand All @@ -23,45 +39,54 @@ impl GasTracker {
/// can be disabled in high-traffic deployments if needed.

/// Administrative hook to set a gas/budget limit per operation.
pub fn set_limit(env: &Env, operation: Symbol, max_units: u64) {
pub fn set_limit(env: &Env, operation: Symbol, max_cpu: u64, max_mem: u64) {
env.storage()
.instance()
.set(&GasConfigKey::GasLimit(operation), &max_units);
.set(&GasConfigKey::GasLimit(operation.clone()), &max_cpu);
env.storage()
.instance()
.set(&GasConfigKey::MemLimit(operation), &max_mem);
}

/// Retrieves the current gas budget limit for an operation.
pub fn get_limit(env: &Env, operation: Symbol) -> Option<u64> {
env.storage()
pub fn get_limits(env: &Env, operation: Symbol) -> (Option<u64>, Option<u64>) {
let cpu = env.storage()
.instance()
.get(&GasConfigKey::GasLimit(operation))
.get(&GasConfigKey::GasLimit(operation.clone()));
let mem = env.storage()
.instance()
.get(&GasConfigKey::MemLimit(operation));
(cpu, mem)
}

/// Hook to call before an operation begins. Returns a usage marker.
pub fn start_tracking(_env: &Env) -> u64 {
// Budget metrics are not directly accessible in contract code.
// Budget metrics are not directly accessible in contract code via Env.
// This hook remains for interface compatibility and future host-side logging.
0
}

/// Hook to call immediately after an operation.
/// It records the usage, publishes an observability event, and checks the admin cap.
/// It records usage, publishes an observability event, and checks admin caps.
pub fn end_tracking(env: &Env, operation: Symbol, _start_marker: u64) {
// Placeholder for actual cost. Host-side diagnostics should be used for precise gas monitoring.
#[cfg(not(test))]
let actual_cost = 0;

#[cfg(test)]
let actual_cost = env.storage().temporary().get::<Symbol, u64>(&symbol_short!("t_gas")).unwrap_or(0);
let cost = Self::get_actual_cost(env, operation.clone());

// Publish observability event: [ "gas_used", operation.clone() ] -> cost_used
// Publish observability event: [ "gas_used", operation ] -> cost
env.events().publish(
(symbol_short!("gas_used"), operation.clone()),
actual_cost,
cost.clone(),
);

// Optional: admin-set gas budget cap per call (abort if exceeded)
if let Some(limit) = Self::get_limit(env, operation) {
if actual_cost > limit {
let (cpu_limit, mem_limit) = Self::get_limits(env, operation);

if let Some(limit) = cpu_limit {
if cost.cpu > limit {
panic_with_error!(env, crate::err::Error::GasBudgetExceeded);
}
}
if let Some(limit) = mem_limit {
if cost.mem > limit {
panic_with_error!(env, crate::err::Error::GasBudgetExceeded);
}
}
Expand Down
176 changes: 37 additions & 139 deletions contracts/predictify-hybrid/src/gas_test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![cfg(test)]

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

#[test]
Expand All @@ -12,11 +12,15 @@ fn test_gas_limit_storage() {

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

// Set limit
GasTracker::set_limit(&env, operation.clone(), 5000);
assert_eq!(GasTracker::get_limit(&env, operation), Some(5000));
// 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));
});
}

Expand All @@ -27,6 +31,9 @@ fn test_gas_tracking_observability() {
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);
});
Expand All @@ -42,173 +49,64 @@ fn test_gas_tracking_observability() {

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() {
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 limit to 500
GasTracker::set_limit(&env, operation.clone(), 500);
// Set CPU limit to 500
GasTracker::set_limit(&env, operation.clone(), 500, 2000);

// Mock the cost to 1000 (exceeds limit)
env.storage().temporary().set(&symbol_short!("t_gas"), &1000u64);
// 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]
fn test_gas_limit_not_exceeded() {
#[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 limit to 1500
GasTracker::set_limit(&env, operation.clone(), 1500);
// Set Mem limit to 500
GasTracker::set_limit(&env, operation.clone(), 2000, 500);

// Mock the cost to 1000 (within limit)
env.storage().temporary().set(&symbol_short!("t_gas"), &1000u64);
// 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_integration_with_vote() {
let env = Env::default();
env.mock_all_auths(); // Fix auth issues in tests
let contract_id = env.register(PredictifyHybrid, ());
let client = crate::PredictifyHybridClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let user = Address::generate(&env);

// Initialize
client.initialize(&admin, &None);

// Create a market
let question = String::from_str(&env, "Test Question?");
let outcomes = vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")];
let oracle_config = crate::OracleConfig::none_sentinel(&env);

let market_id = client.create_market(
&admin,
&question,
&outcomes,
&30,
&oracle_config,
&None,
&86400,
&None,
&None,
&None,
);

// Setup token for staking
let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_id = token_contract.address();

// Set token for staking in contract storage
env.as_contract(&contract_id, || {
env.storage()
.persistent()
.set(&Symbol::new(&env, "TokenID"), &token_id);
});

// Fund user with tokens and approve contract
let stellar_client = StellarAssetClient::new(&env, &token_id);
stellar_client.mint(&user, &1000_0000000); // 1,000 XLM

let token_client = soroban_sdk::token::Client::new(&env, &token_id);
token_client.approve(&user, &contract_id, &i128::MAX, &1000000);

// Clear previous events
let _ = env.events().all();

// Vote
client.vote(&user, &market_id, &String::from_str(&env, "Yes"), &1000000);

// Verify gas_used event for "vote"
let events = env.events().all();
let gas_event = events.iter().find(|e| {
let topics = &e.1;
let topic_0: Result<Symbol, _> = topics.get(0).unwrap().try_into_val(&env);
topic_0.is_ok() && topic_0.unwrap() == symbol_short!("gas_used")
}).expect("Gas used event should be emitted");

let topics = &gas_event.1;
let operation: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap();
assert_eq!(operation, symbol_short!("vote"));
}

#[test]
fn test_integration_with_resolve_manual() {
fn test_gas_limit_not_exceeded() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(PredictifyHybrid, ());
let client = crate::PredictifyHybridClient::new(&env, &contract_id);

let admin = Address::generate(&env);

// Initialize
client.initialize(&admin, &None);

// Create a market
let question = String::from_str(&env, "Test Question?");
let outcomes = vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")];
let oracle_config = crate::OracleConfig::none_sentinel(&env);

let market_id = client.create_market(
&admin,
&question,
&outcomes,
&30,
&oracle_config,
&None,
&86400,
&None,
&None,
&None,
);
let operation = symbol_short!("test_op");

// Setup token for staking
let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_id = token_contract.address();

// Set token for staking in contract storage
env.as_contract(&contract_id, || {
env.storage()
.persistent()
.set(&Symbol::new(&env, "TokenID"), &token_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);
});

// Fast forward to end of market
env.ledger().set_timestamp(env.ledger().timestamp() + (30 * 24 * 60 * 60) + 1);

// Clear previous events
let _ = env.events().all();

// Resolve manually
client.resolve_market_manual(&admin, &market_id, &String::from_str(&env, "Yes"));

// Verify gas_used event for "res_man"
let events = env.events().all();
let gas_event = events.iter().find(|e| {
let topics = &e.1;
let topic_0: Result<Symbol, _> = topics.get(0).unwrap().try_into_val(&env);
topic_0.is_ok() && topic_0.unwrap() == symbol_short!("gas_used")
}).expect("Gas used event should be emitted");

let topics = &gas_event.1;
let operation: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap();
assert_eq!(operation, symbol_short!("res_man"));
}
Loading
Loading