FABRKNT
Reth Expert — Production Engineering
Performance & Systems
Lesson 4 of 25·CONTENT15 min35 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Reth Expert — Production Engineering
Lesson role
CONTENT
Sequence
4 / 25

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::Bar becomes 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. syn for parsing, quote for 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 expand shows 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

KindLooks likeExample
Function-likemy_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:

CrateJob
synparse a TokenStream into Rust AST
quotebuild 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:

  1. Parse the string literal at compile time
  2. Validate every character is a hex digit
  3. Verify the length matches the target type (20 bytes for Address, 32 for B256, ...)
  4. 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:

  1. Parses Solidity-like syntax (custom parser, not syn::ItemImpl — Solidity isn't Rust)
  2. For each function: generates a struct, computes the selector (first 4 bytes of keccak256(signature)), and impls ABI encode/decode
  3. For each event: computes the topic0 hash and impls log decoding
  4. For each contract: emits a wrapper struct that takes a Provider and 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 expand shows expanded code. Zero-cost at runtime.