Lesson 4 — Procedural macros — how sol! and address! work
Question
Procedural macros generate Rust code at compile time. sol! macro turns Solidity ABI into Rust types; address! validates addresses at compile time.
Principle (minimum model)
sol! { contract Foo { ... } }= expand to Rust types matching the Solidity interface.Foo::Barbecomes a Rust struct.- How it works. Macro parses Solidity → generates Rust AST → injects into surrounding code. Compile-time expansion.
address!("0x...")= parse the address at compile time. Invalid address → compile error, not runtime.- Why proc-macros. Compile-time validation catches mistakes early; generated code is zero-cost.
- Implementation. Each proc-macro is a separate Rust crate with
proc-macro = true.synfor parsing,quotefor code-gen. - Common patterns. Derive macros (
#[derive(...)]) generate trait impls. Attribute macros (#[some_attr]) wrap items. Function-like (sol! { ... }) replace with code. - Reading proc-macro source. Read top-down: input AST → transformation → output code.
cargo expandshows the result.
Worked example + steps
Procedural macros — how sol! and address! work
address!("0xabc...") looks like a function call but runs at compile time. So does sol! { contract IERC20 { ... } }.
1. The three kinds
| Kind | Looks like | Example |
|---|---|---|
| Function-like | my_macro!(...) | address!, sol! |
| Derive | #[derive(MyTrait)] | #[derive(Serialize)] |
| Attribute | #[my_attr] | #[tokio::main] |
All three live in crates marked crate-type = ["proc-macro"] in their Cargo.toml. The compiler loads such a crate as a plugin and calls functions inside it that take a TokenStream (the parsed-but-not-yet-typechecked tokens of the macro's input) and return another TokenStream (what the compiler will continue compiling in its place).
2. The toolchain
flowchart LR
Src["Your source<br/>sol! macro"] -->|compiler invokes macro| In[Input TokenStream]
In -->|syn::parse| AST[Rust / DSL AST]
AST -->|your logic| Tree[Generated AST]
Tree -->|quote!| Out[Output TokenStream]
Out -->|compiler continues| Compiled[Compiled binary]
Two crates do 90% of the work:
| Crate | Job |
|---|---|
syn | parse a TokenStream into Rust AST |
quote | build a TokenStream from a template |
3. The actual address! macro
Here's what's surprising: address! is not a procedural macro at all. It's a regular macro_rules! declarative macro.
Here's the real source from crates/primitives/src/bits/macros.rs:
macro_rules! fixed_bytes_macros {
($d:tt $($(#[$attr:meta])* macro $name:ident($ty:ident $($rest:tt)*);)*) => {$(
$(#[$attr])*
#[macro_export]
macro_rules! $name {
() => {
$crate::$ty::ZERO
};
($d ($d t:tt)+) => {
$crate::$ty::new($crate::hex!($d ($d t)+))
};
}
)*};
}
fixed_bytes_macros! { $
macro address(Address);
macro b64(B64);
macro b128(B128);
macro b256(B256);
macro b512(B512);
macro bloom(Bloom);
macro fixed_bytes(FixedBytes<0>);
}
Read it twice. There's a lot here.
A macro that defines macros
fixed_bytes_macros! is an outer macro that generates inner macros. The single invocation at the bottom creates seven macros at once: address!, b64!, b128!, b256!, b512!, bloom!, fixed_bytes!. You write the meta-pattern once, get seven typed convenience macros for free.
The $d:tt trick
$d matches a token tree (in practice: $). This solves a famous problem: when you generate a macro inside a macro, you can't just write $ for the inner macro's variables — Rust's macro parser would consume them as the outer macro's metavariables. So $d is bound to $ and $d ($d t:tt)+ produces $ ( $ t:tt )+ in the generated code. This is a textbook macro-hygiene workaround.
Where compile-time validation lives
The actual hex parsing is delegated to $crate::hex!(...), which is a procedural macro. hex! does:
- Parse the string literal at compile time
- Validate every character is a hex digit
- Verify the length matches the target type (20 bytes for
Address, 32 forB256, ...) - Emit a
[u8; N]array literal
If anything fails, you get a compile error, not a runtime panic. Address::new(...) then takes that array and constructs the typed wrapper.
The empty-input case
() => { $crate::$ty::ZERO };
address!() (no args) returns Address::ZERO — a const. So you can write:
const BURN: Address = address!();
That's a const-evaluable burn-address constant. Try doing that with a runtime parser.
4. sol! — the genuinely procedural macro
address! is declarative; sol! is the real procedural macro. It lives in alloy-rs/core/crates/sol-macro and:
- Parses Solidity-like syntax (custom parser, not
syn::ItemImpl— Solidity isn't Rust) - For each function: generates a struct, computes the selector (first 4 bytes of
keccak256(signature)), and impls ABI encode/decode - For each event: computes the topic0 hash and impls log decoding
- For each contract: emits a wrapper struct that takes a
Providerand lets you call methods naturally
When you do:
sol! {
interface IERC20 {
function balanceOf(address owner) external view returns (uint256);
event Transfer(address indexed from, address indexed to, uint256 value);
}
}
let balance = IERC20::new(token, &provider).balanceOf(owner).call().await?;
— that .balanceOf(owner) call is statically typed, the uint256 becomes a real U256, the selector is computed at compile time, and the ABI encoding is monomorphized. No reflection, no runtime parsing, no string-typed errors.
5. When to write your own
Build a proc macro when you have:
- Repeated boilerplate that compresses neatly into a macro call
- Compile-time validation opportunities (like address parsing)
- DSL ergonomics worth the engineering investment
Don't build one for "saving 5 lines once."
6. Debugging tip
cargo expand shows you the code your macro generates. Always check the expansion when you're stuck.
cargo install cargo-expand
cargo expand --bin my_app
Now you can read what your macro is producing and pinpoint any wrong codegen.
Final check: explain in one sentence the difference between
macro_rules!and a procedural macro. If your answer is just "one's older," go deeper — what does each operate on, and where does each run? The Rust ecosystem is built on this distinction; without it, you can't read the code that builds your binary.
Summary (3 lines)
- Proc-macros = compile-time code generation.
sol! { ... }→ Rust types from Solidity ABI;address!("0x...")→ compile-time validation. - Three flavours: derive (
#[derive(...)]) / attribute (#[some_attr]) / function-like (sol! { ... }). - Build with
syn(parse) +quote(gen).cargo expandshows expanded code. Zero-cost at runtime.