#![warn(missing_docs)]
#![warn(clippy::all)]
#![warn(rust_2018_idioms)]
mod cache;
mod error;
mod rpc;
mod source;
pub mod trace;
pub use error::{ForkError, Result};
pub use rpc::{FetchedEntry, LatestLedger, NetworkMetadata, RpcClient, RpcConfig};
pub use source::{FetchMode, RpcSnapshotSource};
pub use trace::{Trace, TraceFrame, TraceResult};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use log::{info, warn};
use soroban_sdk::testutils::{Ledger as _, SnapshotSourceInput};
use soroban_sdk::{Env, IntoVal, Symbol, Val};
const LEDGER_INTERVAL_SECONDS: u64 = 5;
pub struct ForkedEnv {
env: Env,
source: Rc<RpcSnapshotSource>,
cache_path: Option<PathBuf>,
ledger_sequence: u32,
timestamp: u64,
network_id: [u8; 32],
protocol_version: u32,
}
impl std::ops::Deref for ForkedEnv {
type Target = Env;
fn deref(&self) -> &Env {
&self.env
}
}
impl ForkedEnv {
pub fn env(&self) -> &Env {
&self.env
}
pub fn fetch_count(&self) -> u32 {
self.source.fetch_count()
}
pub fn warp(&self, ledgers: u32, seconds: u64) {
self.env.ledger().with_mut(|info| {
info.sequence_number += ledgers;
info.timestamp += seconds;
});
}
pub fn warp_time(&self, seconds: u64) {
let ledgers = (seconds / LEDGER_INTERVAL_SECONDS) as u32;
self.warp(ledgers, seconds);
}
pub fn warp_ledger(&self, ledgers: u32) {
let seconds = ledgers as u64 * LEDGER_INTERVAL_SECONDS;
self.warp(ledgers, seconds);
}
pub fn deal_token(
&self,
token: &soroban_sdk::Address,
to: &soroban_sdk::Address,
amount: i128,
) {
let e = &self.env;
let to_val: Val = to.into_val(e);
let current: i128 = e.invoke_contract(token, &Symbol::new(e, "balance"), {
let mut v = soroban_sdk::Vec::new(e);
v.push_back(to_val);
v
});
let delta = amount - current;
if delta == 0 {
return;
}
let target_val: Val = to.into_val(e);
if delta > 0 {
let delta_val: Val = delta.into_val(e);
let mut args = soroban_sdk::Vec::new(e);
args.push_back(target_val);
args.push_back(delta_val);
e.invoke_contract::<()>(token, &Symbol::new(e, "mint"), args);
} else {
let abs_delta: Val = delta.unsigned_abs().into_val(e);
let mut args = soroban_sdk::Vec::new(e);
args.push_back(target_val);
args.push_back(abs_delta);
e.invoke_contract::<()>(token, &Symbol::new(e, "burn"), args);
}
}
pub fn trace(&self) -> trace::Trace {
let events = self.diagnostic_events();
trace::Trace::from_events(&events)
}
pub fn print_trace(&self) {
eprintln!("{}", self.trace());
}
pub fn diagnostic_events(&self) -> soroban_env_host::events::Events {
match self.env.host().get_diagnostic_events() {
Ok(events) => events,
Err(e) => {
warn!("soroban-fork: get_diagnostic_events failed: {e:?}");
soroban_env_host::events::Events(Vec::new())
}
}
}
pub fn save_cache(&self) -> Result<()> {
let Some(path) = &self.cache_path else {
return Ok(());
};
do_save_cache(
&self.source,
path,
self.ledger_sequence,
self.timestamp,
self.network_id,
self.protocol_version,
)
}
}
impl Drop for ForkedEnv {
fn drop(&mut self) {
let Some(path) = self.cache_path.as_ref() else {
return;
};
match do_save_cache(
&self.source,
path,
self.ledger_sequence,
self.timestamp,
self.network_id,
self.protocol_version,
) {
Ok(()) => {
let count = self.source.entries().len();
info!("soroban-fork: saved {count} entries to {}", path.display());
}
Err(e) => {
warn!("soroban-fork: cache save error on drop: {e}");
}
}
}
}
#[derive(Clone, Debug)]
pub struct ForkConfig {
rpc_url: String,
cache_path: Option<PathBuf>,
network_id: Option<[u8; 32]>,
fetch_mode: Option<FetchMode>,
pinned_ledger: Option<u32>,
pinned_timestamp: Option<u64>,
max_protocol_version: Option<u32>,
rpc_config: RpcConfig,
tracing: bool,
}
impl ForkConfig {
pub fn new(rpc_url: impl Into<String>) -> Self {
Self {
rpc_url: rpc_url.into(),
cache_path: None,
network_id: None,
fetch_mode: None,
pinned_ledger: None,
pinned_timestamp: None,
max_protocol_version: None,
rpc_config: RpcConfig::default(),
tracing: false,
}
}
pub fn cache_file(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_path = Some(path.into());
self
}
pub fn network_id(mut self, id: [u8; 32]) -> Self {
self.network_id = Some(id);
self
}
pub fn fetch_mode(mut self, mode: FetchMode) -> Self {
self.fetch_mode = Some(mode);
self
}
pub fn at_ledger(mut self, sequence: u32) -> Self {
self.pinned_ledger = Some(sequence);
self
}
pub fn pinned_timestamp(mut self, unix_seconds: u64) -> Self {
self.pinned_timestamp = Some(unix_seconds);
self
}
pub fn max_protocol_version(mut self, version: u32) -> Self {
self.max_protocol_version = Some(version);
self
}
pub fn rpc_config(mut self, config: RpcConfig) -> Self {
self.rpc_config = config;
self
}
pub fn tracing(mut self, enabled: bool) -> Self {
self.tracing = enabled;
self
}
pub fn build(self) -> Result<ForkedEnv> {
let client = Arc::new(rpc::RpcClient::new(
self.rpc_url.clone(),
self.rpc_config.clone(),
)?);
let source = source::RpcSnapshotSource::new(client.clone());
let source = match self.fetch_mode {
Some(mode) => source.with_fetch_mode(mode),
None => source,
};
if let Some(ref path) = self.cache_path {
if path.exists() {
match cache::load_snapshot(path) {
Ok(entries) => {
let count = entries.len();
source.preload(entries);
info!(
"soroban-fork: pre-loaded {count} entries from {}",
path.display()
);
}
Err(e) => {
warn!(
"soroban-fork: cache load error, starting fresh ({}): {e}",
path.display()
);
}
}
}
}
let latest = client.get_latest_ledger()?;
let network_id = match self.network_id {
Some(id) => id,
None => client.get_network()?.network_id,
};
let sequence = self.pinned_ledger.unwrap_or(latest.sequence);
let protocol_version = match self.max_protocol_version {
Some(max) if latest.protocol_version > max => {
info!(
"soroban-fork: capping protocol version {} -> {} (max_protocol_version)",
latest.protocol_version, max
);
max
}
_ => latest.protocol_version,
};
let timestamp = self.pinned_timestamp.unwrap_or(latest.close_time);
let sdk_ledger_info = soroban_env_host::LedgerInfo {
protocol_version,
sequence_number: sequence,
timestamp,
network_id,
base_reserve: cache::DEFAULT_BASE_RESERVE,
min_persistent_entry_ttl: cache::DEFAULT_MIN_PERSISTENT_ENTRY_TTL,
min_temp_entry_ttl: cache::DEFAULT_MIN_TEMP_ENTRY_TTL,
max_entry_ttl: cache::DEFAULT_MAX_ENTRY_TTL,
};
let source_rc = Rc::new(source);
let input = SnapshotSourceInput {
source: source_rc.clone(),
ledger_info: Some(sdk_ledger_info),
snapshot: None,
};
let env = Env::from_ledger_snapshot(input);
let (level, level_label) = if self.tracing {
(soroban_env_host::DiagnosticLevel::Debug, "Debug")
} else {
(soroban_env_host::DiagnosticLevel::None, "None")
};
env.host().set_diagnostic_level(level).map_err(|e| {
error::ForkError::Host(format!("set_diagnostic_level({level_label}) failed: {e:?}"))
})?;
if self.tracing {
info!("soroban-fork: tracing enabled (DiagnosticLevel::Debug)");
}
info!("soroban-fork: forked at ledger {sequence} (protocol {protocol_version})");
Ok(ForkedEnv {
env,
source: source_rc,
cache_path: self.cache_path,
ledger_sequence: sequence,
timestamp,
network_id,
protocol_version,
})
}
}
fn do_save_cache(
source: &RpcSnapshotSource,
path: &std::path::Path,
sequence: u32,
timestamp: u64,
network_id: [u8; 32],
protocol_version: u32,
) -> Result<()> {
let entries = source.entries();
if entries.is_empty() {
return Ok(());
}
cache::save_snapshot(
path,
&entries,
sequence,
timestamp,
network_id,
protocol_version,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fork_config_builder_records_overrides() {
let cfg = ForkConfig::new("https://example.test")
.cache_file("/tmp/ignored.json")
.network_id([7u8; 32])
.fetch_mode(FetchMode::Lenient)
.at_ledger(12345)
.pinned_timestamp(999)
.max_protocol_version(25)
.tracing(true);
assert_eq!(cfg.rpc_url, "https://example.test");
assert_eq!(
cfg.cache_path.as_deref(),
Some(std::path::Path::new("/tmp/ignored.json"))
);
assert_eq!(cfg.network_id, Some([7u8; 32]));
assert_eq!(cfg.fetch_mode, Some(FetchMode::Lenient));
assert_eq!(cfg.pinned_ledger, Some(12345));
assert_eq!(cfg.pinned_timestamp, Some(999));
assert_eq!(cfg.max_protocol_version, Some(25));
assert!(cfg.tracing);
}
#[test]
fn fork_config_tracing_default_is_off() {
let cfg = ForkConfig::new("https://example.test");
assert!(!cfg.tracing);
}
#[test]
fn fork_config_debug_redacts_nothing_sensitive() {
let cfg = ForkConfig::new("https://example.test");
let s = format!("{cfg:?}");
assert!(s.contains("example.test"));
}
#[test]
fn explicit_network_id_override_is_stored() {
let cfg = ForkConfig::new("https://example.test").network_id([0xAB; 32]);
assert_eq!(cfg.network_id, Some([0xAB; 32]));
}
}