FABRKNT
Building with the Stack — Real-World Rust EVM Apps
Application Patterns
Lesson 10 of 11·CONTENT50 min90 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Building with the Stack — Real-World Rust EVM Apps
Lesson role
CONTENT
Sequence
10 / 11

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_call at 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_call is 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_traceTransaction prestate, 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):

  1. matches_provider_for_recent_blocks — over the last 10 blocks, every transaction your Revm trace matches the reference provider's debug_traceTransaction output.
  2. 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:

  1. 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_call at the parent block gives you ground-truth return data.
  2. A fabricated call against current state — pick a contract + method (e.g., USDC.balanceOf(some_holder)), run it both via provider eth_call and 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_call returns the function's return bytes without sending a transaction — exact same execution path the contract would use, just without persisting state changes.
  • eth_estimateGas returns 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 (like balanceOf) 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 that call and estimate_gas are 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's Context builder lets you pick.
  • Address::ZERO as caller for view-style calls — no signature needed, just like an eth_call from 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_estimateGas includes 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 use tracing::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:

SymptomLikely causeFix
Output is consistently 0x or empty when it shouldn't beRevm 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 boundaryRevm'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 precompileCustom 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 inputRPC 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 offsetDifferent 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 varianceHot 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

GapWhat real validation harnesses do
Sampling strategyPick 1000 random historical txs over the last 7 days; validate all. Look for systematic drift.
State-diff comparisondebug_traceTransaction with prestate + statediff modes gives byte-level state changes. Compare to Revm's journal entries. (Costly RPC; sample sparingly.)
Hardfork regressionAfter upgrading Revm, re-validate around the last 5 hardfork blocks. New Revm versions sometimes change spec activations.
Custom-precompile awarenessYour Revm setup must include every precompile the target chain has. RIP-7212, EIP-2537, custom op-stack precompiles, MEV-Share helper precompiles, etc.
CI integrationRun validation as part of your CI pipeline. Fail merges if the diff allowance is exceeded.
Multi-provider cross-checkRun 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.
PerformanceCaching 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

  1. 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)
  2. 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)
  3. 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)
  4. 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)
  5. 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":

  1. Minimal MEV searcher (mempool → fork-sim → arb)
  2. Reorg-aware Postgres indexer (ExEx + reorg dispatch)
  3. Custom RPC endpoint (jsonrpsee + extend_rpc_modules)
  4. Wallet backend (signer pool + nonce mgr + replace-on-stuck)
  5. EIP-7702 sponsor (Type 4 tx + paymaster pattern)
  6. Foundry-style cheatcode (custom precompile + harness)
  7. Swap aggregator (Revm fork + cross-venue quotes)
  8. Frontrun-resistant order router (capstone — integrates 1-7)
  9. Cross-client validation harness (this lesson) — turns the previous eight from "demos" into "production-trusted"
  10. 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.