Lesson 19 — Custom ChainSpec — forks, genesis, and the precompile schedule
Question
ChainSpec = genesis + forks + precompile schedule + chain ID. Customise per chain. Reth's NodeBuilder takes one as input.
Principle (minimum model)
- Genesis state. Pre-deployed contracts + initial balances + nonces. Defined in JSON.
- Hardfork schedule. Block-number-keyed list: "at block 100, activate Berlin. At block 200, activate London."
- Precompile schedule. Which precompiles at which block. Mainnet has 9; custom chains may add more at specific blocks.
- Chain ID. Unique per chain. 1 = mainnet, 137 = polygon, etc. Custom = pick unused.
- Validation. Reth's
ChainSpec::validatechecks for consistency. Conflicts fail fast. - Testing. Spin up a node with the chainspec; mine some blocks; assert hardforks activate.
- Production examples. Sepolia testnet, OP Mainnet, Hyperliquid, Tempo. Each has its own chainspec.
Worked example + steps
Custom ChainSpec — forks, genesis, and the precompile schedule
The block validates on mainnet but rejects on your chain. Same block, same client binary, same Revm — different result. Why? Because something inside ChainSpec said "at this height, the rules are different here." A wrong fork height, a wrong precompile schedule entry, a wrong base-fee parameter — any one of them, and your chain forks itself off the network in one block.
ChainSpec is the Rust struct that owns "what makes this chain different from mainnet Ethereum at the protocol level" — chain ID, fork activation, base fee curve, genesis allocation, precompile schedule. If you're going to read or build a Reth-based chain, this is the type you read first.
1. What ChainSpec is
In reth-chainspec, ChainSpec is a struct (with various extensions in chain-specific crates) that captures:
| Category | What it controls |
|---|---|
| Chain ID | EIP-155 replay protection key |
| Hardfork activation | Block-height- or timestamp-based switches for protocol upgrades |
| Base fee params | EIP-1559 parameters (elasticity, change denominator) |
| Genesis | Initial allocations, state root, gas limit |
| Precompile schedule | Which precompile addresses are active at each fork |
| Misc legacy params | Block gas limits, DAO fork, mining difficulty (legacy) |
For Reth-based L2s, the chain provides an extended ChainSpec — e.g., the OP chain spec wraps the base ChainSpec and adds OP-specific fork tracking (Bedrock, Canyon, Ecotone, Fjord, ...).
2. The hardfork list as the chain's history
Reading the hardfork enum out loud is the fastest way to understand a chain.
For OP Stack you'll find an enum roughly like:
pub enum OptimismHardfork {
Bedrock,
Regolith,
Canyon,
Ecotone,
Fjord,
Granite,
Holocene,
// ...
}
Each variant comes with activation logic (block height on mainnet, separate timestamp on each network like Sepolia, Base, etc.). Reading this enum + its activation table = reading the chain's entire protocol history.
For Tempo, you can verify the same shape directly in tempoxyz/tempo — different fork names, same enum + activation-table structure.
3. The precompile schedule
A precompile is a "native function" living at a reserved address (0x00..01 through 0x00..0a on mainnet, plus optional extras). Each chain decides which precompiles exist at which fork.
OP Stack inherits most of Ethereum's precompiles and adds a few of its own. Future hardforks add more. The precompile schedule is essentially:
At fork F, address A maps to native function impl I
You'll find this in the chain's EVM config crate (covered in the next lesson), but the activation gating lives in ChainSpec — because activation is a consensus rule.
4. Genesis encoding
Genesis is just "the state at block 0." A custom chain ships:
- A genesis JSON file (allocations, gas limit, initial difficulty/seal)
- A
GenesisRust struct in the chainspec crate, often loadable from the JSON - A computed genesis state root that all nodes must agree on
If you're auditing a chain, verify the genesis state root in code matches the network. Disagreement here means every node disagrees on block 1.
5. What's special about an L2 chainspec
L2 chainspecs (Optimism, Base, ...) also track:
- L1 chain ID the L2 is anchored to (for cross-domain message verification)
- L1 block oracle address on the L2 (the contract that records the current L1 block hash)
- Sequencer address (for sequencer-signed batch validation)
- Withdrawal config (the time delay for L2→L1 withdrawals)
These don't apply to a Tempo-style L1, but illustrate the kind of thing that lives in an extended ChainSpec.
6. Reading exercise
In crates/optimism/chainspec/ (or wherever the OP chainspec lives in your reth checkout):
- Find the struct that represents an OP chain spec
- Read its hardfork list out loud
- Locate the function that answers "is fork F active at block height H, timestamp T?"
- Find where Bedrock's activation block is hard-coded for OP mainnet vs Base
Now do the same for any other chain in awesome-reth's "Layer 2" section.
Final check: if I asked you "what fork activation rule does chain X use at block N?", what files would you need to read, and in what order? If your answer is more than 2 files, you're over-complicating it — ChainSpec + the activation table is the whole story.
Summary (3 lines)
- ChainSpec = genesis + hardfork schedule + precompile schedule + chain ID. JSON config.
- Validation via
ChainSpec::validate. Test by spinning up node + mining. - Production: each L1/L2 has its own chainspec. Reth: pluggable via NodeBuilder. Next: custom Executor.