Lab 9 — Validate Your Revm Simulation Against a Production Provider
Question
Your revm simulation only matters if it matches the production chain. Build a differential tester — run the same tx through your revm simulation and against a production RPC; assert the results match.
Principle (minimum model)
- Differential testing setup. For a candidate tx, simulate via revm (fork at block N) + simulate via
eth_callat block N on a production RPC + assert outputs match. - Why this matters. Subtle bugs in your simulation (wrong opcode pricing / missing precompile / wrong system contract) only show up when you compare to production. The differential is the canonical detection.
- Helios as the reference. Helios is a trustless Ethereum light client; its
eth_callis your gold-standard reference because it verifies state proofs against the chain. - Per-opcode trace diffing. When outputs differ, dump the per-opcode trace from both sides; bisect to find the first divergent opcode. Reveals which opcode/precompile is wrong.
- Edge cases caught. Storage-modification refunds + EIP-2929 access list + precompile rounding errors + chain-specific deposit transactions on L2s — all surface through the diff.
- Test gate. Run the diff suite against 1000 random mainnet txs (sampled by block range); zero divergences = pass.
- Production use. MEV searchers run this in CI to catch revm-version-bump regressions; rollup teams run it to validate their custom opcode tables.
Worked example + steps
Validate Your Revm Simulation Against a Production Provider
Your arb bot's Revm fork says the swap nets 2.95 WETH. The chain — running mostly Geth and Nethermind, since Reth is still only ~7-12% of execution-client share — actually delivers 2.93. The bot just lost money to a bug in your simulation. Every Revm-based system you built in this tier has the same exposure: the MEV searcher in Lesson 1 predicts arbs on Revm, the aggregator in Lesson 7 quotes on Revm, the capstone in Lesson 8 scores frontrun risk on Revm. If Revm disagrees with the Geth/Nethermind majority that actually runs mainnet, every one of those systems silently ships wrong answers. ~200 lines below build the cross-check.
📌 Scope honesty. We diff Revm against a JSON-RPC provider for a single transaction's gas + return data. Production validation harnesses extend this to: full state-diff comparison via
debug_traceTransactionprestate, statistical sampling across thousands of historical txs, hardfork-boundary regression tests, and CI integration. The kernel — what does "they match" actually mean, and how do you check it cheaply? — is the same.
Acceptance criteria
The lesson is complete when these tests pass (full code at the end in §Test gate):
matches_provider_for_recent_blocks— over the last 10 blocks, every transaction your Revm trace matches the reference provider'sdebug_traceTransactionoutput.coverage_includes_create_and_call_paths— known transactions exercising CREATE / CREATE2 / CALL / DELEGATECALL / STATICCALL each individually match the reference.
Test-first reading. The walkthrough below shows how to construct the Revm trace and how to call debug_traceTransaction — the inputs both tests compare.
Why this matters (the real reason)
The discipline is cheap. The cost of skipping it is your bot's P&L, your aggregator's user-facing quote, your router's threat score — all silently off. From the Reth team's benchmarking philosophy: "any divergence from mainnet behavior is a bug." That's the bar.
flowchart LR
Tx["Test transaction<br/>(real mainnet tx hash<br/>or fabricated call)"] --> Revm["Revm fork<br/>at parent block"]
Tx --> Prov["Provider<br/>(Infura / QuickNode<br/>= Geth or Reth backend)"]
Revm --> R1["gas_used + output"]
Prov --> R2["gas_used + output"]
R1 --> Diff["Diff"]
R2 --> Diff
Diff --> Pass["✅ identical"]
Diff --> Fail["❌ debug<br/>(hardfork? precompile?<br/>RPC caching?)"]
Cargo.toml
[package]
name = "revm-cross-validation"
version = "0.1.0"
edition = "2021"
[dependencies]
alloy-eips = "1.0"
alloy-primitives = "1.5"
alloy-provider = "1.0"
alloy-network = "1.0"
alloy-rpc-types = "1.0"
alloy-sol-types = "1.5"
revm = { version = "38", features = ["alloydb"] }
tokio = { version = "1", features = ["full"] }
eyre = "0.6"
Step 1: Pick a test case
Two flavors of test target. Use both:
- A historical mainnet transaction — replay an already-mined tx at its parent block. The receipt gives you ground-truth gas_used; the provider's
eth_callat the parent block gives you ground-truth return data. - A fabricated call against current state — pick a contract + method (e.g.,
USDC.balanceOf(some_holder)), run it both via providereth_calland via Revm fork at the same block. The provider's response is ground truth.
Flavor 2 is simpler to start with (no historical RPC needed), so we'll build that one. Flavor 1 is in the drill.
use alloy_primitives::{address, Address, Bytes, U256};
use alloy_sol_types::{sol, SolCall};
sol! {
function balanceOf(address account) external view returns (uint256);
}
const USDC: Address = address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48");
const HOLDER: Address = address!("47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503"); // a known whale
fn build_calldata() -> Bytes {
balanceOfCall { account: HOLDER }.abi_encode().into()
}
Step 2: Get the production provider's answer
use alloy_eips::BlockId;
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::TransactionRequest;
use alloy_network::TransactionBuilder;
async fn provider_answer(
rpc_url: &str,
block: u64,
to: Address,
data: Bytes,
) -> eyre::Result<(u64, Bytes)> {
let provider = ProviderBuilder::new().connect(rpc_url).await?;
let tx = TransactionRequest::default()
.with_to(to)
.with_input(data);
// Ground truth output via eth_call at the chosen block
let output = provider.call(&tx).block(BlockId::number(block)).await?;
// Ground truth gas via eth_estimateGas at the same block
let gas = provider.estimate_gas(&tx).block(BlockId::number(block)).await?;
Ok((gas, output))
}
Walk:
eth_callreturns the function's return bytes without sending a transaction — exact same execution path the contract would use, just without persisting state changes.eth_estimateGasreturns the gas units the call would consume. Slightly higher than the actual minimum on-chain gas because it includes a safety buffer; for view calls (likebalanceOf) the buffer is small.- Both pinned to a specific
block— so when we run Revm against the same block's state, we're comparing apples to apples.
🔍 Find in repo. Open
alloy_provider::Provider. Note thatcallandestimate_gasare part of the same trait — when you swap providers (Infura → QuickNode → your own Reth node), nothing about your validation code changes. That's the abstraction earning its keep.
Step 3: Run the same call locally via Revm
Same fork pattern as Lesson 1 / Lesson 7. Pin to the same block as Step 2:
use alloy_provider::{network::Ethereum, DynProvider};
use revm::{
context::TxEnv,
context_interface::result::{ExecutionResult, Output},
database::{AlloyDB, CacheDB},
database_interface::WrapDatabaseAsync,
primitives::TxKind,
Context, ExecuteEvm, MainBuilder, MainContext,
};
type ForkedDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, DynProvider>>>;
async fn revm_answer(
rpc_url: &str,
block: u64,
to: Address,
data: Bytes,
) -> eyre::Result<(u64, Bytes)> {
let provider = ProviderBuilder::new().connect(rpc_url).await?.erased();
let alloy_db = WrapDatabaseAsync::new(AlloyDB::new(provider, BlockId::number(block)))
.ok_or_else(|| eyre::eyre!("AlloyDB init"))?;
let mut db = CacheDB::new(alloy_db);
let mut evm = Context::mainnet().with_db(&mut db).build_mainnet();
let tx = TxEnv::builder()
.caller(Address::ZERO)
.kind(TxKind::Call(to))
.data(data)
.gas_limit(10_000_000)
.build()?;
let result = evm.transact_one(tx)?;
match result.result {
ExecutionResult::Success { gas_used, output: Output::Call(out), .. } => {
Ok((gas_used, out.into()))
}
other => eyre::bail!("Revm execution did not succeed: {other:?}"),
}
}
Walk:
AlloyDB::new(provider, BlockId::number(block))pins the fork to the exact block the provider answered against. Same input, same world.Context::mainnet()uses the mainnet hardfork rules. If the chain you're validating against is not mainnet (e.g., a custom L2), use the matching spec — Revm'sContextbuilder lets you pick.Address::ZEROas caller for view-style calls — no signature needed, just like aneth_callfrom a generic address.
Step 4: Diff
async fn validate(rpc_url: &str, block: u64, to: Address, data: Bytes) -> eyre::Result<()> {
let (prod_gas, prod_out) = provider_answer(rpc_url, block, to, data.clone()).await?;
let (revm_gas, revm_out) = revm_answer(rpc_url, block, to, data).await?;
println!("== Output bytes ==");
println!(" provider: 0x{}", hex::encode(&prod_out));
println!(" revm: 0x{}", hex::encode(&revm_out));
if prod_out != revm_out {
eyre::bail!("OUTPUT MISMATCH");
}
println!(" ✅ match");
println!("== Gas ==");
println!(" provider: {prod_gas}");
println!(" revm: {revm_gas}");
// Allow a small spread because eth_estimateGas includes a buffer (~10%).
let diff = prod_gas.abs_diff(revm_gas);
let allowance = (prod_gas / 10).max(5_000);
if diff > allowance {
eyre::bail!("GAS MISMATCH: diff {diff} > allowance {allowance}");
}
println!(" ✅ within allowance");
Ok(())
}
Walk:
- Output bytes are compared exactly. A byte-level diff is the right contract — if your Revm version returns even a single byte different from the provider, downstream code that decodes the response will produce different values.
- Gas comparison allows a spread because
eth_estimateGasincludes a buffer (often 10-20%) that Revm's exact gas accounting won't add. Compare order of magnitude, not exact equality. println!instead of fancy reporting is fine for the kernel. Production wrappers usetracing::error!+ a structured diff so failures are queryable in logs.
Step 5: When they don't match — debug taxonomy
Real validation runs find mismatches. Here's the diagnosis tree:
| Symptom | Likely cause | Fix |
|---|---|---|
| Output is consistently 0x or empty when it shouldn't be | Revm spec mismatch (e.g., you built with Context::mainnet() but the chain is op-mainnet). | Match the chain spec: OpEvm, Context::op_mainnet(), etc. |
| Output differs only at a hardfork boundary | Revm's hardfork-activation block disagrees with the chain. | Pin Revm's spec to the actual hardfork active at that block — see SpecId |
| Output differs only when the contract calls a precompile | Custom precompile your Revm doesn't have (e.g., RIP-7212 secp256r1 active on some Lesson 2s). | Add the precompile to your Revm precompile registry (see Lesson 6). |
| Output flickers — sometimes match, sometimes don't, same input | RPC caching. The provider returned a stale state from a different block. | Pin to a finalized block (subtract ~32 from latest), and re-run. |
| Gas mismatches by a constant offset | Different intrinsic gas accounting (you skipped 21,000 base, or vice versa). | Reconcile: are you measuring just the call's gas or the full tx's gas? |
| Gas spreads with random variance | Hot vs cold storage access. The provider may have warmer state due to recent calls. | Re-run after a clean cycle, or fork at a synthetic state where you control warm/cold. |
Most divergences land in the top 3 rows in practice — chain-spec / hardfork / precompile mismatches.
What's missing for production-grade validation
| Gap | What real validation harnesses do |
|---|---|
| Sampling strategy | Pick 1000 random historical txs over the last 7 days; validate all. Look for systematic drift. |
| State-diff comparison | debug_traceTransaction with prestate + statediff modes gives byte-level state changes. Compare to Revm's journal entries. (Costly RPC; sample sparingly.) |
| Hardfork regression | After upgrading Revm, re-validate around the last 5 hardfork blocks. New Revm versions sometimes change spec activations. |
| Custom-precompile awareness | Your Revm setup must include every precompile the target chain has. RIP-7212, EIP-2537, custom op-stack precompiles, MEV-Share helper precompiles, etc. |
| CI integration | Run validation as part of your CI pipeline. Fail merges if the diff allowance is exceeded. |
| Multi-provider cross-check | Run the same validation against 2-3 providers (QuickNode, Infura, Alchemy). If providers disagree with each other, your validation is moot — pick one as source of truth. |
| Performance | Caching the provider's answer + only re-validating changed lessons. Reduces RPC bill. |
Build the kernel above, add the production-grade habits as your needs grow. Most teams start with a few hand-picked test cases and grow from there.
Drill
- Historical tx replay. Pick a real mainnet tx hash. Fetch its receipt → use the parent block as fork point → diff its receipt.gas_used against your Revm replay's gas_used. (1 hour)
- Custom precompile case. Pick an op-stack chain (Base, Optimism). Try to validate a tx that uses a precompile present on op-stack but not on mainnet (e.g., Lesson 1 block info precompile). Watch the failure mode. What does the diff show? (1 hour)
- Sampling harness. Wrap the validate function in a loop that walks the last 100 successful txs from a single contract (Uniswap V3 router is dense). Track pass/fail. What's the failure rate? Is the failure pattern systematic or random? (2 hours)
- Multi-provider cross-check. Run the same validation against QuickNode + Alchemy. If the two providers disagree on the same call at the same block, what does that say about your validation harness's underlying assumption? (1.5 hours)
- CI wire-up. Make the validation script exit-code 1 on any failure. Wire it into a GitHub Action that runs nightly against mainnet. Fail PRs that introduce >0.1% mismatch rate vs the baseline. (3 hours)
Finish drill 5 and you have, structurally, the same continuous-validation discipline shipped at every serious Revm-based searcher / wallet / aggregator team. The discipline is what separates "Revm code that works on my laptop" from "Revm code I trust in production."
Test gate
Per Test gate — every app in this tier ships with passing tests, this lesson is the test gate for the rest of the tier — but it has its own gate too: a differential trace test against a non-Revm provider over a small recent block range.
The premise of the whole lesson is that "your Revm trace matches Geth/Erigon's debug_traceTransaction output." The test makes that claim machine-checkable.
// tests/revm_vs_provider.rs
#[tokio::test]
async fn matches_provider_for_recent_blocks() {
// Use any provider that exposes debug_traceTransaction:
// - Erigon: built-in
// - Geth: --gcmode=archive --http.api debug
// - Alchemy: paid plan; rate-limited
let reference = ProviderBuilder::new().connect_http(REFERENCE_RPC.parse().unwrap());
for block_num in (LATEST - 10)..=LATEST {
let block = reference.get_block_by_number(block_num.into(), true).await.unwrap().unwrap();
for tx in block.transactions.into_transactions() {
let our_trace = our_revm_trace(tx.hash).await.unwrap();
let reference_trace = reference.debug_trace_transaction(tx.hash).await.unwrap();
assert_traces_equivalent(&our_trace, &reference_trace,
"tx {:?} diverged from reference at block {}", tx.hash, block_num);
}
}
}
#[tokio::test]
async fn coverage_includes_create_and_call_paths() {
// Pick known transactions that exercise CREATE, CREATE2, CALL, DELEGATECALL,
// STATICCALL — each must individually match the reference
}
The lesson — and by extension your trust in everything you built in Lesson 1–Lesson 8 — is not complete until both pass on a real recent-block range. A single divergent tx means your simulation lies about something, and you cannot know which sim-dependent decision in Lesson 1–Lesson 8 was wrong without first finding it.
📺 Further watching
Nh19f_2fWLc | Dragan Rakita — EVM Technical walkthrough — the spec your Revm needs to follow exactly to match production
You've finished the Building tier (now for real)
Ten lessons covering everything from "I have an arbitrage idea" to "I can guarantee my Revm matches Geth in production" and "I can monetize any of it per request":
- Minimal MEV searcher (mempool → fork-sim → arb)
- Reorg-aware Postgres indexer (ExEx + reorg dispatch)
- Custom RPC endpoint (jsonrpsee + extend_rpc_modules)
- Wallet backend (signer pool + nonce mgr + replace-on-stuck)
- EIP-7702 sponsor (Type 4 tx + paymaster pattern)
- Foundry-style cheatcode (custom precompile + harness)
- Swap aggregator (Revm fork + cross-venue quotes)
- Frontrun-resistant order router (capstone — integrates 1-7)
- Cross-client validation harness (this lesson) — turns the previous eight from "demos" into "production-trusted"
- Machine-payments endpoint (HTTP 402 + MPP) — the monetization layer on top of any of the above
Pick the build that maps to your target employer / project most closely. Open the production gaps. Ship as a small public repo. That's the artifact you bring to the conversation.
🧭 Where you are now in the stack: you've shipped the compiler / VM layer's correctness verification — differential trace comparison of your Revm fork against a production JSON-RPC provider, with gas + return-data parity and CREATE/CALL coverage as the gate. Same discipline as IEEE 754 conformance and TLS interop, applied to Revm. Every other app in this tier (Lesson 1, Lesson 7, Lesson 8) depends on this verification layer. Next lesson moves to the networking layer's payment protocol — HTTP 402 + MPP — as the monetization edge on top of the whole tier.
Summary (3 lines)
- Differential tester = simulate via revm fork + simulate via production
eth_call+ assert outputs match. Helios as the trustless reference. - Per-opcode trace diff bisects divergences. Edge cases: storage refunds + EIP-2929 + precompile rounding + L2-specific deposits.
- CI gate: 1000 random mainnet txs, zero divergences. Used by MEV searchers + rollup teams to catch regressions. Next: MPP payments.