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
2 changes: 1 addition & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ fn main() {
println!("cargo:rustc-env=BUILD_TIME={build_time}");

println!("cargo:rerun-if-changed=build.rs");
}
}
5 changes: 4 additions & 1 deletion contracts/ledger-time-helper/src/test.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions src/nonce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
88 changes: 58 additions & 30 deletions src/router/multihop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;

Expand Down Expand Up @@ -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<HopResult>,
}

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -182,13 +189,18 @@ pub fn execute_route(env: &Env, route: &Route) -> Result<RouteResult, ContractEr
total_steps: route.steps.len(),
started_at: env.ledger().timestamp(),
};
env.storage().temporary().set(&ROUTE_EXEC_KEY, &snapshot);
let route_key = crate::storage::ephemeral::EphemeralStorageKey::ActiveRoute;
env.storage().temporary().set(
&route_key,
&RouteComputationState {
snapshot,
running_amount: 0,
total_fees: 0,
hop_results: Vec::new(env),
},
);

// ── Phase 3: Sequential hop execution ────────────────────────────────
let mut running_amount: u64 = 0;
let mut hop_results: Vec<HopResult> = Vec::new(env);
let mut total_fees: u64 = 0;

for i in 0..route.steps.len() {
let step = route
.steps
Expand All @@ -200,7 +212,11 @@ pub fn execute_route(env: &Env, route: &Route) -> Result<RouteResult, ContractEr
let amount_in = if i == 0 {
step.amount_in
} else {
running_amount
env.storage()
.temporary()
.get::<_, RouteComputationState>(&route_key)
.ok_or(ContractError::RouteExecutionFailed)?
.running_amount
};

// Execute the single-hop swap against the pool contract.
Expand All @@ -210,33 +226,44 @@ pub fn execute_route(env: &Env, route: &Route) -> Result<RouteResult, ContractEr
if hop_result.amount_out < step.min_amount_out {
// Explicitly remove snapshot before returning error to keep
// temporary storage clean even on the happy-path exit.
env.storage().temporary().remove(&ROUTE_EXEC_KEY);
env.storage().temporary().remove(&route_key);
return Err(ContractError::SlippageExceeded);
}

running_amount = hop_result.amount_out;
total_fees = total_fees
let mut state: RouteComputationState = env
.storage()
.temporary()
.get(&route_key)
.ok_or(ContractError::RouteExecutionFailed)?;
state.running_amount = hop_result.amount_out;
state.total_fees = state
.total_fees
.checked_add(hop_result.fee_collected)
.ok_or(ContractError::Overflow)?;

hop_results.push_back(hop_result);
state.hop_results.push_back(hop_result);
env.storage().temporary().set(&route_key, &state);
}

// ── Phase 4: Finalize — clean up snapshot ───────────────────────────
env.storage().temporary().remove(&ROUTE_EXEC_KEY);
let state: RouteComputationState = env
.storage()
.temporary()
.get(&route_key)
.ok_or(ContractError::RouteExecutionFailed)?;
env.storage().temporary().remove(&route_key);

// Emit a settlement event for off-chain indexers.
let _ = emit_simple2(
&env,
EV_ROUTE_OK,
symbol_short!("route"),
(sender.clone(), running_amount, route.steps.len()),
(sender.clone(), state.running_amount, route.steps.len()),
);

Ok(RouteResult {
final_amount_out: running_amount,
hop_results,
total_fees,
final_amount_out: state.running_amount,
hop_results: state.hop_results,
total_fees: state.total_fees,
})
}

Expand Down Expand Up @@ -313,6 +340,8 @@ fn execute_single_hop(
.variable_pool
.checked_add(fee_collected as u64)
.ok_or(ContractError::Overflow)?;
// Durable accounting is kept outside the transient route buffer. In the
// full pool integration this is also where token balances/reserves live.
env.storage()
.instance()
.set(&fees::FeesStorageKey::CorridorPool(step.asset_in), &pool);
Expand All @@ -330,7 +359,12 @@ fn execute_single_hop(

/// Return the currently executing route snapshot, if any.
pub fn get_active_snapshot(env: &Env) -> Option<RouteSnapshot> {
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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
20 changes: 14 additions & 6 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -196,15 +200,19 @@ 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()
.instance()
.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 {
Expand All @@ -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);
}
}


11 changes: 11 additions & 0 deletions src/storage/ephemeral.rs
Original file line number Diff line number Diff line change
@@ -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");
5 changes: 1 addition & 4 deletions tests/benchmarks/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions tests/benchmarks/tests/swap_transaction_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading