Previous entries in the Low Level EVM Series have approached programming EVM from first principles — take small groups of opcodes from the The Yellow Paper, write simple program sequences, write out the stack operations, enter the sequence into evm.codes Playground and verify the results.
But working like this is a real chore, and the complexity of the approach scales very poorly. It’s also difficult to test within the limitations of a web-based virtual environment.
We are fortunate to have Huff, a low level language for EVM that provides helpful abstractions over many of the cumbersome parts related to control logic, function selection, and stack manipulation. It is a local tool with a compiler to generate program and deployment code, and you can integrate it into your deployment and testing architecture without much trouble.
Huff was originally written by Aztec Protocol, and the first compiler (huffc) was written in Typescript. The github repo for huffc was archived in July 2022, and development continued with in the github repo for huff-rs, a Rust compiler. The Rust compiler was archived in October 2024, and development has continued in the github repo for huff2, a rewrite of huff-rs with additional improvements.
The README indicates that huff2 is not ready for production use yet, so I will focus on huff-rs.
Installing Huff
You can install huff-rs by hand or use the huffup
tool. At the time of writing, huffup
installs the last nightly version (nightly-4c4ae27378224b6a3a1afd35116f0da710ff1418) from April 21, 2024.
Many people are rightly wary of the curl | bash
method of installing tools. If so, you can download the last nightly release and copy the huffc file into some location in your system’s PATH
.
Our First Huff Program
We will return to the first example presented in Part I, which simply added two zeros together:
[0x00] PUSH0 // [0]
[0x01] PUSH0 // [0,0]
[0x02] ADD // [0]
The same program in Huff looks like this:
add_zeros.huff
#define macro MAIN() = takes(0) returns(1) {
push0 // [0]
push0 // [0,0]
add // [0]
}
Macros
A key abstraction provided by Huff is the concept of a macro. A macro is a series of opcodes that is referenced by name. If you want to perform some operation, you can define that operation in a macro, and then use that macro label elsewhere in the code. So you get the flexibility of writing efficient low-level operations, but being able to glue them together at a high level, avoiding copy-pasting and modifying the program counter (PC) by hand.
Each macro is defined with the #define macro NAME()
syntax, and they accept a declaration of how many stack items the macro requires at the start — takes(x)
— and leaves — returns(x)
. If not provided, they are assumed to be zero.
MAIN
is special because it placed at PC=0, so it is the entry point for every contract call. If you have multiple functions, this is where you would place your dispatch logic. For simple contracts that don’t need any macros or functions, all contract logic can be defined directly in MAIN
.
Another convention is that the opcode mnemonics are lower-cased, while macro names are upper-cased. This is a Huff requirement, but fortunately the evm.codes playground accepts lower-cased opcode mnemonics so copy-pasting sections of Huff doesn’t require transformation.
Simplified Pushes
Huff also allows you to push bytes directly to the stack without explicitly requiring a PUSH0-PUSH32 opcode.
If we want to adde 1 to the stack, we can write the value by itself, and PUSH1 is used automatically:
add_ones.huff
#define macro MAIN() = takes(0) returns(1) {
0x1 // [1]
0x1 // [1,1]
add // [2]
}
Any value up to 32 bytes can be written this way.
Multiple Opcodes Per Line
Any sequence of operations can be written on a single line to improve readability. The only requirement is that they appear in order of operation. The same code above could be condensed to a single line:
add_ones_condensed.huff
#define macro MAIN() = takes(0) returns(1) {
0x1 0x1 add // [2]
}
In-lined Operations
Let’s say that we want to use the “add zeros” and “add ones” logic in the same contract. We can extract those operations into their own macros, and then “call” them wherever that logic should execute:
add_ones_or_twos.huff
#define macro ADD_ZEROS() = takes(0) returns(1) {
0x0 0x0 add // [0]
}
#define macro ADD_ONES() = takes(0) returns(1) {
0x1 0x1 add // [2]
}
#define macro MAIN() = takes(0) returns(2) {
ADD_ZEROS()
ADD_ONES()
}
The marker to execute a macro looks just like a function, which is clear and familiar to those who use high level languages.
Macros act like automatic copy-pastes, you can write SOME_MACRO()
wherever you would execute some repetitive block of opcodes, and the compiler will replace them automatically.
Bytecode Equivalence
The example above doesn’t introduce any runtime overhead. If we were to write the code above in raw opcodes, we’d have this:
[0x00] PUSH0
[0x01] PUSH0
[0x02] ADD
[0x03] PUSH1 0x1
[0x05] PUSH1 0x1
[0x07] ADD
Which converts to bytecode 5f5f016001600101, or [5f][5f][01][6001][6001][01] with each opcode separated with a bracket set.
Compile add_ones_or_twos.huff
with the --bin-runtime
option to reveal the runtime code, which is the actual code that the contract will execute after deployment:
btd@dev:~/code/huff$ huffc add_ones_or_twos.huff --bin-runtime
⠙ Compiling...
5f5f016001600101
The bytecode is the same. Huff has given us this flexibility with zero gas overhead.
Named Constants
Keep reading with a 7-day free trial
Subscribe to Degen Code to keep reading this post and get 7 days of free access to the full post archives.