FABRKNT
Inside Revm — Reading the EVM Engine
Inside Revm
Lesson 9 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
9 / 17

Lesson 8 — Drill: ship a fork

Question

Drill: fork revm + add a custom opcode + ship a working binary. ~50 lines of Rust + a cargo workspace.

Principle (minimum model)

  • Fork revm via Cargo workspace. revm = { git = "https://github.com/your-fork/revm" }.
  • Pick byte 0xC0. Currently INVALID; safe to repurpose.
  • Implement DOUBLE opcode. fn double<H: ?Sized, IT: ITy>(interp: &mut Interpreter<H, IT>) { gas!(interp, 5); let val = interp.stack.pop(); interp.stack.push(val.wrapping_mul(U256::from(2))); }.
  • Write into table. Edit revm's instruction table; add instructions[0xC0] = double;.
  • Test. Deploy Solidity that uses assembly { let x := /* push 5 */ /* call 0xC0 */ }; assert stack ends with 10.
  • Document. README explains: this fork adds DOUBLE at 0xC0 with gas cost 5. Anyone running your fork can verify.
  • Why this drill. Production custom-L1 builders do this every day. Hyperliquid + OP-Stack + Berachain all maintain forks. You're building the same skill.

Worked example + steps

Drill: ship a fork

You've read the three-line mechanics and the three caveats. Now wire one yourself. This drill takes you from "I've read about custom opcodes" to "I have wired one in a real revm checkout and watched it execute." Three lines plus the surrounding harness — bring cargo up in another window.

Setup

You should already have the revm checkout from the earlier drill. If not:

git clone https://github.com/bluealloy/revm
cd revm
cargo build  # confirm clean build before proceeding

If cargo build failed, fix that before the drill.

Drill 1 — Find unallocated opcode bytes (don't trust the lesson)

The lesson showed 0x0C–0x0F as unallocated. Verify on the actual file of the version you'd fork.

🔍 Open crates/interpreter/src/instructions.rs. Scan the table-construction function. Any byte that does not appear on the left side of an assignment is unallocated.

There's no single right answer — but if your answer is "I just trusted the lesson's table," you skipped the drill. Verify against source.

Drill 2 — Define your own opcode

Pick one unallocated byte. Define a constant for it. Implement a function with the same shape as add — but stack-profile 1-in, 1-out, in-place (a "double the top" opcode):

const DOUBLE_TOP: u8 = 0x0C;  // or whichever you picked

pub fn double_top<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
    popn_top!([], op, context.interpreter);
    *op = (*op).wrapping_mul(U256::from(2));
    Ok(())
}

The empty [] means no values popped — only op is bound, as a &mut to the current top of stack. That's how you express a 1-stack-in, 1-stack-out, in-place-mutating opcode (vs. add's 2-in, 1-out). The macro's arity matcher pays off here — same macro, different stack profiles, no second function.

Drill 3 — Wire it into the table

Add to your standard-table copy:

let mut table = standard_table();
table[DOUBLE_TOP as usize] = Instruction::new(double_top);

That's all the wiring. The dispatch loop will now call double_top whenever it sees byte 0x0C.

Drill 4 — Run bytecode that uses your opcode

Encode bytecode that pushes a value, executes your opcode, and stops:

PUSH1 0x05  // push 5 onto the stack — bytes: 0x60 0x05
DOUBLE_TOP  // your custom opcode — byte: 0x0C
STOP        // 0x00

In hex: 60 05 0C 00.

Run this bytecode in your revm-with-table fork. The stack should end with 10 (= 5 × 2).

🔧 The wiring is left as the drill. Use revm's existing test harness in crates/interpreter/tests/ (or write a one-shot binary in examples/). The point is to construct the EVM context, install your modified table, run the bytecode, and assert the final stack value.

If you got 10 on the stack, you've shipped a fork. Your client now executes a chain incompatible with mainnet — and you've felt the difference between "I read about it" and "I did it."

Drill 5 — Instrument the opcode and watch the dispatch reach you

Add an eprintln! inside double_top so you can see dispatch actually reach your Rust function:

pub fn double_top<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
    popn_top!([], op, context.interpreter);
    let before = *op;
    *op = (*op).wrapping_mul(U256::from(2));
    eprintln!("DOUBLE_TOP: {:#x} -> {:#x}", before, *op);  // ← add this
    Ok(())
}

Run with cargo run (or cargo test -- --nocapture). Feed bytecode 60 05 0C 00 and you should see exactly one log line: DOUBLE_TOP: 0x5 -> 0xa. That is the moment the instruction-table dispatch reached the Rust function you wrote. One byte of bytecode (0x0C) triggered three lines of Rust — and you've physically observed the causal chain.

Try a bytecode with branching (e.g. PUSH1 3 PUSH1 1 LT JUMPI ... DOUBLE_TOP) and watch the eprintln! line appear / not appear depending on the branch. That is dispatch's net behaviour.

End-of-lesson recall

Without scrolling, in your own words:

  1. What's the mechanical difference between popn_top!([op1], op2, ...) and popn_top!([], op, ...)? What does each tell you about the opcode's stack profile?
  2. What stack profile does your double_top have? (X-in, Y-out, side-effects?)
  3. If you wanted to ship double_top to mainnet, what's the first thing that would break — and at which moment in time?

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

After this drill, you've actually shipped a custom opcode in code. More importantly: you've felt the cost. Next: how revm gets state — the Database trait.

Summary (3 lines)

  • Drill: fork revm + add DOUBLE opcode at 0xC0 + ship binary. ~50 lines + cargo workspace.
  • Same skill production custom-L1 builders use daily. Hyperliquid / OP-Stack / Berachain all maintain forks.
  • Documentation matters: README explains the custom opcode. Next: Database trait module.