Deep EVM #10: Huff Stack Management — takes(), returns(), and the Art of dup/swap
Engineering Team
The Stack Machine Mental Model
The EVM is a stack machine. There are no registers, no named variables — just a last-in-first-out stack of 32-byte words, 1024 slots deep. Every opcode either pushes, pops, or rearranges items on this stack. If you cannot hold the current stack state in your head, you will produce buggy bytecode. This article is about building that mental model.
Notation Convention
Throughout this article (and in Huff comments), we represent stack state with brackets where the leftmost item is the top of the stack:
// [top, second, third, ..., bottom]
0x01 // [1]
0x02 // [2, 1]
add // [3]
Every Huff macro should have a stack comment after each opcode. This is not optional — it is the only way to audit correctness.
DUP: Duplicating Stack Items
The EVM provides DUP1 through DUP16. DUPn copies the n-th item from the top and pushes it onto the stack. The stack grows by 1.
// Stack: [a, b, c, d]
dup1 // [a, a, b, c, d] — copy top
dup3 // [c, a, a, b, c, d] — copy 3rd from top
Gas cost: 3 gas for any DUPn. This is one of the cheapest operations in the EVM.
When to DUP
DUP is your tool for non-destructive reads. Many opcodes consume their arguments (ADD pops two, pushes one), so if you need a value again later, DUP it before feeding it to a consuming opcode.
#define macro SAFE_SUB() = takes(2) returns(1) {
// takes: [a, b] — compute a - b, revert if b > a
dup2 dup2 // [a, b, a, b]
lt // [a < b?, a, b]
revert_underflow jumpi // [a, b]
sub // [a - b]
done jump
revert_underflow:
0x00 0x00 revert
done:
}
Notice the dup2 dup2 — we duplicate both a and b because lt will consume them, but we still need the originals for the sub.
SWAP: Rearranging the Stack
The EVM provides SWAP1 through SWAP16. SWAPn exchanges the top item with the (n+1)-th item. The stack size stays the same.
// Stack: [a, b, c, d]
swap1 // [b, a, c, d] — swap top with 2nd
swap3 // [d, a, c, b] — swap top with 4th
Gas cost: 3 gas for any SWAPn.
When to SWAP
SWAP reorders arguments for opcodes that expect a specific ordering. For example, SUB computes stack[0] - stack[1]. If your values are in the wrong order:
// Stack: [b, a] — but we want a - b
swap1 // [a, b]
sub // [a - b]
The Depth-16 Limitation
DUP and SWAP only reach 16 deep. If a value is at position 17 or deeper, you cannot access it with a single opcode. This is a hard EVM constraint.
Strategies for deep stacks:
- Restructure your logic to keep needed values near the top. This is the best approach.
- Use memory as scratch space. Store a value with
MSTORE, retrieve it later withMLOAD. Costs 3+3=6 gas vs 3 gas for DUP, but breaks the depth barrier. - Break the macro into smaller macros that each operate on fewer stack items.
#define macro STASH_TO_MEMORY() = takes(1) returns(0) {
// takes: [value]
0x80 mstore // [] — stash at 0x80 (scratch space)
}
#define macro RECALL_FROM_MEMORY() = takes(0) returns(1) {
0x80 mload // [value]
}
In MEV contracts we often reserve 0x80..0xc0 as a scratch area for values that would otherwise push the stack past 16.
Common Patterns
Pattern 1: Keeping a Value Through a Consuming Operation
You have [x] and need to call an opcode that consumes x but you still need x afterward.
// Want: compute hash of x, but keep x
// Stack: [x]
dup1 // [x, x]
0x00 mstore // [x] — memory[0] = x
0x20 0x00 // [0, 32, x]
keccak256 // [hash, x]
Pattern 2: Rotating Three Items
You have [a, b, c] and need [c, a, b]:
swap2 // [c, b, a]
swap1 // [c, a, b]
2 opcodes, 6 gas. There is no single-opcode rotation in the EVM.
You have [a, b, c] and need [b, c, a]:
swap1 // [b, a, c]
swap2 // [b, c, a]
Pattern 3: Cleaning Up Unwanted Stack Items
After a computation you may have extra items. Use pop (2 gas) to discard:
// Stack: [result, garbage1, garbage2]
swap2 // [garbage2, garbage1, result]
pop // [garbage1, result]
pop // [result]
Or more efficiently:
// Stack: [result, garbage1, garbage2]
swap1 pop // [result, garbage2]
swap1 pop // [result]
Pattern 4: Duplicating a Pair
You need to copy the top two items:
// Stack: [a, b]
dup2 // [b, a, b]
dup2 // [a, b, a, b]
Notice you DUP in reverse order. dup2 first copies b (which is at position 2), then dup2 copies a (now at position 2 because we grew the stack). This pattern appears constantly in comparison-before-arithmetic code.
Stack Visualization Discipline
When writing Huff, adopt this discipline:
- Comment every line with the stack state after execution.
- Verify takes/returns — count stack items at entry and exit.
- Trace every branch — at each JUMPI, both the taken and not-taken paths must leave the stack in a valid state.
- Watch for stack drift — if a loop body does not perfectly balance pushes and pops, the stack will grow or shrink on each iteration.
#define macro TRANSFER() = takes(3) returns(0) {
// takes: [amount, from, to]
// Load sender balance
dup2 // [from, amount, from, to]
sload // [bal_from, amount, from, to]
// Check sufficient balance
dup1 dup3 // [amount, bal_from, bal_from, amount, from, to]
gt // [amount > bal_from?, bal_from, amount, from, to]
insufficient jumpi // [bal_from, amount, from, to]
// Deduct from sender
dup2 // [amount, bal_from, amount, from, to]
swap1 sub // [bal_from - amount, amount, from, to]
dup3 // [from, new_bal, amount, from, to]
sstore // [amount, from, to]
// Add to receiver
dup3 // [to, amount, from, to]
sload // [bal_to, amount, from, to]
add // [new_bal_to, from, to]
swap2 // [to, from, new_bal_to]
sstore // [from]
pop // []
done jump
insufficient:
0x00 0x00 revert
done:
}
Every line has a stack comment. Every branch terminates cleanly. This is the only way to write correct Huff.
Debugging Stack Errors
The most common bugs in Huff:
- Stack underflow — Popping from an empty stack. The EVM reverts at runtime. Cause: miscounted
takesor missing a DUP. - Stack imbalance at JUMP — A JUMPDEST reached from two different paths expects different stack states. The compiler will not catch this.
- Off-by-one in DUP/SWAP —
dup3vsdup4when you added an extra push earlier. This is why stack comments are mandatory.
huffc has a --stack-check flag that performs basic stack analysis:
huffc src/Contract.huff -r --stack-check
It catches obvious underflows but cannot trace all dynamic jump paths. For complex contracts, trace execution manually with forge debug or evm-trace.
Advanced: The Stack as a Register File
Experienced Huff developers think of the top ~8 stack positions as a register file:
Position 1 (top): Working register — current computation
Position 2-3: Argument registers — inputs to next operation
Position 4-6: Local variable registers — values needed soon
Position 7-8: Context registers — loop counters, base pointers
Position 9+: Spill area — rarely accessed, consider memory
This mental model helps you decide when to SWAP a value to the top vs. when to DUP it, and when to spill to memory.
Summary
Stack management is the core skill for Huff development. DUP for non-destructive reads, SWAP for reordering, and memory for values beyond depth 16. Comment every line with the stack state. Verify every branch. In the next article, we will use these skills to build an O(1) function dispatcher with packed jump tables — where precise stack management directly translates to gas savings.