rvm.fork instead of --rpc-url
I have been building a high-throughput coverage-guided fuzzer for Solidity. For
a long time I called it raptor internally. That name shows up in earlier
posts about the SharedBackend and
the coverage inspector.
When I published the crate, raptor was already taken on crates.io, so the
public name is now ripfuzz. Same project, new
crate name.
The cheatcode surface is rvm, not Foundry’s vm. Same idea (call into a
cheatcodes address from the harness), different name so it is clear this is
ripfuzz’s API.
This post is about how campaigns opt into remote state. I used to fork from the CLI. That worked for one chain. It fell apart for cross-chain audits. So I moved forking into a cheatcode.
The old model
Fork mode used to look like this:
ripfuzz run Harness \
--rpc-url "$RPC_URL_ETH" \
--rpc-block 21000000Chain::new either built an empty sandbox or a single forked chain. The choice
was fixed at process start. Every worker cloned that one chain. The harness
never got to decide.
That is fine when you only care about mainnet at one block. It is awkward when
you care about a bridge on Ethereum and Polygon in the same campaign. CLI flags
encode one (url, block). Cross-chain needs many.
Foundry solves this with createFork, selectFork, createSelectFork, and
makePersistent. That API is powerful, but heavy for how I actually write
harnesses. I wanted one verb.
The new model
Campaigns always start empty. Remote state is opt-in from Solidity:
function setup() external {
string memory rpc = rvm.getEnv("RPC_URL_BSC");
rvm.fork(rpc, 113_613_922);
}Two overloads:
struct ForkConfig {
uint32 retries;
uint64 backoffMs;
uint64 timeoutMs;
uint64 rateLimit; // 0 = unlimited
}
function fork(string calldata url, uint256 blockNumber) external;
function fork(string calldata url, uint256 blockNumber, ForkConfig config)
external;No fork ids. Identity is (url, block). Calling fork again with the same
pair is a no-op. Calling it with a different pair creates or selects that fork.
Single-arg rvm.fork(url, block) uses the same defaults the CLI used to own:
| Setting | Default |
|---|---|
| retries | 3 |
| backoff | 100 ms |
| timeout | 30_000 ms |
| rate limit | none |
Override them from the harness with the ForkConfig overload. No --rpc-*
flags remain on the CLI.
I also load .env from the project directory at campaign start, so
rvm.getEnv("RPC_URL_BSC") just works when the key lives in .env.
Multi-chain in the harness
Cross-chain actions become modifiers:
string constant ETH_RPC = /* from env or constant */;
string constant POLYGON_RPC = /* ... */;
modifier onEthereum() {
rvm.fork(ETH_RPC, 21_000_000);
_;
}
modifier onPolygon() {
rvm.fork(POLYGON_RPC, 50_000_000);
_;
}
function bridgeToL2() external onEthereum {
// PolyBridger @ 0xabc is Ethereum state
}
function bridgeToL1() external onPolygon {
// same address, Polygon state
}Each fuzzer action is its own transaction. The modifier forks first, then runs the body. That is the shape I want: one active chain per action, explicit in the harness, no CLI restarts.
How the database does it
The hard part is not the cheatcode surface. It is switching backends while revm still holds a journal.
I did not rebuild Chain on every fork. I multiplex inside the database:
Database
├── Empty(CacheDB) // before any fork
└── Multi
├── active: (url_hash, block)
├── forks: Map → CacheDB<ForkDB>
└── backends: Map url_hash → SharedBackendWhen rvm.fork runs mid-transaction:
- Normalize the key as
(url_hash(url), blockNumber). - If that fork already exists, select it.
- If not, create a
SharedBackend(or reuse one for the same URL), fetchchain_idand the block header, build a freshCacheDB<ForkDB>, and select it. - Copy persistent local accounts into the selected overlay.
- Update
block_envandchain_idon the live EVM context.
Remote accounts live only in their fork’s CacheDB. Local accounts (deployer,
rvm address, CREATE targets, rvm.addr results) are copied across switches so
the harness survives setup and action modifiers.
Same address on two chains is the interesting case. A bridge deployed at
0x1111…1111 on Ethereum and Polygon must not share storage. The multi-fork
map gives each (url, block) its own overlay, so:
fork(eth)thenrvm.load(bridge, 0)sees Ethereum statefork(polygon)thenrvm.load(bridge, 0)sees Polygon statervm.store/rvm.dealon one fork does not leak into the other- switching back restores the previous overlay, including local mutations
I added tests for exactly that isolation path. It is the PolyBridger-shaped bug class: same address, different chain, independent state.
What stays local
Not everything is per-fork:
| Kind | Across forks |
|---|---|
| Remote storage / balance / code | Isolated |
Harness, deployer, CREATE, rvm.addr | Shared (persistent) |
| Labels | Global |
| Coverage | Keyed by bytecode hash, not address or chain |
Coverage stays on bytecode hash on purpose. The fuzzer asks “did this code path run?”, not “did this address on this chain run?”. Same bytecode on two chains shares one edge map. Different bytecode at the same address already gets different ids. If multi-chain campaigns later under-explore, I would boost corpus interestingness for new fork context rather than split the coverage key.
CLI cleanup
I removed the entire Fork Mode section from the CLI:
--rpc-url--rpc-block--rpc-retries--rpc-backoff--rpc-timeout--rpc-rate-limit
Forking is harness-only. Defaults live in ForkDBConfig (same numbers as the
old flags). Disk cache still lands under .ripfuzz/cache, keyed by chain and
block the way SharedBackend already did.
Library tests that need a pre-forked chain still call
Chain::fork_with_transport. Campaigns go through the cheatcode.
That is the point. CLI configuration is a bad place to encode campaign structure. A fuzzer campaign is a program. The harness already describes deploy, setup, actions, and invariants. Fork choice belongs there too.
Foundry’s create/select model is complete. For ripfuzz I only needed
create-or-select in one call, keyed by (url, block), with automatic local
persistence. That matches how I write multi-chain modifiers.
The database layer has to own multi-fork. If you try to swap Chain.database
while revm is mid-transaction, you fight the journal. Multiplexing behind
DatabaseRef keeps the inspector simple: call fork, update block env, return
success.
And isolation tests for same-address / different-chain are not optional. Once you support multi-fork, that is the first bug class people will hit. Mock two RPCs, pin two blocks, prove storage and balance do not leak, then prove mutations survive a round trip. Do that before you trust a bridge campaign.
That is the whole change: empty by default, rvm.fork when you need remote
state, multi-fork isolation for free, and the CLI out of the way.
| Tags | rust , revm , fuzzing , solidity , evm |
|---|