Rewrite It in Rust: Simulation
Part VI: Sim Pool Really
The most painful part of high-volume searching is simulation.
Before every value capture can occur, you must run it against your best view of the chain state. The boundary between your efficient searcher and the generalized (read: slow) RPC is the point where scalability goes to die.
Let’s fix that, shall we?
eth_call
The easiest approach is to use eth_call, which executes everything but stops short of applying the state transition.
This can be used to call read-only functions, such as balanceOf on an ERC-20 token, or to execute a state-changing function without broadcasting it, like checking for the successful execution of a swap.
eth_call has advanced overrides features that allow you to tweak the environment for each simulation:
State values: account balance, nonce, code at some address, values in storage, etc.
Block values: block number, timestamp, gas limit, blob and base fee, etc.
A key limitation of eth_call is that is can only execute one transaction at a time. The state that would results from TX_1 is not visible on the eth_call of TX_2, which is critically important for multi-transaction bundles.
I covered eth_call in detail:
You could wire up a bunch of state inspection functions to chain multiple eth_calls together, but there’s a better way.
eth_simulateV1
eth_simulateV1 has a similar structure to eth_call, supports the same overrides, but supports multiple sequenced transactions instead of just one. And the state transitions of one transaction persist to the next simulation. In this way, a [TX_1, TX_2, … TX_n] bundle simulation can be executed where the final state of TX_1 is exposed to TX_2, TX_2 feed TX_3, and so on.
I’ve covered eth_simulateV1 as well, and used it as a key element of my Base flashblocks backrunner project:
The Hidden Bottleneck
While eth_simulateV1 is very flexible, it still locks you to an external RPC and commits you to unavoidable network I/O. This cost can be substantially reduced by running a local node on the same computer or at least the same network.
But even after minimizing that cost, there is still significant simulation overhead from data marshaling, e.g. searcher process data → JSON-RPC → node process data → JSON-RPC → searcher process data.
And it gets worse! Each RPC request is executed in a vacuum and from a “fresh” global state. Perhaps you see a mempool transaction that you want to backrun (TX_1), so you prepare several eth_simulateV1 bundles across the different paths available from the affected pool. Each time the node receives that simulation bundle, it will execute TX_1 again. Node software may cache the state values to avoid disk I/O and state derivation, but it’s largely out of your hands unless you want to take the burden of maintaining a forked version of your preferred node.
Just Rewrite The EVM Bro
If you zoom out a bit and survey the Ethereum software ecosystem, it quickly becomes clear that nobody wants to reinvent an EVM implementation. Most software builds on top of a reference library for their given language. Python has EELS, Rust has revm, Go has the core-geth vm, and Javascript has EthereumJS.
We’re already on the Rust train, so we’ll focus on revm.
The revm documentation is quite sparse, but the github repo provides several nice examples that illustrate how to use it.
Architecture
We are primarily concerned with state access. Revm provides state access through a Database trait that expects four methods:
basic, which gets account informationcode_by_hash, which gets the code stored on-chainstorage, which gets the value stored at an address and slotblock_hash, which gets the block hash by number
There is an equivalent trait DatabaseRef which takes a &self instead of &mut self.
Both traits are implemented by several Revm built-in structs:
impl Database for BenchmarkDBimpl<DB> Database for BalDatabase<DB>
where DB: Databaseimpl<DB> Database for State<DB>
where DB: Databaseimpl<E> Database for EmptyDBTyped<E>
where E: DBErrorMarker + Error + Send + Sync + 'static>impl<ExtDB> CacheDB<ExtDB>
where ExtDB: DatabaseRefimpl<L, R> Database for Either<L, R>
where L: Database,
R: Database<Error = <L as Database>::Error>impl<T> Database for WrapDatabaseAsync<T>
where T: DatabaseAsyncimpl<T> Database for WrapDatabaseRef<T>
where T: DatabaseRef
The most interesting of these for our purpose is CacheDb, which wraps a reference to a Database.
If we construct a Database and wrap it in CacheDb, we can execute queries against that database, using the caching feature to retrieve values more quickly.
And since Database and DatabaseRef are public traits, we can implement them on our own structs. Revm even provides a built-in wrapper wraps an Alloy provider. I already showed how fast Alloy’s providers can be, so this is a big win.
Cached Single Calls
The unlock here is that CacheDb can wrap an Alloy provider connection to your RPC. You can then pass the cached DB to your new EVM instance and then run queries against it.

