Lesson 4 — Drill: prove you can read interpreter source
Question
Drill: read 3 more opcodes — mul / sub / exp — without help. Verify each uses the same macro pattern + understand what differs.
Principle (minimum model)
mul. Same shape asadd.wrapping_mulinstead ofwrapping_add. Identical macro pattern.sub. Same shape.wrapping_sub. Identical macros.expis different. Usesgas!_dynamic!because the cost depends on the exponent's byte length. Read the cost calculation.- Static-cost opcodes. Reuse the same
gas!(interp, OPCODE_GAS)macro for fixed costs. - Dynamic-cost opcodes. Use
gas!_dynamic!(interp, cost_fn)wherecost_fn(state)computes the cost. Memory-expansion-aware. - Why this drill. If you can read mul / sub / exp unaided, you can read the rest of arithmetic.rs unaided. Pattern transferred.
- Hot-path discipline. Every arithmetic opcode is hot. Macros + inline +
wrapping_*are not optional. Production parallel: every Rust EVM uses the same discipline.
Worked example + steps
Drill: prove you can read interpreter source
You've read add and its macro. Now prove you can read the rest of the file without the lesson holding your hand. Three drills, run in a real revm checkout with cargo open in another window. Every one is do, then write down what you observed — not "read about."
Setup
git clone https://github.com/bluealloy/revm
cd revm
cargo build # warm the toolchain
If cargo build failed, fix that before proceeding. The remaining drills assume a working build — there is no version of these drills that's "read along."
Drill 1 — Find mul, prove the shape
Open crates/interpreter/src/instructions/arithmetic.rs. Find the mul function. Compare it line-by-line to add.
The answer: both are 2-stack-in, 1-stack-out, fixed-gas, no-side-effect opcodes. Anything matching that profile compiles to the exact same control-flow shape: popn_top!([a], b, ctx.interpreter); *b = a.OP(*b); Ok(()). The differences are the OP (wrapping_add vs wrapping_mul) and the gas charge (both happen to be 3, in current Ethereum).
If your written answer was less specific than that, you didn't earn this drill — re-read.
Drill 2 — Find exp, find the dynamic gas charge
exp is in the same file. It's longer than add and mul. Two reasons:
- The math is more complex (it's exponentiation, not a single op).
- The gas charge is dynamic — it depends on the size of the exponent.
🔍 Find the
gas!macro call insideexpthat charges per byte of the exponent. Read its arithmetic.
The answer: dispatch can charge a fixed cost up-front, but exp's cost depends on a runtime value (the size of the exponent operand). You can only know that cost inside the function body, after you've inspected the operand. So exp charges itself.
This generalizes. Any opcode whose cost is shaped by an operand has to charge mid-function. File this — you'll meet the same pattern in sha3, mload, and the call-family opcodes.
Drill 3 — Break consensus on purpose
This is the canonical "prove you understand" drill. You won't believe how brittle consensus is until you've broken it on purpose.
- Open
crates/interpreter/src/instructions/arithmetic.rs. - Find
add. Changewrapping_addtosaturating_add. Save. - From the repo root:
cargo test -p revm-interpreter. - Watch tests fail. Read at least one failure message — note that the failure is a numeric mismatch, not a panic.
What you've just done: patched a single library function call, and your client now disagrees with every other Ethereum client in the world about the result of 0xFFF...FF + 1. The first transaction that overflows ADD would fork your node off mainnet.
🔧 Now revert your change and confirm the tests pass again:
git checkout crates/interpreter/src/instructions/arithmetic.rs cargo test -p revm-interpreterThe point isn't the change. It's the empirical proof that consensus is one library function call away from being lost — and now you've felt that.
If you didn't actually run cargo test and watch real output, you skipped the drill. The drill is the running. There's no version of this drill where you read it and "got it."
Drill 4 — Instrument add and watch the data flow
Reading source is one mode. Watching data move through the source you read is a stronger one.
-
Open
crates/interpreter/src/instructions/arithmetic.rs. -
Add
eprintln!at the top and bottom ofadd:pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result { popn_top!([op1], op2, context.interpreter); eprintln!("ADD: {:#x} + {:#x} = ?", op1, *op2); // ← add this *op2 = op1.wrapping_add(*op2); eprintln!("ADD result: {:#x}", *op2); // ← and this Ok(()) } -
Re-run with
cargo test -p revm-interpreter -- --nocapture. The--nocaptureflag tells cargo to showeprintln!output. -
Read the output. Count how many times ADD ran across the test suite.
This is the production reality compressed to your terminal: every Ethereum mainnet transaction containing 0x01 causes this exact function to run, with these exact operands, producing this exact result. Across years, on millions of nodes.
🔧 Revert when done.
git checkout crates/interpreter/src/instructions/arithmetic.rs. Theeprintln!is for learning; production code never instruments hot paths this way (it uses the existing tracing integration — covered in Inside Reth).
After watching ADD execute, the "the interpreter is just a Rust function loop" framing stops being theoretical.
End-of-lesson recall
Without scrolling, in your own words on paper:
- What is the mechanical property of
addandmulthat makes their source identical? - Why does
expcharge gas inside its body instead of via dispatch? - What single change converts
addfrom EVM-compliant to EVM-incompatible — and what test failure mode did you observe?
If any answer is shaky, the lesson isn't done with you. Re-run the drill or re-read.
After this, you've read more EVM source than 99% of Solidity developers ever will — and you've proven it by making the chain disagree with itself, then putting it back. The next lesson zooms out from one opcode to the table that dispatches all 256.
Summary (3 lines)
- Drill: read mul / sub / exp unaided. mul/sub use same shape as add; exp uses
gas!_dynamic!. - Static vs dynamic cost gates are the differentiator. Same macro pattern throughout arithmetic.rs.
- Pattern transferred. Next: custom opcodes module.