soroban_fork/lib.rs
1//! # soroban-fork
2//!
3//! Lazy-loading mainnet / testnet fork for Soroban tests.
4//!
5//! Think [Foundry's Anvil](https://book.getfoundry.sh/anvil/), but for Stellar
6//! Soroban. When a test reads a ledger entry that isn't cached, it's fetched
7//! from the Soroban RPC on the fly. State changes are local only — the real
8//! network is never mutated. On drop, lazy-fetched entries can be persisted
9//! to disk in the standard `stellar snapshot create` format, so a second run
10//! is fully local.
11//!
12//! ```rust,no_run
13//! use soroban_fork::ForkConfig;
14//!
15//! let env = ForkConfig::new("https://soroban-testnet.stellar.org:443")
16//! .cache_file("fork_cache.json")
17//! .build()
18//! .expect("fork setup");
19//!
20//! // `env` derefs to `soroban_sdk::Env`, backed by real network state.
21//! env.mock_all_auths();
22//! ```
23//!
24//! ## Error handling
25//!
26//! [`ForkConfig::build`] returns [`Result<ForkedEnv, ForkError>`] so
27//! transport, cache, and XDR errors are all recoverable. Inside the VM
28//! loop — where the trait signature forbids returning errors — the source
29//! honors [`FetchMode::Strict`] (panic on transport failure) or
30//! [`FetchMode::Lenient`] (log + treat entry as missing).
31//!
32//! ## Logging
33//!
34//! The crate uses the [`log`] facade — no output appears unless the test
35//! binary initializes a logger (e.g. `env_logger`). Typical invocation:
36//!
37//! ```bash
38//! RUST_LOG=soroban_fork=info cargo test
39//! ```
40
41#![warn(missing_docs)]
42#![warn(clippy::all)]
43#![warn(rust_2018_idioms)]
44
45pub mod auth_tree;
46mod cache;
47mod error;
48pub mod fees;
49mod rpc;
50mod source;
51pub mod test_accounts;
52pub mod trace;
53pub mod workspace;
54
55/// JSON-RPC server mode. Available with the `server` cargo feature —
56/// pulls in tokio + axum + tower-http; library-mode users (default)
57/// don't pay for these deps.
58#[cfg(feature = "server")]
59pub mod server;
60
61pub use auth_tree::AuthTree;
62pub use error::{ForkError, Result};
63pub use rpc::{FetchedEntry, LatestLedger, NetworkMetadata, RpcClient, RpcConfig};
64pub use source::{FetchMode, RpcSnapshotSource};
65pub use trace::{Trace, TraceFrame, TraceResult};
66pub use workspace::{workspace_wasm, workspace_wasm_in, workspace_wasm_with};
67
68use std::cell::OnceCell;
69use std::path::PathBuf;
70use std::rc::Rc;
71use std::sync::Arc;
72
73use log::{info, warn};
74use soroban_sdk::testutils::{Ledger as _, SnapshotSourceInput};
75use soroban_sdk::{Env, IntoVal, Symbol, Val};
76
77/// Average Stellar ledger close time in seconds. Used by [`ForkedEnv::warp_time`]
78/// and [`ForkedEnv::warp_ledger`] to keep the two advancement modes in sync.
79const LEDGER_INTERVAL_SECONDS: u64 = 5;
80
81// ---------------------------------------------------------------------------
82// ForkedEnv
83// ---------------------------------------------------------------------------
84
85/// A forked Soroban environment backed by real network state.
86///
87/// Derefs to [`soroban_sdk::Env`] so all standard SDK methods work directly.
88/// When dropped, any lazy-fetched entries are persisted to the cache file
89/// if one was configured.
90pub struct ForkedEnv {
91 env: Env,
92 source: Rc<RpcSnapshotSource>,
93 cache_path: Option<PathBuf>,
94 ledger_sequence: u32,
95 timestamp: u64,
96 network_id: [u8; 32],
97 protocol_version: u32,
98 /// Network passphrase from the fork-time `getNetwork` call. Kept so
99 /// the optional JSON-RPC server can answer `getNetwork` without a
100 /// re-query and so future reproducibility tools can verify a cache
101 /// matches its declared network. `None` only when the user
102 /// overrode `network_id` and we never had a passphrase to keep.
103 passphrase: Option<String>,
104 /// Lazily resolved Soroban resource-fee schedule, sourced from the
105 /// six on-chain `ConfigSetting` entries the first time it's asked
106 /// for and reused thereafter.
107 fee_configuration: OnceCell<fees::FeeConfiguration>,
108 /// Pre-funded deterministic accounts the fork minted at build
109 /// time, exposed for test/demo code that wants to use them as
110 /// transaction sources. Empty when the user disabled them via
111 /// [`ForkConfig::without_test_accounts`].
112 test_accounts: Vec<test_accounts::TestAccount>,
113}
114
115impl std::ops::Deref for ForkedEnv {
116 type Target = Env;
117 fn deref(&self) -> &Env {
118 &self.env
119 }
120}
121
122impl ForkedEnv {
123 /// Borrow the underlying [`Env`]. Handy when you need the `Env` but not
124 /// the `ForkedEnv` wrapper — passing `&*forked` also works through `Deref`.
125 pub fn env(&self) -> &Env {
126 &self.env
127 }
128
129 /// Number of RPC calls served through the fork's cache since construction.
130 /// A cached (hit) read does not count; only lazy fetches that actually
131 /// reached the network.
132 pub fn fetch_count(&self) -> u32 {
133 self.source.fetch_count()
134 }
135
136 /// Advance the ledger sequence and timestamp that the `Env` reports
137 /// to contracts.
138 ///
139 /// **Not ledger close.** Unlike a real Stellar ledger close, this
140 /// does not process any pending state — there are no pending
141 /// transactions in this model, and no contract code runs as a side
142 /// effect of the warp. It only changes what
143 /// `env.ledger().sequence_number()` and `env.ledger().timestamp()`
144 /// return on subsequent reads. Use this when contract logic
145 /// conditionally branches on ledger time (vesting cliffs, auction
146 /// windows, oracle staleness checks).
147 ///
148 /// **TTL caveat.** Bumping the sequence past a cached entry's
149 /// `live_until_ledger_seq` does not automatically simulate Soroban's
150 /// archival/restore flow — soroban-fork does not yet model entry
151 /// expiry. Tests that rely on TTL-expiry semantics will see false
152 /// positives (the entry stays "live" past its real-mainnet expiry).
153 /// Tracking issue: <https://github.com/lobotomoe/soroban-fork/issues>.
154 pub fn warp(&self, ledgers: u32, seconds: u64) {
155 // Saturating arithmetic: pre-v0.8 these `+=` ops were
156 // reachable only from lib-mode test code, where an overflow
157 // panic was a fine signal of "your test math is wrong". v0.8
158 // wires `warp` through `fork_closeLedgers` over JSON-RPC, so
159 // any client can request unbounded advances; saturating keeps
160 // wire-driven misuse from panicking the worker thread (which
161 // would kill the whole server). The saturated values are
162 // already past any meaningful real-Stellar ledger horizon, so
163 // tests reaching them are intentionally pathological anyway.
164 self.env.ledger().with_mut(|info| {
165 info.sequence_number = info.sequence_number.saturating_add(ledgers);
166 info.timestamp = info.timestamp.saturating_add(seconds);
167 });
168 }
169
170 /// Advance time by `seconds`; ledger sequence moves proportionally
171 /// (~5 seconds per ledger, matching Stellar's target close rate).
172 pub fn warp_time(&self, seconds: u64) {
173 let ledgers = (seconds / LEDGER_INTERVAL_SECONDS) as u32;
174 self.warp(ledgers, seconds);
175 }
176
177 /// Advance by `ledgers` ledgers; timestamp moves proportionally.
178 pub fn warp_ledger(&self, ledgers: u32) {
179 let seconds = ledgers as u64 * LEDGER_INTERVAL_SECONDS;
180 self.warp(ledgers, seconds);
181 }
182
183 /// Set a SEP-41 token balance to an exact amount. Soroban equivalent
184 /// of Foundry's `deal()`.
185 ///
186 /// Computes the delta against the current balance and calls `mint`
187 /// (if increasing) or `burn` (if decreasing) on the token contract.
188 /// Requires [`Env::mock_all_auths`] because the caller has to stand
189 /// in as the token's admin for `mint` and as `to` for `burn`.
190 ///
191 /// ```rust,ignore
192 /// env.mock_all_auths();
193 /// env.deal_token(&usdc, &alice, 1_000_000 * UNIT);
194 /// ```
195 pub fn deal_token(
196 &self,
197 token: &soroban_sdk::Address,
198 to: &soroban_sdk::Address,
199 amount: i128,
200 ) {
201 let e = &self.env;
202 let to_val: Val = to.into_val(e);
203
204 let current: i128 = e.invoke_contract(token, &Symbol::new(e, "balance"), {
205 let mut v = soroban_sdk::Vec::new(e);
206 v.push_back(to_val);
207 v
208 });
209
210 let delta = amount - current;
211 if delta == 0 {
212 return;
213 }
214
215 let target_val: Val = to.into_val(e);
216 if delta > 0 {
217 let delta_val: Val = delta.into_val(e);
218 let mut args = soroban_sdk::Vec::new(e);
219 args.push_back(target_val);
220 args.push_back(delta_val);
221 e.invoke_contract::<()>(token, &Symbol::new(e, "mint"), args);
222 } else {
223 let abs_delta: Val = delta.unsigned_abs().into_val(e);
224 let mut args = soroban_sdk::Vec::new(e);
225 args.push_back(target_val);
226 args.push_back(abs_delta);
227 e.invoke_contract::<()>(token, &Symbol::new(e, "burn"), args);
228 }
229 }
230
231 /// Network passphrase from the fork-time `getNetwork` call, or `None`
232 /// if the user overrode `network_id` (in which case we never queried
233 /// the upstream RPC and don't know the passphrase). Used by the
234 /// optional JSON-RPC server's `getNetwork` method.
235 pub fn passphrase(&self) -> Option<&str> {
236 self.passphrase.as_deref()
237 }
238
239 /// SHA-256 hash of the network passphrase. Returned by
240 /// `getNetwork` as `networkId` after hex encoding.
241 pub fn network_id(&self) -> [u8; 32] {
242 self.network_id
243 }
244
245 /// Protocol version reported by the forked Env.
246 pub fn protocol_version(&self) -> u32 {
247 self.protocol_version
248 }
249
250 /// Ledger sequence the forked Env *currently* reports — reads
251 /// the live value out of [`Env::ledger`] so any [`Self::warp`] /
252 /// [`Self::warp_ledger`] / `fork_closeLedgers` calls are reflected
253 /// immediately. At fork build the value matches the upstream
254 /// RPC's latest (or [`ForkConfig::at_ledger`]); fork-mode
255 /// extensions that advance time move it forward from there.
256 ///
257 /// The fork-point sequence (used as cache metadata in
258 /// [`Self::save_cache`]) is preserved separately on the
259 /// `ledger_sequence` field so cache provenance stays accurate
260 /// even after the env has been warped.
261 pub fn ledger_sequence(&self) -> u32 {
262 self.env.ledger().get().sequence_number
263 }
264
265 /// Close-time the forked Env *currently* reports (Unix seconds).
266 /// Live reading from [`Env::ledger`] — `fork_closeLedgers` and
267 /// [`Self::warp_time`] move it; the fork-point timestamp is
268 /// preserved separately for cache provenance.
269 pub fn ledger_close_time(&self) -> u64 {
270 self.env.ledger().get().timestamp
271 }
272
273 /// Direct access to the snapshot source. Useful when the JSON-RPC
274 /// server needs to resolve `getLedgerEntries` requests through the
275 /// same cache the SDK reads from — exposing it here avoids
276 /// duplicating the lazy-fetch logic in the server module.
277 pub fn snapshot_source(&self) -> &Rc<RpcSnapshotSource> {
278 &self.source
279 }
280
281 /// Pre-funded deterministic test accounts the fork minted at
282 /// build time. Empty when [`ForkConfig::test_account_count`] was
283 /// set to `0`.
284 ///
285 /// Each carries the secret seed (so test/demo code can sign
286 /// envelopes from the account) and the public key. Use
287 /// [`test_accounts::TestAccount::account_strkey`] for the
288 /// `G...` source-account string JS-SDK clients expect.
289 pub fn test_accounts(&self) -> &[test_accounts::TestAccount] {
290 &self.test_accounts
291 }
292
293 /// The on-chain Soroban resource-fee schedule for this forked
294 /// network, resolved lazily on first call.
295 ///
296 /// Implementation: at first call we fetch the six `ConfigSetting`
297 /// entries that compose the schedule (one upstream-RPC round-trip
298 /// per uncached key, then served from the snapshot cache forever)
299 /// and decode them into a [`fees::FeeConfiguration`]. Subsequent
300 /// calls return the same cached reference.
301 ///
302 /// Used by the JSON-RPC server's `simulateTransaction` to compute
303 /// honest `minResourceFee` numbers; library callers can also
304 /// invoke this directly for fee-projection tests.
305 pub fn fee_configuration(&self) -> Result<&fees::FeeConfiguration> {
306 if let Some(cfg) = self.fee_configuration.get() {
307 return Ok(cfg);
308 }
309 let cfg = fees::fetch_fee_configuration(&self.source)?;
310 // `set` only fails if another thread (or re-entrant call) already
311 // populated the cell. ForkedEnv is `!Send`, so the only way to hit
312 // that path is re-entry from inside `fetch_fee_configuration` —
313 // and the snapshot source there does not call back into us.
314 let _ = self.fee_configuration.set(cfg);
315 Ok(self
316 .fee_configuration
317 .get()
318 .expect("fee_configuration just populated"))
319 }
320
321 /// Reconstruct the cross-contract call tree from the host's diagnostic
322 /// event stream.
323 ///
324 /// Returns an empty [`Trace`] if [`ForkConfig::tracing`] was not set
325 /// to `true` at build time, or if no contract calls have happened
326 /// yet.
327 ///
328 /// **Per-invocation scoping.** The host's `InvocationMeter` clears
329 /// the events buffer at the start of every top-level
330 /// `invoke_contract`, so each `trace()` reflects only the most
331 /// recent top-level call — earlier calls' events are gone. This is
332 /// what you usually want for per-test assertions; if you need
333 /// history across multiple invocations, capture each `trace()`
334 /// before the next call.
335 ///
336 /// See [`crate::trace`] for the wire-format details and known
337 /// caveats (single-`Vec`-arg ambiguity, trapped vs. rolled-back
338 /// frames).
339 pub fn trace(&self) -> trace::Trace {
340 let events = self.diagnostic_events();
341 trace::Trace::from_events(&events)
342 }
343
344 /// Print the call-tree to stderr in a Foundry-`-vvvv`-style indented
345 /// format. Convenience for debug sessions; equivalent to
346 /// `eprintln!("{}", env.trace())`.
347 pub fn print_trace(&self) {
348 eprintln!("{}", self.trace());
349 }
350
351 /// Raw access to the host's diagnostic event stream.
352 ///
353 /// Returns an empty [`soroban_env_host::events::Events`] when tracing
354 /// is off or when reading the buffer fails (the failure path is
355 /// extraordinarily rare — host event externalisation only fails on
356 /// budget exhaustion, which means the test was already broken).
357 /// We log the underlying error at `warn!` and hand back an empty
358 /// stream rather than panicking, because `trace()` is most often
359 /// called from a test that's already failing and a panic here would
360 /// hide the original failure.
361 pub fn diagnostic_events(&self) -> soroban_env_host::events::Events {
362 match self.env.host().get_diagnostic_events() {
363 Ok(events) => events,
364 Err(e) => {
365 warn!("soroban-fork: get_diagnostic_events failed: {e:?}");
366 soroban_env_host::events::Events(Vec::new())
367 }
368 }
369 }
370
371 /// Reconstruct the recording-mode authorization tree from the host's
372 /// last top-level invocation.
373 ///
374 /// Returns an empty [`AuthTree`] in two cases:
375 /// 1. No top-level `invoke_contract` has run yet — the host has no
376 /// "previous invocation" to read payloads from.
377 /// 2. The invocation completed but made no `require_auth` demands.
378 ///
379 /// Both are normal, not errors. We log the underlying host error at
380 /// `warn!` and hand back an empty tree, mirroring [`Self::trace`].
381 /// A failing test that calls this method to debug an auth issue
382 /// should not be made worse by a panic from the debug helper itself.
383 ///
384 /// **Per-invocation scoping.** Like [`Self::trace`], the recorded
385 /// payloads reflect only the most recent top-level invocation;
386 /// earlier invocations' payloads are gone. Capture each
387 /// `auth_tree()` before the next call if you need history.
388 ///
389 /// See [`crate::auth_tree`] for the limits inherited from
390 /// `soroban-env-host` (no `last_auth_failure`, no enforceable
391 /// `strict_auth` mode flag).
392 pub fn auth_tree(&self) -> AuthTree {
393 AuthTree::from_payloads(self.auth_payloads())
394 }
395
396 /// Print the auth tree to stderr in a Foundry-`-vvvv`-style indented
397 /// format. Convenience for debug sessions; equivalent to
398 /// `eprintln!("{}", env.auth_tree())`.
399 pub fn print_auth_tree(&self) {
400 eprintln!("{}", self.auth_tree());
401 }
402
403 /// Raw access to the host's recorded auth payloads from the last
404 /// top-level invocation.
405 ///
406 /// Parallel to [`Self::diagnostic_events`]. Returns an empty `Vec`
407 /// (with a `warn!` log) on host error rather than propagating —
408 /// most callers reach for this from a debug context where a panic
409 /// in the helper would hide the original failure.
410 pub fn auth_payloads(&self) -> Vec<soroban_env_host::auth::RecordedAuthPayload> {
411 match self.env.host().get_recorded_auth_payloads() {
412 Ok(payloads) => payloads,
413 Err(e) => {
414 warn!("soroban-fork: get_recorded_auth_payloads failed: {e:?}");
415 Vec::new()
416 }
417 }
418 }
419
420 /// Explicitly persist the cache. Called automatically on drop when a
421 /// `cache_file` is configured, but can be invoked manually — useful
422 /// if you want cached state before a panic would drop the env.
423 pub fn save_cache(&self) -> Result<()> {
424 let Some(path) = &self.cache_path else {
425 return Ok(());
426 };
427 do_save_cache(
428 &self.source,
429 path,
430 self.ledger_sequence,
431 self.timestamp,
432 self.network_id,
433 self.protocol_version,
434 )
435 }
436}
437
438impl Drop for ForkedEnv {
439 fn drop(&mut self) {
440 let Some(path) = self.cache_path.as_ref() else {
441 return;
442 };
443 match do_save_cache(
444 &self.source,
445 path,
446 self.ledger_sequence,
447 self.timestamp,
448 self.network_id,
449 self.protocol_version,
450 ) {
451 Ok(()) => {
452 let count = self.source.entries().len();
453 info!("soroban-fork: saved {count} entries to {}", path.display());
454 }
455 Err(e) => {
456 warn!("soroban-fork: cache save error on drop: {e}");
457 }
458 }
459 }
460}
461
462// ---------------------------------------------------------------------------
463// ForkConfig
464// ---------------------------------------------------------------------------
465
466/// Builder for a [`ForkedEnv`]. All fields have sensible defaults — the only
467/// required input is the RPC URL.
468#[derive(Clone, Debug)]
469pub struct ForkConfig {
470 rpc_url: String,
471 cache_path: Option<PathBuf>,
472 network_id: Option<[u8; 32]>,
473 fetch_mode: Option<FetchMode>,
474 pinned_ledger: Option<u32>,
475 pinned_timestamp: Option<u64>,
476 max_protocol_version: Option<u32>,
477 rpc_config: RpcConfig,
478 tracing: bool,
479 /// Number of pre-funded test accounts to mint at build time.
480 /// `0` disables them. Default: 10.
481 test_account_count: usize,
482 /// Trustlines to pre-create for each test account against
483 /// Stellar-Classic-issued assets. Required so that SAC `transfer`
484 /// to a test account succeeds — without a trustline the host
485 /// returns `Error(Contract, #13) "trustline missing"`.
486 ///
487 /// Default targets the **mainnet USDC** issuer (Circle), since
488 /// that's what most demos and dapp tests reach for. Forks
489 /// pointing at testnet or a custom network should override via
490 /// [`ForkConfig::test_account_trustlines`] — the default's
491 /// issuer would still preload, but testnet's USDC SAC routes
492 /// through a different issuer and would never write to it.
493 test_trustlines: Vec<soroban_env_host::xdr::TrustLineAsset>,
494}
495
496impl ForkConfig {
497 /// Create a new fork config pointing at a Soroban RPC endpoint.
498 ///
499 /// Common endpoints:
500 /// - Testnet: `https://soroban-testnet.stellar.org:443`
501 /// - Mainnet (public): `https://soroban-rpc.mainnet.stellar.gateway.fm`
502 ///
503 /// The network ID is queried from the RPC's `getNetwork` method during
504 /// [`Self::build`] — no URL heuristics, no defaults. If you're running
505 /// an offline fork (not usually the point), supply the ID explicitly
506 /// via [`Self::network_id`].
507 pub fn new(rpc_url: impl Into<String>) -> Self {
508 Self {
509 rpc_url: rpc_url.into(),
510 cache_path: None,
511 network_id: None,
512 fetch_mode: None,
513 pinned_ledger: None,
514 pinned_timestamp: None,
515 max_protocol_version: None,
516 rpc_config: RpcConfig::default(),
517 tracing: false,
518 test_account_count: 10,
519 test_trustlines: vec![test_accounts::usdc_mainnet_trustline_asset()],
520 }
521 }
522
523 /// Path to a JSON file for persisting fetched entries.
524 ///
525 /// If the file exists at [`Self::build`] time, its entries pre-populate
526 /// the cache (compatible with `stellar snapshot create` output). On
527 /// [`ForkedEnv`] drop, the cache is written back — subsequent runs are
528 /// fully local.
529 pub fn cache_file(mut self, path: impl Into<PathBuf>) -> Self {
530 self.cache_path = Some(path.into());
531 self
532 }
533
534 /// Override the network ID (SHA-256 of the network passphrase).
535 ///
536 /// Normally fetched from the RPC. Override only if you need a specific
537 /// value — e.g. for regression tests of signatures computed against an
538 /// older network ID.
539 pub fn network_id(mut self, id: [u8; 32]) -> Self {
540 self.network_id = Some(id);
541 self
542 }
543
544 /// Select the fetch mode. Defaults to [`FetchMode::Strict`] — RPC errors
545 /// inside the VM loop panic, which is what tests want.
546 pub fn fetch_mode(mut self, mode: FetchMode) -> Self {
547 self.fetch_mode = Some(mode);
548 self
549 }
550
551 /// Pin the ledger sequence the forked `Env` reports to contracts.
552 ///
553 /// The RPC itself always returns entries at the latest ledger — this
554 /// setting only shifts what `env.ledger().sequence_number()` reports,
555 /// which is what contract logic reads. For full state reproducibility,
556 /// pair with [`Self::cache_file`].
557 pub fn at_ledger(mut self, sequence: u32) -> Self {
558 self.pinned_ledger = Some(sequence);
559 self
560 }
561
562 /// Pin the ledger timestamp the forked `Env` reports (Unix seconds).
563 ///
564 /// The default is the close time of the ledger we're forking from,
565 /// fetched via `getLedgers` at build time. That keeps tests
566 /// deterministic across runs — the previous wall-clock default made
567 /// every run depend on when it was started, which is a footgun
568 /// silently waiting to bite anyone who asserts on contract logic that
569 /// reads `env.ledger().timestamp()`.
570 ///
571 /// Pin an explicit value when reproducing a known historical
572 /// scenario or when the network's reported close time would conflict
573 /// with the test's assumed timeline.
574 pub fn pinned_timestamp(mut self, unix_seconds: u64) -> Self {
575 self.pinned_timestamp = Some(unix_seconds);
576 self
577 }
578
579 /// Cap the protocol version the VM reports to contracts.
580 ///
581 /// Useful when the real network has upgraded past what your `soroban-sdk`
582 /// version knows — the contract sees `max`, not the live value, and
583 /// protocol-specific asserts still pass.
584 pub fn max_protocol_version(mut self, version: u32) -> Self {
585 self.max_protocol_version = Some(version);
586 self
587 }
588
589 /// Replace the default RPC transport configuration (timeouts, retries,
590 /// batch size). See [`RpcConfig`] for tunables.
591 pub fn rpc_config(mut self, config: RpcConfig) -> Self {
592 self.rpc_config = config;
593 self
594 }
595
596 /// Enable call-tree tracing.
597 ///
598 /// When `true`, [`Self::build`] flips the host into
599 /// [`DiagnosticLevel::Debug`](soroban_env_host::DiagnosticLevel) so
600 /// every cross-contract call emits `fn_call`/`fn_return` diagnostic
601 /// events. Read the resulting tree via [`ForkedEnv::trace`] or print
602 /// it with [`ForkedEnv::print_trace`].
603 ///
604 /// **Must be set before building the env** — flipping the diagnostic
605 /// level after the first invocation does not retroactively capture
606 /// earlier calls.
607 ///
608 /// **Cost:** the host runs in debug mode, which charges a separate
609 /// "shadow" budget for diagnostic-event bookkeeping. For typical
610 /// integration tests this is negligible; for fuzzing-style workloads
611 /// with thousands of invocations, consider leaving tracing off and
612 /// flipping it on only for the failing case.
613 pub fn tracing(mut self, enabled: bool) -> Self {
614 self.tracing = enabled;
615 self
616 }
617
618 /// Number of pre-funded deterministic test accounts to mint at
619 /// fork-build time. Each gets ~100K XLM. Default: 10. Set to `0`
620 /// to skip account pre-population — useful when the only thing
621 /// you'll do with the fork is read mainnet state, never construct
622 /// envelopes.
623 ///
624 /// The accounts are deterministic: the same seed string
625 /// produces the same keypairs across runs and across machines,
626 /// so test code can reference them by index without juggling
627 /// fixtures. Read them back via [`ForkedEnv::test_accounts`].
628 pub fn test_account_count(mut self, count: usize) -> Self {
629 self.test_account_count = count;
630 self
631 }
632
633 /// Replace the list of Classic assets the fork pre-creates
634 /// trustlines for, on each test account. Default is
635 /// `[mainnet USDC]`.
636 ///
637 /// **When to override.** Forking a non-mainnet network (testnet,
638 /// futurenet, custom) — the default issuer doesn't exist there,
639 /// so USDC SAC operations would still fail. Pass the assets
640 /// your dapp actually transacts with, with the right issuers
641 /// for that network. Pass an empty `Vec` to skip trustline
642 /// pre-creation entirely (tests that only use Soroban-native
643 /// tokens and Contract-address recipients don't need them).
644 ///
645 /// Builds a trustline with `flags = AUTHORIZED_FLAG`, `limit =
646 /// i64::MAX`, `balance = 0`. Equivalent to the user having run
647 /// `ChangeTrust` and the issuer having authorized the trustline
648 /// — minus the round-trip through the issuer's KYC.
649 pub fn test_account_trustlines(
650 mut self,
651 assets: Vec<soroban_env_host::xdr::TrustLineAsset>,
652 ) -> Self {
653 self.test_trustlines = assets;
654 self
655 }
656
657 /// Build the forked environment.
658 ///
659 /// Steps:
660 /// 1. Construct an [`RpcClient`](crate::RpcConfig) from this config.
661 /// 2. Pre-load entries from `cache_file` if it exists.
662 /// 3. Resolve network metadata + latest ledger sequence via RPC
663 /// (unless overridden by [`Self::network_id`] / [`Self::at_ledger`]).
664 /// 4. Return a [`ForkedEnv`] that derefs to [`Env`].
665 pub fn build(self) -> Result<ForkedEnv> {
666 let client = Arc::new(rpc::RpcClient::new(
667 self.rpc_url.clone(),
668 self.rpc_config.clone(),
669 )?);
670
671 let source = source::RpcSnapshotSource::new(client.clone());
672 let source = match self.fetch_mode {
673 Some(mode) => source.with_fetch_mode(mode),
674 None => source,
675 };
676
677 // Pre-load from cache file
678 if let Some(ref path) = self.cache_path {
679 if path.exists() {
680 match cache::load_snapshot(path) {
681 Ok(entries) => {
682 let count = entries.len();
683 source.preload(entries);
684 info!(
685 "soroban-fork: pre-loaded {count} entries from {}",
686 path.display()
687 );
688 }
689 Err(e) => {
690 warn!(
691 "soroban-fork: cache load error, starting fresh ({}): {e}",
692 path.display()
693 );
694 }
695 }
696 }
697 }
698
699 let latest = client.get_latest_ledger()?;
700
701 // Resolve network metadata: explicit override wins; otherwise
702 // fetch from the upstream RPC. We keep the passphrase around
703 // (for the optional JSON-RPC server's `getNetwork`) when we
704 // queried it ourselves; an explicit `network_id` override
705 // intentionally has `None` passphrase since we don't know what
706 // the caller used.
707 let (network_id, passphrase) = match self.network_id {
708 Some(id) => (id, None),
709 None => {
710 let meta = client.get_network()?;
711 (meta.network_id, Some(meta.passphrase))
712 }
713 };
714
715 let sequence = self.pinned_ledger.unwrap_or(latest.sequence);
716 let protocol_version = match self.max_protocol_version {
717 Some(max) if latest.protocol_version > max => {
718 info!(
719 "soroban-fork: capping protocol version {} -> {} (max_protocol_version)",
720 latest.protocol_version, max
721 );
722 max
723 }
724 _ => latest.protocol_version,
725 };
726 let timestamp = self.pinned_timestamp.unwrap_or(latest.close_time);
727
728 let sdk_ledger_info = soroban_env_host::LedgerInfo {
729 protocol_version,
730 sequence_number: sequence,
731 timestamp,
732 network_id,
733 base_reserve: cache::DEFAULT_BASE_RESERVE,
734 min_persistent_entry_ttl: cache::DEFAULT_MIN_PERSISTENT_ENTRY_TTL,
735 min_temp_entry_ttl: cache::DEFAULT_MIN_TEMP_ENTRY_TTL,
736 max_entry_ttl: cache::DEFAULT_MAX_ENTRY_TTL,
737 };
738
739 let source_rc = Rc::new(source);
740 let input = SnapshotSourceInput {
741 source: source_rc.clone(),
742 ledger_info: Some(sdk_ledger_info),
743 snapshot: None,
744 };
745
746 let env = Env::from_ledger_snapshot(input);
747
748 // We always set the diagnostic level explicitly. soroban-sdk's
749 // `new_for_testutils` unconditionally turns on Debug mode (so its
750 // `auths()` and `events()` hooks always work) — that means our
751 // `tracing(false)` would be a silent lie unless we override.
752 // `set_diagnostic_level` is the public hook; the InvocationMeter
753 // tracks metrics independently of the diagnostic level, so flipping
754 // to None here doesn't break the SDK's other test machinery.
755 let (level, level_label) = if self.tracing {
756 (soroban_env_host::DiagnosticLevel::Debug, "Debug")
757 } else {
758 (soroban_env_host::DiagnosticLevel::None, "None")
759 };
760 env.host().set_diagnostic_level(level).map_err(|e| {
761 error::ForkError::Host(format!("set_diagnostic_level({level_label}) failed: {e:?}"))
762 })?;
763 if self.tracing {
764 info!("soroban-fork: tracing enabled (DiagnosticLevel::Debug)");
765 }
766
767 info!("soroban-fork: forked at ledger {sequence} (protocol {protocol_version})");
768
769 // Pre-mint deterministic test accounts. Doing this AFTER the
770 // env is built means the freshly-written Account entries
771 // never go through SDK initialisation — they sit in the
772 // snapshot source's cache, served on-demand by the next
773 // `getLedgerEntries` (the basis of JS-SDK's `getAccount`).
774 //
775 // Each account also gets a USDC trustline so DEX scenarios
776 // (XLM→USDC on Phoenix / Soroswap) work straight away —
777 // without one, the SAC fails to credit the account with
778 // `Error(Contract, #13) "trustline missing"`. This is the
779 // same shape `ChangeTrust` writes on real mainnet, just
780 // bootstrapped at fork time. Not a workaround: it's the
781 // honest representation of "this test account holds USDC".
782 let test_accounts = test_accounts::generate(self.test_account_count);
783 if !test_accounts.is_empty() {
784 let trustline_count = self.test_trustlines.len();
785 let mut preloads: Vec<(
786 soroban_env_host::xdr::LedgerKey,
787 soroban_env_host::xdr::LedgerEntry,
788 Option<u32>,
789 )> = Vec::with_capacity(test_accounts.len() * (1 + trustline_count));
790 for a in &test_accounts {
791 let (key, entry) = a.ledger_entry(sequence);
792 preloads.push((key, entry, None));
793 for asset in &self.test_trustlines {
794 let (tl_key, tl_entry) = a.trustline_entry(asset.clone(), sequence);
795 preloads.push((tl_key, tl_entry, None));
796 }
797 }
798 source_rc.preload(preloads);
799 info!(
800 "soroban-fork: minted {} pre-funded test account{} \
801 ({} trustline{} each)",
802 test_accounts.len(),
803 if test_accounts.len() == 1 { "" } else { "s" },
804 trustline_count,
805 if trustline_count == 1 { "" } else { "s" }
806 );
807 }
808
809 Ok(ForkedEnv {
810 env,
811 source: source_rc,
812 cache_path: self.cache_path,
813 ledger_sequence: sequence,
814 timestamp,
815 passphrase,
816 network_id,
817 protocol_version,
818 fee_configuration: OnceCell::new(),
819 test_accounts,
820 })
821 }
822}
823
824// ---------------------------------------------------------------------------
825// Helpers
826// ---------------------------------------------------------------------------
827
828fn do_save_cache(
829 source: &RpcSnapshotSource,
830 path: &std::path::Path,
831 sequence: u32,
832 timestamp: u64,
833 network_id: [u8; 32],
834 protocol_version: u32,
835) -> Result<()> {
836 let entries = source.entries();
837 if entries.is_empty() {
838 return Ok(());
839 }
840 cache::save_snapshot(
841 path,
842 &entries,
843 sequence,
844 timestamp,
845 network_id,
846 protocol_version,
847 )
848}
849
850// ---------------------------------------------------------------------------
851// Tests
852// ---------------------------------------------------------------------------
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 #[test]
859 fn fork_config_builder_records_overrides() {
860 let cfg = ForkConfig::new("https://example.test")
861 .cache_file("/tmp/ignored.json")
862 .network_id([7u8; 32])
863 .fetch_mode(FetchMode::Lenient)
864 .at_ledger(12345)
865 .pinned_timestamp(999)
866 .max_protocol_version(25)
867 .tracing(true);
868
869 assert_eq!(cfg.rpc_url, "https://example.test");
870 assert_eq!(
871 cfg.cache_path.as_deref(),
872 Some(std::path::Path::new("/tmp/ignored.json"))
873 );
874 assert_eq!(cfg.network_id, Some([7u8; 32]));
875 assert_eq!(cfg.fetch_mode, Some(FetchMode::Lenient));
876 assert_eq!(cfg.pinned_ledger, Some(12345));
877 assert_eq!(cfg.pinned_timestamp, Some(999));
878 assert_eq!(cfg.max_protocol_version, Some(25));
879 assert!(cfg.tracing);
880 }
881
882 #[test]
883 fn fork_config_tracing_default_is_off() {
884 // Tracing has a measurable cost (host runs in debug mode + a
885 // separate budget). Default off keeps fast tests fast.
886 let cfg = ForkConfig::new("https://example.test");
887 assert!(!cfg.tracing);
888 }
889
890 #[test]
891 fn fork_config_debug_redacts_nothing_sensitive() {
892 // ForkConfig is Debug; this sanity-tests it doesn't panic and
893 // renders the URL (no secrets in config today).
894 let cfg = ForkConfig::new("https://example.test");
895 let s = format!("{cfg:?}");
896 assert!(s.contains("example.test"));
897 }
898
899 /// The `getNetwork`-based network_id path is exercised in
900 /// `tests/network.rs` (marked `#[ignore]` so offline CI still passes).
901 #[test]
902 fn explicit_network_id_override_is_stored() {
903 let cfg = ForkConfig::new("https://example.test").network_id([0xAB; 32]);
904 assert_eq!(cfg.network_id, Some([0xAB; 32]));
905 }
906}