soroban-fork
Lazy-loading mainnet/testnet fork for Soroban tests. Think Foundry's Anvil, but for Stellar Soroban.
When a test reads a ledger entry that isn't in the local cache, soroban-fork fetches it from the Soroban RPC on the fly. No need to pre-snapshot every contract your test might touch.
Install
[]
= "0.1"
Usage
use ForkConfig;
use ;
How it works
Your test calls contract.total_assets()
|
v
Soroban VM needs a ledger entry
|
v
RpcSnapshotSource.get(key)
|
+----+----+
| |
Cache Cache miss
hit |
| v
| getLedgerEntries RPC call
| |
| v
| Cache result locally
| |
+----+----+
|
v
Return entry to VM
- First run: entries are fetched from the Soroban RPC as needed. Each unique entry = one HTTP call (batched in chunks of 200 if pre-fetching).
- Subsequent runs: if
cache_fileis set, entries are loaded from disk. Only new entries trigger RPC calls. - State changes are local: the real network is never modified. Deposits, transfers, and other mutations happen in memory only.
API
ForkConfig
new // Soroban RPC endpoint
.cache_file // optional: disk persistence + auto-save on drop
.network_id // optional: override the SHA-256 network id
.fetch_mode // optional: Strict (default) or Lenient
.at_ledger // optional: pin the Env's reported sequence
.pinned_timestamp // optional: pin the Env's close time
.max_protocol_version // optional: cap the protocol the VM reports
.rpc_config
.build? // returns Result<ForkedEnv, ForkError>
Network metadata (passphrase + SHA-256 id) is fetched from the RPC's
getNetwork method at build time — no URL heuristics, no silent defaults.
Override with .network_id(bytes) only if you actually need to.
ForkedEnv
Returned by ForkConfig::build(). Implements Deref<Target = Env> so all SDK
methods work transparently. Adds fork-specific capabilities:
let env = new.cache_file.build?;
// Use like a regular Env (via Deref)
env.mock_all_auths;
let result: i128 = env.invoke_contract;
// Fork-specific methods
env.fetch_count; // number of RPC calls made
env.save_cache?; // explicit save (also called automatically on drop)
env.warp_time; // advance ledger timestamp + sequence
env.deal_token; // Foundry-style balance deal
env.env; // &Env (for edge cases where Deref doesn't suffice)
FetchMode
Controls behavior when the RPC fails from inside the VM loop (where the
SnapshotSource trait can't return a typed error):
Strict(default): panic. Best for tests — a fetch failure means the test setup is wrong, and you want the stack trace.Lenient: log atwarn!level and returnNone. Useful when partial state is acceptable.
RpcConfig
Transport tunables. Defaults: 3 retries with 300 ms exponential backoff,
30 s per-request timeout, 200-key batch size (Soroban RPC cap). Customize
via .rpc_config(RpcConfig { .. }) on the builder.
RpcSnapshotSource
The core primitive. Implements soroban_env_host::storage::SnapshotSource:
use Arc;
use ;
use RpcClient; // re-exported
let client = new;
let source = new;
source.preload; // pre-load entries from a snapshot file
let all_entries = source.entries; // export for persistence
Errors
Every public fallible API returns Result<T, ForkError>. The error enum
discriminates transport failures, RPC-level errors, XDR codec failures,
cache I/O, and protocol-violation cases — no string-typed errors.
Logging
Uses the log facade — no output unless a logger
is initialized in the test binary. Typical setup:
RUST_LOG=soroban_fork=info
Combining with stellar snapshot create
For maximum speed, pre-snapshot known contracts and let soroban-fork handle the rest lazily:
# Snapshot the main contracts you know about
let env = new
.cache_file // pre-loaded entries skip RPC
.build;
// Calls to vault/strategy use cached entries (fast).
// Calls to USDC token or other dependencies are fetched lazily from RPC.
Diagnostics
Every lazy fetch is logged to stderr with human-readable key types:
[soroban-fork] forked at ledger 2070078 (protocol 25)
[soroban-fork] fetch #1: ContractData(instance)
[soroban-fork] fetch #2: ContractCode(dee2d494...)
[soroban-fork] fetch #3: ContractData(persistent)
[soroban-fork] saved 3 entries to test_cache.json
env.fetch_count() returns the total number of RPC calls for programmatic assertions.
Cache format
The cache file uses the same JSON format as stellar snapshot create (LedgerSnapshot). You can:
- Use a
stellar snapshot createoutput as the cache input - Share cache files between team members for reproducible tests
- Inspect cached entries with
stellar xdr decode
Cache is saved automatically when ForkedEnv is dropped, including all entries
that were lazy-fetched during the test. This means the second run of a test with
cache_file set will be fully local -- zero RPC calls.
Limitations
- No block production: there's no
evm_mineequivalent. The ledger timestamp/sequence is fixed at the fork point. - No impersonation: there's no
vm.prank(). Useenv.mock_all_auths()for auth bypassing. - No RPC server: unlike Anvil, this doesn't expose a JSON-RPC endpoint. It's a library for Rust tests.
- Footprint discovery: Soroban requires declaring the transaction footprint before execution. The fork tool handles this transparently via the recording-mode footprint in the test environment.
Requirements
- Rust 1.80+
soroban-sdk25.x (withtestutilsfeature)- Network access to a Soroban RPC endpoint
Why this exists
The Stellar SDK supports snapshot-based fork testing via stellar snapshot create + Env::from_snapshot_file(). But you must know every contract address your test will touch in advance. Miss one dependency and the test fails.
This tool adds the missing piece: lazy loading on cache miss. It implements SnapshotSource (the trait that feeds ledger entries to the Soroban VM) with an RPC fallback. The standard soroban_sdk::Env works unchanged.
See stellar/rs-soroban-sdk#1440 for the upstream issue tracking this gap.
License
MIT OR Apache-2.0