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.8"
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
.tracing // optional: capture cross-contract call tree
.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.
The Env's reported timestamp defaults to the close time of the latest
ledger, fetched via getLedgers at build time. Tests are reproducible
across runs out of the box — pin an explicit value via
.pinned_timestamp(...) only when you need to anchor to a specific
moment (e.g. reproducing a known historical scenario).
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
plus full jitter (so concurrent test runners don't synchronise their
retries into a thundering herd), 30 s per-request timeout, 200-key batch
size (Soroban RPC cap). Customize via .rpc_config(RpcConfig { .. }) on
the builder. HTTP 408, 425, 429, and 5xx responses are retried; other
4xx codes fail fast and include the response body for diagnostics.
Tracing — Foundry-style call trees
Set .tracing(true) on the builder to capture cross-contract call trees.
The host runs in DiagnosticLevel::Debug, every fn_call/fn_return
emits a diagnostic event, and env.trace() reconstructs the tree:
let env = new
.tracing
.build?;
env.;
env.print_trace;
[TRACE]
[CABC…XYZ1] deposit(GACC…QRST, 1000000)
[CCDE…UVW2] transfer_from(GACC…QRST, CABC…XYZ1, 1000000)
← ()
[CFGH…IJK3] invest(1000000)
← 1010000
← 1010000
Programmatic access via env.trace() returns a Trace with structured
TraceFrames — useful for asserting call structure or balances inside
a test. Failed calls render as [rolled back]; WASM traps show as
TRAPPED (no fn_return).
Per-invocation scoping. The host's InvocationMeter clears the
events buffer at the start of every top-level invoke_contract, so each
trace() reflects only the most recent top-level call. Capture before
the next call if you need history. See the
trace module docs
for wire-format details and caveats (single-Vec-arg ambiguity).
JSON-RPC server mode
Library mode (everything above) is for Rust tests. Server mode turns soroban-fork into a Stellar Soroban RPC drop-in that any tooling — JS, Python, Go SDKs, Stellar Lab, Freighter, custom clients — can point at:
# → serving JSON-RPC on http://127.0.0.1:8000
Then any client speaking the Stellar RPC dialect:
import from "@stellar/stellar-sdk";
const server = ;
const account = await server.;
const result = await server.; // hits the fork
Or via the Rust stellar-rpc-client, raw curl, or anything that
understands the spec.
Pre-funded test accounts (new in v0.7)
The fork mints 10 deterministic test accounts at build time, each with 100K XLM and a USDC trustline ready to receive. Same seed produces the same accounts every run, so test code can hard-code addresses by index. The CLI prints them on startup:
soroban-fork v0.7
Listening on http://127.0.0.1:8000
Available test accounts:
(0) GBXXX...AB12 (100000.0000000 XLM) -> SAXXX...CD34
(1) GCYYY...EF56 (100000.0000000 XLM) -> SAXXX...GH78
...
Pass them to JS-SDK's Keypair.fromSecret(...) to sign envelopes.
After every successful sendTransaction, the source account's
sequence number auto-increments — so chained getAccount →
TransactionBuilder → sendTransaction loops just work.
Real DEX flow works end-to-end. A test account can swap XLM → USDC against the live Phoenix DEX (or Soroswap, Aquarius, …) and the USDC actually lands in its trustline. Smoke-tested: 1000 XLM → 167.4020548 USDC at the live mainnet pool reserves.
No hidden hardcode. The trustline default targets the mainnet
USDC issuer (Circle); for testnet, futurenet, or a custom fork,
override via ForkConfig::test_account_trustlines(vec![...]). The
trustlines are written with flags = AUTHORIZED_FLAG, limit = i64::MAX —
shape-equivalent to running ChangeTrust then having the issuer
authorize, just bootstrapped at build time. Auth runs in trust mode
(Recording(false)) so unsigned envelopes from test code apply
without ceremony.
Override count via --accounts N (set to 0 to disable). For
library users, ForkConfig::test_account_count(n).build() exposes
the same machinery; read accounts back with env.test_accounts().
Deploy your own contracts onto the fork (new in v0.7)
The same sendTransaction accepts HostFunction::UploadContractWasm
and HostFunction::CreateContract, so you can deploy custom contracts
straight onto the forked mainnet state and have them call live
production contracts. The test suite's
server_deploy_and_invoke_custom_contract covers the full loop:
- Upload a tiny
add(i32, i32) -> i32WASM - Create the contract instance from the uploaded hash
- Invoke
add(2, 3)on the deployed contract — returns 5
Cross-protocol scenarios (your contract calls Blend, Phoenix, Soroswap, etc.) follow the same pattern: dependencies the deployed contract reaches into get lazy-fetched from mainnet and cached locally.
The headline showcase: cheatcode-only deploy
What makes the toolset matter, in one test:
fork_setCodeinstalls your WASM bytes (noUploadContractWasmenvelope)fork_setStorageinstalls the contract instance entry pointing at that WASM, at a synthetic contract address (noCreateContractenvelope, no source-account juggling, no salt)simulateTransactioninvokes your cheatcode-deployed contract, returns the result- The same fork still serves live mainnet contracts —
XLM SAC.decimals()returns7, yoursynth_contract.add(2,3)returns5, both in the same simulation context
Two cheatcode calls and a contract is callable. That's the Foundry-vm.etch-equivalent — the headline reason this toolset exists. Live in server_cheatcode_only_deploy_coexists_with_mainnet; end-to-end against mainnet, ~70 LoC.
Methods supported in v0.8.7
getHealth— fork status + latest ledgergetVersionInfo— server version + protocol versiongetNetwork— passphrase + protocol version + network ID (proxied from the upstream RPC at fork-build time, then served locally)getLatestLedger— fork's reported ledger sequence + protocolgetLedgers— single-element page describing the fork point with realledgerCloseTime(Unix-seconds string, per Stellar convention)getLedgerEntries— base64-XDRLedgerKeyarray → array of entries; routed through the fork's lazy-fetch cache, so first hit proxies upstream and subsequent hits are localsimulateTransaction— accepts a base64-XDRTransactionEnvelopewith oneInvokeHostFunctionOp, runs it via the host's recording-mode primitive, returns:results[0].xdr— the function's return value (ScVal)results[0].auth— auth entriessendTransactionwould needtransactionData—SorobanTransactionDatawith recorded footprint andresourceFeematchingminResourceFeeevents— diagnostic events emitted during simulationcost.cpuInsns/cost.memBytes— real numbers from the host'sBudget, not awrite_bytesproxyminResourceFee— derived from the live on-chain Soroban fee schedule viacompute_transaction_resource_fee(since v0.5.2)latestLedger— fork's reported ledger
sendTransaction(new in v0.6) — applies the host invocation's writes back to the snapshot source so subsequent reads see them. Auth runs in trust mode (Recording(false)) so unsigned envelopes from test code apply without ceremony. Returnsstatus("SUCCESS"/"ERROR"),hash(sha256 of the envelope),appliedChanges(number ofLedgerEntryChanges written), and the original envelope echo.getTransaction(new in v0.6) — receipt lookup by hash. Returns"SUCCESS"/"FAILED"/"NOT_FOUND", plus the original envelope, the host function'sScValreturn value, and the applied-changes count when found.
Fork-mode extensions (fork_*)
Non-standard methods, only available against soroban-fork. The
fork_ prefix marks the namespace boundary explicitly so a
client can distinguish "this works against any Stellar RPC" from
"this only works against the fork."
fork_setLedgerEntry(new in v0.8, renamed fromanvil_setLedgerEntryin v0.8.1) — force-write a base64-XDRLedgerEntryto anyLedgerKeydirectly in the snapshot source, bypassing host-level checks. Load-bearing primitive for stress-test scenarios — oracle price manipulation, force-set token balances, replace contract code, all reduce to this one entry write.fork_setStorage(new in v0.8.2) — sugar overfork_setLedgerEntryfor the common case of writing into a contract's storage. Takescontract(strkey),key(base64 ScVal),value(base64 ScVal), optionaldurability("persistent"(default) /"temporary"), and optionalliveUntilLedgerSeq. The handler builds theContractDataXDR server-side so clients don't have to assemble the multi-level enum nesting themselves. Use this for oracle price overrides and contract-storage scenarios.fork_setCode(new in v0.8.3) — upload WASM bytes as aContractCodeledger entry, keyed by sha256 of those bytes. Takeswasm(base64) and optionalliveUntilLedgerSeq. Returns{ ok, hash, latestLedger }— the hash is server-derived (the host computes it the same way), so callers can wire a follow-upCreateContract(orfork_setStorageover aContractInstanceScVal) to point at the uploaded code without any host invocation.fork_setBalance(new in v0.8.4, Soroban-token path added in v0.8.7) — Foundry'sdeal()-equivalent for Stellar. Three asset shapes:"native"(default) — XLM, balance lives onAccountEntry. Auto-creates the account with master threshold 1 if missing.{ code, issuer }— Classic credit asset (USDC, EURC, …), balance lives onTrustLineEntry. Auto-creates the trustline withflags = AUTHORIZED,limit = i64::MAX— equivalent to having runChangeTrustand the issuer authorising.{ contract }(v0.8.7) — any SEP-41-shaped Soroban token (the SAC for Classic assets, custom Soroban tokens like BLND). Handler simulatesbalance(to), computes the delta, and invokesmint(to, delta)orburn(to, |delta|)with trust-mode auth bypassing admin checks.amountis a decimal string. For Classic paths it'si64stroops; for the contract path it'si128.- Takes
account(G-strkey) for the recipient and the asset discriminant above. Returns{ ok, latestLedger }.
fork_etch(new in v0.8.6) — Foundry'svm.etch-equivalent. Hot-swap the WASM under any contract address in one wire call. Takescontract(strkey),wasm(base64 bytes), optionalliveUntilLedgerSeq. Internally: install ContractCode, then read-modify-write the contract's instance entry to point at the new code hash. Storage is preserved verbatim — if the existing instance carries contract state, swapping code keeps that state intact (the hotfix scenario). Auto-creates the instance entry if the target address has none yet — works on any address regardless of prior state, just like Anvil. One wire call replaces thefork_setCode+fork_setStoragedance the v0.8.5 showcase uses.fork_closeLedgers(new in v0.8, renamed fromanvil_minein v0.8.1) — closeledgersledgers (default 1) and bump close-time bytimestampAdvanceSeconds(defaultledgers * 5— Stellar's average close rate). Stellar's verb is closing a ledger; pushes time-sensitive contract logic (vesting cliffs, oracle staleness) past thresholds without orchestrating real transactions.
What v0.8.7 server does NOT support
Listed up front so nothing surprises you:
getEvents— historical event filtering. Diagnostic events emitted during simulation are reachable viasimulateTransaction's response.- Ergonomic
fork_*wrappers —setNonce,impersonate. The primitivefork_setLedgerEntrycovers all of these once the client constructs the right XDR;fork_setStorage(v0.8.2),fork_setCode(v0.8.3),fork_setBalance(v0.8.4 + v0.8.7),fork_etch(v0.8.6) are the sugar wrappers landed so far. fork_snapshot/fork_revert— saved-state checkpoints. Scoped to v0.9 (theRc<HostImpl>snapshot model needs its own design pass — either a journaling layer overRpcSnapshotSourceor a clone-on-snapshot of the entire cache map).- Ledger close as a
sendTransactionside-effect — each send applies its writes and bumps the source'sseq_num, but does not automatically advanceenv.ledger().sequence_number(). Usefork_closeLedgers(orenv.warp(...)from lib mode) to push the ledger forward. Auto-close on send is a v0.8.x ergonomic followup. resultMetaXdrongetTransaction— Stellar'sTransactionMeta::V3carries state-change deltas in a Stellar-core-XDR-heavy shape; v0.6 returnsreturnValueXdrandappliedChangesinstead. Full meta XDR is a v0.6.x followup.
Architecture: single-threaded actor
axum HTTP handlers run on a multi-thread tokio runtime; commands flow
through a bounded mpsc channel to one OS thread that owns the
ForkedEnv. The SDK's Env contains Rc<HostImpl> and is !Send, so
it can't live behind Arc<RwLock> — single-thread ownership with
explicit messaging is the load-bearing constraint of this design.
[HTTP handler 1]──┐
[HTTP handler 2]──┼──mpsc::channel──→ [worker thread] owns ForkedEnv
[HTTP handler N]──┘ │
└─→ snapshot_source.get()
└─→ on cache miss → upstream RPC
Cache misses on getLedgerEntries block the worker for one upstream
round-trip. Steady state (after first contact) is local.
Library API for server mode
If you'd rather embed the server in your own Rust process (CI test harness, custom Stellar tooling), use the library API:
use ;
async
For tests that need to bind ephemeral ports and shut down programmatically:
let running = builder
.listen // OS-assigned port
.start
.await?;
let url = format!;
// ... drive the server with a real client ...
running.shutdown.await?;
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
RpcSnapshotSource is Send + Sync, so it can be wrapped in Arc and
shared across threads — useful for parallel test runners and the
upcoming RPC-server mode. Internally the cache stores XDR-encoded bytes
and parses to LedgerEntry only at the SDK boundary, so no Rc ever
crosses threads.
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
Examples
Runnable demos against live Stellar mainnet. Each one targets a real contract — Blend lending, Phoenix DEX — to show where lazy-fork pays off compared to fabricated reserves in a snapshot test.
# What does my 50K USDC deposit do to the Blend Fixed pool?
# What's my fill price market-selling 1M XLM into Phoenix?
# Phoenix vs Soroswap on the same XLM/USDC trade — how big is the
# cross-DEX price gap right now?
MAINNET_RPC_URL overrides the upstream RPC. Each example prints
the forked ledger sequence and the number of RPC fetches it triggered.
For server-mode tooling (@stellar/stellar-sdk, Stellar Lab,
Freighter), examples/server_demo.mjs shows the JSON-RPC dialect
working from Node — no npm install, no XDR dance, just fetch():
# shell A — start the fork server
# shell B — drive it from Node
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
What soroban-fork does NOT yet do — listed up front so nothing surprises you in production:
No(closed in v0.6.) Server-modesendTransaction/ state mutation through RPC.sendTransactionapplies writes back to the snapshot source so subsequent reads see them;getTransactionretrieves receipts by hash. (closed in v0.7:) the fork now mints 10 pre-funded test accounts at build, auto-incrementsseq_numafter every successful send, and acceptsUploadContractWasm+CreateContracthost functions — full deploy-then-call workflow against forked mainnet works. (closed in v0.8:)fork_setLedgerEntryandfork_closeLedgersextensions land — force-write anyLedgerEntryand advance the reported ledger directly. Ergonomicfork_*wrappers (impersonate / setBalance / setCode / setStorage) are a v0.8.x followup;fork_snapshot/fork_revertare scoped to v0.9.- No TTL / archival simulation. Soroban entries carry a
live_until_ledger_seq; on real mainnet they become archived past that ledger and need aRestoreFootprintoperation. We tracklive_untilin the cache but do not yet model expiry — bumpingenv.ledger()past an entry'slive_untilwill not flip it to archived. Tests that depend on TTL-expiry semantics will see false-positives. - No historical state.
at_ledger(N)shifts only whatenv.ledger().sequence_number()reports; the actual ledger entries are always fetched at the RPC's current latest. Pin to a specific ledger only when paired withcache_filefor reproducibility, not when expecting historical state. - Tracing renders structure, not metering.
env.trace()captures the call tree with decoded args and return values. It does not yet render per-frame gas / cost units, contract events, or decodedHostErrorreasons. (Diagnostic events from the host carry call structure but not metering numbers; metering is planned. Server-modesimulateTransactiondoes return realcost.cpuInsnsseparately.) Server(closed in v0.5.2.)simulateTransactionfee fields are stubbed.minResourceFeeis now derived from the live on-chain fee schedule viacompute_transaction_resource_fee, andcost.memBytesreadsBudget::get_mem_bytes_consumeddirectly. Bandwidth + historical-data fees use the actual envelope size received over the wire.- 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.91+ (the Soroban SDK 25.3.1 floor)
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