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 inexamples/). 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:
- What's the mechanical difference between
popn_top!([op1], op2, ...)andpopn_top!([], op, ...)? What does each tell you about the opcode's stack profile? - What stack profile does your
double_tophave? (X-in, Y-out, side-effects?) - If you wanted to ship
double_topto 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.