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:

Shell
ripfuzz run Harness \
  --rpc-url "$RPC_URL_ETH" \
  --rpc-block 21000000

Chain::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:

Solidity
function setup() external {
    string memory rpc = rvm.getEnv("RPC_URL_BSC");
    rvm.fork(rpc, 113_613_922);
}

Two overloads:

Solidity
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:

SettingDefault
retries3
backoff100 ms
timeout30_000 ms
rate limitnone

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:

Solidity
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:

Plain Text
Database
├── Empty(CacheDB)           // before any fork
└── Multi
    ├── active: (url_hash, block)
    ├── forks: Map → CacheDB<ForkDB>
    └── backends: Map url_hash → SharedBackend

When rvm.fork runs mid-transaction:

  1. Normalize the key as (url_hash(url), blockNumber).
  2. If that fork already exists, select it.
  3. If not, create a SharedBackend (or reuse one for the same URL), fetch chain_id and the block header, build a fresh CacheDB<ForkDB>, and select it.
  4. Copy persistent local accounts into the selected overlay.
  5. Update block_env and chain_id on 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:

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:

KindAcross forks
Remote storage / balance / codeIsolated
Harness, deployer, CREATE, rvm.addrShared (persistent)
LabelsGlobal
CoverageKeyed 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:

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.