diff --git a/build.rs b/build.rs index 290592c..633c7f0 100644 --- a/build.rs +++ b/build.rs @@ -16,4 +16,4 @@ fn main() { println!("cargo:rustc-env=BUILD_TIME={build_time}"); println!("cargo:rerun-if-changed=build.rs"); -} \ No newline at end of file +} diff --git a/contracts/ledger-time-helper/src/test.rs b/contracts/ledger-time-helper/src/test.rs index bf12f4e..29015c6 100644 --- a/contracts/ledger-time-helper/src/test.rs +++ b/contracts/ledger-time-helper/src/test.rs @@ -1,7 +1,10 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::{Ledger, LedgerInfo}, Env}; +use soroban_sdk::{ + testutils::{Ledger, LedgerInfo}, + Env, +}; #[test] fn test_current_ledger_timestamp() { diff --git a/src/nonce.rs b/src/nonce.rs index 0e471c4..22e66ce 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -40,6 +40,8 @@ pub fn consume_nonce( }; let key = NonceKey::State(coordinator.clone()); + // Replay-protection state is intentionally durable. Moving this counter + // to temporary storage would allow an expired nonce to be replayed. env.storage() .persistent() .set(&key, &next_state); diff --git a/src/router/multihop.rs b/src/router/multihop.rs index a9af187..790ffd1 100644 --- a/src/router/multihop.rs +++ b/src/router/multihop.rs @@ -19,7 +19,7 @@ //! cleans it up on success. If the transaction fails (any hop returns an //! error), Soroban's atomicity guarantees the snapshot is also reverted. -use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Vec}; use crate::events::{emit_simple2, EV_ROUTE_OK}; use crate::fees::{self, CorridorFeePool}; @@ -29,10 +29,6 @@ use crate::{AssetId, ContractError}; // Storage keys // --------------------------------------------------------------------------- -/// Temporary storage key for the active route execution context. -/// Cleared on success; automatically reverted by the ledger on failure. -const ROUTE_EXEC_KEY: Symbol = symbol_short!("RTEXEC"); - /// Maximum number of hops allowed in a single route to bound compute. const MAX_ROUTE_HOPS: u32 = 8; @@ -102,6 +98,17 @@ pub struct RouteSnapshot { pub started_at: u64, } +/// Scratch state for a route. It is kept in temporary storage so it is +/// automatically rent-cleaned; balances and fee pools are never stored here. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RouteComputationState { + pub snapshot: RouteSnapshot, + pub running_amount: u64, + pub total_fees: u64, + pub hop_results: Vec, +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -182,13 +189,18 @@ pub fn execute_route(env: &Env, route: &Route) -> Result = Vec::new(env); - let mut total_fees: u64 = 0; - for i in 0..route.steps.len() { let step = route .steps @@ -200,7 +212,11 @@ pub fn execute_route(env: &Env, route: &Route) -> Result(&route_key) + .ok_or(ContractError::RouteExecutionFailed)? + .running_amount }; // Execute the single-hop swap against the pool contract. @@ -210,33 +226,44 @@ pub fn execute_route(env: &Env, route: &Route) -> Result Option { - env.storage().temporary().get(&ROUTE_EXEC_KEY) + env.storage() + .temporary() + .get::<_, RouteComputationState>( + &crate::storage::ephemeral::EphemeralStorageKey::ActiveRoute, + ) + .map(|state| state.snapshot) } /// Simulated swap outcome containing all computed details for frontends. @@ -502,10 +536,7 @@ mod tests { sender, steps: Vec::new(&env), }; - assert_eq!( - validate_route(&env, &route), - Err(ContractError::EmptyRoute) - ); + assert_eq!(validate_route(&env, &route), Err(ContractError::EmptyRoute)); } #[test] @@ -587,10 +618,7 @@ mod tests { sender, steps: Vec::new(&env), }; - assert_eq!( - estimate_route(&env, &route), - Err(ContractError::EmptyRoute) - ); + assert_eq!(estimate_route(&env, &route), Err(ContractError::EmptyRoute)); } #[test] diff --git a/src/storage.rs b/src/storage.rs index 96663a3..98cf2a5 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -4,8 +4,12 @@ //! replacing dynamic Map structures with fixed-size tuple keys for gas efficiency. //! It also provides helper functions for node profile management, subscription //! rent extension, and asset price TTL management. -use soroban_sdk::{contracttype, Address, Env, Symbol, Map}; use crate::NodeProfile; +use soroban_sdk::{contracttype, Address, Env, Map, Symbol}; + +/// Helpers and keys for short-lived calculation state. +#[path = "storage/ephemeral.rs"] +pub(crate) mod ephemeral; /// Fixed-size tuple-based storage keys for gas-optimized lookups. /// Replaces dynamic Map structures with direct tuple keys. @@ -196,7 +200,9 @@ pub fn check_and_prune_feed_stake(env: &Env, node: Address, asset: u32) -> bool } else { stakes.set(node.clone(), new_node_total); } - env.storage().instance().set(&crate::STAKE_REGISTRY_KEY, &stakes); + env.storage() + .instance() + .set(&crate::STAKE_REGISTRY_KEY, &stakes); let total: u64 = env .storage() @@ -204,7 +210,9 @@ pub fn check_and_prune_feed_stake(env: &Env, node: Address, asset: u32) -> bool .get(&crate::TOTAL_STAKED_KEY) .unwrap_or(0u64); let new_total = total.saturating_sub(val.amount); - env.storage().instance().set(&crate::TOTAL_STAKED_KEY, &new_total); + env.storage() + .instance() + .set(&crate::TOTAL_STAKED_KEY, &new_total); true } else { @@ -220,8 +228,8 @@ pub fn update_feed_stake_activity(env: &Env, node: Address, asset: u32) { if let Some(mut val) = env.storage().persistent().get::<_, FeedStakeValue>(&key) { val.last_active = env.ledger().timestamp(); env.storage().persistent().set(&key, &val); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); } } - - diff --git a/src/storage/ephemeral.rs b/src/storage/ephemeral.rs new file mode 100644 index 0000000..48336ec --- /dev/null +++ b/src/storage/ephemeral.rs @@ -0,0 +1,11 @@ +//! Keys for calculation state that must not incur durable storage rent. + +use soroban_sdk::{contracttype, symbol_short, Symbol}; + +#[contracttype] +#[derive(Clone)] +pub enum EphemeralStorageKey { + ActiveRoute, +} + +pub const ACTIVE_ROUTE_LABEL: Symbol = symbol_short!("RTEXEC"); diff --git a/tests/benchmarks/src/profile.rs b/tests/benchmarks/src/profile.rs index 96f5d7a..c604c9c 100644 --- a/tests/benchmarks/src/profile.rs +++ b/tests/benchmarks/src/profile.rs @@ -54,10 +54,7 @@ where .budget() .cpu_instruction_cost() .saturating_sub(cpu_before), - memory_bytes: env - .budget() - .memory_bytes_cost() - .saturating_sub(mem_before), + memory_bytes: env.budget().memory_bytes_cost().saturating_sub(mem_before), }; usage.log(); usage diff --git a/tests/benchmarks/tests/swap_transaction_budget.rs b/tests/benchmarks/tests/swap_transaction_budget.rs index 1ede4cd..99628cb 100644 --- a/tests/benchmarks/tests/swap_transaction_budget.rs +++ b/tests/benchmarks/tests/swap_transaction_budget.rs @@ -2,7 +2,9 @@ use price_oracle::{ContractError as OracleError, PriceOracle, PriceOracleClient}; use soroban_sdk::{symbol_short, vec, Env, Symbol}; -use stellarflow_benchmarks::profile::{assert_swap_path_within_limits, measure_entrypoint, EntrypointUsage}; +use stellarflow_benchmarks::profile::{ + assert_swap_path_within_limits, measure_entrypoint, EntrypointUsage, +}; const PRICE_DECIMALS: u32 = 9; const PRICE_TTL_LEDGERS: u64 = 3_600; @@ -13,7 +15,12 @@ fn setup_oracle_with_swap_pair(env: &Env) -> (PriceOracleClient<'static>, Symbol let client = PriceOracleClient::new(env, &contract_id); let source = symbol_short!("NGN"); let destination = symbol_short!("GHS"); - client.set_price(&source, &1_000_000_000_i128, &PRICE_DECIMALS, &PRICE_TTL_LEDGERS); + client.set_price( + &source, + &1_000_000_000_i128, + &PRICE_DECIMALS, + &PRICE_TTL_LEDGERS, + ); client.set_price( &destination, &50_000_000_i128, @@ -57,10 +64,14 @@ fn swap_oracle_entrypoints_log_resources_and_stay_within_budget() { assert_eq!(batch.len(), 2); })); - usages.push(measure_entrypoint(&env, "get_price_with_status:source", || { - let with_status = client.get_price_with_status(&source); - assert!(with_status.data.price > 0); - })); + usages.push(measure_entrypoint( + &env, + "get_price_with_status:source", + || { + let with_status = client.get_price_with_status(&source); + assert!(with_status.data.price > 0); + }, + )); let total_cpu = env .budget() diff --git a/tests/integration.rs b/tests/integration.rs index 6caed18..8798b4a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,17 +1,18 @@ use soroban_sdk::{ - symbol_short, Address, Env, + symbol_short, testutils::{Address as _, Ledger}, + Address, Env, }; mod mocks; +use mocks::oracle_mocks::{ + mock_oracle_advance_time, mock_oracle_get_price, mock_oracle_has_price, mock_oracle_set_prices, + mock_oracle_update_price, setup_mock_oracle, +}; use mocks::token_mocks::{ - mock_approve, mock_allowance, mock_balance_of, mock_set_balance, mock_transfer, + mock_allowance, mock_approve, mock_balance_of, mock_set_balance, mock_transfer, mock_transfer_from, setup_mock_token_state, MockTokenState, }; -use mocks::oracle_mocks::{ - mock_oracle_get_price, mock_oracle_has_price, mock_oracle_set_prices, - mock_oracle_update_price, mock_oracle_advance_time, setup_mock_oracle, -}; /// Integration test: mock token setup, approval, and transfer work /// entirely offline without any live network connectivity. @@ -26,16 +27,21 @@ fn test_mock_token_approval_and_transfer_offline() { let recipient = Address::generate(&env); // Set up a mock token contract with an initial balance for `owner`. - let token_state = setup_mock_token_state( - &env, - &admin, - &[(owner.clone(), 1_000_000_i128)], - ); + let token_state = setup_mock_token_state(&env, &admin, &[(owner.clone(), 1_000_000_i128)]); // Verify the initial balance was minted correctly. - assert_eq!(mock_balance_of(&env, &token_state.token_id, &owner), 1_000_000_i128); - assert_eq!(mock_balance_of(&env, &token_state.token_id, &spender), 0_i128); - assert_eq!(mock_balance_of(&env, &token_state.token_id, &recipient), 0_i128); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &owner), + 1_000_000_i128 + ); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &spender), + 0_i128 + ); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &recipient), + 0_i128 + ); // Simulate owner approving spender to spend up to 500_000 tokens. mock_approve(&env, &token_state.token_id, &owner, &spender, 500_000_i128); @@ -55,9 +61,18 @@ fn test_mock_token_approval_and_transfer_offline() { ); // Verify balances after the transfer. - assert_eq!(mock_balance_of(&env, &token_state.token_id, &owner), 800_000_i128); - assert_eq!(mock_balance_of(&env, &token_state.token_id, &spender), 0_i128); - assert_eq!(mock_balance_of(&env, &token_state.token_id, &recipient), 200_000_i128); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &owner), + 800_000_i128 + ); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &spender), + 0_i128 + ); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &recipient), + 200_000_i128 + ); } /// Integration test: mock oracle price updates work entirely offline. @@ -103,8 +118,14 @@ fn test_mock_oracle_price_updates_offline() { &[(ghs.clone(), 4_500_000_i128), (ngn.clone(), 1_600_000_i128)], ); - assert_eq!(mock_oracle_get_price(&env, &oracle_id, ghs).unwrap(), 4_500_000_i128); - assert_eq!(mock_oracle_get_price(&env, &oracle_id, ngn).unwrap(), 1_600_000_i128); + assert_eq!( + mock_oracle_get_price(&env, &oracle_id, ghs).unwrap(), + 4_500_000_i128 + ); + assert_eq!( + mock_oracle_get_price(&env, &oracle_id, ngn).unwrap(), + 1_600_000_i128 + ); // Advance ledger time and verify the oracle still works. mock_oracle_advance_time(&env, 3600); @@ -124,11 +145,7 @@ fn test_offline_trade_with_token_and_oracle_mocks() { let counterparty = Address::generate(&env); // Deploy a mock token contract and mint tokens to the trader. - let token_state = setup_mock_token_state( - &env, - &admin, - &[(trader.clone(), 10_000_000_i128)], - ); + let token_state = setup_mock_token_state(&env, &admin, &[(trader.clone(), 10_000_000_i128)]); // Deploy a mock oracle and set the NGN/USDC price. let (oracle_id, _oracle_client) = setup_mock_oracle(&env); @@ -148,10 +165,19 @@ fn test_offline_trade_with_token_and_oracle_mocks() { assert_eq!(ngn_price, 1_500_000_i128); // Verify trader has sufficient balance. - assert_eq!(mock_balance_of(&env, &token_state.token_id, &trader), 10_000_000_i128); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &trader), + 10_000_000_i128 + ); // Approve the counterparty to receive tokens on behalf of the trader. - mock_approve(&env, &token_state.token_id, &trader, &counterparty, sell_amount); + mock_approve( + &env, + &token_state.token_id, + &trader, + &counterparty, + sell_amount, + ); assert_eq!( mock_allowance(&env, &token_state.token_id, &trader, &counterparty), sell_amount @@ -168,12 +194,18 @@ fn test_offline_trade_with_token_and_oracle_mocks() { ); // Verify post-trade balances. - assert_eq!(mock_balance_of(&env, &token_state.token_id, &trader), 10_000_000 - sell_amount); - assert_eq!(mock_balance_of(&env, &token_state.token_id, &counterparty), sell_amount); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &trader), + 10_000_000 - sell_amount + ); + assert_eq!( + mock_balance_of(&env, &token_state.token_id, &counterparty), + sell_amount + ); // Verify the oracle price is still accessible (no side effects). assert_eq!( mock_oracle_get_price(&env, &oracle_id, ngn).unwrap(), 1_500_000_i128 ); -} \ No newline at end of file +} diff --git a/tests/mocks/mod.rs b/tests/mocks/mod.rs index 8073db7..59522c5 100644 --- a/tests/mocks/mod.rs +++ b/tests/mocks/mod.rs @@ -1,2 +1,2 @@ +pub mod oracle_mocks; pub mod token_mocks; -pub mod oracle_mocks; \ No newline at end of file diff --git a/tests/mocks/oracle_mocks.rs b/tests/mocks/oracle_mocks.rs index b6d24e1..653c25c 100644 --- a/tests/mocks/oracle_mocks.rs +++ b/tests/mocks/oracle_mocks.rs @@ -1,7 +1,4 @@ -use soroban_sdk::{ - testutils::Address as _, - Address, Env, Symbol, -}; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; /// A minimal mock price oracle contract for offline integration tests. /// Deployed with `env.register_contract` so that contract calls behave as @@ -65,5 +62,6 @@ pub fn mock_oracle_has_price(env: &Env, oracle_id: &Address, asset: Symbol) -> b /// Advance the mock ledger timestamp by `seconds`, useful for TTL / staleness tests. pub fn mock_oracle_advance_time(env: &Env, seconds: u64) { let current_ts = env.ledger().timestamp(); - env.ledger().with_mut(|li| li.timestamp = current_ts + seconds); -} \ No newline at end of file + env.ledger() + .with_mut(|li| li.timestamp = current_ts + seconds); +} diff --git a/tests/mocks/token_mocks.rs b/tests/mocks/token_mocks.rs index 52f3549..fc65fa8 100644 --- a/tests/mocks/token_mocks.rs +++ b/tests/mocks/token_mocks.rs @@ -1,8 +1,5 @@ -use soroban_sdk::{ - testutils::Address as _, - Address, Env, -}; use soroban_sdk::token; +use soroban_sdk::{testutils::Address as _, Address, Env}; /// Represents the state of a mock token contract. pub struct MockTokenState { @@ -30,7 +27,13 @@ pub fn setup_mock_token_state( } /// Simulate a token approval: `owner` approves `spender` to spend `amount`. -pub fn mock_approve(env: &Env, token_id: &Address, owner: &Address, spender: &Address, amount: i128) { +pub fn mock_approve( + env: &Env, + token_id: &Address, + owner: &Address, + spender: &Address, + amount: i128, +) { env.mock_all_auths(); let client = token::Client::new(env, token_id); client.approve(owner, spender, &amount); @@ -93,4 +96,4 @@ pub fn setup_mock_token_with_allowances( } state -} \ No newline at end of file +} diff --git a/tests/unit.rs b/tests/unit.rs index 86c43f3..c4f98a0 100644 --- a/tests/unit.rs +++ b/tests/unit.rs @@ -6,8 +6,8 @@ use soroban_sdk::{ /// The main contract from the root crate. use stellarflow_contracts::{ - ContractError, TimeLockedUpgradeContract, TimeLockedUpgradeContractClient, - DEFAULT_HEARTBEAT_INTERVAL, PriceVarianceConfig, StakingTierConfig, + ContractError, PriceVarianceConfig, StakingTierConfig, TimeLockedUpgradeContract, + TimeLockedUpgradeContractClient, DEFAULT_HEARTBEAT_INTERVAL, }; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -223,7 +223,9 @@ fn test_deposit_set_and_get_staking_tier_config() { tier4_min: 10000, }; let signers: Vec
= Vec::new(&env); - assert!(client.try_set_staking_tier_config(&admin, &config, &signers).is_err()); + assert!(client + .try_set_staking_tier_config(&admin, &config, &signers) + .is_err()); } // ═════════════════════════════════════════════════════════════════════════════