Lesson 1 — Building add step by step: signature and body
Question
Build the add opcode from naive to canonical. 5 steps; each adds a real constraint.
Principle (minimum model)
- Step 0 — naive.
fn add(stack: &mut Vec<U256>) { let b = stack.pop().unwrap(); let a = stack.pop().unwrap(); stack.push(a + b); }. Three problems: no overflow handling + usesVec::pop(slow) + concrete Vec type (not generic). - Step 1 —
&mut Hgeneric Host. ReplaceVec<U256>with&mut HwhereH: ?Sizedlets us pass&mut dyn Host. Generic over the host. - Step 2 —
?Sized. Add?Sizedbound soHcan be a dynamic-sized type. Allows&mut dyn Host. - Step 3 —
IT: ITy. Add an inner-type parameter. Lets the opcode work with different inner stack representations (e.g. RAM vs in-cache). - Step 4 — in-place stack ops.
stack.last_mut()instead ofpop + push+wrapping_addfor safety. Faster and consensus-safe. - Step 5 — real signature.
pub fn add<H: ?Sized, IT: ITy>(interp: &mut Interpreter<H, IT>). Five generics; the canonical form.
Worked example + steps
Building add step by step: signature and body
ADD is the simplest non-trivial EVM opcode: pop two numbers, push their sum. A weekend hobby EVM would do it in five lines of Rust. Revm does it in four, but those four lines hide a generic signature with two type parameters, a ?Sized opt-out, a macro that compiles to a stack-underflow guard with a branch-prediction hint, and a wrapping_add whose alternative would fork your client off mainnet on the first overflow.
Here's the real source from bluealloy/revm:
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
popn_top!([op1], op2, context.interpreter);
*op2 = op1.wrapping_add(*op2);
Ok(())
}
Walk it line by line and you get six new ideas at once. Easier path: build it up. Start from the dumbest add you could write, and earn each piece of complexity. By the end of this lesson you'll have built everything except the macro on line 2 — that's the next one.
📂 Open
bluealloy/revmin another tab. You'll be cross-checking the build-up against the real source.
Step 0 — The naive add
If you were writing an EVM in Rust without thinking too hard, your add would look like:
pub fn add(stack: &mut Vec<U256>) -> Result<(), &'static str> {
let a = stack.pop().ok_or("underflow")?;
let b = stack.pop().ok_or("underflow")?;
stack.push(a + b);
Ok(())
}
Pop two values. Add them. Push the result. Done.
The two are:
- It only works with one host environment.
&mut Vec<U256>is a concrete type. You can't swap in a tracer's stack, a fuzzer's stack, or an Inspector-sandboxed stack without rewriting the function. - It pops and pushes — three stack operations. Real
adddoes one — overwrite the new top in place.
We'll fix #1 first (the signature), then #2 (the body).
Step 1 — Make it generic over the host
Revm has to plug into multiple environments:
- Production execution (the main path)
- Tracing (record every stack op for debugging)
- Inspector sandbox (let an external observer step the EVM)
- Fuzzers, mainnet forks, state-test runners
Each has a slightly different stack/state shape. We don't want six copies of add.
First-attempt fix: make the function generic over a Host trait (a Rust trait is a shared interface — like a Java interface, but resolved at compile time).
pub fn add<H: Host>(host: &mut H) -> Result {
// ... same body, but calling host.stack instead of a concrete Vec
}
H: Host reads as "any type that implements the Host trait." One source. One specialized binary per concrete H at compile time (Rust's monomorphization — the compiler stamps out a copy per type that uses it).
Two catches:
- With many host types, monomorphization explodes compile times.
- You can't pass a trait object like
&mut dyn Host(adyn Traitis Rust's runtime-dispatched trait pointer, the equivalent of a Java interface reference).<H: Host>only accepts types whose size is known at compile time.
Why would you want a trait object? Because sometimes you build the host at runtime — selected by a config flag, or constructed dynamically by a test harness. Trait objects are how you say "I don't know which concrete Host impl this is until runtime — please use a vtable."
That's where ?Sized comes in.
Step 2 — Allow trait objects: H: ?Sized
Rust silently adds a Sized bound to every generic parameter. Without ?Sized, H must be a type whose size is known at compile time — which excludes dyn Host (a trait object's size depends on the runtime concrete type behind it).
Adding ?Sized:
pub fn add<H: Host + ?Sized>(host: &mut H) -> Result {
// ...
}
Now host: &mut dyn Host is a valid argument. One compiled add works against any Host impl, at the cost of a vtable indirection per host call.
🔍 Open
revm/src/host.rs. Find one place wheredyn Hostis actually constructed. That's the empirical proof this opt-out earns its complexity.
Step 3 — Add the second generic: IT: ITy
H handles host plug-ability. But what about the execution mode — production vs. traced vs. Inspector-sandboxed?
Revm uses a second generic, IT, to select that at compile time:
pub fn add<IT: ITy, H: Host + ?Sized>(host: &mut H) -> Result {
// ...
}
IT is an "interpreter-types" marker — think of it as a strategy parameter. The same source compiles to specialized binaries, one per execution mode. Without IT, you'd write add three times — once for production, once for tracing, once for the sandbox.
You now have the real signature of add (the parameter type is wrapped in revm's Ictx<...> for ergonomics, and the explicit Host bound moves into Ictx itself — the generics that remain on add are the IT: ITy and H: ?Sized we just earned):
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
That matches the source. You built it.
Step 4 — Fix the body: write through a reference
Naive body:
let a = stack.pop().ok_or(StackUnderflow)?;
let b = stack.pop().ok_or(StackUnderflow)?;
stack.push(a + b);
Three stack operations. Each is a memory write or capacity check. The interpreter's hot path (the inner loop that runs once per opcode in every transaction) runs this thousands to tens of thousands of times per tx, millions per mainnet block, hundreds of millions per second under CI / fuzz — that overhead is the difference between competitive and uncompetitive throughput.
Better: pop one value, then mutate the new top in place through a &mut reference.
let a = stack.pop().ok_or(StackUnderflow)?; // pop op1
let b = stack.last_mut().ok_or(StackUnderflow)?; // &mut to new top
*b = a + *b; // overwrite in place
One pop, one in-place write. No push.
Just Ok(()) — there's nothing to return because the data flow happens through the reference, not the return value. Look back at the real signature: -> Result with no associated value. That's why.
Step 5 — Use wrapping_add
One detail left. Replace + with wrapping_add:
*b = a.wrapping_add(*b);
Why? EVM consensus requires ADD to wrap modulo 2²⁵⁶. Use + and you have a release/debug-divergent client (Rust's + panics in debug, wraps in release). Use saturating_add and you fork the network on first overflow — you'll prove that empirically in the drill lesson.
If your answer wasn't 0x0, pause. EVM consensus depends on this exact behavior.
What you've built
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
let op1 = context.interpreter.stack.pop().ok_or(StackUnderflow)?;
let op2 = context.interpreter.stack.last_mut().ok_or(StackUnderflow)?;
*op2 = op1.wrapping_add(*op2);
Ok(())
}
That is the real add semantically, just with the stack manipulation done by hand. The real source factors those middle two lines into a macro called popn_top! — which earns its own lesson, next.
Recall before moving on
Without scrolling, in your own words:
- What does
IT: ITybuy us at compile time? What would happen without it? - What does
?Sizedallow that the default doesn't? - Why is the body one in-place write instead of pop-add-push?
- What does
U256::MAX.wrapping_add(U256::from(1))produce, and why does it matter?
If any answer is shaky, scroll back. The next lesson refactors the body into a macro — you can't follow the refactor if you don't own the version we just built.
🧭 Where you are now in the stack: you've built one VM-layer opcode to functional parity with the real Revm source, in 4 lines that exercise
IT: ITy,?Sized, in-place stack ops, andwrapping_add. Next lesson extracts the body into a macro — the boilerplate-reduction pattern CPython and the JVM have practiced for decades, EVM-flavored.
Summary (3 lines)
- 5-step buildup: naive →
&mut Hgeneric →?Sized→IT: ITy→ in-place stack ops withwrapping_add→ real signature. - Each step adds a constraint: host generality + dynamic-size + inner-type + consensus safety + speed.
- Final:
pub fn add<H: ?Sized, IT: ITy>(interp: &mut Interpreter<H, IT>). Next: macro factoring.