FABRKNT
Inside Revm — Reading the EVM Engine
Inside Revm
Lesson 13 of 17·CONTENT12 min25 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
Inside Revm — Reading the EVM Engine
Lesson role
CONTENT
Sequence
13 / 17

Lesson 12 — Drill: implement ZeroDb and watch revm read state

Question

Drill: implement ZeroDb that returns zero for everything. Then trace what revm reads during a tx execution. Sees how often the Database is hit.

Principle (minimum model)

  • struct ZeroDb; + impl Database. Each method returns the zero value: Account::default() / Bytecode::default() / U256::ZERO / B256::ZERO.
  • type Error = Infallible;. Never fails.
  • Test. Run a tx with ZeroDb backing; assert it succeeds. Use Solidity that touches some state.
  • Add println! to each method. Now you can see what revm reads.
  • Observation 1. basic(tx.from) is called first (sender lookup). Always.
  • Observation 2. code_by_hash(tx.to) is called next (target contract code). If to is None, this is skipped.
  • Observation 3. storage(addr, key) is called per SLOAD. The frequency depends on the contract.
  • Observation 4. block_hash(number) is called only by BLOCKHASH opcode. Most txs never hit this.
  • Takeaway. Revm is read-heavy at the start; gets light during pure computation. Caching the early reads helps.
  • Production parallel. Reth's StateProviderDatabase caches aggressively after first read. Same pattern.

Worked example + steps

Drill: implement ZeroDb and watch revm read state

You've read the trait shape and the three reference impls. Now build your own — the smallest one anyone can write. A Database that always says "balance zero, slot zero, no code." Stub four methods, plug it into Revm, run a transaction, and watch exactly which reads the EVM actually performs. (Spoiler: fewer than you'd guess. The EVM is very lazy about state.)

The target

A Database impl that always returns "balance = 0, no code, slot = 0":

struct ZeroDb;

impl Database for ZeroDb {
    type Error = std::convert::Infallible;

    fn basic(&mut self, _: Address) -> Result<Option<AccountInfo>, Self::Error> {
        Ok(Some(AccountInfo::default()))
    }
    fn code_by_hash(&mut self, _: B256) -> Result<Bytecode, Self::Error> {
        Ok(Bytecode::default())
    }
    fn storage(&mut self, _: Address, _: StorageKey) -> Result<StorageValue, Self::Error> {
        Ok(StorageValue::ZERO)
    }
    fn block_hash(&mut self, _: u64) -> Result<B256, Self::Error> {
        Ok(B256::ZERO)
    }
}

type Error = std::convert::Infallible — we literally cannot fail. Every call returns Ok(...). Infallible is the conventional "this never errors" type.

Drill 1 — Predict before plugging it in

Answers:

  1. Returns 0. basic returns AccountInfo::default() (balance 0).
  2. Returns 0. storage returns U256::ZERO — same as a fresh Ethereum slot.
  3. Returns 0. code_by_hash returns empty Bytecode (length 0).
  4. Succeeds with no execution. A CALL to an EOA (no code) is a valid Ethereum operation — transfer value (here zero), return. No revert.
  5. Returns B256::ZERO. Useful as a placeholder for tests.

If you missed any of these, your mental model of Database × EVM semantics needs another pass — re-read the build-up lesson before continuing the drill.

Drill 2 — Plug ZeroDb into Revm and execute a 1-tx block

Write a one-shot examples/zero_db_drill.rs (or use revm's existing test harness):

use revm::{database_interface::Database, Evm, primitives::*};

fn main() {
    let mut evm = Evm::builder()
        .with_db(ZeroDb)
        .build();

    // PUSH1 0x42 PUSH1 0x00 SSTORE STOP
    // Push 0x42, push 0x00, write 0x42 to slot 0, stop.
    let bytecode = hex::decode("604260005500").unwrap();

    let result = evm.transact(&bytecode);
    println!("{:?}", result);
}

Yes. SSTORE is a write, not a read — and Database doesn't see writes (those go through DatabaseCommit, which we deliberately didn't implement). The pre-existing slot value is read via storage (returns 0, fine). The new value 0x42 is staged in revm's journaling layer and never reaches ZeroDb. The tx commits successfully.

Drill 3 — Now watch reads happen

Add println!s to ZeroDb:

fn basic(&mut self, addr: Address) -> Result<Option<AccountInfo>, Self::Error> {
    println!("[ZeroDb] basic({addr})");
    Ok(Some(AccountInfo::default()))
}
fn storage(&mut self, addr: Address, key: StorageKey) -> Result<StorageValue, Self::Error> {
    println!("[ZeroDb] storage({addr}, {key})");
    Ok(StorageValue::ZERO)
}
// ... same pattern for code_by_hash and block_hash

Re-run. You'll see exactly which reads revm needs — and only those reads. No phantom queries. No eager state loading. Lazy, on-demand, exact.

🔧 Question: How many distinct method calls did you observe? Which methods? On which keys?

The exact answer depends on the bytecode and harness, but for PUSH1 0x42 PUSH1 0x00 SSTORE STOP you'll see something like:

  • One basic(tx.from) to validate the sender's nonce/balance
  • One storage(tx.to, 0) to read the existing slot for SSTORE refund accounting

Two reads. That's it. You now understand the entire harness around Revm — every other database is just this, with real data.

Drill 4 — Make it fail (optional, harder)

Replace Infallible with a custom error and have storage return Err(...) for one key:

#[derive(Debug)]
struct DbErr(String);
impl revm::database_interface::DBErrorMarker for DbErr {}

struct PickyDb;

impl Database for PickyDb {
    type Error = DbErr;
    // basic, code_by_hash, block_hash all Ok(...)
    fn storage(&mut self, _: Address, key: StorageKey) -> Result<StorageValue, Self::Error> {
        if key == StorageKey::from(13u64) {
            Err(DbErr("slot 13 is unlucky".into()))
        } else {
            Ok(StorageValue::ZERO)
        }
    }
    // ... rest
}

Run a tx that does SLOAD(13). What does revm do? (Hint: it's not a revert — it's a different category of failure.)

The tx aborts as a "fatal external error" — distinct from a revert. Reverts are consensus; database errors are infrastructure. Revm bubbles Self::Error up to the caller without converting it to a revert, so your harness can decide whether to retry, log, or propagate. That's why Error is your type, not revm's.

End-of-lesson recall

Without scrolling, in your own words:

  1. Why does ZeroDb::basic return Ok(Some(AccountInfo::default())) instead of Ok(None)?
  2. Why didn't SSTORE in Drill 2 ever call ZeroDb for the write?
  3. What's the difference between a tx revert and a Database::Error bubbling up?

If any answer is shaky, the lesson isn't done with you. Re-run the drill or re-read the build-up.

After this drill, you have a working mental model of how revm gets state — every other database is just ZeroDb with real data behind it.

Two lessons left before the final quiz. First, how Revm itself is tested — the harness that proves correctness against the Ethereum spec for every fork. Then revmc, the JIT/AOT compilation path. After both, Inside Revm is done.

Summary (3 lines)

  • ZeroDb = returns zero for everything. Trace revm's read pattern with println!.
  • basic(tx.from) + code_by_hash(tx.to) at start; storage(addr, key) per SLOAD; block_hash only on BLOCKHASH.
  • Read-heavy start; light middle. Caching pays off. Reth's StateProviderDatabase uses this insight. Next: testing.