Introduction
ethlambda is a minimalist, fast and modular implementation of a Beacon Chain and Lean Chain client, written in Rust.
This book collects the design notes and operator-facing references for ethlambda. It is split into five parts:
- Design describes the shape of a running node: the architecture of its actors, workers and shared storage, and how they are wired together at startup.
- Consensus explains how the Lean Chain advances: the slot and interval structure that schedules every validator duty, the 3SF-mini justification and finalization rules, and the LMD-GHOST fork choice algorithm. These documents are implementation-agnostic; ethlambda-specific behaviour is called out in blockquotes.
- Beacon Chain covers the Ethereum Beacon Chain side: its state transition, the mainnet wire, and the execution layer pairing.
- Operations documents observable surfaces of a running node: Prometheus metrics, checkpoint sync, and the fork choice visualization served by the API.
- Development collects notes for contributors, starting with the spec deviations where ethlambda departs from leanSpec (Lean Chain) or consensus-specs (Beacon Chain).
For build and contribution instructions, see the
README and
CONTRIBUTING.md
in the repository.
Visual references
Two standalone HTML infographics ship alongside this book and are copied verbatim into the rendered output:
Community
- Telegram: ethlambda group, where we post daily updates; drop by to ask questions or chat about anything Lean-related.
- X (Twitter): @ethlambda_lean for occasional updates.
- Weekly community call: every Friday, streamed live on @class_lambda; the call link is posted on Telegram beforehand.
- Ecosystem coordination: the PQ Interop calls on
ethereum/pmcover cross-client Lean Ethereum work and related updates; the meeting links are posted on each issue.
Related projects
On the Lean Chain, ethlambda is one of several clients under active development. For comparison and cross-client testing:
Architecture
A running node is two actors, a few helper tasks, and one shared Store. The actors are
genservers: each owns its state, and the only way in is a message.
BlockChainServer(crates/blockchain/src/lib.rs) drives consensus. It runs the slot clock, performs validator duties, imports blocks and attestations, and is the sole writer of consensus state.P2PServer(crates/net/p2p/src/lib.rs) owns the network side: gossip publication, request/response, peer bookkeeping and long-range sync.
Everything else hangs off those two: an aggregation worker on a blocking thread, the libp2p swarm loop on its own task, and the axum servers that expose the node to the outside world.
This page describes the Lean Chain node, ethlambda node. ethlambda beacon runs the
same two actors over its own Store, but its chain actor (BlockChain::spawn_beacon) has
no validator keys, no aggregator role and no duties for the sync gate to hold back: it
imports blocks and runs the Beacon Chain’s fork choice. The mainnet wire
and Beacon Chain state transition cover that side.
Note: for what a genserver is, read this blogpost on the
spawnedcrate.
Tick (self-message, one per interval)
┌──────────────────────────────┐
▼ │
┌─────────────────────────────┐ │ ┌────────────────────┐
│ BlockChainServer │───────────────┘ │ aggregation │
┌───▶│ actor, sole state writer │─────── jobs ────────▶│ worker │
│ │ │◀───── aggregates ────│ (blocking thread) │
│ └──────────────┬──────────────┘ └────────────────────┘
│ │
│ P2PToBlockChain │ BlockChainToP2P
│ new block/vote │ publish, fetch block
│ ▼
│ ┌─────────────────────────────┐ SwarmCommand ┌────────────────────┐
└────│ P2PServer │────────────────▶│ swarm adapter │
│ actor, gossip + req/resp │◀────────────────│ (libp2p task) │◀══▶ peers
└─────────────────────────────┘ SwarmEvent └────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ Store: pluggable key-value backend + in-memory fork-choice buffers │
│ written by BlockChainServer, read by P2PServer and the API servers │
└────────────────────────────────────────────────────────────────────┘
Actor protocols
The two actors never share memory: they talk over the typed protocols in
crates/net/api/src/lib.rs.
| Direction | Messages |
|---|---|
BlockChain → P2P | publish_block, publish_attestation, publish_aggregated_attestation, fetch_block |
P2P → BlockChain | new_block (tagged Gossip or Sync), new_attestation, new_aggregated_attestation |
Both refs start as None. The InitP2P and InitBlockChain messages fill them in right
after spawn, so neither actor needs the other to exist at construction time.
The BlockChainServer
The tick loop
The actor schedules its first Tick for genesis time, and every handler re-arms the next one
at the following interval boundary. A handler that overruns that boundary re-arms with a zero
delay instead, so the interval it just missed still gets its duty. The handler derives
(slot, interval) from the wall clock, compares it against the store’s own interval counter,
and skips ticks the store already passed.
store::on_tick then walks the store clock forward one interval at a time, fast-forwarding
if it fell more than a slot behind, so a late tick still runs each interval’s bookkeeping in
order. That walk and the actor split the duties:
| Interval | In store::on_tick | In the actor |
|---|---|---|
| 0 | accept new attestations, if we propose this slot | nothing: the build ran at the previous interval 4 |
| 1 | nothing | produce attestations, arm the early-aggregation check |
| 2 | nothing | start the aggregation session |
| 3 | update the safe target | nothing |
| 4 | accept accumulated attestations | build and publish the next slot’s block |
See Slots and Intervals for what each duty means at the protocol level, and why the proposer builds one interval early.
The actor also advances its XMSS signing keys on every tick, and catches them up to the current slot once at spawn. The keys are one-time and slot-bound, so a node that skipped this would have nothing left to sign with.
Block import
Blocks take the same path whether they arrived on gossip or came back from a BlocksByRange
sync request. The actor verifies the signature, then runs the state transition
(crates/blockchain/state_transition): process_slots advances the pre-state through empty
slots, process_block validates the header and applies the block’s attestations, and the
result is rejected unless the recomputed state root matches the one the proposer committed
to. Justification and finalization move as part of that transition, following the
3SF-mini rules. The actor then writes the block and its post-state, and
recomputes the head with LMD GHOST (crates/blockchain/fork_choice).
Aggregation off the message loop
XMSS proving costs hundreds of milliseconds, so it cannot run on the actor loop: a blocked
actor stops importing blocks. The actor instead snapshots aggregation inputs from the store,
ranks candidates by consensus value, and hands a job list to a spawn_blocking worker
(crates/blockchain/src/aggregation.rs). The worker holds no store access, streams one
AggregateProduced message back per finished job, and ends with AggregationDone. The actor
publishes each result on gossip when it arrives.
A soft deadline cancels the worker through a CancellationToken, so an overrunning slot
cannot eat the next one. The session can also start up to EARLY_AGGREGATION_WINDOW before
interval 2, once two thirds of the expected signatures are in. Either entry point counts as
the slot’s one session, so a slot aggregates once. Starting early buys proving time, not an
earlier publication: the worker holds each finished aggregate until the interval-2 boundary
before delivering it to the actor.
Block import runs a second, smaller aggregation path. reaggregate.rs splits an imported
block’s merged proof back into per-attestation aggregates and folds them into the local pool,
which is how a node that only saw a vote inside a block gets its fork-choice weight.
Aggregators go one step further and republish those aggregates on gossip. Each split runs a
fresh SNARK, so reaggregate.rs caps how many it does per block, and the actor skips the
whole path while the node is catching up.
Subnet-windowed aggregation
Two aggregators handed the same pool of existing proofs would otherwise pick the same two
children every session, since the greedy selection in aggregation.rs is deterministic: all
that duplicated leanVM proving buys nothing once one of them publishes. Each aggregator instead
scores that pool through a window: a contiguous run of subnets starting at its duty subnet, the
first value of --aggregate-subnet-ids (or the lowest subnet it subscribes to, if that flag is
unset). A proof outside the window still counts if it partly overlaps, but earns credit only
for its in-window share, so aggregators with different windows tend to land on different
children without anyone being excluded from merging.
The width is derived, not chosen: wide enough to hold two proofs at the reach of the aggregator’s anchor, capped at the committee count, so it only widens once a data root’s proof has actually climbed. The anchor is the largest-coverage proof in the candidate’s pool that touches the aggregator’s own duty subnet. Picking it by coverage rather than by reach keeps a sparse proof, one validator in each of many subnets, from setting the width for everybody; requiring it to touch the duty subnet means “no anchor” says “no peer has covered my subnet”, which is exactly when this node’s raw signatures are irreplaceable, and the narrowest window then leaves it aggregating those instead of merging other aggregators’ proofs. The price is that two aggregators reading one lopsided pool can derive different widths, so their windows nest rather than tile; that costs a round of climbing, not correctness.
A window can still decline a merge the unwindowed pool would have allowed, when the proof pool is sparse relative to the window’s contiguous span (a strided aggregator placement is the common cause); selection then retries once with the full committee set, so the feature can only improve on the pre-window selection, never regress below it.
--skip-redundant-aggregation trades that safety net away on purpose. With it set, an
aggregator sits out any candidate whose derived width it does not own in the current slot
(duty_subnet % width == slot % width), and the freed job goes to the next-best attestation
data rather than to a narrower merge of the same one. Ownership rotates with the slot, so
every duty subnet gets a turn, and the narrowest width is owned by everyone, so a candidate
with no anchor on this node’s subnet is never skipped. The full-width fallback is disabled
under the flag: every width below the committee count has several owners, so retrying there
would rebuild exactly the duplication the flag buys away.
The rotation guarantees an owner at every width only when every subnet below the committee count has an aggregator holding it as its duty subnet, so treat that as a precondition for the flag. On a sparser placement a width can have no owner in a given slot even with every configured node healthy: with duty subnets {0, 2} at committee count 4, nothing owns width 4 in an odd slot, and with the fallback off that merge level is dropped for the slot. Leave the flag unset on a placement that does not cover every subnet.
Sync gate
sync_status.rs tracks how far the local head lags the slot clock. Past the threshold the
node reports itself syncing and stops attesting and proposing, since a head derived from a
partial view is not worth voting for. A hysteresis band stops the state from flapping at the
boundary, and a network-wide stall (nobody else is ahead either) leaves the node synced so
its validators can help the chain recover. The same status feeds the lean_node_sync_status
metric and, through a shared controller, the /lean/v0/node/syncing endpoint.
--disable-duty-sync-gate reduces the gate to observe-only.
Missing parents
The actor cannot import a block whose parent is unknown, so it parks the block in
pending_blocks under its parent root and records the deepest missing ancestor it can find,
walking back through already-stored pending blocks, in pending_block_parents. That ancestor
is what it asks the P2PServer to fetch. Once the ancestor lands, the actor cascades down
the parent index and re-imports every block that was waiting.
Chain events
The actor is the sole publisher on an EventBus (crates/blockchain/src/events.rs), which
carries seven topics: head moves, imports and gossip sightings of blocks, single votes and
aggregates, plus justification and finalization updates. The bus is best-effort:
emission never blocks the actor, and a slow subscriber loses events instead of
back-pressuring consensus. The API server subscribes one receiver per SSE client.
The P2PServer
Only the swarm adapter task touches the libp2p::Swarm. The actor sends it SwarmCommands
(publish, dial, send request, send response) and receives SwarmEvents back as actor
messages. That split keeps non-Clone swarm types (response channels, for one) out of the
typed protocol, and keeps the adapter polling network I/O while the actor is busy with a
message.
What the actor does with those events:
- Gossip. It decodes blocks, aggregates and per-subnet attestations, then forwards them
to the
BlockChainServer. The node computes its subscriptions once at startup from its validator set and aggregator role, and never revisits them. - Status. Sent on the first connection to a peer, not on every redundant one. When a peer
reports a head ahead of ours, the actor opens a long-range sync range or extends the one it
has, then requests
BlocksByRangebatches one at a time, dropping peers that fall behind the range. BlocksByRoot. Backs thefetch_blockrequests above. Retries use exponential backoff and prefer a peer that has not already failed for that root, falling back to the full connected set once every peer has failed.
The P2PServer holds its own Store clone, which it only ever reads, so it answers Status
and BlocksBy* requests without a round trip through the consensus actor.
The Store
The Store follows a similar approach as ethrex’s: a safe, easy to use interface over a
pluggable key-value backend (StorageBackend, RocksDB for a normal node and in-memory for
tests and the Hive test driver).
Cloning one shares the backend, the state LRU cache, and the in-memory buffers that fork
choice runs on: the new and known attestation payload buffers, the latest votes, and the
gossip signatures awaiting aggregation. The node never persists those buffers, since they
only matter for the slot they belong to.
Every component gets a clone, but only the BlockChainServer writes consensus state. A
reader can therefore hold a handle without interleaving with a state transition.
See Data Storage for the table layout, the snapshot/diff scheme used for states, and pruning rules.
HTTP API
We use axum as our API router, with requests served in tokio tasks. Handlers read node state
without messaging the actors: the Store is router state, and the EventBus and the runtime
controllers arrive as extension layers. Metrics and debug endpoints live in their own routers,
on a port you configure separately: distinct ports bind two independent servers, equal ports
merge all three routers onto a single listener. See HTTP API for the endpoint
reference.
Startup
bin/ethlambda/src/main.rs wires the node in a fixed order. Everything before the actors
spawn is fail-fast, so a misconfigured node stops at boot instead of hours later.
- Install the tracing subscriber, parse CLI options, register metrics, raise the file-descriptor limit for RocksDB’s unbounded table cache.
- Load the node key, then genesis config, validator config, bootnode ENRs and validator keys.
- Open the database, then pick an anchor: resume from disk if the on-disk head is recent enough, otherwise checkpoint sync from the configured URLs, and otherwise build the genesis state. A stale database with no checkpoint URL configured is resumed anyway, with a warning, since that is the setup the node was given.
- Build the shared handles: aggregator controller, sync-status controller, event bus, and the subnet set that both the swarm and the actor need to agree on.
- Spawn
BlockChainServer(which schedules its first tick for genesis time), build the swarm, spawnP2PServer, and wire the two together withInitP2PandInitBlockChain. - Spawn the API and metrics servers.
Shutdown runs in reverse: the first ctrl+c stops both actors and cancels the servers’ shutdown token, and three more force the process to exit if a graceful stop hangs.
Note: booting with
HIVE_LEAN_TEST_DRIVER=1short-circuits everything from step 2 on and exposes only the Hive test-driver endpoints, so a driver run never touches the node key, the genesis config or any other consensus prerequisite.
Slots and Intervals
A Lean Chain slot is divided in 5 intervals of equal length. Every duty a validator
owes the chain is due in one of them. The slot lasts 4 seconds by default, so the
offsets below are the 800 ms grid; a network that sets MILLISECONDS_PER_SLOT in its
config file scales every offset by the same factor. That key can only stretch the
grid: 4 seconds is also the floor, since a few client timings are fixed in
milliseconds rather than expressed as a fraction of the slot.
| Interval | Offset | Duty | Who acts | What it publishes |
|---|---|---|---|---|
| 0 | t+0 ms | Block proposal | the slot’s proposer | the block, on the block topic |
| 1 | t+800 ms | Vote propagation | every validator | a signed attestation, on its subnet topic |
| 2 | t+1600 ms | Vote aggregation | aggregators | an aggregated attestation, on the aggregation topic |
| 3 | t+2400 ms | Safe target computation | every validator | nothing: local bookkeeping |
| 4 | t+3200 ms | Head update | every validator | nothing: local bookkeeping |
ONE SLOT (4000 ms)
┌────────────┬────────────┬────────────┬────────────┬────────────┐
│ Interval 0 │ Interval 1 │ Interval 2 │ Interval 3 │ Interval 4 │
│ t+0 ms │ t+800 ms │ t+1600 ms │ t+2400 ms │ t+3200 ms │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│ block │ vote │ vote │safe target │ head │
│ proposal │propagation │aggregation │computation │ update │
└────────────┴────────────┴────────────┴────────────┴────────────┘
◄───────────── gossiped ─────────────▶ ◄───── local only ───────▶
The grid comes from a genesis timestamp every node shares, so the schedule needs no coordination messages: a node reads its clock, works out which interval it is in, and knows which duty is due. The order is a dependency chain, since each interval consumes what the previous one produced. A duty that overruns its interval is not rescheduled: it lands late, and the slot moves on without it.
In ethlambda’s Lean Chain: the intervals are the
SlotIntervalvariants incrates/blockchain/src/lib.rs.INTERVALS_PER_SLOTis fixed incrates/common/types/src/constants.rs(each interval carries a distinct duty, so the count is not a knob), while the slot duration is read from the network’s config file intoChainConfig, which derives the interval length from it. Every node on a network must agree on the value, and other clients still hold it at compile time: setting it only has an effect where every node reads the key.
Interval 0: Block proposal
A block proposer, selected in a round-robin fashion (slot % num_validators), proposes a
new block and gossips it to the network. Right before building the block, the proposer
merges their “new attestations buffer” into their fork-choice view. They then include
attestations that the proposer has recently seen into their block. Other validators verify
the block and its contents, and merge the votes it includes into their fork-choice view.
After importing a block, all validators recompute their head, and update
the latest finalized and justified checkpoints according to the block’s
post-state.
A block body carries at most MAX_ATTESTATIONS_DATA aggregated attestations: distinct
(slot, head, target, source) tuples, each paired with a bitfield naming the validators
bound to it.
Genesis occupies slot 0, so proposals start at slot 1, and nothing forces a slot to be
filled: a proposer that is offline or too slow leaves an empty slot, and the next block
simply points its parent root at an older block.
In ethlambda’s Lean Chain: block proposal is merged into the previous slot’s head-update interval: the proposer advances its store to the next slot, builds the block there, and holds publication until the slot boundary. That buys the build one extra interval of headroom and leaves no actor work at the block-proposal tick itself.
Interval 1: Vote propagation
Validators gossip their votes for the block they consider to be the head of the chain, and
append to it a (source, target) finality vote.
These votes are in aggregation subnets and are imported by aggregators. Aggregators verify
the votes in their subnet and store them for later aggregation.
In ethlambda’s Lean Chain: a validator’s subnet is
validator_index % attestation_committee_count, and a node only aggregates for subnets it subscribed to at startup. Aggregation is also gated on the aggregator role, seeded by--is-aggregatorand flippable at runtime through the admin API. A chain whose validators all decline the role still gossips votes and logs them as processed, but no aggregate is ever produced, so every block is empty and the chain never justifies.
Interval 2: Vote aggregation
Aggregators aggregate the votes they have received and gossip the resulting aggregated attestations to the network. These aggregated attestations are imported by all validators, who verify and store them in a “new attestations buffer”.
Aggregation earns its own interval because collapsing a subnet’s worth of XMSS signatures into one proof is the heaviest recurring computation in the client. It is also what makes a block affordable, since a block carrying raw votes would need one full XMSS signature per voter, quickly going over the network bandwidth limit.
In ethlambda’s Lean Chain: the proofs run on an off-thread worker so the blockchain actor’s message loop stays responsive, and a session may start up to
EARLY_AGGREGATION_WINDOWbefore the interval boundary once two thirds of the signatures are in. At the session’s soft deadline the actor stops handing out new jobs, but a proof already in flight finishes and publishes late rather than being discarded.
Interval 3: Safe target computation
Validators compute the safe target they’ll use when deciding which finality vote to cast on the next slot. The safe target is computed based on the votes received in the current slot.
It is LMD-GHOST again, but run over just the votes that arrived this slot and with a two-thirds weight threshold, where head selection applies none. The safe target therefore sits at or behind the head and advances only once a branch is backed by a supermajority. Deriving targets from it is what stops 3SF-mini from justifying a branch the network has not visibly converged on.
Interval 4: Head update
Validators merge the aggregated attestations they have in their “new attestations buffer” into their fork-choice view, and recompute their head.
This is the slot’s second and last promotion point; the first is the proposer’s, just before it builds. Until a vote is promoted it carries no weight in head selection, which is what keeps a validator’s fork-choice view from shifting under it mid-slot. The safe target is the exception: it reads the unpromoted buffer directly, which is how it stays a view of this slot alone. See why staged promotion for the reasoning.
3SF-mini: Justification & Finalization
ethlambda’s Lean Chain (ethlambda node) uses 3SF-mini (Three-Stage Finality,
minimal version) for justification and finalization. Unlike the Ethereum Beacon Chain’s
epoch-based Casper FFG, which ethlambda beacon follows, 3SF-mini operates at the
slot level: any slot can be justified, not just epoch boundaries.
Quick Example: Three Slots to Finality
4 validators, slot N already finalized and justified.
source target
│ │
▼ ▼
Slot N ──[ N-2 ]──[ N-1 ]──[ N ]
F J H
source target
│ │
▼ ▼
Slot N+1 ──[ N-2 ]──[ N-1 ]──[ N ]────[ N+1 ]
F F J H
source target
│ │
▼ ▼
Slot N+2 ──[ N-2 ]──[ N-1 ]──[ N ]────[ N+1 ]────[ N+2 ]
F F F J H
H = head J = justified F = finalized
At each slot, validators vote for the newest block as their target, citing the latest justified checkpoint as their source:
- Slot N+1: Votes
source=N, target=N+1. Three of four vote (3×3=9 >= 2×4=8), so N+1 is justified. - Slot N+2: Votes
source=N+1, target=N+2. Three of four vote, so N+2 justified. N+1 and N+2 are consecutive justifiable slots and both are justified, so N+1 is finalized.
In the ideal case, each block carries attestations that justify the parent slot and finalize the one before it. In practice, forks, missed slots, and delayed votes can break this cadence. The rest of this document explains the rules that make this work, and what happens when things go wrong.
Concepts
| Term | Meaning |
|---|---|
| Justified | A checkpoint backed by at least two-thirds of validator votes |
| Finalized | A checkpoint that can never be reverted |
| Source | The latest justified checkpoint (vote origin) |
| Target | The checkpoint being voted for (vote destination) |
| Justifiable | A slot that could become justified (per the 3SF-mini schedule) |
Justification via Supermajority
A checkpoint becomes justified when at least two-thirds of validators attest to it as a target:
JUSTIFICATION
─────────────
Validators: V0 V1 V2 V3 V4 V5 V6 V7 V8
│ │ │ │ │ │ │
└───┴───┴───┴───┴───────┴───┘
│
7 out of 9 votes
(3×7=21 >= 2×9=18) ✓
│
▼
┌──────────────┐
│ Checkpoint C │
│ JUSTIFIED ✓ │
└──────────────┘
The threshold is computed as: 3 × vote_count >= 2 × validator_count
In ethlambda’s Lean Chain: Justification and finalization are processed inside
process_attestations()incrates/blockchain/state_transition/src/lib.rs, called fromprocess_block(). The supermajority check is3 * vote_count >= 2 * validator_count.
Attestations must also pass validity checks before they count:
- Source checkpoint must already be justified
- Target must not already be justified
- Neither source nor target may have a zero-hash root
- Source slot < Target slot (time flows forward)
- Both checkpoints must reference known blocks
- Target slot must be justifiable per the 3SF-mini schedule (see below)
The Justifiability Schedule
Not every slot can be justified, only slots at specific distances from the last finalized slot. This is the novel part of 3SF-mini.
A slot is justifiable if delta = slot - finalized_slot matches any rule:
In ethlambda’s Lean Chain: The function
slot_is_justifiable_after(slot, finalized_slot)incrates/blockchain/state_transition/src/lib.rsimplements this check. It usesisqrt()for perfect square detection and the identity4n(n+1) + 1 = (2n+1)²for pronic number detection.
┌───────────────────────────────────────────────────────┐
│ JUSTIFIABILITY RULES │
│ │
│ Rule 1: delta ≤ 5 (always justifiable) │
│ │
│ Rule 2: delta = n² (perfect squares) │
│ 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, ... │
│ │
│ Rule 3: delta = n(n+1) (pronic numbers) │
│ 2, 6, 12, 20, 30, 42, 56, 72, 90, 110, ... │
│ │
└───────────────────────────────────────────────────────┘
Visualizing the first 40 slots after finalization (✓ = justifiable):
delta: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
✓ ✓ ✓ ✓ ✓ ✓ ✓ · · ✓ · · ✓ · · · ✓ · · · ✓
╰─ delta ≤ 5 ──╯ 2×3 3² 3×4 4² 4×5
delta: 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
· · · · ✓ · · · · ✓ · · · · · ✓ · · · ·
5² 5×6 6²
| delta | Rule | Formula | Gap since previous |
|---|---|---|---|
| 0–5 | 1 | ≤ 5 | - |
| 6 | 3 | 2×3 | 1 |
| 9 | 2 | 3² | 3 |
| 12 | 3 | 3×4 | 3 |
| 16 | 2 | 4² | 4 |
| 20 | 3 | 4×5 | 4 |
| 25 | 2 | 5² | 5 |
| 30 | 3 | 5×6 | 5 |
| 36 | 2 | 6² | 6 |
Key property: Gaps between justifiable slots grow, but never become infinite. As more time passes since finalization, the network gets progressively wider windows to accumulate votes. This creates a natural backpressure: if the network is struggling to reach a two-thirds majority (e.g., due to partitions or validator dropouts), the increasing gaps give more time for the supermajority to form.
Finalization
A justified checkpoint becomes finalized when it is the source of a justification whose target is the next justifiable slot. In other words, there must be no justifiable slots between source and target: the two must be consecutive entries in the justifiability schedule.
In ethlambda’s Lean Chain: The
try_finalize()function iterates over slots between source and target and callsslot_is_justifiable_afteron each. If any slot is justifiable, finalization fails (source and target aren’t consecutive). The check usesoriginal_finalized_slot(the finalized slot at the start of block processing), not the current one, since finalization can advance mid-processing.
FINALIZATION CHECK
──────────────────
Example 1: Finalization FAILS
Finalized=10 Source=13 (justified) Target=16 (justified)
[ 10 ] · · · [ 13 ] 14 15 [ 16 ]
▲ ▲
│ └── delta=5 ≤ 5 → justifiable!
└────── delta=4 ≤ 5 → justifiable!
Justifiable slots exist between S and T → NOT FINALIZED ✗
(13 and 16 are not consecutive justifiable slots)
Example 2: Finalization SUCCEEDS
Finalized=10 Source=16 (justified) Target=19 (justified)
[ 10 ] · · · [ 16 ] 17 18 [ 19 ]
▲ ▲
│ └── delta=8 → not justifiable ✓
└────── delta=7 → not justifiable ✓
No justifiable slots between S and T → S is FINALIZED ✓
(16 and 19 are consecutive: delta=6=2×3, then delta=9=3²)
The reasoning: if a justifiable slot exists between source and target, validators could have directed their votes to that intermediate slot instead, potentially on a different fork. By requiring source and target to be consecutive justifiable slots, the protocol ensures that no alternative justification path can exist between them.
Justifiable Slot Backoff
The justifiability schedule acts as a backoff mechanism to increase finalization rate
during periods of asynchrony. By “diluting” the possible targets of a justification
vote (via the slot_is_justifiable_after function), the protocol increases the window
during which votes for a given slot can be included, improving the chances of achieving
the required two-thirds majority.
Since finalization requires two consecutively justifiable slots to both be justified, this backoff isn’t immediately reset after finalization occurs; it only lowers over time when synchrony is restored.
Example: Extended asynchrony with gradual recovery.
F=0. Justifiable slots grow sparser as delta increases:
delta ≤ 5: 0 1 2 3 4 5 (gap = 1)
delta 6–20: 6 9 12 16 20 (gap = 3–4)
delta 20–36: 20 25 30 36 (gap = 5–6)
...
delta ~1000: 900 930 961 992 1024 (gap = 30–32)
30² 30×31 31² 31×32 32²
Phase 1: Long asynchrony, slow progress.
Validators vote, but with many justifiable targets, votes scatter
and no single slot reaches >=2/3. As gaps widen, votes concentrate.
Near slot 1000, the 32-slot gap between 992 and 1024 means
no competing justifiable target exists for 32 slots after 992.
All votes funnel toward 1024 once it is built.
Phase 2: Slot 992 finalized.
Slot 992 justified (source = earlier justified slot).
Slot 1024 justified (source = 992).
slot: 0 ... 992 1024
F J ······· J
▲ ▲
source ──────────▶ target
Slots 993–1023: any justifiable from F=0?
Perfect squares? 31²=961 (before), 32²=1024 (boundary). None.
Pronic? 31×32=992 (boundary), 32×33=1056 (after). None.
No justifiable slots between them → slot 992 FINALIZED ✓
Phase 3: Partial reset. Backoff shrinks but doesn’t vanish.
New F=992. Justifiable slots shift:
slot: 992 993 994 995 996 997 998 ··· 1001 ··· 1004 ··· 1008 ··· 1022 ··· 1028
F ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
╰── delta ≤ 5 ──╯ 2×3 3² 3×4 4² 5×6 6²
Dense slots 993–998 are already in the past!
Near the current slot (~1024), justifiable slots are ~6 apart:
... 1022 1028 1034 1041 ...
δ=30 δ=36 δ=42 δ=49
5×6 6² 6×7 7²
└──6──┘ └──6──┘ └──7──┘
Gaps shrank from 32 → 6, but didn't reset to 1.
Phase 4: Further finalization closes the gap.
Justify 1022 and 1028, finalize 1022. New F=1022.
From F=1022, at slot ~1028 (delta = 6):
slot: 1022 1023 1024 1025 1026 1027 1028
F ✓ ✓ ✓ ✓ ✓ ✓
╰────── delta ≤ 5 ──────╯ 2×3
Gaps are back to 1. Fast finalization resumes.
Summary of gradual recovery:
┌───────────────────┬──────┬───────┬───────┬──────────────┐
│ Finalization step │ F │ Head │ Delta │ Nearby gaps │
├───────────────────┼──────┼───────┼───────┼──────────────┤
│ Before any │ 0 │ ~1000 │ ~1000 │ 31–32 │
│ After 1st (992) │ 992 │ ~1024 │ ~32 │ 6–7 │
│ After 2nd (1022) │ 1022 │ ~1028 │ ~6 │ 1 │
└───────────────────┴──────┴───────┴───────┴──────────────┘
Each finalization step reduces the delta between the finalized
slot and the chain head, progressively tightening the gaps.
When finalization advances, the following cleanup occurs:
justified_slotswindow shifts forward (old slots pruned)LiveChainentries for finalized slots are pruned- Gossip signatures and aggregation proofs for finalized blocks are cleaned up
- Future fork choice runs start from the finalized slot’s successor
In ethlambda’s Lean Chain: The
justified_slotsbitlist uses relative indexing (index 0 =finalized_slot + 1). When finalization advances,shift_window()incrates/blockchain/state_transition/src/justified_slots_ops.rsdrops the now-finalized prefix. The attestation target is also walked back to the nearest justifiable slot viaslot_is_justifiable_afterincrates/blockchain/src/store.rs.
End-to-End: From Head Selection to Finalization
This section connects LMD-GHOST fork choice with 3SF-mini. The quick example above showed the happy path; here we focus on what happens when things go wrong.
Recap: Attestation Anatomy
Each attestation carries three checkpoints, each determined by a different mechanism:
┌────────────────────────────────────────────────────────────────┐
│ ATTESTATION │
│ │
│ head Newest block the validator sees │
│ ← LMD-GHOST with min_score = 0 │
│ │
│ target Block the validator wants justified next │
│ ← Derived from safe target, walked back to nearest │
│ justifiable slot (feeds into 3SF-mini) │
│ │
│ source Latest justified checkpoint │
│ ← Read from store state │
└────────────────────────────────────────────────────────────────┘
The safe target is computed by running LMD-GHOST with a two-thirds vote threshold. Only blocks backed by a supermajority qualify, so the safe target is always at or behind the head. The attestation target is derived by walking back from the head toward the safe target (max 3 steps), then to the nearest justifiable slot. See Safe Target Selection for details.
In ethlambda’s Lean Chain:
get_attestation_target()incrates/blockchain/src/store.rsimplements this walk-back.JUSTIFICATION_LOOKBACK_SLOTS = 3provides a liveness guarantee: even if the safe target is stuck, the target eventually advances once the head moves far enough ahead.
Lagging Safe Target (Fork with Delayed Convergence)
When validators disagree about the head, the safe target lags behind: no single branch has two-thirds support. This delays justification until the fork resolves.
Setup: 9 validators, finalized=100, justified=101
Safe target threshold: >=6 votes (2/3 of 9)
Slots 102–103: Fork splits votes. No progress.
┌──[ B102a ]──[ B103a ] V0–V4 (5)
[ F=100 ]──[ J=101 ]─┤
└──[ B102b ]──[ B103b ] V5–V8 (4)
Neither branch clears two-thirds → safe target stuck at B101. Walk-back from head always lands on source (B101). No attestation can advance justification.
Slot 104: V7 and V8 switch sides. Fork resolves.
V7 and V8 receive B102a (delayed by the partition) and switch to the a-branch.
┌──[ B102a ]──[ B103a ]──[ B104a ] V0–V4, V7, V8 (7)
[ F=100 ]──[ J=101 ]─┤
└──[ B102b ]──[ B103b ]──[ B104b ] V5–V6 (2)
B102a subtree now has 7 votes >= 6 → safe target = B102a. Walk-back from B104a lands on B102a (2 steps). Slot 102 is justifiable (delta=2 ≤ 5).
source=101 ──▶ target=102 7/9 votes → 3×7=21 >= 2×9=18 → JUSTIFIED ✓
Finalization: no slots between 101 and 102 → 101 FINALIZED ✓
After slot 104: finalized=101, justified=102.
Slots 105–106: Full convergence and recovery.
All 9 validators on the a-branch. Slot 105: target=B104a → B104a JUSTIFIED. But finalization fails: slot 103 (between source=102 and target=104) is justifiable but was never justified (lost in the fork).
Slot 106: target=B105a → B105a JUSTIFIED. No justifiable slots between 104 and 105 → 104 FINALIZED. Finalization jumped from 101 to 104, skipping 102 and 103.
FORK WITH DELAYED CONVERGENCE
═════════════════════════════
Slot: 100 101 102 103 104 105 106
Status: F J · · · · ·
fork ──────┤
resolves
Head: · B101 B102a B103a B104a B105a B106a
Safe: · B100 B101 B101 B102a B104a B105a
stuck ─────┘ ▲
│
V7+V8 switch, safe target unsticks
Justified: · 101 ─ ─ 102 104 105
Finalized: · · ─ ─ 101 ─ 104
▲
finalization jumps ──┘
(102,103 skipped; 103 was never justified)
Comparison with Casper FFG
Both 3SF-mini and Casper FFG are finality gadgets built on the same foundation: supermajority links between checkpoints. They differ fundamentally in their unit of time and what that implies for validator participation. For a thorough treatment of Casper FFG as used in Ethereum, see the eth2book chapter on Casper FFG.
Slots vs Epochs: The Core Architectural Split
3SF-mini: Every Validator, Every Slot
In 3SF-mini, all validators vote in every slot. A checkpoint can be justified at any slot (subject to the justifiability schedule), and finalization can happen as soon as two consecutive justifiable slots are both justified.
3SF-mini (4-second slots, 4 validators)
Slot 100 Slot 101 Slot 102 Slot 103
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│V0 V1 │ │V0 V1 │ │V0 V1 │ │V0 V1 │
│V2 V3 │ │V2 V3 │ │V2 V3 │ │V2 V3 │
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
│ │ │ │
4 votes 4 votes 4 votes 4 votes
per slot per slot per slot per slot
Every validator participates in every slot.
>=2/3 threshold checked per-slot → can justify any slot.
This is simple and fast, but it means every validator must produce and verify a vote
every slot. The total message load scales as validators × slots.
Casper FFG: Validators Split Across an Epoch
Ethereum’s beacon chain has ~1,000,000 active validators. Having all of them vote every 12-second slot would be unmanageable. Instead, Casper FFG groups 32 slots into an epoch, and splits the validator set across the slots within it:
Casper FFG (12-second slots, 32 per epoch, ~900k validators)
Epoch N
┌─────────────────────────────────────────────────────────────┐
│ Slot 0 Slot 1 Slot 2 ... Slot 30 Slot 31 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │~28125│ │~28125│ │~28125│ ... │~28125│ │~28125│ │
│ │valids│ │valids│ │valids│ │valids│ │valids│ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │ │ │
└────┼───────────┼───────────┼───────────────┼───────────┼────┘
└───────────┴───────────┴───┬───────────┴───────────┘
│
All ~900k votes
collected over 32 slots
│
▼
Epoch checkpoint
(first slot of epoch)
Each validator attests exactly ONCE per epoch.
The full >=2/3 tally is only meaningful at epoch boundaries.
Each validator is shuffled into a committee assigned to one specific slot. Within that slot, the committee may be further split (up to 64 sub-committees) for parallel aggregation. The result: each validator only attests once per epoch, and the network processes ~28,000 attestations per slot instead of ~900,000.
The trade-off:
| 3SF-mini | Casper FFG | |
|---|---|---|
| Who votes when | All validators, every slot | Each validator once per epoch (in its assigned slot) |
| Messages per slot | N (all validators) | N / 32 (one committee) |
| Supermajority known after | 1 slot (all votes in) | 1 epoch (need all 32 committees) |
| Fastest finalization | 2 slots = 8 seconds | 2 epochs = ~12.8 minutes |
| Practical validator limit | Hundreds–thousands | Millions |
Epochs exist because of a scalability constraint, not a protocol-theory preference. If you could process a million votes per slot, Casper FFG wouldn’t need epochs at all. 3SF-mini sidesteps this by targeting a smaller validator set, which lets it operate at slot granularity.
Finalization Logic
Both require a chain of justified checkpoints, but the rules differ in what they check.
Casper FFG uses k-finality. The original rule (k=1) requires a direct supermajority link from a checkpoint to its immediate successor: justify epoch N+1 with source=N, and N is finalized. Ethereum generalizes this to k=2, which handles the case where the network falls slightly behind:
Casper FFG — 1-finality (ideal case):
Epoch N Epoch N+1
┌─────┐ ┌─────┐
│ CP │══════▶│ CP │ Supermajority link N → N+1
│ J ✓ │ │ │
└─────┘ └─────┘
Processing this link:
1. Epoch N+1 becomes JUSTIFIED (target of a supermajority link)
2. Epoch N becomes FINALIZED (direct successor justified)
Casper FFG — 2-finality (one epoch behind):
Epoch N Epoch N+1 Epoch N+2
┌─────┐ ┌─────┐ ┌─────┐
│ CP │ │ CP │ │ CP │
│ J ✓ │ │ J ✓ │ │ │
└─────┘ └─────┘ └─────┘
│ │
└══════ supermajority ══════┘
link N → N+2
The direct link N→N+1 didn't form in time.
Instead, a link forms from N→N+2. Processing this link:
1. Epoch N+2 becomes JUSTIFIED (target of a supermajority link)
2. Epoch N becomes FINALIZED (all intermediates are justified)
The 2-finality rule is a recovery mechanism: even if the network missed the ideal one-epoch finalization window, it gets a second chance. Ethereum tracks the justification status of the last 4 epoch boundaries to detect both cases. In practice, most finalization happens via 1-finality during normal operation; 2-finality kicks in during brief network hiccups.
3SF-mini takes a different approach entirely:
Slot S Slot T
┌─────┐ ┌─────┐
│ CP │──────▶│ CP │ No justifiable slots exist
│ J ✓ │ │ J ✓ │ between S and T
└─────┘ └─────┘
∴ Slot S is FINALIZED
Rule: Finalized when NO intermediate checkpoints could exist
Instead of checking that intermediate checkpoints are justified, 3SF-mini checks that no intermediate checkpoints could exist at all. This is a stronger guarantee: validators’ votes between source and target could only have gone to the target, since there’s nowhere else to direct them. This structural property is also why 3SF-mini doesn’t need Casper’s surround-vote slashing condition.
Casper’s k-finality is essentially a tolerance parameter: “how many epochs behind can we be and still finalize?” Ethereum chose k=2, meaning it tolerates one missed epoch. 3SF-mini doesn’t need this concept because the justifiability schedule itself adapts. Instead of tolerating missed windows, it makes the windows wider when the network is struggling.
Adaptive Backoff (unique to 3SF-mini)
Casper FFG has a fixed checkpoint every epoch, regardless of network conditions. 3SF-mini’s justifiability schedule adapts: gaps between justifiable slots grow under prolonged asynchrony (via the perfect square and pronic number rules), creating natural vote concentration when the network is struggling to reach a two-thirds majority. Casper FFG has no equivalent; its epoch spacing is the same whether the network is healthy or partitioned. See Justifiable Slot Backoff for a detailed walkthrough.
👻 LMD-GHOST fork choice algorithm
A deep dive into how the LMD-GHOST (Latest Message Driven, Greedy Heaviest Observed SubTree) fork choice algorithm works. LMD-GHOST is the fork choice rule used by Ethereum’s consensus layer and its derivatives. Each validator’s latest attestation is their single active vote, and the algorithm follows the heaviest branch at every fork.
This document is implementation-agnostic, with details of ethlambda’s Lean Chain
(ethlambda node) called out in blockquotes marked “In ethlambda’s Lean Chain”.
ethlambda’s Beacon Chain follower (ethlambda beacon) runs the consensus-specs fork
choice instead; see its section of the Beacon Chain state transition.
Much of the conceptual framing in this document is inspired by Ben Edgington’s Eth2 Book, particularly the LMD GHOST chapter. Highly recommended reading for anyone interested in Ethereum consensus.
Background & History
The GHOST protocol was introduced by Sompolinsky and Zohar in a 2013 paper. Its core idea: instead of choosing the heaviest chain, we choose the heaviest subtree, counting orphaned blocks as evidence of support for their ancestors.
The “LMD” in LMD-GHOST stands for Latest Message Driven: only each validator’s most recent attestation counts, preventing vote amplification. LMD-GHOST is the fork choice rule used by the Ethereum Beacon Chain and Lean Ethereum.
Why Fork Choice?
In a distributed system where validators propose blocks concurrently, the blockchain can fork: two valid blocks may appear at the same slot, creating competing chains. The fork choice rule answers a critical question:
Which chain tip should I follow?
┌──────────┐
┌────▶│ Block C │ ← Chain tip 1
│ │ slot 5 │
┌──────────┐ │ └──────────┘
│ Block A │─┤
│ slot 3 │ │ ┌──────────┐
└──────────┘ └────▶│ Block D │ ← Chain tip 2
│ slot 5 │
└──────────┘
Which tip should validators follow?
Every node in the network must be able to independently arrive at the same answer using only its local view of blocks and attestations. The fork choice rule is what makes this possible. It is a deterministic function from a node’s observed state to a single chain tip.
From Heaviest Chain to Heaviest Subtree
The simplest fork choice rule is heaviest chain: follow the chain tip with the most accumulated weight. This works when fork rates are low, but breaks down when honest validators fork within a common branch:
HEAVIEST CHAIN vs HEAVIEST SUBTREE
──────────────────────────────────
An attacker with 40% of stake forks at A.
The honest majority (60%) builds on B but forks into C and D:
┌───B──┬──C V0, V1, V2 vote for C (30%)
A ────┤ └──D V3, V4, V5 vote for D (30%)
│
└───X──Y──Z V6, V7, V8, V9 vote for Z (40%)
Heaviest chain:
Z has 40% of votes, C and D each have 30%.
Attacker wins! ✗
Heaviest subtree (LMD-GHOST):
At A: B subtree has 60% (C + D), X subtree has 40%.
Pick B. Then at B: C has 30%, D has 30% (tiebreaker).
Honest majority wins. ✓
LMD-GHOST is strictly better when honest validators fork within a common subtree. Instead of requiring all honest validators to agree on a single chain tip (which is impossible under network delay), it aggregates their support at each level of the tree.
How Subtree Weight Works (the “GHOST” Part)
The key insight behind the “Heaviest Observed SubTree” part of LMD-GHOST: a vote for a block is implicitly a vote for all its ancestors.
When a validator attests to block F as their head, they are also expressing support for every block on the path from the root to F:
Validator attests: head = F
A ── B ── C ── D ── E ── F
▲ ▲ ▲ ▲ ▲ ▲
│ │ │ │ │ │
└────┴────┴────┴────┴────┘
All ancestors implicitly supported
This is why LMD-GHOST counts the subtree weight: a block’s weight includes every attestation for any of its descendants, because those attestations implicitly endorse the ancestor too. The algorithm exploits this by walking backward from each attested head and incrementing every block along the path.
LMD: Why Only the Latest Message?
The “LMD” in LMD-GHOST stands for Latest Message Driven. Each validator’s most recent attestation is their only vote. All previous attestations are discarded.
Validator 7's attestation history:
Slot 10: attests to head = B ← discarded
Slot 11: attests to head = C ← discarded
Slot 12: attests to head = E ← THIS is the active vote
Only the slot 12 attestation counts for fork choice.
Why only the latest? Two reasons:
-
Prevents double-voting. If all messages counted, a validator could cast many attestations and amplify their influence. With LMD, each validator gets exactly one active vote regardless of how many attestations they’ve broadcast.
-
Reflects current knowledge. A validator’s latest attestation reflects their most recent view of the chain. Older attestations may reference blocks that are no longer on the best chain. Keeping only the latest ensures fork choice uses the most up-to-date information.
The fork choice store maintains a mapping of validator_index → latest attestation.
When a new attestation arrives from a validator, it replaces their previous entry:
Fork choice store (latest messages):
┌──────────────┬──────────────────────────────┐
│ Validator │ Latest Attestation │
├──────────────┼──────────────────────────────┤
│ 0 │ head=E, target=C, source=A │
│ 1 │ head=D, target=C, source=A │
│ 2 │ head=E, target=C, source=A │
│ 3 │ head=F, target=D, source=A │
│ ... │ ... │
└──────────────┴──────────────────────────────┘
One row per validator. New attestation → overwrite row.
LMD-GHOST Step by Step
The algorithm takes a set of inputs and produces a single block root: the head of the chain.
Inputs
| Input | Purpose |
|---|---|
| Start root | The justified checkpoint (root of the subtree to search) |
| Block tree | The set of known blocks: root → (slot, parent) |
| Attestations | Latest message per validator: validator_index → attestation |
| Min score | Minimum weight for a branch to be considered (0 = follow any branch; higher = conservative) |
In ethlambda’s Lean Chain: The function is
compute_lmd_ghost_head()incrates/blockchain/fork_choice/src/lib.rs. The block tree comes from theLiveChainstorage index, andmin_scoreis 0 for head selection or ⌈2V/3⌉ for safe target computation.
The Algorithm
First, accumulate weights. Each attestation “paints” the path from its head back to the start root. In the simplest form (equal-weight validators), this adds +1 to every block on the path. In systems with balance-weighted voting, the validator’s effective balance is added instead.
Validator 0 attests to head = F
J ─ A ─ B ─ C ─ D ─ E ─ F (J = justified root)
+1 +1 +1 +1 +1 +1 J is at start_slot, not counted
Validator 1 attests to head = D
J ─ A ─ B ─ C ─ D
+1 +1 +1 +1
Accumulated weights:
Block: J A B C D E F
Weight: ─ 2 2 2 2 1 1
│
└ start_root (not weighted, used as the descent origin)
In ethlambda’s Lean Chain: All validators have equal weight (+1 per vote). The Ethereum Beacon Chain instead weights votes by effective balance (up to 2048 ETH).
Then, greedily descend. Starting from the start root, at each node pick the child with the most weight. Repeat until reaching a leaf:
J ──┬── B (5) ← pick B (higher weight)
└── G (2)
B ──┬── C (3) ← pick C (higher weight)
└── H (2)
C ──── D (3) ← only child, continue
D ── (no children) → HEAD = D!
Children below min_score are ignored during the descent. With min_score = 0
(normal head selection) all children are visible. With a higher threshold, only
branches with strong support are followed. This is used for
safe target selection.
The Tiebreaker
When two children have exactly equal weight, a deterministic tiebreaker is needed. Without one, different nodes could pick different heads from the same data, breaking consensus. The tiebreaker is lexicographically higher block root hash, i.e., higher hash value wins.
Equal weight scenario:
Parent
│
┌───┴───┐
B (3) C (3) ← Equal weight!
root: root:
0x3a.. 0x7f.. ← 0x7f > 0x3a, so pick C
The choice of “higher hash wins” is a convention. Any deterministic rule would work; what matters is that all nodes apply the same one.
Worked Example: Head Selection
Consider a network with 5 validators (indices 0–4) and the following block tree
rooted at the justified checkpoint J at slot 10:
BLOCK TREE
──────────
Slot 10 ┌──────┐
(justified) │ J │ ← Justified checkpoint (start_root)
└──┬───┘
│
Slot 11 ┌──┴───┐
│ A │
└──┬───┘
┌──┴────────┐
│ │
Slot 12 ┌──┴───┐ ┌──┴───┐
│ B │ │ C │
└──┬───┘ └──┬───┘
│ │
Slot 13 ┌──┴───┐ ┌──┴───┐
│ D │ │ E │
└──────┘ └──────┘
Latest attestations (one per validator):
| Validator | Attested Head | Path back from head to J |
|---|---|---|
| 0 | D | D → B → A → (J) |
| 1 | D | D → B → A → (J) |
| 2 | E | E → C → A → (J) |
| 3 | E | E → C → A → (J) |
| 4 | E | E → C → A → (J) |
Accumulate weights by walking backward from each attested head, adding +1 per block (stopping at J’s slot):
V0 (head=D): D+1 B+1 A+1
V1 (head=D): D+1 B+1 A+1
V2 (head=E): E+1 C+1 A+1
V3 (head=E): E+1 C+1 A+1
V4 (head=E): E+1 C+1 A+1
| Block | Weight | Explanation |
|---|---|---|
| A | 5 | On path of all 5 validators |
| B | 2 | On path of V0, V1 |
| C | 3 | On path of V2, V3, V4 |
| D | 2 | Head of V0, V1 |
| E | 3 | Head of V2, V3, V4 |
Greedily descend from J, always picking the heaviest child:
Start at J
└─▶ A (only child, weight 5)
├── B (weight 2)
└── C (weight 3) ← Pick C (3 > 2)
└─▶ E (only child, weight 3)
└─▶ No children → HEAD = E ✓
Result: The canonical head is Block E. Even though both branches have the same depth, the C→E branch has 3 votes vs B→D’s 2 votes.
RESOLVED HEAD
─────────────
Slot 10 ┌──────┐
│ J │
└──┬───┘
│
Slot 11 ┌──┴───┐
│ A │ ✓ canonical
└──┬───┘
┌──┴────────┐
│ │
Slot 12 ┌──┴───┐ ┌──┴───┐
│ B │ │ C │ ✓ canonical (weight 3 > 2)
└──┬───┘ └──┬───┘
│ │
Slot 13 ┌──┴───┐ ┌──┴───┐
│ D │ │ E │ ★ HEAD
└──────┘ └──────┘
What If a Vote Changes?
Suppose validator 1 now sees block E and switches their attestation from D to E:
Before: V0=D, V1=D, V2=E, V3=E, V4=E → Head = E (3 vs 2)
After: V0=D, V1=E, V2=E, V3=E, V4=E → Head = E (4 vs 1)
The head didn't change, but the margin increased from 1 to 3.
If instead V2 and V3 had switched to D:
After: V0=D, V1=D, V2=D, V3=D, V4=E → Head = D (4 vs 1)
The head reorgs from E to D.
Fork Choice vs Finality
An important conceptual distinction: LMD-GHOST provides fork choice, not finality.
LMD-GHOST gives the network a way to agree on the current head of the chain at any moment, but the head can change. A block selected by fork choice today could be reorged away tomorrow if attestations shift. LMD-GHOST alone provides no guarantee that any block is permanent.
Finality, the guarantee that a block can never be reverted, comes from a separate mechanism called a finality gadget. LMD-GHOST is designed to compose with any finality gadget (e.g., Casper FFG in the Ethereum Beacon Chain, or 3SF-mini in Lean Ethereum).
┌────────────────────────────────────────────────────┐
│ CONSENSUS = TWO LAYERS │
│ │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ LMD-GHOST │ │ Finality Gadget │ │
│ │ │ │ │ │
│ │ "Which tip │ │ "Which blocks are │ │
│ │ is best │ │ permanent and can │ │
│ │ right now?"│ │ never be reverted?" │ │
│ │ │ │ │ │
│ │ Dynamic, │ │ Monotonic, only │ │
│ │ can reorg │ │ moves forward │ │
│ └──────┬──────┘ └──────────┬───────────┘ │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Full Consensus │ │
│ └──────────────────┘ │
└────────────────────────────────────────────────────┘
In ethlambda’s Lean Chain: The finality gadget is 3SF-mini, which operates at the slot level rather than epoch boundaries.
The two layers interact: LMD-GHOST runs its greedy descent starting from the latest justified checkpoint (not genesis). This means finality constrains fork choice: once a checkpoint is finalized, no fork choice run will ever consider blocks before it.
┌─────────┐ ┌─────────┐ ┌──── ...
│FINALIZED│────────▶│JUSTIFIED│────────▶│ fork choice
│ slot 50 │ │ slot 55 │ │ runs here
└─────────┘ └─────────┘ └──── ...
│ │
│ └── start_root for LMD-GHOST
│
└── everything before this is permanent
This has a major practical benefit: finality allows aggressive pruning of the block tree. Without finality, fork choice would need to consider every block since genesis, and the tree would grow without bound. With finality, all blocks at or before the finalized checkpoint can be discarded from the fork choice’s working set.
In ethlambda’s Lean Chain: The
LiveChainindex (the in-memory block tree used by fork choice) is pruned every time finalization advances, keeping it bounded to only the non-finalized portion of the chain.
Attestation Pipeline
In a naive implementation, every attestation would influence fork choice the instant it arrives. This creates problems: validators with faster network connections see different heads than slower ones, and the proposer’s view of the chain could shift mid-block-construction.
Lean Ethereum solves this with a staged promotion pipeline: attestations are collected into a pending set and only promoted to the active fork choice set at designated moments. This ensures all validators operate on a consistent view.
ATTESTATION LIFECYCLE
─────────────────────
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Network │ │ Pending │ │ Active │
│ (gossip) │──────▶│ Attestations │──────▶│ Attestations │
│ │ │ │ │ │
└──────────────┘ └──────────────────┘ └──────────────────┘
│ │
NOT used for Used for fork choice
fork choice weight calculations
│ │
Promoted at ─────────────▶ designated intervals
fixed points
In ethlambda’s Lean Chain: The two stages are called “new” and “known” attestations, held in the in-memory
new_payloadsandknown_payloadsbuffers of theStorerespectively. Promotion happens at tick intervals 0 (if proposing) and 4 (end of slot).
Why Staged Promotion?
The staged design serves two purposes:
-
Consistency: All validators promote attestations at the same moments, reducing divergence in head selection. Without batching, validators with faster network connections would see different heads than slower ones.
-
Proposer fairness: The proposer computes the block against a known, fixed set of attestations. If new attestations could influence fork choice mid-computation, different validators might disagree on the head.
On-Chain vs Off-Chain Attestations
Attestations arrive from two sources, and how they enter the pipeline matters:
| Source | Enters As | Reason |
|---|---|---|
| Network gossip | Pending | Must wait for promotion window |
| Block body (on-chain) | Active | Already consensus-validated |
| Proposer’s own attestation | Pending | Prevents proposer weight advantage |
The proposer’s own attestation enters as pending (not active) deliberately. If it were immediately active, the proposer would gain an unfair weight advantage for their own block, a circular dependency where proposing a block gives you an extra vote toward making that block canonical.
Safe Target Selection
The safe target is a conservative head computed with a high weight threshold.
It constrains the target field in attestations, which feeds into
3SF-mini for justification and finalization decisions. Validators
still vote for the newest head they see (regular LMD-GHOST with min_score = 0)
in the head field. The safe target only affects which blocks can progress
toward finality. It is computed by running the same LMD-GHOST algorithm but with
a non-zero min_score in the filtering phase.
SAFE TARGET vs HEAD
────────────────────
Regular head (min_score = 0):
Follow heaviest branch, even with a slim margin
┌── B (3 votes) ← HEAD (3 > 2)
J ── A ──┤
└── C (2 votes)
Safe target (min_score = ⌈2V/3⌉):
Only follow branches with supermajority support
V = 5 validators, threshold = ⌈10/3⌉ = 4
┌── B (3 votes) ← Below threshold (3 < 4), pruned
J ── A ──┤
└── C (2 votes) ← Below threshold (2 < 4), pruned
Safe target = A (no children pass threshold)
This means the safe target lags behind the head. It only advances when a branch accumulates overwhelming support, making it resistant to temporary fluctuations:
Timeline of safe target vs head:
Slot: 10 11 12 13 14 15 16
Head: J A B D D E F
Safe: J J J A A A D
│
Safe target is always ────┘
at or behind the head
The safe target prevents 3SF-mini from finalizing unstable branches: without it, a slim-majority fork could reach justification and finalization before the network converges. By requiring supermajority support for the target, only branches with strong consensus can progress toward finality, even though validators’ head votes freely follow the newest chain tip.
Reorgs
A reorg (reorganization) occurs when the fork choice head switches from one branch to another. This happens when a competing branch accumulates more attestation weight than the current head’s branch.
REORG SCENARIO
──────────────
Before (head = D):
┌── B ── D ★ HEAD (weight 4)
J ── A ──┤
└── C ── E (weight 3)
New attestations arrive, 3 validators switch to E:
┌── B ── D (weight 2)
J ── A ──┤
└── C ── E ★ HEAD (weight 5) ← REORG!
The canonical chain changed from J─A─B─D to J─A─C─E
Blocks B and D are no longer canonical (but remain in the block tree).
Reorgs are normal during transient network conditions but should be rare in stable operation. They cannot cross a finalization boundary: once a block is finalized, it is permanently part of the canonical chain.
In ethlambda’s Lean Chain: Reorgs are detected by checking whether the old and new heads share a common prefix, and tracked via Prometheus metrics (
lean_fork_choice_reorgs_total).
LMD-GHOST Variants
LMD-GHOST is one of several variants that have been proposed and studied. Understanding the design space helps explain why LMD was chosen.
| Variant | Full Name | What Counts | Trade-off |
|---|---|---|---|
| IMD | Immediate Message Driven | All attestations ever | Maximizes data but creates unbounded storage and is vulnerable to long-range rewriting |
| LMD | Latest Message Driven | Only each validator’s most recent attestation | Good balance: one vote per validator, reflects current view, bounded storage |
| FMD | Fresh Message Driven | Only attestations from current/previous epoch | Prevents very old attestations from influencing fork choice, but validators who go offline lose influence immediately |
| RLMD | Recent Latest Message Driven | Latest attestation, but only if within N epochs | Parameterized compromise between LMD and FMD; tunable staleness threshold |
The Ethereum consensus mini-spec originally used IMD-GHOST but switched to LMD in November 2018 due to superior stability properties.
IMD: All attestations count LMD: Only latest counts
V0: slot 5 → head B V0: slot 5 → head B (overwritten)
V0: slot 8 → head C V0: slot 8 → head C ← active
V0: slot 11 → head E V0: slot 11 → head E ← active
V0 contributes 3 votes! V0 contributes 1 vote.
Validators who attest more Equal influence regardless
often have outsized influence. of attestation frequency.
ethlambda Implementation Reference
This section covers ethlambda-specific details: scheduling, Beacon Chain differences, source code locations, and performance.
Tick-Based Scheduling
ethlambda divides time into slots, each split into 5 intervals. The slot lasts
4 seconds unless the network’s config file sets MILLISECONDS_PER_SLOT, so the 800 ms
intervals below are the default grid, as described in
Slots and Intervals. Fork choice operations are scheduled
at specific intervals:
ONE SLOT (4000 ms)
┌────────────┬────────────┬────────────┬────────────┬────────────┐
│ Interval 0 │ Interval 1 │ Interval 2 │ Interval 3 │ Interval 4 │
│ t+0 ms │ t+800 ms │ t+1600 ms │ t+2400 ms │ t+3200 ms │
├────────────┼────────────┼────────────┼────────────┼────────────┤
│ │ │ │ │ │
│IF PROPOSER:│ ALL │ aggregators│update_safe │accept_new_ │
│ accept new │ VALIDATORS:│ publish │_target() │attestations│
│ attestation│ produce │ aggregated │ │() │
│ + propose │ attestation│ attestation│ (2/3 vote │ │
│ block │ │ │ threshold) │update_head │
│ │ │ │ │() │
│update_head │ │ │ │ │
│() │ │ │ │ │
└────────────┴────────────┴────────────┴────────────┴────────────┘
◄─────────────── Slot N ──────────────────────────────────────────►
Detailed sequence:
Interval 0 ─ Slot boundary
│
├── Am I the proposer for this slot?
│ ├── YES: promote new → known attestations
│ │ run fork choice → update_head()
│ │ build block using known attestations
│ │ publish block to network
│ └── NO: (wait for block from proposer)
│
Interval 1 ─ Attestation production
│
├── All validators, proposer included:
│ └── Create attestation with:
│ • head = current fork choice head (newest head)
│ • target = derived from safe_target (for 3SF-mini)
│ • source = latest_justified checkpoint
│ Publish attestation to gossipsub
│
Interval 2 ─ Aggregation
│
├── Aggregators: aggregate their subnet's gossip signatures
│ └── Publish the aggregated attestation to gossipsub
│
Interval 3 ─ Safe target update
│
├── Recalculate safe_target using 2/3 supermajority threshold
│ └── Only blocks with ≥ ⌈2V/3⌉ attestation weight qualify
│ (V = total validators)
│
Interval 4 ─ End of slot
│
├── Promote new → known attestations
└── Run fork choice → update_head()
Differences from the Ethereum Beacon Chain
Lean consensus simplifies several parts of the Ethereum Beacon Chain’s fork choice. This
section describes ethlambda’s Lean Chain (ethlambda node); its Beacon Chain follower
(ethlambda beacon) runs the Beacon Chain’s own fork choice, proposer boost and
equivocation discounting included.
| Aspect | Lean Chain | Ethereum Beacon Chain |
|---|---|---|
| Vote weight | Equal: 1 vote per validator | Proportional to effective balance (up to 32 ETH) |
| Proposer boost | None | Yes: newly proposed blocks get temporary bonus weight |
| Equivocation handling | Not in fork choice | Equivocating validators’ weight excluded |
| Attestation frequency | Every slot | Once per epoch |
| Committee structure | All validators attest each slot | Validators split into per-slot committees |
| Slot duration | 4 seconds (configurable) | 12 seconds |
No proposer boost. The Beacon Chain adds a “proposer boost”, a temporary weight bonus given to newly proposed blocks to prevent balancing attacks. The Lean Chain has no such boost. Instead, proposer fairness is handled through the two-stage attestation pipeline (the proposer’s own attestation enters as “new”, not “known”).
No balance weighting. In the Beacon Chain, a validator with 32 ETH of effective balance has more fork choice weight than one with 16 ETH. On the Lean Chain, every validator has exactly equal weight (1 vote = 1 unit of weight), simplifying the algorithm and analysis.
No equivocation discounting. The Beacon Chain’s fork choice detects validators who equivocate (attest to conflicting blocks in the same slot) and excludes their weight. This addresses the “nothing at stake” problem where validators can costlessly vote for multiple forks. The Lean Chain’s fork choice does not do this.
Key Files
| File | Component |
|---|---|
crates/blockchain/fork_choice/src/lib.rs | Core LMD-GHOST algorithm (compute_lmd_ghost_head) |
crates/blockchain/src/store.rs | Store: head update, safe target, attestation promotion |
crates/blockchain/src/lib.rs | BlockChain actor: tick scheduling, interval dispatch |
crates/common/types/src/attestation.rs | AttestationData type (head, target, source, slot) |
crates/common/types/src/state.rs | Checkpoint (root + slot), State |
crates/storage/src/api/ | LiveChain table, StorageBackend trait |
Data Flow Summary
┌───────────┐ ┌──────────────┐ ┌───────────────┐
│ Gossipsub │────────▶│ New │──(promote)─▶│ Known │
│ (network) │ │ Attestations │ │ Attestations │
└───────────┘ └──────────────┘ └───────┬───────┘
│
┌───────────┐ │
│ LiveChain │──── { root → (slot, parent) } ───────────────┤
│ (index) │ │
└───────────┘ │
▼
┌─────────────────┐
┌───────────┐ │ compute_lmd_ │
│ Justified │──── start_root ───────────────▶│ ghost_head() │
│Checkpoint │ │ │
└───────────┘ └────────┬────────┘
│
┌──────┴──────┐
│ │
▼ ▼
┌──────────┐ ┌───────────┐
│ HEAD │ │ SAFE │
│ (min=0) │ │ TARGET │
└──────────┘ │ (min=2V/3)│
└───────────┘
Performance Characteristics
| Operation | Time Complexity | Description |
|---|---|---|
| Weight accumulation | O(A × D) | A = attestations, D = max chain depth from justified root |
| Greedy descent | O(D × B) | D = depth, B = max branching factor |
| Attestation promotion | O(V) | V = total validators |
| LiveChain lookup | O(B) | B = non-finalized blocks |
In practice with a small validator set and bounded non-finalized chain length,
all operations complete in sub-millisecond time. The // TODO: add proto-array implementation comment in the source indicates a future optimization path:
proto-array is an O(1) amortized fork choice algorithm used by most Beacon Chain
clients.
Beacon Chain state transition
The beacon module of ethlambda-state-transition
(crates/blockchain/state_transition/src/beacon/) implements the Ethereum
Beacon Chain consensus specification,
ethereum/consensus-specs, phase0 through gloas.
This is not the Lean consensus protocol the rest of the crate implements. The two
sit in one crate for the reason their types sit in one crate: a caller
dispatching on a state’s fork can then reach either chain’s rules without the two
living in separate dependency trees. They share nothing else. Nothing above
beacon in this crate reads anything inside it, and nothing inside it reads
lean’s own modules.
The containers, presets, configuration and primitives this module transitions are
in ethlambda-types, under its own beacon namespace, because
ethlambda-storage and the networking crates need those types and must not
depend on blst and c-kzg. What lives here is the behavior: the state
transition, the fork choice store, the helpers, and the two cryptography modules.
blst, c-kzg and num-bigint are therefore on the lean binary’s dependency
path, since ethlambda-blockchain, ethlambda-rpc and ethlambda-test-fixtures
all depend on this crate. The module is not feature-gated; that is the accepted
cost of holding both chains’ rules in one place.
The types are re-exported at the paths they had when they were defined here, so
crate::beacon::containers, crate::beacon::preset and crate::beacon::config
still resolve, and
ethlambda_state_transition::beacon::containers::X and
ethlambda_types::beacon::containers::X
are one type by one name. Two definitions travel in the other direction, for the
same reason they moved: fork_choice re-exports LatestMessage and PowBlock,
which ethlambda-storage persists, and helpers::misc re-exports
compute_fork_data_root, which the networking crate’s fork digest is built on.
One consequence reaches every match in the module. ethlambda-types gives
BeaconState and ForkName a Lean variant, so that one state type can carry
either chain, and every match on either needs an arm for it. No lean value can
reach this module: a fixture case’s fork is parsed from a directory name and
ForkName::ALL has no lean entry, and a beacon block or a deposit set produces a
beacon state. So those arms panic and name themselves, through
lean_state_unreachable/lean_fork_unreachable (src/beacon/lean_boundary.rs) and
lean_is_not_a_fixture_fork in the spec tests, rather than widening signatures to
a Result no correct caller could ever see. They are named arms rather than a
catch-all _, so a real fork added to the enum still breaks every match that has
to grow an arm for it.
Correctness is defined by the released spec test fixtures, pinned in the
Makefile.
Running the tests
make consensus-spec-tests # download the fixture tarballs (mainnet and minimal)
make cryptography-specs # download the BLS/KZG test vectors
make test-beacon # build and test once per preset
make test-beacon is the two per-preset targets, test-beacon-mainnet and
test-beacon-minimal, run one after the other. Either can be run on its own,
which is what CI does: the presets get a job each, so the two build and run
concurrently instead of end to end.
A run reads its own preset’s tree and opens nothing else.
CONSENSUS_SPEC_TESTS_CONFIGS narrows the download to that, and CI sets it.
Locally the default fetches both, so both presets can be run without
re-downloading. The BLS and KZG vectors are a separate, preset-independent
download: consensus-specs shipped them itself, under a general config,
through v1.7.0-alpha.12, but v1.7.0-alpha.13 (consensus-specs #5398) moved them
to ethereum/cryptography-specs, which make cryptography-specs fetches into
its own cryptography-specs/ tree.
The download is stamped with the release it came from and the configs it holds
(consensus-spec-tests/.version-<version>-<configs>), so changing either wipes
the tree and fetches afresh. Without the version in that name a bump would be a
silent no-op: the extracted directories already exist, so make would consider
them current and both presets would report green against the old fixtures.
Without the configs, a narrowed download would mark a tree missing one preset as
complete, and that preset’s next run would fail on a missing directory rather
than fetching what it needs. The wipe matters as much as the re-download, since
the tarballs unpack side by side into one directory, and layering a new release
over an old one would keep cases the new one deleted.
make test covers the whole workspace, in two halves, and still needs no
fixture download. The halves are all that test-node’s --exclude flags do;
nothing is dropped to keep the beacon suite out. Two gates keep it that way,
both tied to the beacon-spec-tests feature that make test-beacon turns on:
- The
beacon_spec_teststarget declaresrequired-features = ["beacon-spec-tests"], socargo testskips building it entirely. - The BLS and KZG fixture vectors are unit tests inside the module, which no
target gate reaches, so each carries
#[cfg_attr(not(feature = "beacon-spec-tests"), ignore = ...)]. They report as ignored rather than disappearing, which is whymake test-beaconalso passes--lib: 15 tests would otherwise never run.
Neither gate touches the module itself, which always compiles. What they stand for is the fixture tree, not the code.
Layout
| Module | Holds |
|---|---|
preset | Compile-time constants, mainnet or minimal |
config | Runtime configuration: fork schedule, churn limits, blob schedule |
constants | Values the spec fixes outright: domain types, flag weights, sentinels |
fork | ForkName, ordered oldest to newest |
primitives | Scalar aliases, Root, and the fixed-length byte strings |
bls | BLS12-381 via blst |
kzg | KZG via c-kzg, plus the two challenge functions c-kzg does not export |
containers | The BeaconState enum, fork-invariant containers, per-fork containers |
helpers | The spec’s helper functions |
stf | The state transition: slots, blocks, operations, epoch processing |
genesis | Building a genesis state from Eth1 deposits |
upgrade | Fork upgrades between per-fork state shapes |
fork_choice | The LMD GHOST store |
lean_boundary | The two #[track_caller] panics for the Lean arm of the shared enums |
The first five come from ethlambda_types::beacon and are re-exported by
beacon/mod.rs; the rest are defined here.
Three kinds of parameter
The specification distinguishes constants, presets, and configuration, and so does this module, because they have genuinely different lifetimes.
Presets (preset) set container sizes, so they must be compile-time
constants: SszVector<Root, { preset::SLOTS_PER_HISTORICAL_ROOT }>. The preset
therefore cannot be a runtime value or a type parameter. Stable Rust cannot take a
const-generic argument from a trait’s associated const, so the preset is selected
by Cargo feature: mainnet by default, minimal with preset-minimal. The test
target builds the crate twice and each run walks only its own fixture tree.
Configuration (config) is runtime, because the transition fixture suite
moves a fork’s activation epoch per test case. That is the concrete reason fork
scheduling is not a preset.
Constants (constants) are fixed by the specification and vary by neither.
Retuning a value without redefining a function
The specification expresses a retuned constant as a fresh name per fork
(MIN_SLASHING_PENALTY_QUOTIENT, then ..._ALTAIR, then ..._BELLATRIX) and
redefines the function that reads it, so each fork carries its own copy of
that function differing in one identifier. preset::retuned
(crates/common/types/src/beacon/preset.rs, reached here as crate::preset)
selects the value by fork instead, in four functions, so slash_validator and its neighbors stay a single copy each
rather than one per fork.
This is the one place in the crate where a preset value is chosen at runtime. It is sound because none of these values bound a container: they are divisors and multipliers in balance arithmetic, not container shape.
The minimal preset is not a uniformly scaled mainnet: it overrides only
phase0’s retuned values and inherits every later fork’s from mainnet
unchanged. So a value can move one way across a fork boundary under mainnet
and the other way under minimal: INACTIVITY_PENALTY_QUOTIENT falls from
phase0 to altair under mainnet and rises under minimal. A test asserting that
slashing gets uniformly harsher across the forks holds under mainnet and is
false under minimal, so preset::retuned’s own tests pin the fork-to-constant
mapping directly, which holds under both presets, rather than any numeric
relationship, which does not.
How forks are represented
Containers that change between forks are defined once per fork as plain structs that derive their SSZ encoding, decoding, and merkleization, and an enum wraps them:
#![allow(unused)]
fn main() {
pub enum BeaconState {
Phase0(phase0::BeaconState),
// one variant per fork that changes the state's shape
}
}
Deriving the SSZ traits is the whole reason for that shape. Container serialization and merkleization is the highest-risk code in the crate, and the per-fork field lists are not a growing tail:
previous_epoch_attestationsandcurrent_epoch_attestationsexist only in phase0. From altair on they are absent from both the encoding and the merkle tree, replaced in position byprevious_epoch_participationandcurrent_epoch_participation, which have a different type.latest_execution_payload_headerkeeps its name from bellatrix on, but is a different container only in bellatrix, capella, and deneb; electra and fulu reuse deneb’s shape unchanged, and gloas drops it for a block hash and the latest bid.- The field count crosses a power of two at electra, so the state’s merkle tree is five levels deep through deneb and six from electra on. The same logical field has a different generalized index in different forks. Gloas’s state is a progressive container (EIP-7495), whose tree has no fixed depth at all.
SignedBeaconBlock::Fuluwrapselectra::SignedBeaconBlockrather than afulutype of its own, since fulu changes no field of a block. It still needs to be its own variant: fulu changes how a block is processed, since the blob commitment limit becomes epoch-dependent, so code that dispatches on fork still has to tell a fulu block from an electra one even though both carry the identical payload. Gloas is a different case: its block body changes (a bid in place of a payload, payload attestations, the parent’s execution requests), soSignedBeaconBlock::Gloashas a type of its own.
| Fork | State fields | HTR leaves | Depth |
|---|---|---|---|
| phase0 | 21 | 32 | 5 |
| altair | 24 | 32 | 5 |
| bellatrix | 25 | 32 | 5 |
| capella, deneb | 28 | 32 | 5 |
| electra | 37 | 64 | 6 |
| fulu | 38 | 64 | 6 |
| gloas | 46 | progressive | none |
A single container with fork-conditional serialization would have to reproduce all
of that by hand. Derived codecs get it from the struct definition, which is
checked field by field against the spec text and then verified by ssz_static.
Since SSZ carries no type tag, the fork cannot be recovered from the bytes, so
decoding takes it from context: BeaconState::from_ssz(fork, bytes).
Not duplicating the state transition once per fork
The cost of per-fork structs is that a naive implementation would copy every function once per fork. Two things prevent that:
-
Accessors for the fork-invariant fields. About twenty of the state’s fields are identical in every fork. One
macro_rules!incontainers/mod.rsgenerates their read and write accessors from a single list, and that list doubles as the crate’s statement of which fields are fork-invariant: a fork that changes one moves it out of the list and gains an explicit match at each use site. -
Matching only where the spec diverges. Functions take the enum and use accessors, matching on the fork only where the specification itself changes behavior, so a match arm can be reviewed against the spec’s own diff.
No block-body enum
BeaconState and SignedBeaconBlock are the only enums; there is no
BeaconBlockBody enum or a body trait. Such a type would have to grow a
method or match arm per fork-specific field or operation list, which defeats
the point of dispatching once: a caller of body.attester_slashings() would
still have to know which fork it is dealing with to make sense of what comes
back, since electra’s attester slashings are not phase0’s. What actually lets
one function serve every fork is narrower and cheaper: process_block_header,
process_randao, and process_eth1_data each take the handful of fields they
read directly, rather than a whole body, so validating a header does not care
whether the body it came from also carries a sync aggregate or an execution
payload. A shared step earns its genericity by needing less, not by being
handed a bigger abstraction to see through. See stf/mod.rs’s module doc for
the full reasoning.
Crossing a fork boundary
process_slots performs the fork-boundary state upgrade itself, inside its
slot loop, at the first slot of the activation epoch, looping again in case a
single configuration activates two forks at the same epoch. state_transition
checks the block’s fork against the state’s own only after calling
process_slots, not before: a block at the first slot of an activation epoch
is legitimately post-fork shaped while the state arriving there is still
pre-fork, which is exactly what a fork transition consists of. Checking first
would reject every legitimate fork-boundary block and make crossing a fork
impossible.
Registry and balances
validators and balances hold one entry per validator, about 2.4M on
mainnet, so they dominate both the cost of a state root and the memory of every
cached state. They are ethlambda_ssz_tree::Lists rather than SszLists (from
gloas on, ethlambda_ssz_tree::ProgressiveLists, described in the Gloas section
below): persistent Merkle trees in the shape of the SSZ one, modeled on the
milhouse lists lighthouse keeps its state in.
- Nodes cache their hash, and states share nodes. A state derived from
another shares every subtree the block did not touch through
Arc, and its root rehashes only the touched paths. A state decoded from storage is rebased onto a cached relative, so it shares memory with it too. - Leaves and inner nodes are page-sized. A leaf holds a contiguous run of elements (32 validators, 512 balances), and an inner node up to 512 child pointers standing for nine binary levels at once. A lookup therefore crosses a handful of nodes rather than one per level of the registry’s depth, and iteration walks each leaf as a slice. A composite leaf also keeps each element’s root once hashed, and a rebuilt leaf carries over the roots of the elements it did not change, so one changed validator costs one validator hash plus a fold of the cached roots.
- Writes are buffered.
get_mut,IndexMutandpushrecord the new value;BeaconState::apply_pending_mutationsfolds everything buffered into the trees in one pass. The state transition calls it at the top ofprocess_slot, afterprocess_block, and at the end ofprocess_slots(epoch processing runs after the loop’s lastprocess_slot). A root taken with writes pending is still correct but computed on a throwaway copy, and the store flushes a state before caching it, since a sharedArccannot be flushed later. - A pass that writes most of a list uses the write cursor instead.
List::iter_cow(andtry_update_each, its closure form) walks the list in order and copies a leaf only when a write in it changes something, comparing against the original at the leaf’s end and keeping the original leaf, itsArcand its hash if nothing differs. Nothing goes through the buffer, so a full sweep avoids a map entry and a sorted copy per element. The changed leaves are swapped into the tree when the cursor is dropped, which rebuilds only the paths to them; a changed composite element’s root is computed then, on the state-transition thread. Writes pending when the pass starts are applied first.BeaconState::registry_muthands outvalidatorsandbalancesas disjoint borrows, so a pass can write one while reading the other. The balance application ofprocess_rewards_and_penaltiesand bothprocess_effective_balance_updatesrun this way: they move together, since once the rewards write bypasses the buffer, the effective balance pass would otherwise pay a tree descent perbalances()[i]instead of a buffer hit.
The access pattern matters. state.validator(i) and state.balance(i) are tree
descents, cheap next to a hash but far from an array index, and they add up
when a helper calls them once per validator:
get_total_active_balance used to build the active-index Vec and then read
every index back, and runs several times per block (once per attestation through
get_base_reward_per_increment, twice per sync aggregate, once per execution
request through the churn limits). In the 2026-09-28 import profile, those
per-index reads and the repeated whole-registry scans were the largest cost left
after hashing. A loop over the registry should walk iter_validators(),
zipped with iter_balances() where it needs both; get_total_active_balance
now does, in one pass.
Block processing does not even pay that pass more than once per epoch. The
total is cached in ActiveBalanceCache (crates/storage/src/active_balance_cache.rs),
held by the Store beside the committee cache and consulted through
ActiveBalanceCacheExt::total_active_balance. The key is the epoch plus the
block root at the last slot of the epoch before it: effective balances are
written only by that epoch’s process_effective_balance_updates, and every
activation or exit lands at least MAX_SEED_LOOKAHEAD epochs ahead, so no block
inside the epoch can move the total, and two states agreeing on that root agree
on it. The cache lives outside the state, so it adds no field to BeaconState
and no SSZ concern; callers with no store (spec runners, block production,
tests) hold a fresh ActiveBalanceCache::default(). Epoch processing, churn
helpers and fork choice keep calling the uncached one-pass
get_total_active_balance. In debug builds every hit is cross-checked against a
fresh computation.
Epoch steps 1-3 (justification, inactivity updates, rewards) follow that rule
through helpers::participation. One walk of iter_validators(), zipped with
the flat participation slices, produces each validator’s flags, its effective
balance and the balance totals (an EpochSummary); the three steps then run
over flat data and write back only the balances that changed. The summary lives
for those three steps only, since registry updates change what it read. The
per-block pulled-up tip needs just the totals, which ParticipationTotals
computes without allocating. The fixtures still call each step alone, so the
public step functions stay and each builds what it needs. The
specification-shaped implementation is kept in participation_reference and
stf::epoch::altair_reference for tests and debug builds: the driver runs it on
a clone for registries of up to 4096 validators and asserts the outcomes agree.
The leak flag is read once per step rather than once per validator, which fails
some states the specification accepts; see
Spec Deviations.
Electra and fulu go further (stf::epoch::single_pass): steps 2-9 (inactivity,
rewards, registry updates, slashings, pending deposits, effective-balance
updates) run in one loop over the registry, each validator on a local copy, with
the changes written back afterwards. What depends on more than one validator is
handled outside the loop: the exit-churn cursor is advanced locally in index
order, the pending-deposit queue is planned before the loop (top-ups of existing
validators inside it, deposits that create validators after it), and pending
consolidations run after it, with the effective-balance update of every
validator they name deferred until they are done. The genesis epoch and states
whose registry-sized lists differ in length fall back to the step-by-step path.
The debug oracle runs the specification-shaped steps on a clone and asserts the
two full states hash equal.
Gloas
Five of gloas’s EIPs shape this module: ePBS (EIP-7732), progressive lists and
containers (EIP-7688), proposer selection that skips slashed validators
(EIP-8045), increased exit and consolidation churn (EIP-8061), and builder
deposits and exits (EIP-8282). EIP-7843 and EIP-7928 only add payload fields
(slot_number, which verify_execution_payload_envelope checks, and
block_access_list). It is the one fork whose containers change shape
wholesale, so its code sits beside the pre-gloas code rather than inside it: stf/gloas.rs and
stf/epoch/gloas.rs (transition), helpers/gloas.rs (helpers), the gloas
section of fork_choice.rs (fork choice), and containers/gloas.rs in
ethlambda-types. Every gloas case that is not ignored passes on both presets.
The live follower follows gloas (ForkName::Gloas.is_followed()): the chain
actor imports gloas blocks and their payload envelopes, a gloas checkpoint
anchor is accepted (it starts EMPTY, with no payload known), and the clock
passing the fork epoch no longer forces the sync status to syncing. Fork choice
records the head node’s payload status next to the head root
(Store::head_payload_status, in memory, recomputed by the first head
computation after a restart), which the envelope by-range server reads to
withhold the head’s envelope while the head is the EMPTY node. The node is
still only a follower: the validator-client endpoints refuse a gloas epoch.
The envelope pipeline in the chain actor
Order of arrival is not order of validity: an envelope can beat its block, a
block can name a parent payload that has not arrived, and the columns an
envelope’s commitments need can lag both. beacon_envelope.rs holds each case
in a queue with its own bound, and beacon_columns.rs bounds the parked
sidecars of the same fork.
| Queue | Holds | Released when |
|---|---|---|
awaiting_import | an envelope whose block is stored but has no post-state (pending on a parent, held for columns or for its parent’s payload), checked against the stored block’s bid | the block imports; evicted with the block when it is discarded and at finality; bounded by the stored blocks, not by arrival slot |
awaiting_block | an envelope whose block is not stored at all | the block imports; evicted after ENVELOPE_AWAITING_BLOCK_TTL_SLOTS, capped per slot and per root |
awaiting_columns | a consensus-checked envelope whose bid has commitments and whose sampled columns are not all stored | the last column is stored |
awaiting_engine | a consensus-valid envelope the execution client gave no answer for | the per-slot redrive gets an answer (see beacon_engine.md) |
blocks_awaiting_parent_payload | a block whose parent is FULL with a payload not yet verified, capped per parent | that parent’s envelope verifies |
Data availability moves with the payload: a gloas block’s commitments are in its
bid, so the columns gate the envelope that reveals the payload, not the block’s
own import. A held block’s parent envelope is requested through
FetchRequest.needs_envelope the moment the block is held, whatever its source:
BlockSource::Sync also marks by-root answers and pending children a cascade
released, so no range batch can be assumed to bring the envelope, and p2p dedups
in-flight roots. The tick re-asks once a slot for every parent still missing
one, which is only what recovers a fetch that gave up; catch-up does not wait
for it.
The two envelope-before-block queues differ in who has vouched for the root.
A range batch’s envelopes arrive when all but its first block are stored and
unimported, so a stored block’s own bid is the authority: an envelope matching
it (envelope_matches_bid) goes to awaiting_import outside the per-slot cap,
and one contradicting it is dropped. Only a root with no stored block, which
nobody has authenticated, is held under the per-slot cap and the TTL.
Everything is swept at finality, except the finalized block’s own envelope.
Fork-choice events are timed at their arrival: the store clock is advanced to
the moment an envelope, block or payload vote reached the node before its
handler runs, because the tick fires once per slot and the timeliness deadlines
would otherwise be judged at the slot’s start. Payload votes already verified
in p2p are applied without a second signature check
(apply_verified_payload_attestation). The PTC vote vectors are not persisted
and are reseeded empty on resume; see data_storage.md.
The deferred payload
A gloas block has no execution payload. Its body commits to a builder’s bid
(process_execution_payload_bid), and the builder reveals the payload later in
a SignedExecutionPayloadEnvelope. The payload is applied by the next
block: process_parent_execution_payload compares that block’s bid against the
parent’s committed one to tell whether the parent was full or empty, and for a
full parent apply_parent_execution_payload runs the execution-layer requests
carried in parent_execution_requests and settles the builder’s payment. An
empty parent must carry no requests.
verify_execution_payload_envelope is the checking half that used to sit in
process_execution_payload, and it mutates nothing: fork choice calls it on a
stored state when an envelope arrives, and only the next block changes state.
Block processing therefore takes no ExecutionEngine, and process_withdrawals
takes no payload, since the expected withdrawals are a function of the state
alone.
Execution-layer requests moved with the payload. Deposit, withdrawal and
consolidation requests, and the two builder requests, are reached only through
apply_parent_execution_payload; gloas’s process_operations no longer calls
the first three. Their behavior did not change, so
apply_parent_execution_payload calls the fulu and electra processors instead
of keeping copies.
Progressive lists and the registry
EIP-7688 makes most lists in the gloas state and body unbounded, merkleized as a
chain of growing subtrees (EIP-7916), and makes the big containers progressive
(EIP-7495); a progressive container has no fixed depth. The other progressive
lists are libssz’s ProgressiveList. The registry is the exception:
validators and balances stay tree-backed, through
ethlambda_ssz_tree::ProgressiveList, which has the same persistent nodes,
update buffer and rebase as the bounded List and the same method set, so a
gloas state shares subtrees with its parent the way earlier ones do.
The registry therefore has two list types across the forks, and an accessor
cannot return either one. The whole-registry accessors were replaced by
element-level ones on BeaconState: validator_count, validator,
validator_mut, balance, balance_mut, push_validator, iter_validators,
iter_balances and validators_root. Each dispatches once and hands back an
element, a count or an iterator. Only the few that need the list itself
(iter_validators, iter_balances, rebasing onto a cached state, pointer
equality) match on the private Registry enum.
One function for both list types
The spec modifies a small part of what gloas touches, and the rest is electra’s text run against a differently typed list. Copying it would fork every fix, so the shared functions take narrow views instead:
| View | Reaches |
|---|---|
PendingQueueFields | The deposit balance cursor and the three pending queues, on electra, fulu and gloas |
ChurnCursorsMut | The four exit and consolidation churn cursors, on the same three forks |
gloas_state, gloas_state_ref | Gloas’s own concrete state, for fields only gloas has |
Both list types deref to a slice of the same element type, so a view can expose
slices and element-level methods and let electra’s process_withdrawal_request,
process_consolidation_request, exit and slashing paths serve a gloas state
unchanged. The churn limit is the one input that differs: electra’s cursor
functions read gloas’s own limits on a gloas state, which is a fork dispatch
inside them rather than a second copy. A function gets a gloas copy only where
the spec marks it modified. The rule for choosing between the views and a
projection is the one in “A recurring bug” below: project only when the return
type must be gloas’s own.
Fork choice
Gloas gives each block two branches to weigh: empty, where no payload was
revealed or attested as timely and available, and full, where the builder’s
payload was. A gloas fork-choice node is therefore (root, PayloadStatus)
with Empty, Full and Pending, where every earlier fork’s node is a bare
root. Nodes are derived on demand from the block tree, each block’s bid and the
set of verified payloads (get_node_children builds them), not stored, which
mirrors the spec’s Store.
ForkRules (PreGloas or Gloas) is the other new dimension. It is derived
from an exhaustive match on a fork or a container, and it is what
validate_on_attestation and update_latest_messages read, since gloas turns
data.index into the payload flag and orders votes by slot instead of target
epoch. A vote’s rules follow its own container, not the clock.
One head computation serves every fork: get_head_node builds a weight table
for the node tree in one bottom-up pass and descends over it. The current
slot’s fork selects the two rules that differ: from GLOAS_FORK_EPOCH on,
blocks carry the payload dimension their bids give them and the proposer boost
is gated by should_apply_proposer_boost; before it, every block is a single
full node and the boost applies whenever it is set. A block’s fork and parent
payload status are recorded at import (BlockPayloadLink), so the walk decodes
no block in the ordinary case; the two exceptions are in
spec_deviations.md. compute_head and the spec-literal
gloas_get_head are kept as the references it is tested against; the cost
argument is in the same page. The spec never describes a tree that
crosses the boundary, so that page also records the rule this module chose.
Two new handlers, on_execution_payload_envelope and
on_payload_attestation_message, join the spec’s list of ways to change the
store.
Fork choice has the same problem on a longer loop: get_head weighs every
validator’s latest vote by the voter’s balance at the justified checkpoint, and
the boost needs the total balance of that state’s active, unslashed
validators. Both read one
JustifiedBalances snapshot (ethlambda-types, held in the store’s
BeaconScratch) instead of a validator(i) descent per vote and a registry
scan per boost. It is built from checkpoint_state(justified) only, keyed by
the checkpoint it came from, so a moved justified checkpoint is a miss with no
hook on the writers; it holds a zero for validators that are inactive or
slashed, and the sum of what it holds, floored at one increment. That sum
leaves slashed-but-active validators out, as calculate_committee_fraction
does since consensus-specs #5679 (v1.7.0-beta.4), so it is not
get_total_active_balance.
Equivocations stay out of it: they are store-level and can change at any time,
so they are filtered per vote. get_weight stays as the specification’s
per-root definition, and the fork-choice fixture runner checks
compute_weights against it at every checks step.
The latest votes are stored the same way, as a dense table indexed by validator
index rather than a hash map, so the vote loop reads votes and snapshot
balances in index order. The table grows to the highest voting index, which is
why set_latest_message only takes indices from validated attestations (a
debug assertion bounds it).
Macros and traits
Two macro_rules! in the whole crate, both local, both replacing boilerplate that
would otherwise run to hundreds of near-identical lines: the fixed-length byte
strings in primitives, and the state accessors in containers. No procedural
macros beyond the SSZ derives, and no trait abstracting over BLS or KZG backends.
Cryptography
bls wraps blst, and kzg wraps c-kzg with the eip-7594 cell and column
functions fulu needs, both matching the versions ethrex uses.
Two details worth knowing:
- Every BLS function re-validates its inputs on each call rather than trusting a
wrapper validated once.
BlsPubkeyis deliberately unvalidated on construction, because deposit processing has to be able to hold a key that never validates, so nothing upstream guarantees a key is a subgroup-correct point. compute_challengeandcompute_verify_cell_kzg_proof_batch_challengeare implemented directly from the spec rather than called, because c-kzg keeps them as private steps of its own proof routines yet both have their own fixture handlers.
Fixture suites
consensus-spec-tests/tests/<config>/<fork>/<runner>/<handler>/<suite>/<case>/,
where <config> is the preset name. Container files are .ssz_snappy: SSZ
compressed with raw snappy, not the framed format. The BLS and KZG vectors
live in a separate tree, cryptography-specs/tests/<kind>/<handler>/<case>/,
flatter still: no fork and no suite level, since neither is preset- or
fork-dependent.
Runners discover their cases from disk rather than listing them, so a fixture release that adds cases needs no code change. Two properties are deliberate: a suite that matches no case fails rather than reporting green, and a container or fork pair with no implementation yet is counted and printed rather than passed over silently, so the output never implies more coverage than exists.
value.yaml goes unread in ssz_static. Using it would need a serde
implementation for every container and would pin down nothing that the serialized
bytes and expected root do not already.
Performance
The mainnet spec suite went from 1576s to about 106s. Two causes:
sha2’sasmfeature selects the CPU’s SHA-256 instructions. Without it,sha2compiles the portable scalar backend on aarch64 regardless of what the CPU supports, and merkleization is almost entirely SHA-256 compressions, so a spec fixture case running two whole-state merkleizations feels the difference more than anything else does.- Every fixture case is its own test, so the harness runs them concurrently at one case per work item. That is finer-grained than a suite-per-test layout could balance, where the slowest suite alone set the wall clock.
CPU time and wall clock separate the two cleanly, since running work in parallel cannot reduce the total CPU time it takes:
| Measure | Before | After | Factor |
|---|---|---|---|
| CPU time | 3041s | 928s | 3.3x, the hardware SHA-256 |
| Wall clock | 1576s | 106s | 14.9x, both causes together |
So parallelism accounts for the remaining 4.9x, taking the run from two of
eleven cores busy to about eight. The 3.3x understates the hashing change,
because the later run does strictly more work: transition went from failing
immediately to running every case.
Epoch-transition precompute
The first block of an epoch used to run process_epoch inline on the import
path (fork_choice::on_block, then process_slots), followed by the rehash
the state-root check needs. The beacon chain actor now does that work ahead of
time, on a blocking worker, and the import resumes from the result.
| Step | What happens |
|---|---|
| Trigger: head | An import leaves a block at the last slot of epoch E as head: precompute (E+1, head_root). Covers a late last-slot block. |
| Trigger: timer | Three quarters into the last slot of E (derived from the configured slot duration), if nothing for the current head is cached or running: precompute from the head, which is an earlier block when the last slot was skipped. |
| Gate | Skipped while the sync tracker says syncing. The head trigger skips a head more than a slot behind the wall clock; the timer trigger skips a head before E’s first slot, since the tracker reports a stalled network as synced and advancing such a head would replay every epoch it missed. Either way a worker crosses one epoch boundary at most. At most one worker runs; a key already cached is not recomputed. |
| Work | Clone the head state, process_slots to the first slot of E+1, flush pending writes, hash_tree_root (so the tree nodes’ hashes are memoized), send the state back to the actor. |
| Store | The store’s state cache under CacheKey::CheckpointState { epoch: E+1, root: head_root }. fork_choice::checkpoint_state derives the same value for that checkpoint (the checkpoint block’s post-state advanced to the epoch’s first slot), so attestation targets for E+1 hit it too. |
| Consume | on_block looks up CheckpointState { block_epoch, parent_root } when the parent’s slot is before the epoch’s first slot and the block is at or after it. A hit clones the entry, advances any remaining skipped slots, and applies the block with stf::apply_block; the post-state is identical to the inline path’s. |
| Miss | Today’s path: clone the parent state and run stf::state_transition. A block that arrives while the worker is still running takes this path too; the late result is stored and goes unused by it. |
The code lives in crates/blockchain/src/epoch_precompute.rs (triggers, worker,
actor handlers) and fork_choice::advance_to_epoch_start /
fork_choice::transition_block in the state-transition crate. The upgrade to a
new fork at the boundary happens inside process_slots, so the precompute needs
no fork-specific code. Gloas is covered by the same key: a gloas block’s
post-state never contains its own payload (the next block applies it inside
process_block, after process_slots), and the store keeps exactly one state
per root, so the entry for (E+1, root) is advanced from the same state an
import of a child of root would clone. Hits and misses, worker time and
trigger counts are exported as lean_beacon_epoch_precompute_*; see
metrics.
One test per fixture case
The suites were once one test apiece, each looping over its own cases and aggregating outcomes. That made every failure a failure of the whole suite: the name in the output was the suite’s, and a single bad case marked thousands of passing ones as part of one failed test.
A fixture case is not known until the fixture tree is walked, and #[test]
needs its tests at compile time, so the spec binary supplies its own harness
(harness = false, with libtest_mimic) and builds its test list at run time.
Each case is then named, counted, filtered, and attributed on its own, and
--test-threads, --ignored, --list, and substring filters all keep working.
A filter selects a whole suite as readily as one case, since a test’s name is
its runner followed by Case::id:
operations/electra/attester_slashing/pyspec_tests/basic_double
Two things that arrangement has to be careful about:
- A case whose fork this module does not implement becomes an ignored test rather than a missing one. The harness counts and names ignored tests, which says more than the tally the old aggregate printed.
- A suite that matches no case at all would otherwise contribute no tests, and a
run of nothing passes. The aggregate used to assert it had matched something;
that check survives as one
<runner>/matched_fixture_casestest per suite, so a stale runner or handler name still fails loudly.
Status
All eight forks, phase0 through gloas, have containers, fork upgrades, state transitions, and epoch processing. At the pinned fixture release every case that is not ignored passes on both presets:
| Preset | Fixture cases passed | Ignored | Lib tests |
|---|---|---|---|
| mainnet | 7175 | 1285 | 433 passed, 1 ignored |
| minimal | 49261 | 5403 | 434 passed, 1 ignored |
The lib counts are with beacon-spec-tests on, and include lean’s own unit
tests, since the two chains share one lib target. The one ignored lib test is
a timing measurement, not a fixture.
Minimal runs more cases because the release ships more fixtures for it, and it
runs two runners mainnet does not: genesis’s initialization and validity.
The release ships no mainnet genesis fixtures, so that whole runner is gated
behind the preset-minimal feature (tests/beacon_spec/genesis.rs).
The gossip validation vectors this crate’s gossip.rs runner covers come from
the same tree as every other suite. The node validates four topics under fulu’s
rules; every other case is ignored by name rather than left unmatched.
Nothing is ignored for being unimplemented in the state transition or fork
choice: HIGHEST_IMPLEMENTED_FORK is gloas, and every gloas case that is not
ignored runs. The ignored cases are deliberate exclusions:
LightClient*containers underssz_static, 5 container types across altair through gloas. The light-client sync protocol is a different layer from the state transition and fork choice, and is not in this module’s scope.networking/gossip_*cases outside what the node validates: every fork’s cases for topics it has no validator for (IGNORED_HANDLERS), non-fulu cases of the aggregate and attestation topics (block and data column validate fulu and gloas), and the vectors inSKIPPED, which assume a bad-block cache.- The
hezefixture tree, one ignored entry. See “Accounting for every fork directory” below.
Accounting for every fork directory
collect identifies a fork by parsing the directory name into a ForkName, and
a name that does not parse is skipped. That skip is silent in a way the
HIGHEST_IMPLEMENTED_FORK gate is not: the cases never become tests, so they are
not counted as ignored either, and nothing in the output says they exist.
gloas does not take this silent path: ForkName::parse("gloas") succeeds, so
its cases become ordinary tests, individually named and run. heze, the one
fork after gloas, is still unparseable and would be silently skipped without
this section’s own accounting.
So UNMODELED_FORKS names heze alone, it reports as one ignored test, and
fixture_forks/every_directory_is_accounted_for fails if the tree holds a fork
directory that is neither parseable nor listed. A release that adds a fork
forces a decision instead of quietly widening the gap.
| Suite | Covers |
|---|---|
bls (cryptography-specs) | Cryptography, fork- and preset-independent |
kzg (cryptography-specs) | Cryptography, fork- and preset-independent |
ssz_static | Every fork’s containers |
shuffling | Committee helpers |
operations, epoch_processing | Every fork’s operation and epoch sub-function |
sanity/blocks, sanity/slots, finality, random, rewards | Whole blocks and slots, end to end |
fork, transition | Fork upgrades, standalone and mid-chain |
genesis | Genesis initialization (minimal only, see above) |
fork_choice | The Store; see below |
Fork choice is fixture-verified
205 mainnet fork_choice cases pass, covering bellatrix’s on_merge_block/
terminal-PoW validation, the v1.7.0 proposer-boost dependent-root gate and
get_proposer_head’s proposer-equivocation branch, deneb’s blob data
availability, fulu’s column data availability, and gloas’s payload-aware
suites: get_head, get_parent_payload_status, on_block, on_attestation,
on_execution_payload_envelope, on_payload_attestation_message,
payload_data_availability, payload_timeliness and ex_ante. Gloas
accounts for 54 of the 205.
The release still ships no phase0 fork_choice suite: the earliest is
altair’s, built from altair-shaped states even though altair changes nothing
about fork choice itself (Store accepts a block from any fork this module
implements; see fork_choice.rs’s own module doc). Landing altair’s state
transition is what let this suite start running at all.
A note on earlier case counts
Counts reported before the runners landed were too high by roughly sevenfold.
collect_all_handlers walked the fork directories and then delegated to
collect, which walks them again, so every case was emitted once per fork
shipping that runner. The cases were always being run; they were counted many
times over. Both collectors now share one suite walk.
A recurring bug: projecting when an accessor was needed
Reach for a concrete per-fork projection, such as altair_state(state), only
when the return type must be that fork’s own. Reach through a BeaconState
accessor when the fields involved are shared. The trap is that a projection
like altair_state(state) matches only BeaconState::Altair, so it compiles
cleanly, passes the fork that introduced the field, and returns
UnsupportedForFork for every later fork that shares the exact same field
through a different variant.
This caused five separate bugs during implementation:
- Capella’s withdrawal sweep, projected to capella’s own state.
- Deneb reusing that same sweep, still projected to capella’s state.
- Altair’s participation fields, projected to altair’s state, so every later fork that also carries participation failed.
- Deneb’s
process_attestation, projected to deneb’s state. slash_validatorcalling phase0’sinitiate_validator_exitat electra, which also left electra’s EIP-7251 churn cursor unadvanced, mispricing every later exit processed in the same epoch.
Deliberate simplifications
- Light client containers and suites are out of scope.
ssz_genericexercises the SSZ library rather than the beacon containers, so it is not a gate.- The state transition mutates in place, as the specification does, so a state
passed to
state_transitionis left partly modified when a block turns out to be invalid. Callers that need the pre-state clone it first, which is what the fixture runners do. ExecutionEngine(stf::mod) stands in for a real execution client: justexecution_valid: bool, read straight from a fixture’sexecution.yamlrather than a payload actually validated.
What the fixture format asserts by omission
A case with a post state must succeed and land exactly on it. A case without
one must be rejected. The second half is what keeps the suites honest: an
implementation that accepted everything would otherwise pass every case that
ships a post-state. That rule lives in one place, check_transition, and every
state-comparing runner goes through it.
Two consequences worth knowing:
- Roughly a quarter of the phase0
operationscases are rejection cases, so the invalid path gets as much coverage as the valid one. - The
rewardssuite is the exception to state comparison: it compares the five per-component delta vectors directly. That is sharper, because the components are summed into balances, so a sign error in one component and a compensating error in another would produce correct balances from incorrect deltas.
Where the specification’s Python does more than it appears to
Two places where a faithful-looking transcription is wrong, both found by the fixtures rather than by reading:
get_matching_target_attestationsevaluatesget_block_rootinside a list comprehension, so it never runs when there are no attestations. That call has its own range assertion, which fails for the epoch a state sits at the start of. Hoisting it out of the loop rejects states the specification accepts.get_attesting_indicesreturns an unordered set, andget_indexed_attestationsorts it. A committee is a shuffled slice of the registry, so filtering it in position order yields attesters in shuffle order, which is almost never ascending.is_valid_indexed_attestationrequires sorted indices, so skipping the sort rejects every valid attestation.
Both are cases where the Python reads as if order or evaluation point does not matter, and both change the result.
The mainnet wire
ethlambda beacon follows the Ethereum Beacon Chain’s gossip. This page
describes what it puts on the wire; discovery.md covers the
discv5 stack it shares with lean, and cli.md the flags and the
startup order.
It follows, from its checkpoint anchor to the tip: a chain actor imports what gossip announces and what range sync fetches, and the two block protocols serve other peers from the same store. Fork choice learns its votes from block bodies and from the aggregate topic, which is how it sees votes for the current head rather than only ones at least a block old; see Aggregate attestations. It publishes only what its Beacon API clients submit (attestations, aggregates, blocks with their columns, operations) and relays validated gossip. It also custodies and serves a slice of the fulu data column matrix, and backbones a slice of the attestation subnets, both sized and selected by its own node id; see Data column sidecars.
Running it
ethlambda beacon \
--node-key ./node-key \
--gossipsub-port 9001
No flag is required, but that is only true for a built-in network:
ethlambda beacon on its own follows mainnet, since --network defaults to
mainnet. Sepolia, Hoodi and Platåberget are built in too, and any other network is a
--network naming a directory of published files (config.yaml and
genesis.ssz, see cli.md):
ethlambda beacon --network hoodi --checkpoint-sync-url https://checkpoint-sync.hoodi.ethpandaops.io
ethlambda beacon --network ./my-network --node-key ./node-key
genesis_time and genesis_validators_root, and therefore the fork digest,
are derived from the resolved network: a built-in network (mainnet,
sepolia, hoodi, plataberget) carries the two values as constants beside
its published config.yaml and bootnode list, and no genesis state; for a
loaded network they are read off that directory’s own genesis.ssz. Nothing
about startup touches the network to get there. discv5 is forced on and needs no flag either: published mainnet
bootnodes are largely seed-only, so a crawl is how a peer is reached.
The fork digest
Computed from the resolved network’s genesis state and fork schedule, never hardcoded as a digest. Startup derives the first one and a running node follows the schedule from there (see below):
epoch = (now - genesis_time) / (SECONDS_PER_SLOT * SLOTS_PER_EPOCH)
fork_version = the resolved network's fork schedule at epoch
base = compute_fork_data_root(fork_version, genesis_validators_root)
digest = base[..4] if epoch < FULU_FORK_EPOCH
= xor(base, sha256(le64(bp.epoch) ++
le64(bp.max_blobs)))[..4] if epoch >= FULU_FORK_EPOCH
bp is the latest blob-schedule entry at or before epoch, falling back to
(ELECTRA_FORK_EPOCH, MAX_BLOBS_PER_BLOCK_ELECTRA). The fulu branch is
EIP-7892’s, which is why mainnet’s digest is 8c9f62fe rather than fulu’s bare
82fae541.
Startup logs the next boundary’s epoch, wall-clock time, fork and digest. A
running node crosses it without a restart (beacon::transition in
ethlambda-p2p, driven by beacon::fork_schedule). Both fork activations and
blob-parameter-only forks move the digest, so both are boundaries. Following the
spec’s “Transitioning the gossip” rules, the next digest’s topics are joined
SUBSCRIBE_LEAD_EPOCHS before its boundary and the previous digest’s topics
are left UNSUBSCRIBE_LAG_EPOCHS after it. At the boundary the node
publishes and advertises the new digest, bumps its metadata sequence number
and moves its discv5 admission filter. While the window is open a gossip
message’s fork comes from its own topic’s digest, and a Status on either
digest is answered, but range sync starts only from a peer on the current
digest (or the next one, within clock skew of the boundary).
One gap: the ENR this node serves over discv5 keeps the eth2 entry it started
with, because ethrex’s DiscoveryServer offers no way to replace its record at
runtime. Peers already connected are unaffected; peers discovering the node
after a boundary read the stale digest and reject the record.
Gossip
Seven global topics, /eth2/{digest}/{name}/ssz_snappy, plus two subnet
families this node’s own node id selects a narrow slice of: the data column
subnets it custodies and the attestation subnets it backbones, both described
below. A gloas digest adds four more, execution_payload,
payload_attestation_message, execution_payload_bid and
proposer_preferences, which BeaconTopics::for_fork subscribes under gloas
digests and not under earlier ones (so one epoch before the fork, when the
digest is joined). An envelope is validated like a block
(its stateful half on the blocking pool) and goes to the chain actor on Accept
and on Queue, since the actor holds an envelope whose block is not imported
yet. A payload attestation is validated on the attestation permit pool and goes
to the actor on Accept only: a vote for a block not imported here is dropped,
since it is valid only within its own slot. Bids and preferences are the
builder market: they never reach the chain actor. Their rules live in
state_transition::beacon::gossip::{execution_payload_bid, proposer_preferences},
the cheap half runs in p2p’s triage (p2p/src/beacon/builder_market.rs) and the
stateful half on a permit pool of their own (builder_validation_permits), so
a bid burst cannot starve blocks, columns or attestations. Whatever is accepted
goes into the node’s one shared BuilderMarket, which the Beacon API and block
production read. Neither is ever queued: a message whose parent or dependent
block is unknown is ignored. A bid is capped at 196,932 decompressed bytes
(Reject(Malformed) above that). The node publishes both: a bid on the digest of
its slot, preferences on the digest of the proposal slot, which in the epoch
before gloas is the gloas digest already joined. Envelopes the node publishes
itself are recorded as known payloads, since gossip never echoes them.
| Topic | Decoded as |
|---|---|
beacon_block | SignedBeaconBlock, fork chosen by the block’s slot |
beacon_aggregate_and_proof | SignedAggregateAndProof, phase0, electra or gloas |
attester_slashing | AttesterSlashing, phase0 or electra |
voluntary_exit | SignedVoluntaryExit |
proposer_slashing | ProposerSlashing |
bls_to_execution_change | SignedBLSToExecutionChange |
sync_committee_contribution_and_proof | SignedContributionAndProof |
sync_committee_{subnet_id} | SyncCommitteeMessage (joined on request only) |
beacon_attestation_{subnet_id} | Attestation, phase0 or electra’s SingleAttestation (gloas keeps the latter) |
execution_payload | SignedExecutionPayloadEnvelope, gloas digests only |
payload_attestation_message | PayloadAttestationMessage, gloas digests only |
execution_payload_bid | SignedExecutionPayloadBid, gloas digests only |
proposer_preferences | SignedProposerPreferences, gloas digests only |
beacon_attestation_{0..63} is no longer wholly unsubscribed. This node holds
SUBNETS_PER_NODE (2 on mainnet) long-lived subscriptions from that family,
chosen by compute_subscribed_subnets(node_id, epoch), a public function of
this node’s own discv5 node id in the same way custody_columns is, so any
peer can compute the set without asking. p2p-interface.md asks every beacon
node to hold them whether or not it runs validators: phase 0 has no shard
committees, so nothing else gives these subnets a stable membership for
validators to publish into. The subscription is therefore owed to the network
rather than to this node’s head: this node verifies and relays what arrives on
it (see Gossip validation below) but never applies it to fork
choice. A lighthouse node with no validators behaves the same way, subscribing
to its own node-id backbone and verifying what arrives on it while
should_process_attestation keeps it out of fork choice unless a local
aggregator duty or --import-all-attestations says otherwise.
One deliberate shortfall: the set is computed once at startup and kept for the
process’s lifetime rather than rotating every EPOCHS_PER_SUBNET_SUBSCRIPTION
epochs. Lighthouse does the same, and reads that constant nowhere.
sync_committee_{0..3} is not in the startup set either: those subnets are
joined on demand, see Sync committee subnets.
blob_sidecar_{subnet_id} stays absent permanently: it is deneb’s format
for blobs, deprecated at fulu in favor of the column matrix below.
data_column_sidecar_{0..127} is no longer in that absent list. This node
subscribes to sampling_size(CUSTODY_REQUIREMENT) of them — the sampling size
floored by SAMPLES_PER_SLOT above CUSTODY_REQUIREMENT itself — chosen by
custody_columns(node_id, …), a public function of this node’s own discv5
node id (das-core.md), so any peer can compute the same set without asking.
NUMBER_OF_CUSTODY_GROUPS and DATA_COLUMN_SIDECAR_SUBNET_COUNT are equal
today, so a column is its own subnet with no reduction. That makes this
node’s total subscription count the seven global topics plus its sampling size
plus SUBNETS_PER_NODE, still far short of a full subscription to every
attestation, sync-committee and data-column subnet, and narrower still than
NUMBER_OF_CUSTODY_GROUPS columns of custody, which is what a supernode
would carry alone. A sidecar decodes as
the fork-agnostic DataColumnSidecar enum, its fork taken from the topic’s
digest (the bytes carry no tag): fulu’s names its block by a signed header and
inclusion proof, gloas’s by a slot and block root, with the commitments it is
checked against living in that block’s bid. The checks it passes before this node keeps or
forwards it are described just below. How a kept sidecar is later served back
out over req/resp is under Data column sidecars.
Every beacon message is held by gossipsub until it has a verdict
(validate_messages() is on for this wire only). Blocks, data column
sidecars, aggregates, subnet attestations and the four operation topics are all
validated by fulu’s gossip rules, or (operations aside) gloas’s modified ones
from the fork on (ethlambda_state_transition::beacon::gossip, one module per
topic family): the checks that need no state run inline in the p2p actor, the
rest on a bounded spawn_blocking task whose verdict comes back to the actor
(crate::beacon::verdict). Two permit pools bound how many of these run at
once, so a burst on one family cannot starve another:
gossip_validation_permits for blocks and columns,
attestation_validation_permits for aggregates, subnet attestations and the
operation topics. A
mainnet slot carries up to MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE aggregates alone, arriving every slot rather
than only during a range sync, which is why that traffic needs a pool of its
own rather than sharing the block and column one. Accept propagates the
message; a message whose dependency is not ready yet is IGNOREd. See
Aggregate attestations for the two topics with
their own section.
Blocks and data column sidecars are, either way, handed to the chain actor,
which parks what it cannot import yet and imports the rest immediately, such
as a sidecar whose slot merely falls outside its parent state’s proposer
lookahead. An aggregate reaches the chain actor only on Accept, carrying the
attesting indices gossip validation resolved. A subnet attestation never
reaches it at all, on any outcome: verifying and relaying it is the whole of
what this node owes the topic (see above), so there is nothing further for the
chain actor to do with one.
The rules above are fulu’s, except that blocks, data columns, aggregates and
subnet attestations also have gloas rules. A gloas aggregate has electra’s bytes
but its own progressive Attestation, so it decodes to its own
SignedAggregateAndProof::Gloas (the signed root differs); a gloas subnet
attestation is electra’s SingleAttestation unchanged. In both, data.index is
the payload flag (0 or 1), and a vote for the full payload (1) is IGNOREd until
the block’s envelope has been seen and its payload validated
(verify_attestation_payload_status). Gloas is a followed fork, so a block at
a gloas slot that fails to decode is REJECTed like one at any other followed
fork; unsupported_fork is kept for a fork the node does not follow
(ForkName::is_followed), none today. A gloas aggregate’s aggregation bits are
an unbounded progressive bitlist, so one longer than electra’s type bound is
REJECTed first, reading its length without expanding it, since expanding is
what the seen-set check does before any other.
Three deliberate departures from the gloas gossip rules, each for want of state
this node does not keep. Where the specification rejects a message whose block
failed validation, this node (having no bad-block cache) cannot tell that from
a block it has not finished importing, so it queues or ignores instead; the
matching reject_block_failed_validation vectors are in SKIPPED. An
execution_payload whose block state is not in the cache p2p reads (a parent
imported moments ago, say) is queued and forwarded to the chain actor, which
verifies every envelope itself, without being propagated. And a
payload_attestation_message is applied to fork choice after p2p verified its
signature, with the actor re-checking only the store-state conditions (known
block, current slot, committee seat).
voluntary_exit, proposer_slashing, attester_slashing and
bls_to_execution_change are validated in p2p like aggregates, by
gossip::operations: the specification’s validate_*_gossip functions
(consensus-specs v1.7.0-beta.1), run against the fork-choice head’s post-state
as it is, with no state advance and no process_*. The seen records (the
first valid exit or credential change per validator, proposer slashing per
proposer, and the validators an attester slashing slashes) live in
SeenOperations, are recorded only on Accept and never pruned, so an
operation a block already included and a peer re-gossips is IGNOREd rather than
REJECTed. An attester slashing’s range and slashability checks run before its
BLS checks. What Accepts goes into Store::operation_pool, which block
production packs from (see rpc.md). The rules
are electra’s shapes against an electra or fulu head: under a gloas head every
operation is IGNOREd (state_unavailable), and a gloas-shaped attester
slashing is IGNOREd (no_consumer) at decode, like a phase0-shaped one.
sync_committee_contribution_and_proof is decoded, logged at debug, and
IGNOREd, since nothing consumes it; an undecodable payload on any topic is
REJECTed.
Publication runs on the Beacon API’s POST /eth/v2/beacon/blocks, the only
path that puts a block of this node’s own on the wire. It gossips the block on
beacon_block, then, if it carries blobs, all 128 data column sidecars, each on
data_column_sidecar_{compute_subnet_for_data_column_sidecar(index)} whether or
not this node custodies that subnet (publishing to a subnet one has not joined
goes through fanout), all under the digest the block’s slot falls in. Operations
posted to the pool/* routes are published on their topics the same way, under
the digest of the wall-clock slot.
Peer scoring
Gossipsub peer scoring is on for this wire only
(crate::beacon::scoring). The parameters are lighthouse’s
(gossipsub_scoring_parameters.rs), ported formula for formula; Grandine,
Teku and Lodestar use the same constants, so thresholds mean the same thing
here as on most of mainnet.
| Threshold | Score | Effect |
|---|---|---|
| gossip | -4000 | no IHAVE/IWANT to or from the peer |
| publish | -8000 | left out of this node’s publishes |
| graylist | -16000 | every RPC ignored, and the peer is disconnected |
| Topic | Weight | Mesh deliveries (P3) |
|---|---|---|
beacon_block | 0.5 | scored |
beacon_aggregate_and_proof | 0.5 | scored |
beacon_attestation_{0..63} | 1/64 each | scored |
voluntary_exit, proposer_slashing, attester_slashing | 0.05 each | off |
Columns, sync committee contributions and BLS changes carry no topic
parameters. Only Prysm scores columns. This node Ignores every contribution
and change (no consumer), and a delivery is only credited once accepted, so
scoring P3 there would penalize every mesh peer for this node’s own gap. All
64 attestation subnets get parameters, not just the backbone ones, because
aggregator duties join others at runtime.
Three differences from lighthouse:
mesh_nis this node’sD(8), not lighthouse’s 5. It only enters the first-message-delivery cap.- P3 waits for this node to keep up. Lighthouse joins these topics only
once synced, so it has no mesh while it catches up. This node subscribes at
startup, and while catching up it
Ignores every aggregate and attestation voting for a block it has not imported. A mesh peer credited with no aggregates scores below the graylist, so a restart would graylist the whole aggregate mesh because of this node’s lag.MeshDeliveryGateturns P3 off while the head lags the wall clock by more thanMESH_DELIVERY_MAX_HEAD_LAGslots, and back on only after an epoch within that lag, so the counters have refilled. While P3 is off its threshold and weight are zero but its cap is not, so the counters keep counting to their real ceiling.SyncStatuscannot serve as this gate: on the mainnet follower it reportedsyncedthrough a 10-minute catch-up up to 98 slots behind, since its network-stall rule reads a lagging freshest-known block as a stalled network. - Leaving a topic waits out the peers’ backoff. Gossipsub’s default
backoff after unsubscribing (10 s) is shorter than its prune backoff
(60 s), and a rust-libp2p peer that sees a mesh peer unsubscribe backs off
from it for the longer one, which the leaver’s PRUNE cannot shorten. An
aggregator subnet left when its duty expires and rejoined within that
window for the next one (a duty in the last slots of epoch N, none in N+1,
one in N+2) re-grafts into peers that reject the graft and charge a
behaviour penalty.
PRUNE_BACKOFFsets both backoffs to the prune value, so the node waits out the same window and is not penalized. Neither consensus-specs nor lighthouse sets these: the spec defers gossipsub v1.1’s extra parameters, and lighthouse keeps the defaults.
The block, aggregate and attestation parameters depend on the active
validator count, so the p2p actor rebuilds them every slot from the head’s
pinned current-epoch shuffling (CommitteeCache::head_current_committees)
and hands them to the swarm. The swarm starts on lighthouse’s placeholder (32
validators, P3 off) until the first refresh, which runs at startup. Every 10
s the swarm task disconnects peers below the graylist. There is no ban list:
a peer’s negative score is kept for retain_score (100 epochs) after it
leaves, so a peer that reconnects comes back graylisted and is dropped again.
Around a fork-digest boundary the node holds two digests’ topics, and every held digest gets the whole parameter set (exits and slashings included), scored by its role:
| Digest | Topic weights | P3 |
|---|---|---|
upcoming, joined SUBSCRIBE_LEAD_EPOCHS ahead | as above | off: nothing is published under it yet |
| current | as above | gated as above |
previous, held UNSUBSCRIBE_LAG_EPOCHS after | 0 | off |
- Parameters before joins.
transition::applyhands the swarm every held digest’s parameters before it subscribes, on the same command channel. Gossipsub tracks a mesh peer on a topic only if the topic had parameters when the peer was grafted; parameters installed later never reach that mesh. - The previous digest weighs nothing. Its traffic moves to the new
digest at the boundary, so its delivery counters drain. Still scored, the
aggregate topic alone would graylist an honest mesh peer 53 slots after the
boundary (at 1M validators), before the topic is left, and leaving it would
charge every mesh peer the sticky P3b. Lighthouse zeroes the same weights
(
remove_topic_weight_except). - A switch restarts the warmup. The new digest’s counters start empty,
but its mesh, formed before the boundary, can already be past the
deliveries’ activation (one epoch for blocks and aggregates). P3 on the new
digest therefore waits
MESH_DELIVERY_WARMUP_SLOTS, as after a catch-up. Lighthouse joins two slots ahead, so activation alone covers it there.
Aggregate attestations
beacon_aggregate_and_proof reaches fork choice. It is how a follower learns
votes for the current head rather than only the votes a block body carries,
which are always at least one block old. beacon_attestation_{subnet_id},
covered in the same section below, never does: this node relays its backbone
subnets without ever applying what arrives on them.
Both topics are validated the same way blocks and columns are, in
ethlambda_state_transition::beacon::gossip::{aggregate,attestation}: cheap
conditions (seen cache, propagation window, data.index == 0, exactly one
committee named) run inline in the p2p actor; the rest run on a blocking
thread, in this order:
- The vote’s block is known (
Store::has_block); if not, IGNORE. - Committees, signatures and ancestry all resolve against the vote block’s
own cached post-state
(
store.cached_state(CacheKey::BlockState(beacon_block_root))), not the specification’s head state and not the target checkpoint’s state either. Three reasons converge: it is the attested chain’s own state, so its shuffling is the one the attesters were actually assigned, where the head (or a checkpoint reached by replaying a different branch) can name the wrong one; it is anO(1)cache read rather than a lookup or a replay; and its ownblock_rootsanswers both ancestry questions without aStore::block_index/LiveChainscan. - The signatures that need only a pubkey, before any committee derivation: an aggregate’s selection proof and aggregator signature, an attestation’s own signature. An unknown validator index REJECTs here rather than paying for a committee lookup first, since a forged message must not be able to reach a shuffling derivation.
- The committees, through the
Store-held cache both actors share; thenis_aggregator(rewritten to take a committee length rather than a pre-fetched cache), committee membership, and, for a subnet attestation, the subnet match (compute_subnet_for_attestation). - An aggregate’s own signature, over the indexed attestation built from that committee. The indices it verifies travel to the chain actor; it never rebuilds them.
- Ancestry against the vote state’s
block_roots: the target is the vote block’s ancestor at the target epoch (REJECT), and the finalized checkpoint is an ancestor of the vote block (IGNORE).
The seen caches (SeenAggregates, keyed both by (target_epoch, aggregator_index) and by (hash_tree_root(data), committee_index);
SeenAttestations, by (target_epoch, attester_index)) live in p2p now,
recorded only on Accept by verdict::settle, and are bounded by capacity
(an LRU, like the block and column seen caches) rather than pruned on
finality: a peer must not get to grow either one just by outlasting
finalization. Recording only after the signatures verify is still what keeps a
garbage aggregate from being able to censor a genuine one for the rest of the
epoch by claiming its (epoch, aggregator) pair first; lighthouse splits the
same way, reading its observed-sets in verify_early_checks and writing them
in verify_late_checks.
Only an aggregate that gossip Accepts reaches the chain actor, carrying the
attesting indices already resolved; the actor never re-derives a committee or
checks a signature for this topic. What is left for it:
- A lighter, actor-local applied-bits gate, a running union of aggregation
bits already applied per
(target_epoch, hash_tree_root(data), committee_index). Not a spec seen-set (p2p owns that one now); it exists so a committee’s other aggregators, each individually accepted by gossip because each is a first-seen, valid message, do not all pay forapply_verified_aggregatewhen the first one already covered their bits. Pruned by the store’s own clock to the current and previous epoch, not by finality, so a stalled chain cannot make this grow without bound either. - The deferral queue.
validate_on_attestationrequiresget_current_slot(store) >= data.slot + 1, and aggregates are published two thirds of the way through the slot they vote for, so every one arrives too early. Without the queue this topic would apply approximately nothing. It drains once per beacon tick, between the clock advancing and the head being recomputed, so released votes are in fork choice before that tick’s head is chosen. The specification licenses this directly (“consider scheduling it for later processing in such case”) and lighthouse has the same queue. Held entries now carry their gossip-resolved attesting indices alongside them, so a drain applies them without recomputing anything.
A subnet attestation is fully verified by this same pipeline and then simply
dropped: nothing forwards it to the chain actor, on any outcome, matching a
lighthouse follower with no validators, which verifies and relays its own
backbone subnets while should_process_attestation keeps them out of its fork
choice.
Sync committee subnets
Sync committee gossip is joined on demand, never at startup. A validator
client’s POST /eth/v1/validator/sync_committee_subscriptions reaches
P2PServer as subscribe_sync_committee_subnets, which subscribes each
subnet (sync_committee_{0..3}) immediately under every digest the node holds,
until the request’s until_epoch (exclusive). Validating all four subnets
permanently would cost every follower up to a committee’s worth of BLS
verifications per slot, so nothing is joined without a request.
| Piece | Behaviour |
|---|---|
| State | BeaconWire::sync_committee_subnets: subnet to until_epoch, extended with the later of two requests; ids of 4 or more are dropped |
| Advertised | MetaData v2 and v3 syncnets is built from that set, and the sequence number moves whenever the set does. The ENR carries no syncnets entry: ethrex’s DiscoveryServer cannot replace the served record at runtime, the same known gap as the eth2 entry |
| Left | the 12 s sweep leaves a subnet once the wall epoch reaches its until_epoch, and prunes the sync committee pool |
| Fork boundaries | beacon::transition::apply subscribes the set under each digest it joins and unsubscribes it under each it leaves, like the aggregator attestation subnets |
| Validation | the cheap half runs inline, the stateful half on spawn_blocking under SYNC_VALIDATION_PERMITS, a pool of its own since the burst coincides with the attestations’. An accepted message is pooled with the seats its signature verified; neither it nor a contribution reaches the chain actor |
| Contributions | sync_committee_contribution_and_proof is always subscribed, validated, relayed and pooled for block production |
| Publishing | the Beacon API pools its own submissions first, then publishes on each subnet its validator holds a seat in, under the digest the message’s slot names, and marks them seen so a peer’s echo is ignored |
Request/response
| Protocol | Direction |
|---|---|
status/1, status/2 | both |
ping/1 | both |
metadata/1, metadata/2, metadata/3 | both |
goodbye/1 | inbound; the reason code is logged and the stream closed |
beacon_blocks_by_range/2 | both |
beacon_blocks_by_root/2 | both |
data_column_sidecars_by_root/1 | both |
data_column_sidecars_by_range/1 | both |
execution_payload_envelopes_by_range/1 | both |
execution_payload_envelopes_by_root/1 | both |
The two data column sidecar protocols are registered because this node
custodies the columns its node id selects and can answer for them out of
Table::DataColumns (see data_storage.md); see
Data column sidecars for what each serves and asks
for. The blob sidecar protocols stay absent: nothing here custodies a whole
blob, only the erasure-coded columns fulu derives it into, and a registered
protocol with no implementation behind it is an untested encoder peers can
reach.
Only version 2 of the two block protocols is registered. Version 1 is deprecated
by the spec, which lets a client answer it with an empty list, and its chunks
carry no <context-bytes>, so serving it would mean a second response encoder
for a shape no mainnet peer needs.
The Status this node sends is derived from its anchored store, so a peer
reading it has a reason to ask for the blocks the two block protocols serve.
There is one window where every checkpoint is still zero, before fork choice has
inserted the block KEY_HEAD names; lighthouse’s relevance check exempts a zero
finalized_root, so that reads as “peer is syncing” rather than as a
conflicting chain. Either way it is answered in the version the stream asked
for: a v1 body on a v2 stream is eight bytes short and the codec refuses to
write it, which drops the connection of every peer that opens the handshake on
status/2.
The two block protocols
Both request bodies have a shape that cannot be guessed from the response they produce:
| Protocol | Request body | On the wire |
|---|---|---|
beacon_blocks_by_range/2 | (start_slot, count, step) | 24 bytes, an SSZ container |
beacon_blocks_by_root/2 | List[Root, MAX_REQUEST_BLOCKS] | 32 * n bytes, an SSZ field |
step is deprecated and must be 1, but it is still on the wire: altair says the
v2 request is unchanged from phase0’s, and lighthouse pins this protocol’s
request length to min == max == 24 bytes, so a two-field body is refused before
it is ever decoded. A body naming any other step is answered with
INVALID_REQUEST, not dropped: the field is carried up to the handler precisely
so the peer learns which rule it broke, where refusing at decode would close the
stream with nothing on it. Phase0 does permit answering a larger step with a
single block, but that leniency is for a transition that finished years ago, and
the spec’s requirement on the requester is a MUST.
The root request is a bare list with no container around it, because the spec says this body “MUST be encoded as an SSZ-field” where the range body is “an SSZ-container”. The difference shows up on the wire: a container holding one variable-length field prefixes it with a four-byte offset.
Neither of those differences reaches above the codec. What a peer is asking
for is the same question on either chain, so req_resp::messages owns the
shared BlocksByRangeRequest, and each chain’s encoder converts to and from its
own wire container: lean’s has no step and fills it with 1 on the way in,
beacon’s carries all three fields. The shared struct is deliberately not
SSZ-derived, so it cannot be written to either wire by accident. The root list
needs no conversion at all, since beacon::primitives::Root is H256 and
both lists are SszList<H256, 1024>; only lean’s container comes off.
Which chain a request arrived on is not on the message either: a node speaks one
wire for its whole life, so the dispatch reads Wire::is_beacon instead. The
same holds for the answer. ResponsePayload::Blocks is one variant for all four
block protocols, because SignedBeaconBlock already carries a Lean variant
and both stores hand blocks back in exactly that type. The narrowing to lean’s
own SignedBlock happens once, where a fetched block is handed to the chain
actor, and lean::encoding::write_blocks_response refuses to put a block of any
other fork on a lean stream.
Two ceilings apply, and they are not the same number. MAX_REQUEST_BLOCKS
(1024) is what an inbound request is judged against, because it is the widest a
peer may ever legitimately have been built to ask for; a count above it is
refused as INVALID_REQUEST. MAX_REQUEST_BLOCKS_DENEB (128) is what an
outbound request is built to and what an answer is truncated to, since the spec
allows “Clients MAY limit the number of blocks in the response” and a peer
asking for more than 128 is running older logic rather than misbehaving.
A range answer comes off the canonical branch in ascending slot order, with
empty slots skipped: BlockRoots holds one root per slot on the branch ending
at the current head, so a sibling block at a slot the head does not descend from
is never read. A root answer follows the order the roots were asked in, and
leaves out any root this node does not hold.
Context bytes
Every successful block chunk carries a four-byte ForkDigest between the result
byte and the payload:
response_chunk ::= <result> | <context-bytes> | <encoding-dependent-header> | <encoded-payload>
The digest is the one that block’s own epoch computes to, not the one this node
is running on, so a backfill labels each chunk with its own fork. On an error
chunk the field is empty, which is why the reader only reads it after a SUCCESS
byte. Serving history therefore needs genesis_validators_root as well as the
current digest, which is why BeaconWire and the codec both carry it.
Going the other way, the fork a chunk decodes under comes from the slot inside
the payload, through the same decode path gossip uses, and the context bytes
are then checked against the digest that slot implies. That is a stronger test
than using them as the decoder key: it catches a peer whose
genesis_validators_root or fork schedule differs from ours, which is exactly
what the digest exists to say and is not otherwise visible until a signature
fails. A mismatch ends the stream, logged at warn with both digests: the one way to
reach it in good faith is a blob schedule of ours that has fallen behind the
network’s.
Execution payload envelopes
Both gloas envelope protocols are registered Full: the server answers, and
the client (req_resp/envelope_client.rs) asks. A chunk carries one
SignedExecutionPayloadEnvelope under the fork digest of its block’s epoch.
The envelope has no slot of its own, so the epoch comes from
payload.slot_number, which verify_execution_payload_envelope pins to the
block’s slot. A chunk whose digest names no gloas-or-later scheduled fork, or
not the digest its slot implies, ends the stream.
| Protocol | Served from |
|---|---|
execution_payload_envelopes_by_root/1 | a point lookup per root in Table::ExecutionPayloadEnvelopes; unknown roots are left out, answers follow the order asked |
execution_payload_envelopes_by_range/1 | the canonical blocks of the window (BlockRoots), each envelope kept only when the next canonical block builds on its payload; the head’s own envelope only when the head node is FULL (Store::head_payload_status, which pairs the status with the head root it describes, so a stale status reads as unknown and the envelope is withheld) |
Both are bounded by MAX_REQUEST_PAYLOADS: a by-root list over it fails to
decode, and a by-range count over it is truncated, which the specification’s
“Clients MAY limit the number of payload envelopes in the response” allows.
A by-range request starting below Store::anchor_slot gets
RESOURCE_UNAVAILABLE, and a window before gloas is simply empty.
The client asks in two situations. By root: the chain actor’s
FetchRequest.needs_envelope (a block whose child waits on its payload) starts
a lookup with the block fetch’s dedup, peer choice and retry ladder; a give-up
is recovered by the actor’s per-slot re-ask. By range: after a
beacon_blocks_by_range/2 answer for a span that reaches gloas has been handed
to the actor, the same peer is asked for that span’s envelopes, and range sync
holds the next block request until that answer is checked and forwarded (or has
failed). The pause exists because the actor keeps an envelope whose block has
not imported only briefly and in small numbers, so a batch’s envelopes must
arrive before the next batch’s blocks do. Everything fetched goes through the
same rules as an execution_payload gossip message
(beacon/envelope_checks.rs) before the actor sees it; a Queue verdict is
forwarded too.
Data column sidecars
Both protocols are registered Full, since with the columns on disk this node
can serve every slot it has custodied:
| Protocol | Served from |
|---|---|
data_column_sidecars_by_root/1 | a point lookup per identifier: the slot is recovered from BlockHeaders off the named root, then each requested column is read straight out of Table::DataColumns |
data_column_sidecars_by_range/1 | a prefix scan over the slot range, restricted to each slot’s canonical root (BlockRoots) and filtered to the requested columns |
Restricting the range answer to the canonical root matters because
Table::DataColumns is never pruned and gossip only asks that a sidecar’s
block name a known, finalized-descendant parent, not a canonical one: a live
fork can leave both siblings’ columns stored at the same slot, and an
unscoped scan would leak the losing side into every future range answer
covering it.
Both per-item lookups are lenient the way the block protocols are: a column
this node never custodied, or a root it holds no header for, is left out of
the answer rather than turned into an error, since the spec’s own words are
“Clients MUST respond with at least one sidecar, if they have it.” A
data_column_sidecars_by_range/1 request starting before Store::anchor_slot
gets RESOURCE_UNAVAILABLE instead of a merely-empty answer, the same
distinction the block-range handler draws and for the same reason: an empty
window this node’s canonical chain skipped is normal, but a window below where
this node’s chain begins cannot be served from any point onward. That floor is
the same value Status advertises as earliest_available_slot, so the refusal
and the advertisement cannot disagree.
max_request_data_column_sidecars() (MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS) bounds what one request may ask for; an answer over that
is truncated, not refused, matching how the block protocols treat a peer
built to an older, wider ceiling.
Asking happens on two paths, bulk and per block.
The bulk one rides with range sync: every BeaconBlocksByRange batch sends a
data_column_sidecars_by_range/1 for the same slot span, covering this node’s
whole custody set. The spec names this protocol for exactly that —
“DataColumnSidecarsByRange is primarily used to sync data columns that may
have been missed on gossip and to sync within the
MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS window” — and it is what keeps a
follower backfilling from a checkpoint anchor from needing the per-block path
at all: the columns are normally already stored by the time their block reaches
the availability gate. Nothing waits on the answer, so a short, empty or
refused one costs nothing and is not retried; the per-block path is the
backstop.
Since the column request is aimed by custody, a batch whose last slot is fulu
or later is held back, blocks included, until every custody column has a known
custodian among the connected peers. Lighthouse’s range sync holds its batches
the same way.
Right after startup, custody is known for almost no peer, since a peer’s
custody arrives with its metadata/3 answer after it connects. A mainnet
follower’s first batch after a fresh checkpoint sync went out 5 s after
startup with two peers and at most one known custodian per column, and 121 of
the 122 holds it caused were missing every custody column. The
batch is re-checked after every metadata answer and every Status answer, and
it goes regardless once RANGE_BATCH_CUSTODY_WAIT has passed, because nothing
here searches for a custodian of a specific column; past that deadline the
uncovered columns go to a couple of peers whose custody is unknown, as before.
The per-block one starts at hold_block_for_columns, called when a
fulu block carrying commitments arrives short of the columns this node
custodies for it. Import holds the block — it stays out of fork choice, but
its header, body and proof are already written, the same way a block missing
its parent is held. What happens next depends on the block’s age. A block
still at or ahead of the current slot asks for nothing yet: its columns are
published alongside it, so the rest are normally already in flight on gossip,
and a peer asked at that moment usually does not have them either; on mainnet
followers at the tip, gossip completed a held block within 0.3 s at p99. A
block already older than the current slot has no such gossip left to race,
since whatever delivered it did so long before this node held it, so it sends
data_column_sidecars_by_root/1 immediately instead: a range-synced catch-up
hits this on every held block, and without it such a follower imported
roughly one block per slot, waiting out the redrive below for a request
gossip was never going to answer. Either way, redrive_held_blocks runs on
every slot tick: for each block still held it sends the same request for
exactly the columns still missing, with
MAX_FETCH_RETRIES attempts and backoff doubling from INITIAL_BACKOFF_MS, a
peer that has already failed this lookup excluded until the whole pool is
exhausted. A lookup that runs out of peers or
retries stops asking until the next tick asks again; see
lean_data_column_fetch_failures_total in metrics.md. The
block is released the moment its last missing column arrives, and dropped
along with any pending descendants once finality passes its slot, whichever
comes first — nothing else times out a hold, so a peer that claims commitments
and never answers pins the block only until finality clears it, not
indefinitely.
Both asking paths aim per column rather than per peer. A peer’s custody set is
a public function of its node id and its advertised custody group count, so
this node computes it and sends each column to someone who actually holds it —
the spec’s own observation that “due to the deterministic custody functions, a
node knows exactly what a peer should be able to respond to”. The count comes
from metadata/3, requested once per connection right behind Status, and is
seeded from the ENR cgc for a peer this node dialed; a peer that has supplied
neither is not assumed to custody anything, and the by-root path falls back to
asking one at random for whatever no known custodian covers. At mainnet’s
CUSTODY_REQUIREMENT a peer holds 8 of 128 columns, so this is the difference
between a request that can be answered and one that usually cannot.
What this node deliberately does not do with its own slice of the matrix: it
does not run compute_matrix or recover_matrix to reconstruct the rest of a
block’s data from it, since reconstruction needs half of
NUMBER_OF_CUSTODY_GROUPS and this node never holds more than its own
sampling size; and, over req/resp, it does not cross-seed a verified column to
a peer that never asked for it: every sidecar this node sends over
data_column_sidecars_by_{root,range} leaves in direct answer to that peer’s
own request. Gossip is the one path where the same column is an
unsolicited push by design: an Accept verdict (see Gossip above)
both hands the sidecar to the chain actor and re-propagates it to every mesh
peer, whether or not any of them asked for it.
What is not wired yet
Serving both protocols is live, off the checkpoint-anchored store, and so is
asking on both. A request
that reaches a node whose data directory is not a beacon one is refused with
RESOURCE_UNAVAILABLE, the spec’s own code for a peer “unable to reply to block
requests”, where INVALID_REQUEST would blame the asker for a request that was
fine. That guard is for a lean directory, not for the ordinary case.
Asking is now driven from two places. A peer’s Status starts a range
session, which sends through request_beacon_blocks_by_range; and
Handler<FetchBlock> reaches request_beacon_block_by_root through
fetch_block_from_peer, which picks its protocol from the wire so a beacon
node cannot put a lean-framed BlocksByRoot on its beacon streams.
Both paths now import. A fetched block reaches the chain actor as
BlockSource::Sync, keeping every per-block range and root check on the way in,
and the by-root path is what resolves a gossiped block’s missing parent.
Range sync is paced by P2PServer::beacon_fetched_through, the highest slot
handed to the actor, not by the store’s head. Delivery is a message and import
is work, so the store trails a delivered batch by the whole actor mailbox.
Driven off the head, every resync tick re-requested the part still draining,
which on the live follower meant 11,213 blocks off the wire to import 100, each
duplicate paying a hash_tree_root before the store could reject it. Paced off
the watermark, the ratio was 1.7:1.
The watermark alone misses gossip, though. Only a range answer advances it, so a
follower that had caught up and then imported from gossip kept it at its last
range batch, and every peer that connected later was asked again for every block
since: 748 blocks in 66 bursts on one follower. A Status answer is
therefore measured against the higher of the watermark and the store’s head
(beacon_sync_target). The head is a safe floor, since a head block’s whole
ancestry is already imported.
Status is store-derived: head from Store::beacon_head, the finalized
checkpoint from Store::beacon_finalized_checkpoint, and
earliest_available_slot from the anchor’s own slot rather than
finalized_epoch * SLOTS_PER_EPOCH, since this node keeps only the unfinalized
window above its anchor and the epoch’s first slot is not necessarily one it
holds. Naming that slot tells a peer not to ask for anything older; naming zero
would claim genesis is in reach. There is one window where beacon_head answers
None, between init_beacon seeding KEY_HEAD and fork choice inserting the
block that root names, and every field but the fork digest falls back to zero
there. That stays honest: lighthouse’s relevance check exempts a zero
finalized_root from its finalized-root comparison, reading it as “this peer is
syncing” rather than as a conflicting chain.
The ENR
| Entry | Value |
|---|---|
eth2 | the computed ENRForkID |
attnets | 64 bits, with this node’s SUBNETS_PER_NODE backbone subnets set |
cgc | CUSTODY_REQUIREMENT |
quic | --gossipsub-port |
tcp | --gossipsub-port, the same number: TCP and UDP are separate namespaces |
udp | --discovery.port |
Two of these advertise less, or more, than they look like:
attnetsnames exactly the subnets this node subscribed to, which is the only honest value: claiming one it does not serve earns peer-score penalties for silence there, and claiming none while serving two loses the peers looking for precisely that. It used to be all-unset, which was honest while this node held no subscription at all.syncnetsnames the sync committee subnets joined on a validator client’s request, so it is all-unset until one asks. It is advertised in MetaData only; the ENR has nosyncnetsentry.cgcadvertisesCUSTODY_REQUIREMENT, the floor below which peers may reject a record outright, notsampling_size(CUSTODY_REQUIREMENT), the larger number of columns this node actually custodies, stores and serves (see Data column sidecars). That undersells rather than oversells: a request for more thancgc’s worth of columns still succeeds, since this node holds every column its own node id assigned it at the sampling size, not merelycgc’s worth.
The tcp entry is what makes us discoverable in return: lighthouse’s discovery
predicate requires enr.tcp4().is_some() || enr.tcp6().is_some() and applies it
as a query filter, so a quic-only record is invisible to it.
Metrics
| Metric | Meaning |
|---|---|
lean_beacon_gossip_messages_total{topic,result} | Gossip received, by topic and by decoded / decode_failed / decompress_failed / unsupported_fork |
lean_beacon_gossip_validation_total{kind,outcome,reason} | Gossip verdicts, now including beacon_aggregate_and_proof and beacon_attestation; see metrics.md |
lean_beacon_gossip_verdict_expired_total{kind} | Verdicts that came too late to propagate anything; should stay at zero |
lean_beacon_status_digest_mismatch_total | Handshakes seen from another fork digest |
lean_beacon_fork_digest{digest} | The digest computed at startup, as a label |
lean_beacon_aggregate_decode_seconds | Time spent decoding one aggregate off the wire |
lean_beacon_aggregate_mailbox_wait_seconds | How long an aggregate sat in the chain actor’s mailbox |
lean_beacon_aggregate_processing_seconds | Time the chain actor spent applying one already-verified aggregate |
lean_beacon_aggregate_end_to_end_seconds | Wire to fork choice, for aggregates applied on arrival |
lean_beacon_aggregate_total{outcome} | Aggregates by applied, invalid, known_subset or queue_full |
lean_beacon_aggregates_deferred | Aggregates held until their own slot has passed |
lean_gossipsub_peers_by_score{band} | Gossipsub peers by score band; see Peer scoring |
lean_gossipsub_score_disconnects_total | Peers disconnected for a score below the graylist |
lean_gossipsub_mesh_delivery_scoring | 1 while P3 is scored, 0 while the head lags or is warming up |
lean_beacon_publish_data_columns_seconds | Time to compute and verify a published block’s cells and build its sidecars; see metrics.md |
The four aggregate histograms no longer cover what they used to: gossip
validation (committees, all three signatures, the seen caches) runs in p2p now
and is folded into lean_beacon_gossip_validation_seconds{kind="beacon_aggregate_and_proof"}
instead. decode is p2p’s own decode step; the other three, all in
ethlambda-blockchain, now measure only apply_verified_aggregate and the
actor’s applied-bits gate, which is why processing reads far lower than it
used to. mailbox_wait is still the one that cannot be derived any other way,
and it is still the failure mode this path introduces: roughly a thousand
aggregates a slot (verified ones only, now) queueing behind block imports arrive too
late to move the head while every other timing still looks healthy.
The lean_ prefix is the repo-wide convention and applies here too. Data
column sidecar metrics (lean_data_columns_stored_total,
lean_data_columns_rejected_total, lean_data_column_kzg_verify_seconds,
lean_data_column_fetch_failures_total, lean_blocks_held_for_columns) and
the disk-growth gauge for
Table::DataColumns (lean_table_bytes{table="data_columns"}) are documented
in full in metrics.md rather than repeated here.
ethlambda beacon serves these on --metrics-port, alongside /health and
the /debug/pprof heap-profiling routes, through the same start_rpc_server
entry point ethlambda node uses: one HTTP call site in run_node serves both
chains. It also binds --api-port and mounts the /lean/v0 API, off the empty
in-memory Store and the default AggregatorController, SyncStatusController
and EventBus this startup path hands it. Those routes answer for a chain that
is not running; treat /metrics as the only meaningful HTTP surface of a
beacon run until the follower gets an API of its own.
Checking it against the live network
No unit test can assert “peers with mainnet”, so this is the procedure.
openssl rand -hex 32 > /tmp/beacon-node-key
RUST_LOG=info,ethlambda_p2p=debug \
cargo run --profile release-fast -p ethlambda --bin ethlambda -- beacon \
--node-key /tmp/beacon-node-key \
--gossipsub-port 9001
gossipsub’s own warnings, chiefly the send-queue-full line a slow peer earns
once per heartbeat, are capped at ERROR by default; add
libp2p_gossipsub=warn to RUST_LOG to see them.
Within 5 seconds:
Derived the mainnet wire parameters genesis_time=1606824023 genesis_validators_root=0x4b363db9… epoch=… fork=fulu fork_digest=8c9f62fe
No fork or blob-schedule boundary is scheduled
Custodying data columns columns=[…]
Backboning attestation subnets subnets_per_node=2 subnets=[…]
Advertising cgc=4; sync committee subnets are joined only on a validator client's request
Beacon P2P node started socket=0.0.0.0:9001 fork_digest=8c9f62fe topics=17 columns=8 attestation_subnets=[…]
HTTP server listening addr=127.0.0.1:5054
Starting discv5 discovery discovery_addr=0.0.0.0:9002 seeds=17 total_bootnodes=17
Local ENR enr=enr:-…
The Advertising cgc=… line names what is true of sync committees: this node
subscribes to no sync-committee subnet at startup, and joins one only when a
validator client asks (see Sync committee
subnets).
It publishes only what its Beacon API clients submit (attestations, aggregates, sync committee messages, blocks with their columns, operations), plus relayed validated gossip.
seeds=17 proves the built-in list parsed; a lower number means a bootnode
ENR was skipped with a warning. topics=17 proves the subscription set: the 7
global topics, the 8 column subnets this run’s (randomly generated) node id
selected, and the 2 attestation subnets the same id selected; columns=8 and
attestation_subnets=[…] confirm the two families directly.
Within 30 seconds:
External IP detected via PONG voting, updating local ENR old_ip=0.0.0.0 new_ip=…
Beacon block decoded slot=… proposer=… fork=fulu block_root=… bytes=…
Beacon aggregate attestation decoded slot=… aggregator=… attesters=… target_epoch=… target_root=… bytes=…
A measured run on 2026-08-31, from a laptop behind NAT with no port forwarding,
reached its first decoded block 16 seconds after start and its first aggregate
at 24 seconds, then took a block every slot. bytes on mainnet is typically
100 KB to 310 KB for a block and about 500 for an aggregate, and attesters
sits in the low hundreds. Peer lifecycle lines (Peer connected, Beacon handshake complete) are at trace: at mainnet peer counts they are what
drowned the log, so raise the level to see them.
What each failure looks like:
| Symptom | Cause |
|---|---|
Peer said goodbye reason=129 right after a connection | nothing local: 129 is lighthouse’s TooManyPeers |
fork_digest is not what a live crawl reports | the blob-schedule branch, or the schedule itself |
connections but no Beacon handshake complete | the Status encoding, or answering the wrong version |
Handshake answered from another fork digest | the digest |
Beacon gossip decode failed for beacon_block | the fork selection or the slot offset |
the same for beacon_aggregate_and_proof alone | the electra boundary in decode_gossip; the block path is fine |
| connected peers above zero, no gossip at all | the topic hash: almost always the digest’s hex formatting or compute_message_id |
every candidate rejected as missing or undecodable eth2 entry | see below |
Both transports, not just QUIC
ethlambda used to dial QUIC only, against the quic port a peer’s ENR
advertises. Most mainnet beacon nodes are reachable over TCP and advertise a
quic entry that does not answer, either because the node is behind a NAT that
forwards only TCP or because QUIC is disabled behind an advertised port. The
result was a long run of Handshake with the remote timed out against otherwise
valid, correctly admitted peers.
The node now listens on and dials both transports, and admission accepts a peer
advertising either, so a dead quic entry falls back to TCP within the same
dial rather than ending it. The TCP transport offers mplex alongside yamux
because mainnet peers answer na to a yamux-only proposal; see the muxers
module in crates/net/p2p/src/lib.rs for that measurement.
A node behind NAT with no inbound UDP can only ever be the dialer, and peers
fill up: expect to wait for a peer that stays, and expect a first connection to
be refused with a Goodbye.
A run where discv5 finds contacts but every one is rejected for a missing eth2
entry means the records reaching the dial loop are not the ones the peers
published. discv5 hands back a full NodeRecord per NODES response, so an
eth2 entry that was there on the wire and absent here is a record-plumbing
problem in the discovery layer rather than anything in admit, which is
covered by unit tests against records carrying every entry. Bootnode records are
a poor control: mainnet’s advertise the phase0 digest and are supposed to be
rejected, just as fork digest mismatch rather than as missing.
The execution layer pairing
ethlambda beacon follows the Ethereum Beacon Chain. A beacon block carries an
execution payload that consensus cannot validate on its own: only an execution
client can say whether the transactions in it are valid and whether the state
root they produce matches. This page describes how the follower asks, what it
does with each answer, and what it does not yet do.
The consensus rules implemented here are
specs/bellatrix/optimistic-sync.md
and the verify_and_notify_new_payload half of each fork’s process_execution_payload.
The wire is
execution-apis/src/engine.
Which methods, and why so few
Six:
| Method | Introduced | Why |
|---|---|---|
engine_newPayloadV4 | prague | Validate one block’s payload |
engine_newPayloadV5 | amsterdam | Validate the payload a gloas envelope reveals |
engine_forkchoiceUpdatedV3 | cancun | Say where the head, safe and finalized blocks are |
engine_forkchoiceUpdatedV4 | amsterdam | The same, from a gloas head on; also carries the node’s custody columns |
engine_exchangeCapabilities | common | Startup handshake |
engine_getClientVersionV1 | identification | Log what the execution client is |
Osaka introduces no new newPayload. Its own document adds only
engine_getPayloadV5 and engine_getBlobsV2/V3; engine_newPayloadV5 belongs
to Amsterdam, where gloas’s envelope import uses it. So the Osaka-current call for a payload is prague’s V4, and the
Osaka-current fork choice notification is cancun’s V3. Amsterdam’s pair (newPayload V5, forkchoiceUpdated V4) is
used from gloas on; the startup handshake warns when the execution client does not advertise either, but only on a network whose schedule includes gloas.
Three method families a full client would have are deliberately absent:
engine_getPayload*outside the gloas path. The follower itself never asks an execution client to start building a block, so its ownforkchoiceUpdatedcalls carrynullpayload attributes. Block production for a validator client (produceBlockV3for fulu,produceBlockV4for gloas) does:forkchoiceUpdatedV3withPayloadAttributesV3thenengine_getPayloadV5before gloas, andforkchoiceUpdatedV4withPayloadAttributesV4(V3’s fields plusslotNumberandtargetGasLimit, the parent bid’s gas limit) thenengine_getPayloadV6(ExecutionPayloadV4, withblockAccessListandslotNumber) from gloas on. The handshake warns when a gloas network’s execution client does not advertiseengine_getPayloadV6.engine_getBlobs*. There is no blob-pool fetch path here; data columns come from peers over the network.engine_getPayloadBodies*. Nothing consumes them.
Blocks before electra are not asked about at all. This node checkpoint-syncs onto
a chain far past bellatrix, capella and deneb and never imports one of their
blocks, and engine_newPayloadV4 would refuse their payloads as an unsupported
fork anyway. Supporting them would mean carrying V1 through V3 as well, for
chains this follower cannot reach.
Where the call sits
process_block (beacon arm)
│
├─ data availability gate ────────────► held, if a custody column is missing
│
├─ engine_newPayloadV4 ◄── this page
│
└─ fork_choice::on_block(…, payload_validity)
│
├─ state_transition, with the engine derived from the verdict
└─ record the block's execution hash and its optimistic status
This diagram is the pre-gloas path. A gloas block carries no payload, only a builder’s bid, so nothing is asked about it at import; the question moves to the envelope that reveals the payload (see Gloas below).
The order is the specification’s own: is_data_available runs before
state_transition, so a block about to be held for its custody columns is never
one this node asks an execution client about.
Every field of the request is a pure function of the block:
parent_beacon_block_root is state.latest_block_header.parent_root, which
after process_block_header is the block’s own parent_root, and the other
three are body fields. That is what lets the round trip happen outside the
state transition, so the state transition itself stays synchronous while the
import cascade above it is async.
The verdict then reaches state_transition as an ExecutionEngine: an
INVALIDATED answer makes verify_and_notify_new_payload return false and the
transition fail from inside process_execution_payload, which is where the
specification puts that failure. The transition is run even when the verdict is
already INVALIDATED, which costs one merkleization on a path that should never
run and buys the failure arriving where a reviewer checks this code against the
specification.
The verdicts
optimistic-sync.md groups the five wire statuses into three outcomes:
| Wire status | Alias | What happens |
|---|---|---|
VALID | Imported. The block and every optimistic ancestor leave optimistic_roots | |
SYNCING, ACCEPTED | NOT_VALIDATED | Imported if is_optimistic_candidate_block allows it, and joins optimistic_roots |
INVALID, INVALID_BLOCK_HASH | INVALIDATED | Not imported. latestValidHash decides how much of the branch dies with it |
A block in optimistic_roots carries normal fork-choice weight and can be the
head. That is the point of optimistic sync, and the sync/optimistic fixture
suite proves it: its one case, from_syncing_to_invalid, has a SYNCING branch
take the head from a VALID one on attestation weight, and then requires the
head to fall back when the SYNCING branch turns out to be invalid.
An invalidated block leaves fork choice by having its LiveChain index row
deleted. That is sufficient because Store::block_index() is the only source
filter_block_tree, compute_node_weights and walk_head read: a root with no
row contributes no weight to any ancestor and can never be walked to. The block
and its state stay in their own tables, so an operator can still inspect what
was rejected.
That an INVALIDATED block is never imported is enforced on the verdict itself,
in on_block, not on the state transition having failed. The transition still
runs first, so the failure arrives from inside process_execution_payload where
the specification puts it and where the sync/optimistic fixture exercises it.
But it only fails there for forks that consult the ExecutionEngine at all:
bellatrix gates that step on is_execution_enabled, and phase0 and altair have
no such step, so on those a condemned block would otherwise transition cleanly.
Optimistic roots
A block imported on NOT_VALIDATED is recorded in optimistic_roots against
its slot. An entry leaves on a later VALID (which clears the whole optimistic
prefix, per optimistic-sync.md’s “all ancestors of the block MUST also
transition”) or INVALIDATED verdict, and, for the ones that get neither, on
finality: an execution client doing a long state sync answers NOT_VALIDATED to
every block, so without that bound the set would take one root per import for
the life of the process.
Besides mark_validated’s own ancestor walk, the Beacon API reads the set:
every execution_optimistic response field (see
rpc.md for a gloas block’s),
is_optimistic on /eth/v1/node/syncing and the 206 on
/eth/v1/node/health (both about the head only), and attestation_data and
aggregate_attestation, which answer 503 rather than hand a validator a
vote for a block the execution client has not validated (see
rpc.md).
is_optimistic_candidate_block
A NOT_VALIDATED verdict is not enough on its own. The block must also either
- have a parent this node has already recorded an execution hash for, or
- be at least
--safe-slots-to-import-optimisticallybehind the wall clock.
Condition 1 is store.beacon_el_block_hash(parent_root).is_some(): the
specification’s is_execution_block(parent), read off a cache of the parent’s
payload hash rather than by decoding the block. The cache holds a hash for every
post-merge block the store has, and none for a pre-merge block (a zero payload
hash is not an execution block). It is filled at import, for the checkpoint
anchor when the store is built, and on resume for the unfinalized window.
The horizon guards against a poisoned merge transition block, whose parent hash names an execution block nobody can produce. Once a node is on a post-merge chain every block’s parent has a recorded hash, so condition 1 carries each one and condition 2 stops mattering.
The anchor is never imported and never asked about (see The anchor is assumed
valid), so its hash is cached when the store is
built; only the hash, not a verdict. That is what lets the first block after a
checkpoint-sync anchor take condition 1 even while the execution client is still
syncing and answers SYNCING to every newPayload. A restart empties the cache,
so resume rebuilds it from the stored blocks.
latestValidHash
An INVALID answer names the last execution block that was valid. Resolving
which beacon block that condemns is a walk up the rejected block’s own
ancestry, not a lookup in a global index, because the specification scopes it
that way: “the child of a block with
body.execution_payload.block_hash == latestValidHash in the chain containing
the block with payload in question”. Two branches can share a parent whose
payload is the last valid one, and only the branch that was rejected may die.
latestValidHash | Condemned |
|---|---|
| An execution hash found on this chain | The child of the block carrying it |
| All zeroes | The deepest indexed ancestor carrying a payload |
null, or a hash not on this chain | The rejected block itself |
The last row is the specification’s own instruction, not a convenience: “when
latestValidHash is a meaningful execution block hash but consensus engine
cannot find a block satisfying
body.execution_payload.block_hash == latestValidHash, consensus engine SHOULD
behave the same as if latestValidHash was null”. A checkpoint-synced follower
meets this whenever the named block is below its anchor.
The finality floor
A condemned root at or below the finalized checkpoint is refused: the log says
so and nothing is removed. Obeying it would delete every LiveChain row from
finality upward, after which get_head fails its “block_root in store.blocks”
check on every call and the node can only report that it cannot compute a head
until its database is rebuilt.
The all-zeroes row above is what reaches this without anyone naming a finalized block: the walk it describes stops where the execution hash cache does, and that cache is bounded by finality, so its deepest entry is the finalized block itself. An execution client condemning finalized history means it and this node disagree about what is final, which is an operator emergency rather than something to resolve by emptying fork choice.
Gloas
A gloas block commits to a builder’s bid and the payload arrives later in a
SignedExecutionPayloadEnvelope, so the execution client judges the envelope,
not the block. The order differs from the specification’s, which asks the engine
last inside one boolean function: the follower runs the pure consensus checks
first (signature, bid, state, data availability), and only an envelope that
passed them is sent to engine_newPayloadV5. That way an unsigned copy with the
honest block hash and a different body cannot earn an INVALID that lands on the
honest payload, and the engine never spends time on an envelope this node would
refuse anyway.
| Answer | What happens to the envelope |
|---|---|
VALID | Applied; the payload is recorded VALID |
SYNCING, ACCEPTED | Applied and recorded SYNCING (not yet validated); a later forkchoiceUpdated VALID promotes it |
INVALID with a latestValidHash | The payload is invalid: not applied, so the block’s FULL node never exists; the block and its EMPTY branch stay |
INVALID with a null latestValidHash, or INVALID_BLOCK_HASH | The envelope’s contents do not hash to the block hash it claims: only that envelope is refused, since the builder’s real one may still arrive |
| no answer after the retry ladder | Kept (it is consensus-valid) and asked again once a slot |
The null-latestValidHash row exists because ethrex has no INVALID_BLOCK_HASH
status: it answers a mismatch with a null hash and an executed-and-failed payload
with its last valid ancestor. latestValidHash and VALID follow the execution
chain (each block’s bid.parent_block_hash), not every verified ancestor, since
a block built past an ancestor’s EMPTY node skips that ancestor’s payload. The
details, and why each departs from the specification, are in
spec_deviations.md.
With the engine down, the per-slot retry asks about the oldest held envelope only and releases the rest once that ask is answered, so an unreachable client costs one retry ladder per slot however many envelopes wait. This is the gloas counterpart of the limitation below, and unlike a block the envelope is not dropped.
forkchoiceUpdated
Sent once after every head recompute, which is once per import cascade and once per tick, not once per block. It is sent even when nothing moved: an execution client doing state sync needs to keep being fed a recent head or its sync cannot converge.
Its three hashes come from the cached execution hash of the head, the justified
checkpoint’s root and the finalized checkpoint’s root. A root with no cached hash
contributes the zero hash, which is a meaningful value here rather than an
absence: EIP-3675 requires finalized_block_hash to be zero before a
post-transition block is finalized, and the specification’s own
get_safe_execution_block_hash returns zero when no payload is justified yet. A
follower whose head has no cached hash is still sitting on its checkpoint
anchor and sends nothing at all.
The cache is pruned to the unfinalized window on every tick and every import,
with the finalized checkpoint’s own root exempted by name. The slot bound alone
does not reach it: a checkpoint is stored as its epoch’s start slot, while its
root is the last block at or before that boundary, so a missed proposal at an
epoch boundary leaves the finalized block below the bound. Dropping its hash
would make every later call carry finalized_block_hash = 0x00..0 and stop the
execution client advancing its own finalized block for the life of the process.
For a gloas head the hashes come from bids rather than from a cached payload
hash: the head node’s bid.block_hash when it is FULL and its
bid.parent_block_hash when it is EMPTY, the finalized block’s
bid.parent_block_hash, and the justified block’s bid.parent_block_hash as
the safe hash. The last is a fallback, since the specification builds the safe
hash on fast confirmation, which the follower does not run. The call is V4 with
the node’s custody columns. A head whose payload status has not been computed
yet (the first moments after a restart) sends nothing.
The response carries a PayloadStatusV1 of its own, and that is the channel by
which a block imported on SYNCING later becomes VALID or is found to be
INVALID. An invalidation reaching the follower this way can remove the head
itself, unlike one reaching it through newPayload, because the block in
question has already been imported and is in the index. When it does, fork
choice is re-run and the head rewritten before the response handler returns:
KEY_HEAD and Table::BlockRoots still name the removed roots otherwise, and
the req/resp handlers read both, so a peer would be advertised and served a
block this node has just refused. Only the head is rewritten there, not
announced: telling the execution client from inside its own response would
re-enter the call being answered, so the new head goes out on the next cascade
or tick.
Configuration
--execution-endpoint <URL> e.g. http://127.0.0.1:8551
--execution-jwt-secret <PATH> file holding 32 bytes of hex
--safe-slots-to-import-optimistically <N> default: the spec's own value
The first two must be given together: an Engine API endpoint always requires
authentication, so an endpoint without a secret is refused at startup, before
anything binds or connects. Clap cannot express “both or neither” across two
optional flags, so the pairing is checked once, in From<BeaconOptions>.
With neither flag, the follower contacts no execution client and every block
gets PayloadValidity::NotRequired, which is exactly what it did before any of
this existed.
Authentication is HMAC-SHA256 over a JOSE header and a claim set whose only
member is iat. Execution clients accept about a minute of skew either way; a
fresh token is minted per request rather than cached, because minting is two
hashes and a cache would need a clock of its own.
The startup handshake warns rather than refuses when the execution client does not advertise a method this node needs: an execution client that under-reports its capabilities still works, and refusing to start over a handshake would turn a cosmetic mismatch into an outage.
Limitations
The retry ladder gives up, and nothing re-drives it
Each call is attempted ENGINE_MAX_ATTEMPTS times, with a per-attempt timeout of
ENGINE_TIMEOUT and a backoff starting at ENGINE_INITIAL_BACKOFF and doubling.
An RPC error is not retried: that is the execution client answering, with a
refusal but an answer, so asking again would get the same refusal and burn the
ladder for nothing.
After the last attempt the block is dropped. optimistic-sync.md requires
exactly that much (“a consensus engine MUST NOT import the block and MUST NOT
apply it to the fork choice store”), but nothing here re-drives the call
afterwards, so a persistently unreachable execution client parks the follower’s
head behind the first block it could not ask about, permanently, until a
restart.
This was accepted knowingly for this change. The intended fix is a tick-driven
re-drive of the set of blocks that got no verdict. lean_engine_no_verdict_total is the
metric that says it is happening: a non-zero rate means the follower has stopped
following rather than merely slowed down.
optimistic_roots does not survive a restart
The optimistic set lives in memory alongside the rest of the beacon fork-choice scratch and is not persisted. After a restart, blocks that were imported optimistically are no longer marked as such.
This is safe because the set is advisory. It records which blocks have not yet
been vouched for, so that a later VALID can clear them and a later INVALID
can be attributed; it never gates whether a block stays in fork choice. On
restart the execution client is asked again about everything the follower
imports from that point on, and forkchoiceUpdated re-establishes the head’s
status on the first tick.
The anchor is assumed valid
A checkpoint-synced follower takes its anchor state and block on trust, without asking an execution client about the anchor’s own payload. That is inherent to checkpoint sync, not specific to this change.
The merge transition is untouched
validate_merge_block and the terminal-PoW checks are bellatrix’s, are reached
from one place, and were removed outright by capella. Nothing here changes them.
Command line
ethlambda follows one of two chains, selected by a subcommand:
| Invocation | Chain |
|---|---|
ethlambda node <flags> | The lean consensus protocol this repository implements |
ethlambda beacon <flags> | The Ethereum Beacon Chain, as a gossip follower |
ethlambda <flags> | node: the subcommand is injected |
Four subcommands follow no chain. ethlambda replay-era replays real chain
history from .era files and checks it against the states stored in them; see
era_replay.md for its flags and checks. ethlambda benchmark is the offline
benchmarking harness, covering block building (benchmark synthetic, run
through make bench) and block import (benchmark import fetch /
benchmark import replay). See benchmarking.md for
what each workload measures; benchmark import’s flags are in
their own section below. ethlambda validator is
the validator client: a separate process that holds keys and performs duties
against a beacon node over the standard REST API, documented in
its own section below. ethlambda slashing-protection
moves that client’s EIP-3076 history in and out of its directory, offline; see
its section below.
node is the default
clap has no native default subcommand, so the binary rewrites its own argv
before parsing. If the first argument is not node, beacon, benchmark,
replay-era, validator, help, -h, --help, -V, or --version, then node is
inserted ahead of it. The function is inject_default_subcommand in
bin/ethlambda/src/command.rs. Every subcommand has to be listed there: a
missing one would have node inserted ahead of it and become unreachable.
ethlambda --genesis c.yaml ... -> ethlambda node --genesis c.yaml ...
ethlambda node --genesis c.yaml ... -> unchanged
ethlambda beacon --gossipsub-port 9001 ... -> unchanged
ethlambda benchmark synthetic ... -> unchanged
ethlambda --help | --version | -h | -V -> unchanged, no injection
This is what keeps every existing caller working with no edit: the Docker
ENTRYPOINT, lean-quickstart’s client-cmds/ethlambda-cmd.sh, the Hive lean
client shim, preview-config.nix, and the docker run blocks in the
devnet-runner skill all pass bare flags.
ethlambda with no arguments at all is left alone, so it prints the subcommand
listing rather than a missing-flag error for node.
Common flags
Taken by both subcommands, with the same meaning and the same defaults, from
one CommonOptions struct flattened into each.
| Flag | Default | Meaning |
|---|---|---|
--data-dir | ./data | RocksDB directory. Both sub-commands open one here now: node’s live chain state, and beacon’s checkpoint-synced (or resumed) anchor |
--gossipsub-port | 9001 | Port for libp2p gossip: UDP for QUIC and TCP for the noise+yamux fallback, same number on both, since they are separate namespaces. Binding TCP puts it in the HTTP servers’ namespace, so it must differ from --api-port and --metrics-port as well as from --discovery.port |
--http-address | 127.0.0.1 | Bind address for both HTTP servers |
--api-port | 5052 | API server port. beacon binds it too, off its own anchored store rather than an empty one; the lean-shaped /lean/v0 routes still don’t answer for it (see beacon flags below) |
--metrics-port | 5054 | Metrics and debug server port. Equal to --api-port merges the routers onto one listener |
--node-key | generates an ephemeral key | Hex file holding the secp256k1 key that is this node’s libp2p and discv5 identity. When omitted, a fresh key is generated in memory each start (logged as a warning), so the PeerId and ENR differ on every restart |
--bootnodes | see below | Bootnode ENR list: one enr:... per line, as a YAML block sequence, a YAML flow sequence, or a plain list (# comments and leading - are both tolerated) |
--checkpoint-sync-url | see below | API base URLs, tried in order until one answers. Supplies node’s starting state on the lean API, and beacon’s anchor on a standard Beacon API; required on a fresh beacon data directory only for a built-in network, since a loaded network anchors at its own genesis.ssz instead. See checkpoint_sync.md |
--discovery.port | 9000 | discv5 UDP port; must differ from --gossipsub-port. See Peer discovery |
--discovery.advertise-ip | bind address | IP published in the ENR |
--discovery.target-peers | 200 | Connected-peer count above which discovery stops dialing |
Three of those have no clap default, because the two chains answer them
differently. A single default_value can only say one thing, so the flag
carries none and each chain resolves an absent value itself:
| Flag | Absent on node | Absent on beacon |
|---|---|---|
--node-key | ephemeral in-memory key, warned about | same |
--bootnodes | no bootnodes: peers only via discv5 (with --discovery.enable) or by being dialed, warned about | falls back to the resolved network’s own bootnode list: a built-in network’s embedded list, or a loaded directory’s bootstrap_nodes.yaml/.txt |
--checkpoint-sync-url | start from a resumable DB, else from genesis | start from a resumable DB, else anchor at the resolved network’s own genesis (loaded network only), else abort |
Whether discv5 runs is not a common flag. beacon always runs it, since
published mainnet bootnode ENRs carry no quic entry and so are not statically
dialable; node runs it only with --discovery.enable (see below). Where it
runs, it binds DEFAULT_DISCOVERY_PORT (9000) unless --discovery.port says
otherwise. The two defaults are one apart because both sockets are UDP; pointing
either flag at the other’s port is rejected at startup, before the node touches
its data directory. Without discovery neither that check nor the ban on
--gossipsub-port 0 applies, since nothing binds the discovery port and no ENR
is published.
node flags
On top of the common flags above.
| Flag | Default | Meaning |
|---|---|---|
--genesis | required | Chain genesis config, e.g. config.yaml |
--validators | required | Validator registry, e.g. annotated_validators.yaml |
--validator-config | required | validator-config.yaml, the node-name registry |
--hash-sig-keys-dir | required | Directory of per-validator XMSS keys |
--node-id | required | The key in annotated_validators.yaml naming this node, e.g. ethlambda_0 |
--is-aggregator | false | Seed the runtime aggregator flag |
--discovery.enable | false | Run discv5 peer discovery; off, the node peers from --bootnodes alone. See Peer discovery |
--aggregate-subnet-ids | this node’s subnets | Subnets to aggregate on; requires --is-aggregator |
--attestation-committee-count | from validator-config.yaml, else 1 | Committees per slot |
--enable-proposer-aggregation | false | Merge same-data proofs when building a block |
--max-attestations-per-block | 3 | Proposer-side self-limit |
--disable-duty-sync-gate | false | Track sync state without suppressing duties |
A shadow-integration build adds the --shadow-xmss-* flags; they are absent
from a normal build.
beacon flags
| Flag | Default | Meaning |
|---|---|---|
--custody-group-count | CUSTODY_REQUIREMENT (4) | How many custody groups this node custodies, advertised as the ENR’s cgc |
--execution-endpoint | none | Base URL of the execution client’s Engine API endpoint, e.g. http://127.0.0.1:8551. Must be given together with --execution-jwt-secret |
--execution-jwt-secret | none | File holding the 32-byte hex JWT secret shared with the execution client |
--safe-slots-to-import-optimistically | the specification’s own value | How far behind the wall clock a block must be before it may be imported optimistically on age alone |
Accepted range is CUSTODY_REQUIREMENT to NUMBER_OF_CUSTODY_GROUPS (4 to
128), enforced at parse time rather than clamped: serving a different set than
the operator asked for is the failure hardest to notice.
It is not the number of columns custodied. That is sampling_size, the
larger of this and SAMPLES_PER_SLOT, so the default still custodies 8 columns
and the two only converge once the flag is raised past 8. A node at 128 is a
supernode, custodying every column; raising the value costs storage and
bandwidth in proportion and makes this node useful to more peers, which on a
network where a cgc=4 peer holds 4 of 128 columns is what decides whether a
lookup finds a custodian at all.
Changing it changes which columns this node custodies, since the custody set is a function of the node id and the count. Sidecars already on disk belong to the old set: nothing is corrupted, but the node advertises a set it has not finished filling until it backfills the difference.
With neither execution flag, the follower contacts no execution client and
imports blocks without validating their payloads, which is what it did before
those flags existed. It then answers attestation_data,
aggregate_attestation and block production with 503, so a validator
client cannot use it. Supplying one without the other is refused at startup: an
Engine API endpoint always requires authentication. See
the execution layer pairing for what each verdict does and
for the limitations that go with the retry ladder.
beacon takes one more flag of its own, and two common flags also mean
something specific here.
| Flag | Default | Meaning |
|---|---|---|
--network | mainnet | A built-in network name (mainnet, sepolia, hoodi or plataberget), or a path to a directory of published network files. A value containing a slash is always a path, so mainnet is the built-in and ./mainnet is a directory. The directory must hold config.yaml and genesis.ssz, and may hold bootstrap_nodes.yaml or bootstrap_nodes.txt |
genesis_validators_root and genesis_time, which the fork digest that keys
every gossip topic, the ENR eth2 entry and discv5 admission is computed from,
come from the resolved network:
--network | genesis values | config | bootnodes |
|---|---|---|---|
mainnet (default), sepolia, hoodi, plataberget | two constants in network::built_in; no genesis state is carried (a built-in network never anchors at genesis, and the states run from 5 MB to 150 MB) | bin/ethlambda/assets/<name>/config.yaml, byte for byte from eth-clients/<name> (Platåberget: ethpandaops/glamsterdam-devnets’ network-configs/devnet-8) | bin/ethlambda/assets/<name>/bootstrap_nodes.yaml, from the same repo |
| a directory | read off the directory’s own genesis.ssz | the directory’s config.yaml | the directory’s bootstrap_nodes.yaml/.txt |
The genesis constants are checked twice at runtime: checkpoint sync verifies the downloaded anchor state against both, and a resumed data directory is checked the same way, so a wrong constant fails at startup rather than following the wrong chain.
beacon therefore takes no genesis config, validator registry, or bootnode file
of its own the way node does: those are read off the resolved network instead
of being separate operator input. A config.yaml (a directory’s, or a built-in
network’s embedded one) is read permissively: absent keys fall back to mainnet’s values,
numbers are accepted quoted or bare, and unrecognised keys (on a current
config, the heze schedule this build does not claim) are dropped
with one warning line naming each. Its PRESET_BASE is checked against the
compiled preset; a mismatch is a hard startup error naming the cargo feature
that would fix it. Sepolia’s config schedules gloas, which this build follows, so
it parses and runs like any other fork. A scheduled fork the build claims but
does not follow gets a separate, explicit warning at startup, since the chain
follower would stop tracking the chain there regardless of what parsed
cleanly; none exists today. The keys this
build runs on compile-time constants for (the custody and subnet counts, the
MAX_REQUEST_* limits, MAX_PAYLOAD_SIZE, the snappy message domains and
MAXIMUM_GOSSIP_CLOCK_DISPARITY) must equal those
constants, or startup fails naming each key that differs: the node cannot
follow a network that sets them otherwise, and /eth/v1/config/spec would
report values it does not use. For a directory, both checks run before its
genesis.ssz is decoded, so a directory built for the other preset fails with
the preset error rather than an SSZ one.
Resuming a data directory under a config.yaml whose CONFIG_NAME differs
from the stored one only logs a warning; the stored name is kept. Any changed
chain value (a fork epoch or version, the slot time, PRESET_BASE) is still a
hard error.
--checkpoint-sync-url now supplies the beacon anchor: a finalized
BeaconState and its anchor block, fetched from a standard Beacon API server
and verified against the genesis identity above plus the anchor’s own internal
consistency (see checkpoint_sync.md). The full anchor
precedence is: a resumable data directory, then --checkpoint-sync-url, then,
for a loaded network only, that directory’s own genesis.ssz, then abort. The
URL is therefore required on a fresh data directory only for a built-in
network: no built-in network carries a genesis state, and anchoring one at genesis
would leave the follower replaying a chain that has been live for years. A loaded network’s own genesis
state is a legitimate anchor instead, since a freshly started devnet has no
checkpoint provider at slot 0 and this is the only way to join one. A
directory already anchored from a previous run resumes without the flag
either way, the same way node’s does.
--api-port is bound here too, off beacon’s own anchored store: one HTTP call
site serves both chains, but it picks the router by the store’s chain, so a
beacon run serves the Beacon API under /eth/v1 and /eth/v2 and answers a
/lean/v0/... path with a 404. See rpc.md.
What ethlambda beacon does today
It follows the resolved network as a non-validating follower. It anchors a
RocksDB-backed store at a checkpoint-synced (or resumed) finalized state, joins
the network’s gossip and req/resp, and the chain actor imports blocks from there
(phase0 through gloas), runs fork choice, and, with --execution-endpoint,
asks an execution client about payloads. It never proposes or attests. See beacon_wire.md for what goes on the wire and
how to check a run against the live network.
One entry point, two chains
Both sub-commands run through run_node in bin/ethlambda/src/main.rs. Each
parses into a single cli::Options, whose network field is Network::Lean or
Network::Mainnet and carries that chain’s own flags. run_node does what is
not chain-specific, branches once, and shares the shutdown:
| step | lean | mainnet |
|---|---|---|
| validate the ports (discv5 rules only where it runs) | shared | shared |
| register metrics, print the banner, log the version | shared | shared |
raise RLIMIT_NOFILE | shared | shared |
HIVE_LEAN_TEST_DRIVER early return | yes | no: those endpoints are lean’s |
resolve --node-key | shared | shared |
resolve --network into a NetworkSource | n/a: lean has no --network | classify the value (built-in name vs. directory); parse the config.yaml (embedded, or the directory’s) and check PRESET_BASE against the compiled preset and the constant-backed keys against their constants; for a directory, then decode genesis.ssz |
read --bootnodes (falls back to the chain’s default list; see the table above) | shared | shared |
| build the aggregator, sync-status and event handles | shared | shared |
open --data-dir’s RocksDB backend | shared | shared |
the one match: produce a ChainSetup | genesis config, validator keys, checkpoint sync or resume onto the shared backend, subnets | beacon::wire_params (genesis metadata, epoch, fork digest), then checkpoint sync or resume onto the same backend |
| build the swarm, spawn P2P, start discv5 | shared; discv5 only with --discovery.enable | shared |
| start the HTTP server | shared | shared |
| spawn the chain actor and wire it to P2P | yes | yes: BlockChain::spawn_beacon |
| ctrl-c, stop and join the actors | shared | shared |
ChainSetup is what the match produces: the wire configuration, the ENR
entries that describe it, the store the req/resp handlers answer from, the
node-name roster, and a ChainActor saying what to spawn: lean’s validator keys
and BlockChainConfig, or beacon’s marker. The operator-supplied half of the discv5 configuration (node
key, ports, bootnodes, peer target) is the same on either chain, so it is filled
in once below the match; everything after it, including the chain actor spawn
and the shutdown, is shared.
beacon::wire_params reads genesis_time and genesis_validators_root off
the resolved network (a built-in network’s two constants, or a loaded
network’s own genesis.ssz, decoded at whatever fork its own schedule names
for epoch 0), derives the wall-clock epoch
and fork digest from them, and logs the next boundary that would move the
digest. It builds nothing: one build_swarm serves both chains, dispatching on
the WireConfig variant for the topics, the req/resp protocol set, the
gossipsub seen_ttl, the identify version and the connection limits.
Both chains run a chain actor, so RunningNode carries a plain BlockChain.
validator flags
ethlambda validator is not a node. It binds no libp2p port, opens no chain
database, runs no discovery and follows no chain: it holds validator keys and
performs duties against a beacon node’s standard REST API. It works against any
conformant beacon node, this repository’s beacon subcommand or another
implementation’s, so none of the common flags apply to it.
Slashing protection lives in
--validators-dir/slashing_protection.json, locked to one process, so--validators-dirmust be writable: a read-only mount of the keys (a Kubernetes Secret, a:rovolume) fails at startup, as does a record that is a symlink (say, onto another volume) rather than a regular file, since a dangling one would read as “no record”; and the directory should be a local disk, since every signature waits for the record to be flushed to it, and the lock that keeps a second process off it is anflock, which a network filesystem may not honour. Unix only. A key with no record is refused at startup unless--init-slashing-protectionis passed. A key moving here from another client brings its history withslashing-protection import, or in the same keymanager API request that imports the key. See Spec Deviations.
| Flag | Default | Meaning |
|---|---|---|
--beacon-nodes | required | Base URLs of the beacon nodes to use, comma-separated or repeated. Tried in list order; the first that answers serves the request, so the order is a preference, not load balancing |
--validators-dir | required | Directory holding the EIP-2335 keystores and validator_definitions.yml. Without that file, keystores in the Lighthouse layout (<0xpubkey>/voting-keystore.json, password in --secrets-dir/<0xpubkey>) are discovered and the file is written |
--secrets-dir | required | Directory holding one password file per keystore, named after the validator’s public key |
--http-address | 127.0.0.1 | Bind address for the metrics and keymanager servers |
--metrics-port | 5064 | Prometheus metrics port |
--keymanager-port | 5062 | Keymanager API port. Only bound with --enable-keymanager |
--suggested-fee-recipient | none | Execution address to receive block rewards, 0x-prefixed, for every validator the keymanager API gives no address of its own. Optional, and startup warns when it is absent: without it the beacon node picks an address, and it will not be yours |
--graffiti | empty | Text for the graffiti field of proposed blocks, for every validator the keymanager API gives no graffiti of its own. At most 32 bytes as UTF-8, right-padded with zeros. Refused rather than truncated if longer. An ethlambda beacon node appends both clients’ versions to it (see rpc.md) |
--enable-keymanager | off | Serve the keymanager API: keystores, plus each validator’s own fee recipient, graffiti and gas limit. Off by default because it mutates key material |
--init-slashing-protection | off | Register every loaded key with no slashing-protection record as never having signed, instead of refusing to start. Dangerous: only for keys that have signed nothing anywhere for at least two epochs, and even then it rules out a double vote only, not a surround of an older vote; a key with a history should bring it instead. Never lowers an existing record; remove it once the keys are registered, and startup warns while it is set for nothing |
What it does today
Attestations, block proposals and attestation aggregation, and from gloas on the self-built payload envelope and the payload timeliness committee vote.
Each epoch it resolves its validators’ indices, fetches their attester duties for this epoch and the next, fetches this epoch’s proposer duties, subscribes to the committee subnets the attester duties need, and registers each validator’s fee recipient with the beacon node. The subscription also tells the node which committees this client will aggregate for, which it has to know in advance so it can collect the votes.
Each slot it wakes at the boundary. If one of its validators proposes that slot, it signs the RANDAO reveal, asks the beacon node for a block, checks that the block is for the slot and proposer it asked about, signs it and publishes it. One third into the slot it fetches the attestation data, signs for every validator due that slot, and submits the batch. Two thirds in, for any duty it was selected to aggregate, it asks the node for the aggregate covering that committee’s votes on the data it just signed, wraps it with the selection proof and publishes it.
Aggregation is not a choice. A validator signs the slot under a dedicated domain, and whether the hash of that signature divides evenly by a modulus from the committee’s size decides it. Signatures are deterministic, so a validator gets one answer per slot, cannot search for a better one, and cannot decline: the beacon node checks the same thing when the aggregate arrives.
Proposals are bounded by that one-third mark rather than by the end of the slot. The specification defines no block-production deadline; the nearest thing it defines is the point attesters stop waiting for a block, which is the same instant. A proposal still running then has already lost most of the block’s value, and every second past it comes out of this client’s own attestations, so it is abandoned and the slot’s attesters still vote.
Electra is the earliest fork it will propose under. A deneb block body has one
field fewer than an electra one, so it would need a container pair of its own,
and it would buy nothing: the attestations this client submits are electra’s
SingleAttestation, which has no earlier form, so a pre-electra chain is one it
cannot serve whatever it does with blocks. A block from any earlier fork is
refused by name rather than failing to decode.
Blocks are fetched and published as SSZ rather than JSON. Signing a block means
computing its hash_tree_root, which only the typed container can give; a
hand-written JSON mapping of an execution payload would be a large surface on
which a single wrong field silently produces a signature over the wrong block.
From gloas on a proposal is two messages. The client asks for the block with
include_payload=true (produceBlockV4), publishes the block bare, then signs
the envelope with the proposer’s own key under the builder domain and publishes
it with its blobs and cell proofs (Eth-Blob-Data-Included: true). Both go out as
SSZ. The envelope shares the proposal’s budget (the attester offset, below), so
a proposal that overruns it is abandoned wherever it is. A failure to publish
the envelope is logged at error and counted, and does not fail the proposal. A
block that commits to a builder’s bid is left alone.
Each epoch it also fetches the payload timeliness committee duties for the
validators it holds, for this epoch and the next when they are gloas ones. A member sleeps until PAYLOAD_ATTESTATION_DUE_BPS of the
slot, asks the node for the
payload attestation data, signs it under the PTC domain and submits it. A 204
(the node knows no block for the slot) means no vote. So a gloas slot runs in
this order: propose at the boundary, attest at ATTESTATION_DUE_BPS_GLOAS,
aggregate at AGGREGATE_DUE_BPS_GLOAS, then the committee vote. The attestation
and the committee vote are each bounded by the end of the slot.
Sync committee duties run beside the attester and committee work, not after it.
Each epoch the client fetches its sync duties for the current period and the
next (POST /eth/v1/validator/duties/sync/{epoch}, asked at the period’s first
epoch) and posts sync_committee_subscriptions for the current period’s seats,
until the period’s end, so the node joins the subnets. The next period’s are
added once its boundary is within four epochs. A member sleeps until
SYNC_MESSAGE_DUE_BPS (SYNC_MESSAGE_DUE_BPS_GLOAS from gloas), reads
/eth/v1/beacon/blocks/head/root, signs it and submits one message per
validator. It refuses to sign when the node reports the head optimistic. A
member selected by the selection-proof hash for a subnet then waits for
CONTRIBUTION_DUE_BPS (CONTRIBUTION_DUE_BPS_GLOAS), fetches the node’s
contribution for that subnet and publishes it wrapped and signed. A subnet whose
contribution cannot be fetched is skipped without stopping the others. Duties
for wall slot S are those of the period containing S + 1, since members
assigned to slot S sign for S - 1. Both halves are bounded by the end of the
slot.
Not implemented: the builder flow and blinded blocks (the client asks for an unblinded block and refuses a blinded one), voluntary exits, doppelganger protection and remote signing.
Duty offsets come from the network
The slot length and the duty offsets are read from the beacon node’s
/eth/v1/config/spec, not divided out of a compiled-in constant. The
specification states them as SLOT_DURATION_MS plus basis points of it,
ATTESTATION_DUE_BPS and AGGREGATE_DUE_BPS, which on mainnet’s 12-second slot
work out at 3999 ms and 8000 ms. Gloas moves both earlier
(ATTESTATION_DUE_BPS_GLOAS, AGGREGATE_DUE_BPS_GLOAS) and adds
PAYLOAD_ATTESTATION_DUE_BPS (and PAYLOAD_DUE_BPS, which the node uses), so
the clock picks the pair by the fork of each slot’s own epoch. The sync
committee offsets (SYNC_MESSAGE_DUE_BPS, CONTRIBUTION_DUE_BPS and their
_GLOAS variants) are read and picked the same way.
SECONDS_PER_SLOT no longer exists in the specification and is accepted only as
a fallback, since deployed nodes still send it. A node sending both is required
to agree with itself.
Proposer duties are less durable than attester ones
Worth knowing if a proposal is ever missed after a reorg. An attester schedule depends on the block two epochs back and survives anything shallower; a proposer schedule depends on the block one epoch back, so a reorg that leaves attester duties untouched can still move a proposer between slots. This client fetches proposer duties once per epoch, at the boundary, so a reorg inside the epoch is not picked up until the next one.
The keymanager API
Off unless --enable-keymanager is passed. It serves GET, POST and
DELETE /eth/v1/keystores, authenticated with a bearer token read from
api-token.txt in the validators directory and generated there on first start
if absent. The token file is written 0600 and a file too short to be a real
token is refused rather than accepted.
It also serves GET, POST and DELETE on
/eth/v1/validator/{pubkey}/feerecipient, /graffiti and /gas_limit, which
give one validator its own value in place of the command line’s default:
| Setting | Default | Takes effect |
|---|---|---|
feerecipient | --suggested-fee-recipient | At the next epoch boundary, when the fee recipients are registered with the beacon node again. A block proposed before then pays the address the node was last given, and the check before signing reports the mismatch |
graffiti | --graffiti | The next proposal |
gas_limit | DEFAULT_GAS_LIMIT | Stored and served only: it matters to builder registrations, and there is no builder flow |
These are held in memory only. A restart puts every validator back on the defaults, so an operator driving these endpoints must set them again after one. Deleting a keystore drops its values too.
A few answers worth knowing: GET feerecipient is a 500 when the validator
has no address and there is no --suggested-fee-recipient default, as
Lighthouse answers it; POST feerecipient refuses the zero address, as the
specification requires; POST graffiti refuses text over 32 bytes rather than
truncating it; and every route answers 404 for a key this client does not
hold.
The keystore routes carry slashing-protection history. POST refuses every
key in the request if its slashing_protection does not parse or is for
another chain, and otherwise writes that history before any key is saved, so a
key never loads ahead of it; a validator the history names without a keystore
in the request gets a record too. DELETE
returns the deleted keys’ records as a minimal EIP-3076 interchange, answering
not_active for a key that is gone but still has a record.
The specification requires TLS. This binds to --http-address, loopback by
default; exposing it further means putting a TLS terminator in front.
Metrics
Served on --metrics-port, prefixed ethlambda_validator_ rather than the
lean_ used elsewhere in this repository, since this process follows the
beacon chain. Every series is registered at startup so it reads zero rather
than being absent before its first event, which is what lets an alert fire on
“attestations stopped”. See Metrics.
slashing-protection
Moves the validator client’s EIP-3076 history in and out of its
--validators-dir, offline. It takes the lock the client holds while running,
so it refuses while a client runs on that directory, and it needs no beacon
node: the record names its own chain.
# Bring a key's history from another client before its first start here.
ethlambda slashing-protection import --validators-dir ./validators interchange.json
# Hand the history over before running the keys elsewhere.
ethlambda slashing-protection export --validators-dir ./validators out.json
ethlambda slashing-protection export --validators-dir ./validators --pubkeys 0xab..,0xcd.. out.json
| Sub-command | Does |
|---|---|
import <file> | Merges a version 5 interchange, complete or minimal, into the record. Each validator is reduced to its highest slot, source and target and merged by maximum, so nothing already recorded is lowered and slashable data in the file is absorbed rather than refused, except an attestation whose source is at or past its target outside genesis, which is refused. All or nothing. With no record yet, the import creates one for the interchange’s chain, and a failed import creates nothing; the client refuses the record at its next start if that is not its beacon node’s chain |
export [--pubkeys ...] <file> | Writes the record, or the named validators’ part of it, as a minimal version 5 interchange, readable by its owner only. A named validator with no record fails the export, so a typo or the wrong directory cannot produce a file that reads as complete. Takes the record’s lock, so the directory must be writable. Refuses to overwrite an existing file |
benchmark import flags
Neither sub-command follows a chain; see benchmarking.md
for what the workload measures and why it exists.
benchmark import fetch
| Flag | Default | Meaning |
|---|---|---|
--url | required | Base URL of the source beacon node, e.g. http://127.0.0.1:5052 |
--from | required | First slot of the range, and its first sample, so it must hold a block. The anchor is the block at the first slot of the epoch before it; the blocks in between are fetched too, and replayed as unsampled warm-up |
--to | required | Last slot of the range, inclusive. May not pass the source’s head. Otherwise not capped: fetch streams, so a long range costs disk and time but not memory |
--corpus | required | Corpus directory to create |
--force | false | Replace an existing corpus in that directory |
--network | mainnet | Which network the source follows |
benchmark import replay
| Flag | Default | Meaning |
|---|---|---|
--corpus | required | Corpus directory written by fetch |
--network | mainnet | Which network the corpus belongs to. Checked against the manifest’s recorded genesis validators root before anything is built |
--data-dir | required | Where to build the replay’s RocksDB store. A real backend rather than the in-memory one, since state persistence is part of what is measured |
--force | false | Replace an existing store in that directory |
--safe-slots-to-import-optimistically | the specification’s own value | How far behind the wall clock a block must be before it may be imported optimistically on age alone. Exposed for parity with beacon’s own flag of the same name; a corpus replay never sees the merge transition block it gates |
--format | human | human or json |
--output | none | Also write the JSON report to this file |
HTTP API
ethlambda exposes HTTP over two independent Axum servers on separate ports, so the API and the metrics/debug surface can have different network policies:
- API server — consensus data (blocks, states, checkpoints, fork choice) and admin controls.
- Metrics & debug server — Prometheus metrics and heap-profiling endpoints. No store access.
Which API server a node serves follows from its store’s chain tag, not from the
sub-command: ethlambda node serves the lean surface under /lean/v0,
ethlambda beacon serves the Beacon API
under /eth/v1 and /eth/v2. The two are alternatives, never merged, because the
lean handlers read state variants and metadata keys a beacon directory does not
carry. Roots are serialized as 0x-prefixed hex strings on both.
Servers & Ports
| Flag | Default | Description |
|---|---|---|
--http-address | 127.0.0.1 | Bind address shared by both servers |
--api-port | 5052 | API server port |
--metrics-port | 5054 | Metrics & debug server port |
If --api-port and --metrics-port are equal, all routers are merged onto a single port.
API Server (:5052)
| Method | Path | Response | Description |
|---|---|---|---|
GET | /lean/v0/health | JSON | Liveness check |
GET | /lean/v0/config/spec | JSON | Protocol constants the node runs with |
GET | /lean/v0/genesis | JSON | Genesis time and validator count |
GET | /lean/v0/states/finalized | SSZ | Latest finalized State |
GET | /lean/v0/blocks/finalized | SSZ, or JSON on request | Latest finalized SignedBlock |
GET | /lean/v0/checkpoints/justified | JSON | Latest justified Checkpoint |
GET | /lean/v0/events | SSE | Live stream of chain events |
GET | /lean/v0/blocks/{block_id} | JSON | Block by root or slot |
GET | /lean/v0/blocks/{block_id}/header | JSON | Block header by root or slot |
GET | /lean/v0/fork_choice | JSON | Fork-choice tree with per-block weights |
GET | /lean/v0/fork_choice/ui | HTML | Interactive D3.js visualization |
GET | /lean/v0/node/identity | JSON | Client version and libp2p peer ID |
GET | /lean/v0/node/syncing | JSON | Sync status relative to the wall clock |
GET | /lean/v0/admin/aggregator | JSON | Current aggregator role |
POST | /lean/v0/admin/aggregator | JSON | Toggle aggregator role at runtime |
GET /lean/v0/health
The handler emits a fixed, compact body (no whitespace):
{"status":"healthy","service":"lean-rpc-api"}
GET /lean/v0/config/spec
Protocol parameters the node is running on. Keys mirror the leanSpec constant names.
MILLISECONDS_PER_SLOT and MILLISECONDS_PER_INTERVAL reflect the network’s config
file rather than a compile-time constant, so a node on an 8-second network reports
8000 and 1600 here:
{
"MILLISECONDS_PER_SLOT": 4000,
"INTERVALS_PER_SLOT": 5,
"MILLISECONDS_PER_INTERVAL": 800,
"HISTORICAL_ROOTS_LIMIT": 262144,
"FORK_DIGEST": "12345678"
}
FORK_DIGEST is the 4-byte hex string (no 0x prefix) embedded in gossipsub topic names.
GET /lean/v0/genesis
{ "genesis_time": 1770407233, "validator_count": 16 }
validator_count is read from the head state’s validator registry. Lean validators are fixed at genesis (no churn), so it always equals the size of the genesis registry.
GET /lean/v0/states/finalized
SSZ-encoded State at the latest finalized checkpoint (Content-Type: application/octet-stream). The served state has its latest_block_header.state_root zeroed to match the canonical post-state representation the state transition produces, so checkpoint-sync peers reconstruct an identical state root. See Checkpoint Sync.
GET /lean/v0/blocks/finalized
SSZ-encoded SignedBlock at the latest finalized checkpoint. The genesis/anchor block has no stored signature, so a placeholder blank proof is synthesized and the endpoint still returns 200. Returns 404 only in the rare case where a non-genesis finalized block’s signature has been pruned below the finalized boundary and can no longer be served.
GET /lean/v0/checkpoints/justified
{ "slot": 128, "root": "0x1a2b…" }
GET /lean/v0/events
Server-Sent Events stream (Content-Type: text/event-stream) of live chain events published by the blockchain actor. Eight event types:
Payload fields mirror the Ethereum beacon-API eventstream where an analog exists: block is the block root, state the state root, and slot stands in for the beacon epoch. justified_checkpoint and aggregate are ethlambda extensions with no beacon topic.
| Event | Payload | Emitted when |
|---|---|---|
head | { "slot": 128, "block": "0x…", "state": "0x…" } | Fork choice selects a new head within HEAD_EVENT_RECENCY_SLOTS (32 slots) of the wall clock; no head events fire during catch-up |
block | { "slot": 128, "block": "0x…" } | A block is imported into the store |
justified_checkpoint | { "slot": 120, "block": "0x…", "state": "0x…" } | The justified checkpoint advances |
finalized_checkpoint | { "slot": 96, "block": "0x…", "state": "0x…" } | The finalized checkpoint advances |
block_gossip | { "slot": 128, "block": "0x…" } | A block is seen on the network, before import |
attestation | { "validator_id": 4, "data": { "slot": 128, "head": {…}, "target": {…}, "source": {…} } } | A single validator vote passes gossip validation (signature omitted) |
aggregate | { "participants": [0, 3, 4], "data": { "slot": 128, "head": {…}, "target": {…}, "source": {…} } } | A committee-signature aggregate is produced locally or accepted from gossip (proof omitted) |
chain_reorg | { "slot": 130, "depth": 1, "old_head_block": "0x…", "new_head_block": "0x…", "old_head_state": "0x…", "new_head_state": "0x…" } | The new head does not descend from the previous one; sent ahead of that head, and never gated on recency. slot is the new head’s; depth is how many slots the old head sat above the two heads’ common ancestor (the Beacon API’s definition, in slots; the lean_fork_choice_reorg_depth metric counts blocks walked instead) |
The topic name travels only on the SSE event: line; the data: line carries the flat JSON payload. Example frame:
event: head
data: {"slot":128,"block":"0x1a2b…","state":"0x3c4d…"}
Filtering with ?topics=
A required comma-separated list of event names selects which events to stream:
curl -N 'http://127.0.0.1:5052/lean/v0/events?topics=head,finalized_checkpoint'
Valid values are exactly the event names above: head, block, justified_checkpoint, finalized_checkpoint, block_gossip, attestation, aggregate, chain_reorg. A Beacon API topic this surface does not serve (data_column_sidecar, say) is refused as unknown. As in the Beacon API eventstream endpoint, topics is mandatory: there is no “subscribe to everything” default; list the topics you want.
| Status | Condition |
|---|---|
200 | Stream opened for the listed topics |
400 | topics is missing or empty, or any listed name is not a known topic (body names the offending value) |
Events are fanned out over a single bounded broadcast channel shared by all topics. A client that reads too slowly skips past the events it missed: they are dropped for that subscriber rather than back-pressured onto the actor, so treat the stream as best-effort and re-sync via the blocks endpoints after a gap. A client that falls behind receives an SSE comment line : error - dropped N messages marking the gap (wire-compatible with Lighthouse) before the stream continues; re-sync via the blocks endpoints rather than trusting the skipped range. Keep-alive comments are sent periodically to hold idle connections open.
Because the ring buffer is shared, the high-rate attestation events (roughly one per validator per slot) dominate its occupancy: a subscriber’s tolerable stall is capacity / total_event_rate, not per-topic, so filtering with ?topics= narrows what you receive but does not widen the lag window against an attestation flood. Subscribers that only need low-rate topics (head, finalized_checkpoint, …) are still evicted at the aggregate rate. If real usage shows this biting, the fix is a per-topic channel split behind the event bus (the subscribe(TopicSet) API is unaffected).
GET /lean/v0/blocks/{block_id} and /header
block_id is either:
- a
0x-prefixed 32-byte hex root, or - a decimal slot.
Slot lookups resolve through the head state’s historical_block_hashes, so only canonical blocks are reachable by slot; blocks on side forks must be addressed by their root. The /header variant returns just the BlockHeader.
| Status | Condition |
|---|---|
200 | Block (or header) found, returned as JSON |
400 | block_id is neither a valid 0x root nor a decimal slot |
404 | No block at that root, or the slot is empty / out of range |
Error bodies are JSON: { "error": "invalid block_id" } / { "error": "block not found" }.
GET /lean/v0/fork_choice
The fork-choice tree from the finalized root, with LMD-GHOST weights computed over the live chain and currently known attestations.
{
"nodes": [
{ "root": "0x…", "slot": 128, "parent_root": "0x…", "proposer_index": 3, "weight": 12 }
],
"head": "0x…",
"justified": { "slot": 128, "root": "0x…" },
"finalized": { "slot": 96, "root": "0x…" },
"safe_target": "0x…",
"validator_count": 16
}
/lean/v0/fork_choice/ui serves an interactive D3.js page rendering this data. See Fork Choice Visualization.
GET /lean/v0/node/identity
{
"version": "ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.97.1",
"peer_id": "16Uiu2HAm7v1x…"
}
version is the full client version string, identical to what ethlambda --version prints: crate semver, git branch and short SHA, target triple, and rustc version. Baked in at compile time from CARGO_PKG_VERSION plus the vergen-git2 build metadata.
peer_id is the node’s libp2p peer ID (base58), derived from the node key and fixed for the lifetime of the process; it matches the identity the node presents to peers on the wire.
GET /lean/v0/node/syncing
{ "is_syncing": false, "head_slot": 1024, "sync_distance": 1, "finalized_slot": 986 }
is_syncing is the node’s own stateful sync decision: head-vs-wall-clock lag with hysteresis and a network-stall override, updated each tick. It is the same signal that gates validator duties and drives the lean_node_sync_status metric, so the endpoint, the gate, and the metric always agree.
sync_distance is the raw number of slots between the node’s current head and the current wall-clock slot, computed per request. Because is_syncing carries hysteresis and stall handling and is not recomputed from sync_distance, the two can point different ways near the threshold or during a network-wide stall.
GET / POST /lean/v0/admin/aggregator
Toggle the aggregator role at runtime without restarting the node (hot-standby model, ported from leanSpec PR #636).
# Read current role
curl http://127.0.0.1:5052/lean/v0/admin/aggregator
# → {"is_aggregator": true}
# Toggle role; body must be a JSON boolean
curl -X POST http://127.0.0.1:5052/lean/v0/admin/aggregator \
-H 'content-type: application/json' -d '{"enabled": false}'
# → {"is_aggregator": false, "previous": true}
| Status | Condition |
|---|---|
200 | Role read / set |
400 | Missing/malformed body, missing enabled, or enabled not a JSON boolean (integers 0/1 and strings are rejected) |
503 | Aggregator controller not wired (does not occur in normal main.rs boot) |
Note: Runtime toggles do not resubscribe gossip subnets, which are frozen at startup. A standby aggregator should boot with
--is-aggregator=true(so subscriptions are in place), then use this endpoint to rotate duties. See the CLAUDE.md “Runtime Aggregator Toggle” notes for the operational model.
Beacon API Server (:5052, on ethlambda beacon)
The subset of the Ethereum Beacon API
that this node can answer from its own store. It replaces the /lean/v0
surface rather than sitting beside it; a /lean/v0 path on a beacon node is a
404.
| Method | Path | Response | Description |
|---|---|---|---|
GET | /eth/v2/beacon/blocks/{block_id} | JSON or SSZ | SignedBeaconBlock at block_id |
GET | /eth/v1/beacon/blocks/{block_id}/root | JSON | That block’s root |
GET | /eth/v1/beacon/execution_payload_envelopes/{block_id} | JSON or SSZ | Gloas SignedExecutionPayloadEnvelope of that block (see below) |
GET | /eth/v1/beacon/headers/{block_id} | JSON | SignedBeaconBlockHeader, plus canonical |
GET | /eth/v2/debug/beacon/states/{state_id} | JSON or SSZ | BeaconState at state_id |
GET | /eth/v1/beacon/states/{state_id}/finality_checkpoints | JSON | That state’s three checkpoints |
GET | /eth/v1/beacon/states/{state_id}/fork | JSON | That state’s Fork: previous and current version, and the epoch it changed |
GET | /eth/v1/beacon/genesis | JSON | Genesis time, validators root, fork version |
GET | /eth/v1/config/spec | JSON | The store’s Config, plus PRESET_BASE, CONFIG_NAME, the preset and the constants (see below) |
GET | /eth/v1/config/deposit_contract | JSON | The Config’s deposit chain id and contract address |
GET | /eth/v1/config/fork_schedule | JSON | Every scheduled fork as {previous_version, current_version, epoch} |
GET | /eth/v1/node/syncing | JSON | Head slot, sync distance, whether the head is optimistic |
GET | /eth/v1/node/health | (status only) | 200 caught up, 206 syncing or on an optimistic head |
GET | /eth/v1/node/version | JSON | Client version string |
GET | /eth/v1/node/identity | JSON | Peer ID, ENR and listen multiaddrs (see below) |
GET, POST | /eth/v1/beacon/states/{state_id}/validators | JSON | Registry entries by index or pubkey, with status |
GET | /eth/v1/beacon/states/{state_id}/validators/{validator_id} | JSON | One registry entry, by index or pubkey; 404 if there is none |
GET | /eth/v1/validator/duties/proposer/{epoch} | JSON | Proposers for any epoch from the head’s up to one past the wall clock’s |
GET | /eth/v2/validator/duties/proposer/{epoch} | JSON | The same, with v2’s dependent_root (what Lighthouse asks for) |
POST | /eth/v1/validator/duties/attester/{epoch} | JSON | Committee assignments for the given indices |
POST | /eth/v1/validator/duties/sync/{epoch} | JSON | Sync committee seats for the given indices, up to the wall clock’s current period plus one |
POST | /eth/v1/validator/liveness/{epoch} | JSON | Whether this node saw each validator act in the epoch (doppelganger protection) |
POST | /eth/v1/validator/duties/ptc/{epoch} | JSON | Payload timeliness committee seats for the given indices (gloas) |
GET | /eth/v1/validator/attestation_data | JSON | What to attest to at slot |
GET | /eth/v1/validator/payload_attestation_data | JSON or SSZ | What a committee member signs for slot (gloas) |
POST | /eth/v2/beacon/pool/attestations | (status only) | Validate and gossip SingleAttestations (JSON or SSZ body) |
GET, POST | /eth/v1/beacon/pool/proposer_slashings | JSON | The operation pool’s ProposerSlashings; validate, pool and gossip one |
GET, POST | /eth/v2/beacon/pool/attester_slashings | JSON | The pool’s AttesterSlashings (GET carries Eth-Consensus-Version, the fork of the wall clock’s current epoch); validate, pool and gossip one |
GET, POST | /eth/v1/beacon/pool/voluntary_exits | JSON | The pool’s SignedVoluntaryExits; validate, pool and gossip one |
GET, POST | /eth/v1/beacon/pool/bls_to_execution_changes | JSON | The pool’s SignedBLSToExecutionChanges; POST takes an array |
POST | /eth/v1/beacon/pool/payload_attestations | (status only) | Validate, pool and gossip PayloadAttestationMessages (gloas; JSON or SSZ body) |
GET | /eth/v1/beacon/pool/payload_attestations | JSON | The pool’s votes as aggregated PayloadAttestations (gloas) |
POST | /eth/v1/validator/beacon_committee_subscriptions | (status only) | Aggregators’ entries join their committee’s subnet |
GET | /eth/v2/validator/aggregate_attestation | JSON | The pooled votes for a data root and committee, aggregated |
POST | /eth/v2/validator/aggregate_and_proofs | (status only) | Validate and gossip SignedAggregateAndProofs (JSON or SSZ body) |
POST | /eth/v1/beacon/pool/sync_committees | (status only) | Validate, pool and gossip SyncCommitteeMessages |
GET | /eth/v1/validator/sync_committee_contribution | JSON | The pooled messages for (slot, subcommittee_index, beacon_block_root), aggregated |
POST | /eth/v1/validator/contribution_and_proofs | (status only) | Validate, pool and gossip SignedContributionAndProofs |
POST | /eth/v1/validator/sync_committee_subscriptions | (status only) | Join sync committee subnets until an epoch |
GET | /eth/v3/validator/blocks/{slot} | SSZ or JSON | An unsigned fulu block built on the head (produceBlockV3) |
POST | /eth/v4/validator/blocks/{slot} | SSZ or JSON | An unsigned gloas block: self-built, with its envelope and blobs when asked, or on a pooled builder bid (produceBlockV4) |
GET | /eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root} | SSZ or JSON | The unsigned envelope produceBlockV4 built (gloas) |
POST | /eth/v2/beacon/blocks | (status only) | Gossip and import a signed fulu or gloas block (publishBlockV2; JSON or SSZ body) |
POST | /eth/v1/beacon/execution_payload_envelopes | (status only) | Gossip a signed envelope and its data columns (gloas; JSON or SSZ body) |
POST | /eth/v1/beacon/execution_payload_bids | (status only) | Validate, pool and gossip a builder’s SignedExecutionPayloadBid (gloas; JSON or SSZ body) |
POST | /eth/v1/validator/proposer_preferences | (status only) | Validate, cache and gossip SignedProposerPreferences (gloas; JSON or SSZ body) |
POST | /eth/v1/beacon/states/{state_id}/builders | JSON | The gloas builder registry, filtered by id and status |
POST | /eth/v1/validator/prepare_beacon_proposer | (status only) | Record each validator’s fee recipient for block production (see below) |
GET | /eth/v1/events | SSE | Live stream of chain events (see below) |
Validator endpoints
These are what ethlambda validator needs to attest through this node. Every
answer is computed from the fork-choice head’s post-state, read off the store
the chain actor writes, so no request waits on the actor.
- Duties are bounded by the wall clock, as the Beacon API defines it: any
epoch up to one past the current one is served (or one past the head’s, if
the head is ahead of a lagging clock), so a validator client’s next-epoch
lookahead at an epoch boundary, while the head is still in the previous
epoch, is answered. Proposer duties (v1 and v2) read fulu’s
proposer_lookahead, which covers the head’s epoch and the next; attester duties read the head state as it is for its previous, current and next epoch. A later epoch is computed from a copy of the head advanced withprocess_slotsto the first epoch that can derive it, taken from fork choice’scheckpoint_statecache and run on a blocking thread. An epoch before the head’s (attester: more than one before) or past the bound is a400.dependent_rootfollows each endpoint’s own definition: the last block before the epoch for v1 proposer duties, and the last block before the previous epoch for v2 proposer duties and attester duties. v2’s is the block fulu’s lookahead depends on, so it changes only on reorgs that can change the duties. Proposer duties v2 is a503while syncing. Attester duties walk every committee of the epoch, a full shuffle per request on mainnet. Gloas epochs are served like fulu ones: gloas keeps fulu’sproposer_lookaheadandupgrade_to_gloascarries it over, so a fulu head already answers proposer duties (v1 and v2) for the first gloas epoch, and a gloas head answers from its own lookahead. - Sync duties read the head state’s
current_sync_committeefor an epoch in the head’s own sync committee period andnext_sync_committeefor the one after. The upper bound is the wall clock’s, as the Beacon API defines it: the period after the clock’s current one. When the head lags a period boundary (its block is late or missing), the later period is read from a copy of the head advanced to the first epoch of the period before it, through fork choice’s checkpoint-state cache on a blocking thread. A period past that bound or before the head’s is a400(an earlier one would need a historical state; seedocs/spec_deviations.md). A validator is matched by pubkey and gets every seat it holds, since the committee is drawn with replacement; one with no seat is left out. An unknown index is a400, and the endpoint is a503while the node is syncing. What a duty asks for is served by the four sync committee endpoints in Sync committee below. - Liveness is this node’s own view, which the Beacon API allows. A
validator is live in an epoch if the head state credits it for that epoch (a
non-zero participation byte, so anything a block already included), or
the node observed it act: an accepted gossip aggregate (its aggregator and
every attester its signature verified) or subnet attestation, an imported
block’s proposer, or a submission through
pool/attestationsoraggregate_and_proofs. The observed half covers what no block has included yet, most of the current epoch; it keeps the newest three epochs recorded, as one bitset each. The window is the store clock’s previous, current and next epoch (the next is alwaysfalse); anything else, and an unknown index, is a400, and the endpoint is a503while the node is syncing. attestation_datafollows phase0’svalidator.md: the head block, the epoch’s boundary block as target, and as source the current justified checkpoint of the head state advanced to the slot’s epoch (through fork choice’s cachedcheckpoint_state, and only when the head is in an earlier epoch). A slot before the head, or past the wall clock, is a400. An optimistic head is a503: the Beacon API requires one whenever the answer’sbeacon_block_roothas not been validated by the execution client, and the head is always that root here. So is every request on a node run without an execution client, whose blocks are never marked optimistic because nothing validates them.committee_indexis optional and ignored: from electra on every committee of a slot attests to the same data.data.indexis0before gloas. At a gloas slot it is the payload-present flag:0when the head block is from the requested slot itself (its payload cannot be revealed yet), otherwise1exactly when fork choice holds the head block’s payload as FULL (Store::head_payload_status, or one freshget_head_nodewalk when none is recorded for this head). A pre-gloas head counts as FULL (see Spec Deviations).states/{state_id}/validators/{validator_id}is one entry of the list endpoint, in the same shape and with the samestate_idhandling,execution_optimisticandfinalized. The id is an index or a0xpubkey; one naming no validator is a404(the list form omits it), a malformed one a400. Lighthouse’s validator client resolves each key to its index this way, and stays inactive without it.pool/attestationschecks each attestation against the electrabeacon_attestation_{subnet_id}gossip conditions it can evaluate (clock window,data.index == 0, target epoch, the voted block known and the target its checkpoint block, committee membership, BLS signature), then gossips it on its subnet through fanout, without subscribing. A rejected attestation comes back in anIndexedErrorMessagewith its position; the valid ones in the same batch are still published. There is no seen-attestation cache. Each accepted attestation also goes into the node’s attestation pool, since gossip never delivers a node its own messages.Eth-Consensus-Versionmust name electra, fulu or gloas here and onaggregate_and_proofs, and must match the fork of each item’s slot: agloasheader on a pre-gloas slot, or an older one on a gloas slot, fails that item (not the batch) withEth-Consensus-Version does not match the attestation slot's fork. At a gloas slotdata.indexmay be0or1, checked with gloas’s payload-status gossip rule (a vote in the voted block’s own slot must be0;1needs the node to have verified the payload, otherwise the item fails). A gloasSingleAttestationis electra’s own container.beacon_committee_subscriptions: each aggregator’s entry makes the node join its committee’s attestation subnet until the end of that slot, so the committee’s votes from other validators reach the pool too: every attestation this node relays has passed thebeacon_attestation_{subnet_id}checks first, and the ones accepted on a joined subnet are pooled. Joined subnets are left once their slot has passed, and never appear inattnets.aggregate_attestationanswers from the pool: every vote held for the data root and committee, as electra’sAttestationwith the BLS aggregate of their signatures; at a gloas slot the answer is{version: "gloas", data}with gloas’sAttestation(the same JSON shape). JSON only.404when nothing is held, and503when the block the votes name is optimistic, whether or not it is the head, or when no execution client is configured.aggregate_and_proofschecks each aggregate with the samebeacon_aggregate_and_proofgossip conditions this node applies to its peers’ aggregates (gossip::aggregate), signatures included, against a fresh seen-cache (a node never receives its own messages, so P2P’s says nothing about them). What passes is gossiped on the topic, goes into the pool, and is handed to the chain actor, which applies it to fork choice and announces it on/eth/v1/events’sattestation. TheEth-Consensus-Versionheader picks the decoder:gloastakes gloas’sSignedAggregateAndProof, the others electra’s.- Request body encodings.
pool/attestations,pool/payload_attestationsandaggregate_and_proofstake either JSON (application/json, also the reading of a request with noContent-Type) or the SSZList[...]of the same items (application/octet-stream): the offset-table encoding every variable-size list uses, which is what prysm sends for aggregates and payload votes, with a JSON retry only on a415, and what nimbus can send for attestations. Any other content type is a415, and a body that does not decode in the type’s encoding a400.POST beacon/blocksandPOST beacon/execution_payload_envelopeschoose the same way (below); the other validator-client submissions are JSON. - The attestation pool holds, the best-covered per data root and
committee: votes from
pool/attestationsand the aggregator subnets, aggregates fromaggregate_and_proofs, and every electra gossip aggregate P2P accepts (gloas aggregates included, in gloas’s container), once all three of its signatures have verified. Gossip aggregates are pooled on arrival, so a slot’s aggregates are there when the next slot’s block is asked for. Entries more than an epoch old are dropped once a slot. prepare_beacon_proposerrecords each validator’s fee recipient, in memory (a validator client repeats the call every epoch).blocks/{slot}advances the head state to the slot and asks the node’s own execution client to build on the head (forkchoiceUpdatedV3with payload attributes, thengetPayloadV5), with the proposer’s fee recipient. The body packs the pool’s best aggregates (committees voting alike merged into one EIP-7549 attestation, up toMAX_ATTESTATIONS_ELECTRA). A candidate is packed only if its target root is the advanced state’s own block root for that epoch and its aggregate signature verifies against that state, so an aggregate made on another branch cannot fail the whole block. The body also votes the state’s owneth1_data, and carries the operation pool’s slashings, exits and BLS changes, packed bypack_operations(each kept only if itsprocess_*succeeds on a scratch copy of the state with everything packed before it applied, up to the preset’s maximums), and the sync aggregate described under Sync committee (empty when nothing verifiable is pooled). The payload bundle’s KZG commitments go in the body. The state root comes from running the block throughprocess_block. The answer is fuluBlockContents: the block, the bundle’s cell proofs and its blobs, withEth-Execution-Payload-Blinded: false; there is no builder flow. It is a503without a configured execution client, or when the bundle is malformed (its commitments, proofs and blobs disagree in count). A slot the schedule does not place at fulu (gloas included) is a400, checked before the execution client;produceBlockV3is the fulu endpoint, and a gloas slot goes toproduceBlockV4.- Graffiti is the validator client’s, with both clients’ codes and
commits appended as far as they fit:
hello RH1a2bLA3c4dfor reth at1a2b…under ethlambda at3c4d…. The execution client’s half comes fromengine_getClientVersionV1, asked alongside the payload build and given up on afterEL_VERSION_TIMEOUT, and is left out when it cannot be had (hello LA3c4d). The suffix shrinks rather than cutting the validator’s text short, in Lighthouse’s tiers, and is appended to every block with no opt-out; thegraffiti_policyparameter Lighthouse’s validator client sends is not read.LAis ethlambda’s own code:identification.mdreserves none for it and lets an unlisted client pick any two letters no listed client uses. POST beacon/blockstakes JSON or SSZ, byContent-Typeas above, and theEth-Consensus-Versionheader (fuluorgloas, else400; any other content type is a415). The JSON is the specification’s: lodestar’s and nimbus’s validator clients post it, teku’s falls back to it, and lighthouse and prysm post SSZ. Both reach the same checks after decoding. Fulu: aSignedBlockContents. A slot the schedule does not place at fulu is a400, checked before any state is advanced. The block is then checked against its parent’s state advanced to its slot: an unknown parent, a slot not after the parent’s, the wrong proposer or a bad proposer signature is a400. Then every blob’s cells are computed and all cell proofs batch-verified on a blocking thread (400on any failure, nothing gossiped); the time that takes islean_beacon_publish_data_columns_seconds. Then the block is gossiped onbeacon_block, then, if it carries blobs, all 128 data column sidecars, each ondata_column_sidecar_{compute_subnet_for_data_column_sidecar(index)}, and last the chain actor gets this node’s custody columns, then the block, to import. Gloas: a baregloas::SignedBeaconBlock(a gloas block carries no payload or blobs, so no sidecars go with it; they follow in the envelope). The slot must be scheduled at gloas, the parent must be held, the slot must be after the parent’s, and the proposer and signature are checked against the parent’s state advanced to the slot (all400otherwise), before the block is gossiped and handed to the chain actor.- The operation pool holds, beside the attestation pool and in memory in
the same
Store(Store::operation_pool): proposer slashings (one per proposer), attester slashings (one perhash_tree_root), voluntary exits and BLS changes (one per validator), first one wins. Entries come from the fourpool/*POST routes and from gossip P2P accepts, so each has passed the topic’s gossip rules; the pool never checks a signature. Entries the head state has made pointless are pruned. pool/proposer_slashings,pool/attester_slashings,pool/voluntary_exitsandpool/bls_to_execution_changes:GETlists the pool.POSTruns the specification’svalidate_*_gossiprule for the topic against the head’s post-state as it is (consensus-specs v1.7.0-beta.1; noprocess_*and no state advance, and an attester slashing’s range and slashability checks run before its BLS checks), pools the operation and gossips it on the topic. A rejected operation is a400.bls_to_execution_changestakes an array: on partial failure the response is a400listing the failures by index, and the valid entries are still pooled and published.
Gloas block production
A gloas proposer signs two things on their own, the block and the envelope that
reveals its payload, and this node serves both halves for a block it builds
itself. It may instead build on a builder’s bid it holds in the shared builder
market (see Builder market); then the builder, not this node,
reveals the payload. The builder-API path (a bid requested from a builder’s URL
through the BuilderConfig’s builders entries) is not implemented: those
entries are decoded and not consulted.
POST /eth/v4/validator/blocks/{slot} (produceBlockV4):
| Query | randao_reveal (required), include_payload (required, true or false), graffiti (optional, 32-byte hex), skip_randao_verification (accepted, ignored) |
| Request headers | Eth-Consensus-Version is optional but must be gloas when present. Accept: application/octet-stream for SSZ, JSON otherwise |
| Body | A BuilderConfig, JSON or SSZ with Content-Type: application/octet-stream: min_bid and builder_boost_factor (govern the bids seen over p2p) and builders (up to 64 entries, decoded and ignored). Integers are quoted strings, as everywhere in the Beacon API. A missing or undecodable one is a 400 |
200 SSZ | Self-built with include_payload=true: BlockContents (block, execution_payload_envelope, kzg_proofs, blobs). Otherwise, and for every bid-won block, the bare gloas::BeaconBlock |
200 JSON | {version: "gloas", consensus_block_value, execution_payload_value, execution_payload_included, data}, where data is the same container as the SSZ body |
| Response headers | Eth-Consensus-Version: gloas, Eth-Execution-Payload-Included (true or false; always false for a bid-won block), Eth-Execution-Payload-Value (wei, decimal; a bid’s value times 10^9 when it won), Eth-Consensus-Block-Value (always 0: the comparison is of execution payload values only). No Eth-Builder-Url: that is for builder-API bids |
400 | A slot the schedule does not place at gloas, a missing or malformed query, a wrong Eth-Consensus-Version, a bad body, a slot not after the head block, or a randao_reveal that does not verify against the slot’s proposer. The slot check precedes the execution-client check, so it is a 400 on any node |
503 | No execution client and no viable pooled bid for the slot, the execution client did not start or return a build (and no bid took its place), the node is building on a FULL parent whose envelope it does not hold, or the winning bid failed to build and there is no local payload to fall back on |
The node advances the head state to the slot, and decides which parent payload
to build on with should_build_on_full over the payload status fork choice
recorded for the head. It then asks its execution client to build
(forkchoiceUpdatedV4 with PayloadAttributesV4, then getPayloadV6). The
fee recipient and gas target of that build come from the proposer’s signed
ProposerPreferences for the slot when the market holds them under the slot’s
dependent root, else from prepare_beacon_proposer (fee recipient, or the zero
address with a warning) and the parent bid’s gas limit. The body packs the
attestation pool’s best aggregates and the payload attestation pool’s votes for
the parent block (an aggregate that does not verify against the advanced state
is dropped, since one bad operation fails the block), and the state root comes
from running the block through process_block. The bid is a zero-value
self-build bid read off the built payload. What was built is cached by (slot, block root), for the current and previous slot only.
Choosing between the local payload and a bid. The bids considered are the
market’s for (slot, head root, parent payload hash) with this build’s
prev_randao, packable into a block on the advanced state, and paying the
preferences’ fee recipient when those are held. The best bid is the highest
whose value + execution_payment is at least the config’s min_bid. It wins iff
builder_boost_factor * bid_gwei > floor(local_value_wei / 10^7) (the
specification’s weighting of the local value by 100, in integers), and the local
payload wins a tie, so a factor of 0 prefers it and 2^64 - 1 prefers the bid.
An engine that sets shouldOverrideBuilder keeps the local payload. Without a
local payload (no engine, or the build failed) the best bid is taken whatever its
weight. A bid-won block caches nothing, so the envelope GET below answers
404 for it, and POST /eth/v1/beacon/execution_payload_envelopes refuses a
proposer-signed (self-build) envelope for it because the builder index differs
from the bid’s. The builder’s own envelope goes through that endpoint as for any
block.
GET /eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}
serves the cached unsigned envelope, for a client that asked for the block with
include_payload=false: {version: "gloas", data} as JSON, or the SSZ
ExecutionPayloadEnvelope on Accept: application/octet-stream, with
Eth-Consensus-Version: gloas. 404 when nothing is cached for the pair
(unknown, or older than the previous slot); 400 for an unparseable slot or
root.
POST /eth/v1/beacon/execution_payload_envelopes takes the signed envelope,
as JSON or SSZ by Content-Type (any other is a 415; teku’s validator client
posts JSON, nimbus posts the bare envelope as JSON and the contents as SSZ,
lighthouse posts SSZ, prysm posts SSZ and retries as JSON on a 415), with
Eth-Consensus-Version: gloas and Eth-Blob-Data-Included (both required, else
400):
true: the body isSignedExecutionPayloadEnvelopeContents(the signed envelope,kzg_proofs,blobs).false: the body is the bare signed envelope, and the blobs and proofs come from the production cache.400when none is cached and the block’s bid commits to blobs.
The envelope’s block is waited for, polling every 50 ms for up to 4 seconds,
since a validator client publishes the envelope right after the block returns,
which is before the chain actor has imported it; a block still unknown after
that is a 400 (unknown block). The envelope must fulfill the block’s bid
(builder index and block hash), the blobs must match the bid’s commitments, the
signature must verify under DOMAIN_BEACON_BUILDER against the block’s
post-state, and every data column built from the blobs must verify against the
commitments and cell proofs; any failure is a 400. A success is 200 once the
envelope and all NUMBER_OF_COLUMNS gloas data column sidecars are handed to
P2P, which gossips them together. A node that does not subscribe to a column’s
subnet publishes through gossipsub fanout.
Builder market
Three endpoints serve the gloas builder market. Bids and preferences go through
the same rules as the execution_payload_bid and proposer_preferences gossip
topics and are held in one shared builder market that p2p also fills, so a
message accepted from either side is seen by both.
POST /eth/v1/beacon/execution_payload_bidstakes oneSignedExecutionPayloadBid, as JSON (or noContent-Type) or SSZ (application/octet-stream, at most 196,932 bytes; any other type is415).Eth-Consensus-Versionis optional and must begloaswhen present. An identical bid already pooled is200and is not republished. Otherwise the gossip rules run (a known parent, the preferences for the slot and their fee recipient and gas limit, a known parent payload, an active funded builder, the signature), and a bid that is anything but accepted is a400whose message is"{outcome}: {reason}"(for instancereject: bad_signatureorignore: preferences_unseen), because there is no status for a valid bid this node would not relay. An accepted bid is pooled for block production and gossiped.500when the network actor is down.POST /eth/v1/validator/proposer_preferencestakes a list ofSignedProposerPreferences(at most(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH, 64 on mainnet), as a JSON array or an SSZ list.Eth-Consensus-Versionis optional and may befuluorgloas, since a validator client submits during the epoch before the fork. Each entry runs the gossip rules in order; an entry already cached, identical, succeeds without a republish. Accepted entries are cached (the first per(proposal slot, dependent root)wins) and gossiped. If any entry fails the answer is400{code, message, failures: [{index, message}]}and the others were still published. The endpoint is not gated on the sync status. A node whose dependent block is unknown answersignore: unknown_blockfor that entry.POST /eth/v1/beacon/states/{state_id}/builderstakes an optional JSON body{ids?: [index | 0x pubkey], statuses?: [pending | active | exited]}(empty or absent selects everything) and answers{execution_optimistic, finalized, data: [{index, status, builder}]}in registry order, integers quoted. An id naming no builder is omitted.exitedis a setwithdrawable_epoch, elseactiveperis_active_builder, elsepending.400for a malformed body, id or status, or a pre-gloas state;404for an unknown state. The Beacon API defines noGETform.
POST /eth/v1/validator/builder_preferences is not served (404).
Sync committee
All four endpoints are JSON only (beacon-APIs lists no SSZ body for them), and
read integers quoted or bare. All share one SharedSyncCommitteePool, which
P2P fills from accepted gossip as well. None of it reaches the chain actor:
sync committee votes have no fork-choice effect.
POST /eth/v1/beacon/pool/sync_committeestakes a JSON array ofSyncCommitteeMessages and needs noEth-Consensus-Version. Each is checked against the head state with the rulessync_committee_{subnet_id}gossip applies (the current slot; the validator holds a seat in the committee its slot names, which is the next committee at a period’s last slot; the signature), then pooled once per seat and published on every distinct subnet its seats sit on. One BLS verification per message, on a blocking thread. Not gated on syncing, likepool/attestations. A refused message comes back in anIndexedErrorMessage(400) as{outcome}: {reason}with its position, and the rest still go out. A body that is not a list of messages is a400with nofailures; a dead network actor is a per-item failure.GET /eth/v1/validator/sync_committee_contribution?slot=&subcommittee_index=&beacon_block_root=answers{data: SyncCommitteeContribution}(noversion): the best pooled contribution for the key, extended by every pooled direct message at a position it does not cover.404when the pool holds nothing for it,400whensubcommittee_indexis not belowSYNC_COMMITTEE_SUBNET_COUNT, and503while the node is syncing or when the block is optimistic or unknown (an aggregator must not sign over a root this node cannot vouch for).POST /eth/v1/validator/contribution_and_proofstakes a JSON array ofSignedContributionAndProofs. Each passes the checkssync_committee_contribution_and_proofgossip applies (current slot, subcommittee in range, a participant, the selection proof selects the aggregator, aggregator is in the subcommittee, and the selection proof, envelope and aggregate signatures) against a fresh seen cache, since P2P’s holds what peers sent. A valid one is pooled and gossiped; the failure shape is the same as above.POST /eth/v1/validator/sync_committee_subscriptionstakes[{validator_index, sync_committee_indices, until_epoch}]. The subnet of a position isindex / SYNC_SUBCOMMITTEE_SIZE; entries are grouped by subnet keeping the latestuntil_epoch(exclusive), clamped to the end of the next sync committee period, and handed to P2P, which joins the subnets immediately. A position at or aboveSYNC_COMMITTEE_SIZEis a400and nothing is joined. A node forgets these on restart, so a client repeats them every epoch.sync_committee_selections(distributed validators) is not served; beacon-APIs lets a node leave it out.
Block production. The block at slot N packs only what is pooled for
(N - 1, parent_root): per subcommittee the best contribution, extended by
pooled direct messages at uncovered positions. The result is verified exactly
as process_sync_aggregate will check it, against the state the block is built
on; if it fails, or nothing is pooled, the block carries the empty aggregate.
Fulu (blocks/{slot}) and gloas (produceBlockV4) do the same, and the
“retry without operations” fallback of each also drops the sync aggregate.
Payload timeliness committee
POST /eth/v1/validator/duties/ptc/{epoch}takes a JSON array of quoted validator indices (an empty array or a bad index is a400) and answers{dependent_root, execution_optimistic, data: [{pubkey, validator_index, slot}]}: one duty per validator, the first slot of the epoch whose committee seats it. An index with no seat, or unknown, gets none. The epoch may be at most one past the later of the head state’s and the wall clock’s epoch (else400). An epoch before the gloas fork answers200with an emptydata, so a client can ask every epoch. A gloas head state answers from itsptc_window; an epoch the head has not caught up to, or the first gloas epoch under a fulu head, is answered from a copy of the head state advanced to the epoch’s first slot, crossing the gloas upgrade where it falls, on a blocking thread. A gloas epoch more than one before the head state’s is a400.dependent_rootis defined as for attester duties. There is no503.GET /eth/v1/validator/payload_attestation_data?slot=answers{version: "gloas", data: {beacon_block_root, slot, payload_present, blob_data_available}}as JSON, or the SSZPayloadAttestationDataonAccept: application/octet-stream, withEth-Consensus-Version: gloas. The data is for the block of that very slot on the head’s chain; a slot with no such block (or whose block is not a gloas block) answers204with no body, which a client reads as “cast no vote”. A slot before the gloas fork is a400, and a node that is syncing answers503, as does one run without an execution client or whose block has an unvalidated execution payload behind it (the same rule as attestation data).payload_presentis whether the block’s envelope reached this node before the slot’s start plusPAYLOAD_DUE_BPSof it, judged by arrival time (see Spec Deviations).blob_data_availableis true when the bid commits to no blobs, the payload is verified, or every column this node custodies for the block is stored.POST /eth/v1/beacon/pool/payload_attestationstakes a JSON array ofPayloadAttestationMessages;Eth-Consensus-Versionis optional and must begloaswhen present. Each message passes the checks gossip applies to a peer’s (the current slot, a known block at the slot, committee membership, signature) against a fresh seen-cache. A valid one is pooled and gossiped; one already pooled is neither pooled nor gossiped again and still counts as accepted. A rejected one comes back in anIndexedErrorMessage(400) as{outcome}: {reason}with its position, and the rest still go out; a head-state cache miss is a per-itemignore: state_unavailable.GET /eth/v1/beacon/pool/payload_attestations?slot=answers{version: "gloas", data}with the pool’s votes as the specification’sPayloadAttestations, those ofslotonly when given: one aggregate per slot andPayloadAttestationData, a bit per seat of that slot’s committee (get_ptcon the head state) and the seats’ signatures aggregated, the way block production packs them. The pool itself keeps the individual messages. JSON only; a vote whose slot falls outside the head state’s committee window is left out.
The payload attestation pool is shared with P2P, which fills it from accepted gossip, so a block this node builds carries the votes its peers sent too.
A cl_type: ethlambda participant in our ethereum-package fork
(lambdaclass/ethereum-package,
branch add-ethlambda) runs an ethlambda validator client that, by default,
talks to this node alone, so every block its validators propose is one this
node built.
Encoding
JSON is the default; SSZ is served on Accept: application/octet-stream,
which is the order lighthouse serves. An Accept listing both is ranked by its
q weights. Every response carrying a fork-versioned container also sets
Eth-Consensus-Version to the lowercase fork name, in both encodings, since SSZ
carries no type tag.
JSON here follows the Beacon API’s own encoding, which is not the lean
surface’s: every integer is a quoted decimal string, byte strings are
0x-prefixed hex, and Uint256 is quoted decimal rather than hex. Errors are
{"code": ..., "message": ...}, where the lean surface uses {"error": ...}.
Serving /eth/v2/debug/beacon/states/finalized as SSZ is what makes this client
checkpoint-syncable from itself: it is the exact path
checkpoint_sync.rs fetches from other clients.
GET /eth/v1/beacon/execution_payload_envelopes/{block_id}
Gloas moves a block’s execution payload into a separate signed envelope. This
serves the one the node has verified, as {version: "gloas", execution_optimistic, finalized, data} JSON or as SSZ on Accept: application/octet-stream, with
Eth-Consensus-Version: gloas either way. It is also what a checkpoint-syncing
node asks for the payload of its anchor (see
checkpoint_sync.md).
| Status | When |
|---|---|
400 | block_id is malformed |
404 | the block is unknown, or the node holds no envelope for it |
The second 404 is deliberately one status for several situations: the store
keeps verified envelopes only, so a withheld payload, a payload not yet
received and a pre-gloas block (which has no envelope; the specification’s
error list names no other status for it) are indistinguishable here. A caller
must read 404 as “unknown to this node”, not as “the payload is empty”.
execution_optimistic under gloas
Before gloas a block’s payload ran inside the block, so the flag is whether the block is in the store’s optimistic set. From gloas on the payload is a separate object that may arrive late or never, and two rules share the flag:
- an envelope response is optimistic iff that payload’s verdict is not
VALID(an unrecorded verdict reads asNOT_VALIDATED); - a block, header, state or finality-checkpoints response for a gloas block
is optimistic iff the latest FULL payload the block builds on is not
VALID. The walk starts at the block’s parent: if the block’s bid names the parent’s payload as its parent block hash it reads the parent’s verdict, and otherwise it steps back to the parent and repeats. A pre-gloas block on the way reads the optimistic set. The block’s own payload does not count, so a withheld payload does not make the chain look unverified.
Pre-gloas responses are unchanged. One helper implements both rules
(shared/optimistic.rs) and every response that carries the flag goes through
it.
GET /eth/v1/config/fork_schedule
The Fork objects of the Config’s schedule, oldest first, as
{previous_version, current_version, epoch} with the epoch quoted. Phase0 is
first, at epoch 0 and its own predecessor; every later fork whose epoch is not
FAR_FUTURE_EPOCH follows, and an unscheduled fork is skipped without breaking
the chain, so each entry’s previous_version is the one before it in the list.
Nimbus’s validator client reads this every epoch, requires that linked-list
shape, and marks a node it cannot decode as incompatible. Nimbus also compares
/eth/v1/config/spec against its own constants before use: the 13 preset
values, the 7 domain types, SECONDS_PER_SLOT (or SLOT_DURATION_MS, which
this node does not report) and every fork’s *_FORK_VERSION and *_FORK_EPOCH,
with altair scheduled. All are served (a unit test pins the list).
GET /eth/v1/config/spec
One flat object holding the network’s configuration, the compiled preset, and
the specification’s constants, as the Beacon API asks. Validator clients depend
on it: lighthouse’s refuses a beacon node whose PRESET_BASE does not match
its own, and treats an absent key as a mismatch.
The key set is lighthouse’s plus gloas’s preset and constant keys, read from
beacon::preset and beacon::constants rather than typed here: PTC_SIZE,
MAX_PAYLOAD_ATTESTATIONS, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD,
MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD, MAX_BUILDERS_PER_WITHDRAWALS_SWEEP,
the four gloas domains (DOMAIN_BEACON_BUILDER, DOMAIN_PTC_ATTESTER,
DOMAIN_PROPOSER_PREFERENCES, DOMAIN_BUILDER_DEPOSIT), the builder
withdrawal prefix and request types, and the BUILDER_INDEX_*,
BUILDER_PAYMENT_THRESHOLD_* and PAYLOAD_BUILDER_VERSION constants. Gloas’s
schedule, timing and churn keys are Config fields, so they were already
reported. Three keys are left out because the specification does not define
them (GAS_LIMIT_ADJUSTMENT_FACTOR, RESP_TIMEOUT, TTFB_TIMEOUT), and so are
gloas’s MAX_*_SIZE preset keys, which bound gossip message sizes the node does
not run on. Domain types and withdrawal prefixes are 0x-prefixed hex;
VERSIONED_HASH_VERSION_KZG is a decimal, as lighthouse reports it.
GENESIS_TIME is absent: /eth/v1/beacon/genesis reports it.
The configuration keys come from the Config the data directory was
initialized with. Some of them (the custody and subnet counts, the
MAX_REQUEST_* limits, MAX_PAYLOAD_SIZE, the snappy message domains,
MAXIMUM_GOSSIP_CLOCK_DISPARITY) the node runs on compile-time constants for,
rather than reading them from Config. Startup refuses a network whose
config.yaml sets any of them to a different value (see
cli.md), so what this endpoint reports is what the node uses.
CONFIG_NAME is the stored name: a resume under a renamed config.yaml warns
and keeps it.
Accepted ids, and three that are refused
block_id and state_id accept head, finalized, justified, a slot
number, and a 0x-prefixed 32-byte root. Two cases are deliberate refusals,
both recorded in Spec Deviations:
genesisis a404on either id.Table::BlockRootsindexes the canonical branch above the store’s anchor, and the anchor’s own slot is never written to it; a checkpoint-synced directory has no genesis block either way.- A
state_idgiven as a0x…root is a404: states are keyed by block root here, with no reverse index. The refusal names the ids that do work.
The anchor’s own slot does resolve, despite being absent from that index:
the lookup falls back to the roots the store can name and accepts one only when
the block under it really sits at that slot. This matters because it is the
slot a checkpoint-syncing peer asks for right after reading the finalized
state. A slot the store holds nothing at is still a 404.
/eth/v1/node/identity reports the peer id, the ENR discv5 publishes, and the
node’s QUIC, TCP and discv5 multiaddrs. The multiaddrs need
--discovery.advertise-ip, since a node bound to 0.0.0.0 does not know the
address peers reach it on; without the flag they are empty and the ENR carries
no IP. metadata is the MetaData the node answers peers with over req/resp,
read at request time, so syncnets and seq_number follow the sync committee
subnets a validator client asks for. That seq_number versions MetaData
alone: the p2p specification counts it separately from the ENR’s seq.
GET /eth/v1/events
The Beacon API eventstream: the same Server-Sent Events stream as
/lean/v0/events (topic on the event: line, payload on
data:, one shared best-effort ring, : error - dropped N messages on a gap),
with the Beacon API’s own topics and payloads. The response also sets
X-Accel-Buffering: no, so a reverse proxy does not hold events back.
curl -N 'http://127.0.0.1:5052/eth/v1/events?topics=head&topics=block,finalized_checkpoint'
topics is required, and may be repeated (?topics=head&topics=block, the
specification’s form) or comma-separated (?topics=head,block, which
lighthouse also accepts). Duplicates collapse. A missing or empty topics, or
a name that is not a Beacon API topic, is a 400 in the Beacon API error shape:
{"code":400,"message":"Invalid topic: weather_forecast"}.
Accepted is not emitted. Every topic in the specification is accepted, so a validator client subscribing to several at once is not turned away, but only these are ever sent:
| Event | Payload | Emitted when |
|---|---|---|
head | {"slot":"10", "block":"0x…", "state":"0x…", "epoch_transition":false, "previous_duty_dependent_root":"0x…", "current_duty_dependent_root":"0x…", "execution_optimistic":false} | The head changed, and is within 32 slots of the wall clock |
block | {"slot":"10", "block":"0x…", "execution_optimistic":false} | A block is imported |
block_gossip | {"slot":"10", "block":"0x…"} | A block passed gossip validation, or was published through POST /eth/v2/beacon/blocks |
finalized_checkpoint | {"block":"0x…", "state":"0x…", "epoch":"2", "execution_optimistic":false} | The finalized checkpoint advanced |
chain_reorg | {"slot":"200", "depth":"50", "old_head_block":"0x…", "new_head_block":"0x…", "old_head_state":"0x…", "new_head_state":"0x…", "epoch":"2", "execution_optimistic":false} | The new head does not descend from the previous one |
attestation | the aggregate’s Attestation | An aggregate passed the beacon_aggregate_and_proof rules: accepted from gossip, or submitted through POST /eth/v2/validator/aggregate_and_proofs |
data_column_sidecar | {"block_root":"0x…", "index":"1", "slot":"1"} | A column this node custodies is stored, from any source |
Never emitted: single_attestation (subnet votes are verified and relayed but
never reach the chain actor), the operation topics (voluntary_exit,
proposer_slashing, attester_slashing, bls_to_execution_change,
contribution_and_proof: no operation pools), payload_attributes, the light
client topics, blob_sidecar, and head_v2 with every other gloas topic (this
build stops at fulu).
How head and finality events behave:
- One
headper head recompute. The chain actor recomputes the head once per tick and once per import cascade, and diffs it against the head it last reported. A cascade importing several blocks announces eachblock, then a singleheadfor where it ended. - Any head change counts, including one no block caused (a tick’s votes
moving the head to a sibling, or an execution client’s
INVALIDverdict moving it back to an ancestor). Lighthouse reports only thechain_reorgfor those; Prysm reports both, as this node does. - Stale heads are not reported. A head more than 32 slots behind the wall
clock (catch-up) sends no
head;blockevents still show progress.chain_reorgandfinalized_checkpointare not gated. - Order: each
blockas it imports, thenchain_reorg,head,finalized_checkpoint. epoch_transitionis whether the head’s epoch is later than the previous head’s. The dependent roots follow the specification, with the genesis block root on underflow (epochs 0 and 1).chain_reorg.depthis how many slots the previous head sat above the two heads’ common ancestor (lighthouse’s definition).execution_optimisticis reported as it is, optimistic heads included (lighthouse suppresses thoseheadevents; Prysm sends them flagged).
An aggregate this node’s own validator client submits reaches the chain actor
too: gossip never delivers a node its own messages, so
POST /eth/v2/validator/aggregate_and_proofs hands each accepted one to the
actor after gossiping it. That is also what puts this node’s own aggregates
into its fork choice.
Event payloads are built only while at least one client is connected, whatever topics that client asked for; with none, the stream costs nothing.
Metrics & Debug Server (:5054)
| Method | Path | Response | Description |
|---|---|---|---|
GET | /metrics | text | Prometheus-format metrics |
GET | /health | JSON | Liveness check (same payload as the API health endpoint) |
GET | /debug/pprof/allocs | pprof | jemalloc heap profile |
GET | /debug/pprof/allocs/flamegraph | SVG | jemalloc heap flamegraph |
The metrics endpoint reads from the global Prometheus registry and needs no store access. See Metrics for the full list of exposed series.
Heap-profiling endpoints are backed by jemalloc’s built-in profiler and are only functional on Linux; other platforms return 501 Not Implemented. On Linux they return 500 if profiling was not enabled at startup.
Test-Driver Endpoints (Hive)
When the binary boots with HIVE_LEAN_TEST_DRIVER=1 (any of 1/true/yes), it runs in test-driver mode instead of the normal API server. The ethereum/hive lean simulator drives these endpoints to replay leanSpec fixtures over HTTP. The driver swaps its in-process Store on every fork_choice/init, so one container can replay many fixtures without restart.
| Method | Path | Response |
|---|---|---|
GET | /lean/v0/health | JSON liveness (for the hive port check) |
POST | /lean/v0/test_driver/fork_choice/init | 204 / 400 |
POST | /lean/v0/test_driver/fork_choice/step | StepResponse |
POST | /lean/v0/test_driver/state_transition/run | StateTransitionResponse |
POST | /lean/v0/test_driver/verify_signatures/run | VerifySignaturesResponse |
Content Types
| Kind | Content-Type |
|---|---|
| JSON | application/json; charset=utf-8 |
| SSE | text/event-stream |
| SSZ | application/octet-stream |
| Prometheus metrics | text/plain; version=0.0.4; charset=utf-8 |
| HTML | text/html; charset=utf-8 |
Metrics
We collect various metrics and serve them via a Prometheus-compatible HTTP endpoint at http://<http_address>:<metrics_port>/metrics (default: http://127.0.0.1:5054/metrics).
For the Lean Chain, a ready-to-use Grafana + Prometheus monitoring stack with pre-configured leanMetrics dashboards is available in lean-quickstart.
The Lean Chain’s metrics follow the leanMetrics specification, with some metrics not yet implemented. The Beacon Chain side has no such specification: its own metrics are among the Custom Metrics (the “Beacon” sections and Data Column Sidecars), and the validator client’s are under Validator client. We have a full list of implemented metrics below, with a checkbox indicating whether each metric is currently supported or not.
Node Info Metrics
| Name | Type | Usage | Sample collection event | Labels | Supported |
|---|---|---|---|---|---|
lean_node_info | Gauge | Node information (always 1) | On node start | name, version | ✅ |
lean_node_start_time_seconds | Gauge | Start timestamp | On node start | ✅ |
On Linux the endpoint also serves the prometheus crate’s process collector,
read from /proc/self at each scrape: process_resident_memory_bytes,
process_virtual_memory_bytes, process_cpu_seconds_total,
process_open_fds, process_max_fds, process_threads and
process_start_time_seconds. They are not part of the leanMetrics spec, and
other platforms do not export them.
PQ Signature Metrics
| Name | Type | Usage | Sample collection event | Labels | Buckets | Supported |
|---|---|---|---|---|---|---|
lean_pq_sig_attestation_signatures_total | Counter | Total number of individual attestation signatures | On each attestation signing | ✅ | ||
lean_pq_sig_attestation_signatures_valid_total | Counter | Total number of valid individual attestation signatures | On each attestation signature verification | ✅ | ||
lean_pq_sig_attestation_signatures_invalid_total | Counter | Total number of invalid individual attestation signatures | On each attestation signature verification | ✅ | ||
lean_pq_sig_attestation_signing_time_seconds | Histogram | Time taken to sign an attestation | On each attestation signing | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ | |
lean_pq_sig_attestation_verification_time_seconds | Histogram | Time taken to verify an attestation signature | On each attestation signature verification | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ | |
lean_pq_sig_aggregated_signatures_total | Counter | Total number of aggregated signatures | On aggregated signature production | ✅ | ||
lean_pq_sig_aggregated_signatures_valid_total | Counter | Total number of valid aggregated signatures | On aggregated signature verification | ✅ | ||
lean_pq_sig_aggregated_signatures_invalid_total | Counter | Total number of invalid aggregated signatures | On aggregated signature verification | ✅ | ||
lean_pq_sig_attestations_in_aggregated_signatures_total | Counter | Total number of attestations included into aggregated signatures | On aggregated signature production | ✅ | ||
lean_pq_sig_aggregated_signatures_building_time_seconds | Histogram | Time taken to build an aggregated attestation signature | On aggregated signature production | 0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4 | ✅ | |
lean_pq_sig_aggregated_signatures_verification_time_seconds | Histogram | Time taken to verify an aggregated attestation signature | On aggregated signature verification | 0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4 | ✅ |
Block Production Metrics
| Name | Type | Usage | Sample collection event | Labels | Buckets | Supported |
|---|---|---|---|---|---|---|
lean_block_aggregated_payloads | Histogram | Number of aggregated_payloads in a block | On block production | 1, 2, 4, 8, 16, 32, 64, 128 | ✅ | |
lean_block_building_payload_aggregation_time_seconds | Histogram | Time taken to build aggregated_payloads during block building | On block production | 0.1, 0.25, 0.5, 0.75, 1, 2, 3, 4 | ✅ | |
lean_block_building_time_seconds | Histogram | Time taken to build a block | On block production | 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 8 | ✅ | |
lean_block_building_success_total | Counter | Successful block builds | On block production | ✅ | ||
lean_block_building_failures_total | Counter | Failed block builds (error building the block, signing the block root, or processing it locally) | On block production failure | ✅ | ||
lean_block_proposal_attestation_build_phase_seconds | Histogram | Phase-level time in block proposal: attestation selection, compaction, state transition, then the seal (proposer signature, type-1 wrap, type-2 merge) | On block production | phase=select_payloads,compact,stf_simulate,sign_proposer,wrap_proposer,merge_type2 | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 | ✅ |
lean_block_proposal_attestation_builds_total | Counter | Attestations selected during block-proposal selection (one per selection-loop round that picks an AttestationData) | On each attestation selection | ✅ | ||
lean_block_proposal_child_payloads_consumed_total | Counter | Child aggregated payloads selected during greedy proof picking (before compaction) | On block production | ✅ | ||
lean_block_proposal_attestation_data_selected | Histogram | Distinct AttestationData entries in the proposal block body | On block production | 0, 1, 2, 4, 8, 16, 32 | ✅ | |
lean_block_proposal_aggregates_selected | Histogram | Aggregated signature proofs in the proposal result after compaction | On block production | 0, 1, 2, 4, 8, 16, 32, 64, 128 | ✅ |
lean_block_building_time_secondsintentionally deviates from the leanMetrics bucket set, which tops out at 1s. Real builds on our devnets routinely run past that, so every sample landed in+Infandhistogram_quantilereported a flat 1s ceiling. The range now covers the same span as thelean_block_proposal_attestation_build_phase_secondsphases it contains.
Fork-Choice Metrics
| Name | Type | Usage | Sample collection event | Labels | Buckets | Supported |
|---|---|---|---|---|---|---|
lean_head_slot | Gauge | Latest slot of the lean chain | On get fork choice head | ✅ | ||
lean_current_slot | Gauge | Current slot of the lean chain | On scrape | ✅(*) | ||
lean_safe_target_slot | Gauge | Safe target slot | On safe target update | ✅ | ||
lean_fork_choice_block_processing_time_seconds | Histogram | Time taken to process block | On fork choice process block | 0.005, 0.01, 0.025, 0.05, 0.1, 1, 1.25, 1.5, 2, 4 | ✅ | |
lean_attestations_valid_total | Counter | Total number of valid attestations | On validate attestation | ✅ | ||
lean_attestations_invalid_total | Counter | Total number of invalid attestations | On validate attestation | ✅ | ||
lean_attestation_validation_time_seconds | Histogram | Time taken to validate attestation | On validate attestation | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ | |
lean_fork_choice_reorgs_total | Counter | Total number of fork choice reorgs | On fork choice reorg | ✅ | ||
lean_fork_choice_reorg_depth | Histogram | Depth of fork choice reorgs (in blocks) | On fork choice reorg | 1, 2, 3, 5, 7, 10, 20, 30, 50, 100 | ✅ | |
lean_tick_interval_duration_seconds | Histogram | Elapsed time between clock ticks in seconds | At the start of each tick interval | 0.4, 0.6, 0.75, 0.8, 0.805, 0.81, 0.815, 0.82, 0.825, 0.85, 0.9, 1.0, 1.2, 1.6 | ✅ | |
lean_gossip_signatures | Gauge | Number of gossip signatures in fork-choice store | On gossip signatures update | ✅ | ||
lean_latest_new_aggregated_payloads | Gauge | Number of new aggregated payload items | On latest_new_aggregated_payloads update | ✅ | ||
lean_latest_known_aggregated_payloads | Gauge | Number of known aggregated payload items | On latest_known_aggregated_payloads update | ✅ | ||
lean_committee_signatures_aggregation_time_seconds | Histogram | Time taken to aggregate committee signatures | On committee signatures aggregation | 0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 3, 4 | ✅ | |
lean_node_sync_status | Gauge | Node sync status | On node sync status change | status=idle,syncing,synced | ✅ |
State Transition Metrics
| Name | Type | Usage | Sample collection event | Labels | Buckets | Supported |
|---|---|---|---|---|---|---|
lean_latest_justified_slot | Gauge | Latest justified slot | On state transition | ✅ | ||
lean_latest_finalized_slot | Gauge | Latest finalized slot | On state transition | ✅ | ||
lean_justified_slot | Gauge | Current justified slot | On state transition | ❌ | ||
lean_finalized_slot | Gauge | Current finalized slot | On state transition | ❌ | ||
lean_finalizations_total | Counter | Total number of finalization attempts | On finalization attempt | result=success,error | ✅ | |
lean_state_transition_time_seconds | Histogram | Time to process state transition | On state transition | 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4 | ✅ | |
lean_state_transition_slots_processed_total | Counter | Total number of processed slots | On state transition process slots | ✅ | ||
lean_state_transition_slots_processing_time_seconds | Histogram | Time taken to process slots | On state transition process slots | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ | |
lean_state_transition_block_processing_time_seconds | Histogram | Time taken to process block | On state transition process block | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ | |
lean_state_transition_attestations_processed_total | Counter | Total number of processed attestations | On state transition process attestations | ✅ | ||
lean_state_transition_attestations_processing_time_seconds | Histogram | Time taken to process attestations | On state transition process attestations | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | ✅ |
Validator Metrics
| Name | Type | Usage | Sample collection event | Labels | Buckets | Supported |
|---|---|---|---|---|---|---|
lean_validators_count | Gauge | Number of validators managed by a node | On scrape | ✅(*) | ||
lean_is_aggregator | Gauge | Validator’s is_aggregator status. True=1, False=0 | On node start | ✅ | ||
lean_attestations_production_time_seconds | Histogram | Time taken to produce attestation | On attestation production | 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1 | ✅ |
Network Metrics
| Name | Type | Usage | Sample collection event | Labels | Supported |
|---|---|---|---|---|---|
lean_attestation_committee_count | Gauge | Number of attestation committees | On node start | ✅ | |
lean_attestation_committee_subnet | Gauge | Node’s attestation committee subnet | On node start | ✅ | |
lean_aggregation_window_width | Histogram | Width in subnets of the subnet window derived for one aggregation candidate | On each aggregation candidate | ✅ | |
lean_aggregation_skipped_redundant_total | Counter | Candidates this aggregator sat out because the redundancy-skipping rotation gave their level to another duty subnet | On each skipped aggregation candidate | ✅ | |
lean_aggregation_window_fallback_total | Counter | Merges the subnet window would have dropped, recovered by retrying selection at the full committee set | On each aggregation candidate the full-width retry recovers | ✅ | |
lean_connected_peers | Gauge | Number of connected peers | On scrape | client=ethlambda,grandine,lantern,lighthouse,qlean,ream,zeam | ✅(*) |
lean_gossip_mesh_peers | Gauge | Number of peers in the gossipsub mesh | On scrape | client=<name>_<N>,unknown (ex. zeam_0) | ✅(*) |
lean_peer_connection_events_total | Counter | Total number of peer connection events | On peer connection | direction=inbound,outbound result=success,timeout,error | ✅ |
lean_peer_disconnection_events_total | Counter | Total number of peer disconnection events | On peer disconnection | direction=inbound,outbound reason=timeout,remote_close,local_close,error | ✅ |
All three are emitted only by aggregators, once per candidate
AttestationDataper interval-2 session.lean_aggregation_window_widthhas buckets 1, 2, 4, 8, 16, 32, 64 and climbs from 1 as the aggregator’s anchor proof climbs the reduction tree; it is capped atlean_attestation_committee_count, so samples pinned there mean the window no longer restricts selection. Compare against that gauge rather than reading the buckets alone: at a committee count that is not a power of two, two different widths can share a bucket. A width stuck at 1 while the network is aggregating means the pool holds nothing on this node’s duty subnet, so it is only aggregating its own raw signatures; check the duty subnet against the aggregator placement.lean_aggregation_skipped_redundant_totalonly increments with--skip-redundant-aggregation, once per candidate handed to another duty subnet; read it againstlean_aggregation_window_width_countfor the share of candidates sat out.lean_aggregation_window_fallback_totalcounts recoveries, not attempts: it increments only when a windowed selection produced no viable job and the full-committee-width retry did. Candidates that are non-viable whatever the window, a lone raw signature or a group with a single proof, never reach the retry and never increment it, so a sparse (strided) aggregator placement across subnets is the only expected cause and the counter should stay at or near zero on a well-tiled deployment. It stays flat entirely under--skip-redundant-aggregation, which disables the fallback.
Custom Metrics (non-leanMetrics)
The metrics below are not part of the leanMetrics specification. They are ethlambda-specific observability around on-wire message sizes and post-quantum aggregated proof sizes.
PQ Signature Sizes
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_aggregated_proof_size_bytes | Histogram | Bytes size of an aggregated signature proof’s proof_data field | On aggregated signature production | 1024, 4096, 16384, 65536, 131072, 262144, 524288, 1048576 |
Network Sizes
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_gossip_block_size_bytes | Histogram | Bytes size of a gossip block message (raw SSZ or snappy on-wire) | On gossip block send/receive | compression=raw,snappy | 10000, 50000, 100000, 250000, 500000, 1000000, 2000000, 5000000 |
lean_gossip_attestation_size_bytes | Histogram | Bytes size of a gossip attestation message (raw SSZ or snappy on-wire) | On gossip attestation send/receive | compression=raw,snappy | 512, 1024, 2048, 4096, 8192, 16384 |
lean_gossip_aggregation_size_bytes | Histogram | Bytes size of a gossip aggregated attestation message (raw SSZ or snappy on-wire) | On gossip aggregation send/receive | compression=raw,snappy | 1024, 4096, 16384, 65536, 131072, 262144, 524288, 1048576 |
lean_reqresp_request_size_bytes | Histogram | Bytes size of a req/resp request (raw SSZ or snappy on-wire) | On req/resp request send/receive | protocol=status,blocks_by_root compression=raw,snappy | 64, 128, 256, 512, 1024, 4096, 16384, 65536 |
lean_reqresp_response_chunk_size_bytes | Histogram | Bytes size of a single req/resp response chunk (raw SSZ or snappy on-wire) | On req/resp response chunk send/receive | protocol=status,blocks_by_root compression=raw,snappy | 128, 1024, 10000, 100000, 500000, 1000000, 5000000, 10000000 |
Peer Discovery
See Peer discovery, which is always on for beacon and
opt-in (--discovery.enable) for node. Counts dials
discovery initiated, as opposed to
the static bootnode dials every node makes. Connection outcomes are not repeated
here: a discovery dial that succeeds or fails shows up in
lean_peer_connection_events_total like any other.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_discovered_peers_dialed_total | Counter | Peers dialed as a result of discv5 discovery | On dialing a discovered peer |
Transport Mix
Which transport actually carried each established connection, read off the
connection’s own multiaddr rather than off the address we dialed: libp2p races a
peer’s QUIC and TCP addresses within one dial, so the answer is not knowable
before the connection exists. tcp counts are what say the fallback in
Peer discovery is doing work rather than merely being
advertised.
Counts connections rather than peers, so it can exceed
lean_peer_connection_events_total{result="success"}, which fires only on a
peer’s first connection. unknown covers a multiaddr naming neither transport,
which nothing ethlambda binds produces.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_peer_connections_by_transport_total | Counter | Established peer connections by the transport that carried them | On connection established | direction=inbound,outbound transport=quic,tcp,unknown |
Why Peers Leave
lean_peer_disconnection_events_total above is leanMetrics-specified down to
its reason values, and those four cannot carry this: measured on the mainnet
follower, 92% of outbound closes land in error, which says only that libp2p
handed back a cause. These two split that bucket without widening the specified
metric, the same way the transport counter above sits beside the specified
connect counter rather than inside it.
lean_peer_goodbye_total is the only one of the three that is not an
inference. A goodbye is the peer stating why it is dropping us, and the two
readings that matter are indistinguishable from the socket alone:
too_many_peers means the peer had no room, while bad_score, banned and
banned_ip mean it decided against this node, and those call for opposite
responses. It has no direction label because goodbye/1 is registered
inbound-only; ethlambda never sends one. The reason is a u64 off the wire, so
the labels are a fixed set with other as the residue: a remote must not be
able to choose this node’s metric cardinality.
lean_peer_disconnect_cause_total covers the closes that carry no goodbye,
read off the ConnectionError variant and the error types inside it. It is
charged on the same event as the specified counter, once per peer fully
disconnecting rather than once per connection, so the two total to the same
number and can be read against each other directly. clean_close is libp2p
reporting no error at all, which for a beacon peer is the ordinary shape of a
deliberate disconnect and should be read beside lean_peer_goodbye_total.
An I/O close almost always arrives as ErrorKind::Other with the muxer’s own
error inside, so the label comes from that inner error. The quic_* values
are QUIC’s close reasons: quic_application_close is the peer’s application
closing (libp2p’s normal close after a goodbye, and go-libp2p’s connection
gater), quic_transport_close a transport-level close. TCP closes resolve to
the socket error beneath the muxer where there is one (unexpected_eof,
connection_reset), otherwise yamux_closed for a clean yamux shutdown.
io_other is the residue and should stay near empty; the Peer connection closed line at DEBUG prints the full cause for whatever lands there.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_peer_goodbye_total | Counter | Goodbye messages received, by the reason code the peer sent | On receiving a goodbye/1 | reason=unknown,client_shutdown,irrelevant_network,fault,unable_to_verify_network,too_many_peers,bad_score,banned,banned_ip,other |
lean_peer_disconnect_cause_total | Counter | Closed peer connections by the cause libp2p reported for the close | On a peer’s last connection closing | direction=inbound,outbound cause=clean_close,keep_alive_timeout,connection_reset,connection_aborted,broken_pipe,not_connected,timed_out,unexpected_eof,quic_application_close,quic_transport_close,quic_reset,quic_timed_out,quic_local_close,quic_other,yamux_closed,yamux_other,mplex_other,io_other |
Reason codes 1, 2 and 3 are the only ones
the spec
names; it reserves [4, 127] and leaves 128 and up to each client. The four
above 127 follow lighthouse’s GoodbyeReason, which is what mainnet peers
actually send.
Peer Supply
Who this node is connected to, and whether those peers can serve what it needs. All three are set from a full re-count of the state that decides them rather than incremented and decremented per event: a gauge meant to reveal a leak must not be able to leak itself.
lean_peers_by_direction read against lean_swarm_established_connections is
that leak check. The first counts peers this node believes it holds; the second
libp2p’s own counters, which the connection limits in
discovery.md are enforced against. A persistent gap is a
connection charged to the cap that no live peer is using.
lean_custody_column_peers is the supply side of the data-availability gate: a
fulu block is held until every column this node custodies arrives, so a column
sitting at zero connected custodians is a stall waiting to happen, and it is
invisible in a total peer count. Only the columns this node samples get a
series, since publishing all 128 would bury the ones that can actually block an
import. Read it beside lean_blocks_held_for_columns. Beacon-only; a lean node
samples nothing and publishes no series here.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_peers_by_direction | Gauge | Connected peers by the direction the connection was opened in | On every connection established and closed | direction=inbound,outbound |
lean_swarm_established_connections | Gauge | Established connections as libp2p itself counts them | On the swarm’s own metric tick | direction=inbound,outbound |
lean_custody_column_peers | Gauge | Connected peers known to custody each data column this node samples | On every connection established and closed, and whenever a peer’s custody is recorded from its metadata/3 answer or its ENR cgc | column=<index> |
Block Import Timing
These record where a block’s time goes between coming off the wire and having a post-state, which is the question lean_fork_choice_block_processing_time_seconds cannot answer: it starts inside store::on_block, so the mailbox hop, the holds, the head update and the per-table size estimates every import pays for all fall outside it. The sections here are the same ones the Block import timing log prints as a tree, and a test asserts the two lists stay identical.
One histogram carries every section, including total for a whole import and the sections an arrival is charged once for. They share a unit and a bucket set, and the query that matters is rate(..._sum[5m]), seconds spent per second, which does not divide by an event count and so does not care that some sections are counted per block and others per arrival. lean_block_import_cascade_blocks is what relates the two counts when you do need them.
total is written only when the block actually imported. A held block publishes every section it crossed and no total, because its import has not finished: that is what keeps a block that waited two slots for its parent out of the import-cost percentiles, and why there is no outcome label to filter on.
parent_wait and columns_wait are separate sections rather than one “pending”, so a block held for its parent and a block held for its custody columns never collapse into the same number. An absent columns_wait still has two readings, and only the log separates them: its da_complete_on_arrival field says whether the columns were never missing, or landed while the block was held for its parent.
The bookkeeping an import triggers (chain-event emission, the finality eviction sweep, the gauge refresh including a RocksDB size estimate per table) has no section of its own. It is charged to the section it follows, which is fc_head on lean and block_atts on beacon. Those three were sections once: across 5248 imports on a mainnet follower none of them reached a millisecond, so they were three rows that never moved in every tree and three label values that never said anything. The work is still counted, just where it happens.
decode is a gossip-only section, so queue is the only one a fetched block crosses before the chain actor. The req/resp codec has already turned the bytes into a block before any handler sees one, leaving no decode boundary to take; the path reports nothing rather than a zero, since a zero reads as free work rather than as unmeasured work and would drag the decode histogram down with samples that measured nothing. Two consequences: decode is a gossip population even though the source label allows sync, and a fetched block’s total starts later in its life than a gossiped block’s, having never counted the request round trip at all.
On the beacon wire, decode for source="gossip" spans more than its name says. BlockArrival::decode_start is still the wire arrival, but handed_off is stamped only once the block has a gossip verdict, so the section also covers the cheap, stateless checks, the stateful check’s own spawn_blocking task, and the verdict’s trip back through the p2p actor’s mailbox. There is no wait for a free validation slot to attribute here either: try_acquire_owned never blocks, and a message arriving with none free is reported Ignore(Overloaded) (and still forwarded to the chain actor) rather than queued. A rising decode on the beacon wire alone therefore does not mean decoding got slower; check lean_beacon_gossip_validation_seconds before assuming so.
engine and fcu are execution-client round trips. They are I/O waits rather than work, so a node whose import time is dominated by them is waiting on its execution client, not spending CPU.
The source label has two values, gossip and sync (req/resp backfill). Two populations are deliberately absent. A block re-delivered to itself because its slot had not started reports under the source it first arrived on, since the hold is already visible as its defer section and a third label value would take the block out of the population it belongs to for every section it has left. A block this node built itself is not measured at all: it crossed no wire, so it has no decode and an empty queue taken when the import began. Both still appear in the log, which names them deferred and local.
Series appear as blocks arrive rather than being seeded, since a third of the phases are beacon-only and a lean node can never write to them.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_block_import_phase_seconds | Histogram | Time one section of a block’s import, or of the arrival carrying it, took | Per section that ran, on each import, hold or failure | phase (see below); source=gossip,sync | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 32 |
lean_block_import_cascade_blocks | Histogram | Blocks one arrival put through the import path (attempts, so a block that ends held or pended counts) | Once per arriving block message | 1, 2, 3, 5, 8, 16, 32, 64, 128 |
Per-block phases, in the order a block crosses them, plus total for a completed import:
| Phase | What it covers | Chain |
|---|---|---|
decode | Snappy decompression, SSZ decode and the root the p2p handler computes; on the beacon wire this also spans gossip validation. Gossip only; see above | both |
queue | The wait in the chain actor’s mailbox | both |
defer | Held because the block’s own slot had not started yet | beacon |
guards | Finality and future-slot checks, the already-imported check, the parent-state lookup | both |
preamble | The checks store::on_block makes before verifying: parent state load, duplicate attestation data scan | lean |
parent_wait | Held because the parent had no post-state | both |
cascade_wait | Between the parent’s import finishing and this block being popped off the cascade queue | both |
da_check | The custody-column availability check itself, not the wait | beacon |
columns_wait | Held because custody columns had not all arrived | beacon |
engine | The engine_newPayload round trip, including its retry ladder | beacon |
verify_struct | Participant bounds checks and pubkey resolution | lean |
verify_crypto | The leanVM multi-message aggregate verification | lean |
stf | The state transition. On beacon this bundles the transition and the state root, and excludes writer_wait, which is nested inside it | both |
writer_wait | Beacon only: the importer’s blocking hand-off of the post-state to the storage crate’s background writer, which waits while the writer’s queue is full. Subtracted from stf, so stf and writer_wait do not overlap | beacon |
db_write | The block write and handing the post-state off to the storage crate’s background writer; the state’s own encode/diff/commit cost is lean_state_write_seconds instead, not this row | lean |
fc_head | update_head | lean |
block_atts | Replaying the block’s own attestations and slashings into fork choice | beacon |
total | The whole import, wire to post-state, spanning any holds. Only on a completed import | both |
Per-arrival phases, charged once per arriving message however many blocks its cascade imported:
| Phase | What it covers |
|---|---|
arrival | The whole handler call: the cascade plus everything after it |
cascade | The block drain alone |
prune | prune_old_data after the cascade (lean) |
get_head | fork_choice::get_head (beacon) |
fcu | The engine_forkchoiceUpdated round trip (beacon) |
Gossip Arrival Timing
These histograms record the absolute distance between a gossip message’s arrival and the start of the interval it was due in, so an arrival that is early by some amount and one that is late by the same amount land in the same bucket; the counters’ position label is what tells them apart. inside means the message arrived within the interval it was due in, not merely somewhere in the right slot: an attestation for slot 10 that lands during slot 10’s interval 2 is after, not inside, since it missed the AttestationProduction interval it was actually due in.
The bucket boundaries are the interval and slot edges of the default 4-second cadence. Prometheus fixes buckets when a histogram is registered, so a network that sets MILLISECONDS_PER_SLOT reads these histograms against the default grid rather than its own; the position label still follows the configured interval width.
Blocks anchor to interval 0 of their own slot and attestations to interval 1 of their data slot; both are unbounded above, so a message that never arrives close to real time can be arbitrarily late. Aggregates anchor instead to the most recent aggregation-interval boundary rather than their own data slot, since a stale-group catch-up aggregate can carry a data.slot several slots in the past; anchoring to the latest boundary bounds the delay to one slot and rules out before entirely.
Only gossip-received blocks are sampled here: blocks fetched via req/resp during sync are excluded, since sync backfill delivers blocks long after they were due and would swamp these histograms with catch-up noise rather than gossip-health signal.
The aggregate metrics do include an aggregator’s own freshly produced aggregates, which never come back over gossip; without them an aggregator would report an empty aggregate profile. The two populations are not quite the same measurement: delivery of a locally produced aggregate is held until the interval-2 boundary, so it lands near zero unless proving overran the interval, whereas a received one adds propagation on top of whenever the producer managed to publish it.
In practice the distribution is bimodal and dominated by production rather than propagation: a mode in the lowest bucket for aggregates that made their interval, plus a tail for those whose proving overran it. A late aggregate is late for every node at once, so that tail shows up on receivers too and is not evidence of a slow network. Read a rising tail as aggregation cost, and cross-check lean_pq_sig_aggregated_signatures_building_time_seconds and lean_committee_signatures_aggregation_time_seconds to confirm.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_gossip_block_arrival_delay_seconds | Histogram | Absolute delay between a gossip block’s arrival and the start of the interval it was due in | On gossip block receipt, before import | 0.05, 0.1, 0.2, 0.4, 0.8, 1.2, 1.6, 2.4, 4, 8, 16 | |
lean_gossip_attestation_arrival_delay_seconds | Histogram | Absolute delay between a gossip attestation’s arrival and the start of the interval it was due in | On gossip attestation receipt | 0.05, 0.1, 0.2, 0.4, 0.8, 1.2, 1.6, 2.4, 4, 8, 16 | |
lean_gossip_aggregation_arrival_delay_seconds | Histogram | Absolute delay between an aggregate becoming available (gossip receipt, or local production) and the most recent aggregation-interval boundary at or before it | On gossip aggregated-attestation receipt, or on local aggregate production | 0.05, 0.1, 0.2, 0.4, 0.8, 1.2, 1.6, 2.4, 4, 8, 16 | |
lean_gossip_block_arrival_total | Counter | Gossip blocks by arrival position relative to the interval they were due in | On gossip block receipt, before import | position=before,inside,after | |
lean_gossip_attestation_arrival_total | Counter | Gossip attestations by arrival position relative to the interval they were due in | On gossip attestation receipt | position=before,inside,after | |
lean_gossip_aggregation_arrival_total | Counter | Aggregates by arrival position relative to the most recent aggregation-interval boundary | On gossip aggregated-attestation receipt, or on local aggregate production | position=inside,after |
Storage
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_table_bytes | Gauge | Estimated byte size of a storage table (key + value bytes) | After each processed block (one update per table); retains its previous value on empty slots | table=<table_name> |
lean_state_write_queue_depth | Gauge | States handed to the background writer but not yet committed | On every hand-off and on every commit | |
lean_state_write_seconds | Histogram | Time the background writer spends encoding, diffing and committing one state | Per state written | |
lean_state_cache_lookups_total | Counter | State cache lookups, by the Store method that made them, the kind of state asked for, and whether the cache held it | On every get_state (and the writer’s own parent read), has_state and cached_state call | method=get_state,has_state,cached_state kind=block,checkpoint result=hit,miss |
On a beacon follower, watch lean_table_bytes{table="data_columns"}. Every
other table’s series either stays flat or is bounded by pruning; data_columns
backs Table::DataColumns, the one table with no pruning rule (see
data_storage.md), so this is the disk-growth
trajectory for the whole node. Budget roughly 360 KB per slot across the
columns this node custodies at the blob cap, nearer 1 GB per day at current
mainnet blob counts, and size the disk against however long the node is meant
to run before a pruner exists.
lean_state_write_queue_depth is the early warning for a slow disk. Up to
three states can be in flight without the importer ever blocking — two queued
plus the one the writer thread is currently encoding, diffing and committing —
so a depth resting at or below three is healthy overlap. A depth climbing past
three means insert_state blocked on the full channel waiting for the writer,
and lean_state_write_seconds says whether that time went into the encode or
the commit. A depth that stays at zero means the writer has nothing
outstanding.
Read lean_state_cache_lookups_total per method, not summed.
cached_state with kind="block" is gossip validation’s lookup of the voted
or parent block’s state, made once per attestation and aggregate, so it runs far
more often than the other two and a summed rate is mostly its own. A get_state
or has_state miss means the state was not resident and came from the writer’s
buffer or RocksDB. A cached_state miss with kind="checkpoint" means fork
choice’s checkpoint_state rebuilt the state, running process_slots up to the
epoch boundary when the checkpoint’s block precedes it.
Beacon Gossip Validation
Every beacon gossip message gets a verdict before gossipsub forwards it
(validate_messages() is on for the beacon wire only), beacon_aggregate_and_proof
and beacon_attestation_{subnet_id} included: both are validated the same way
blocks and columns are, in ethlambda_state_transition::beacon::gossip::{aggregate,attestation}.
See beacon_wire.md for the flow and
beacon_wire.md for the two topics’
own section. These are ethlambda-specific, not part of the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_beacon_gossip_validation_total | Counter | Verdicts, by topic kind, outcome and reason | On every verdict reported to gossipsub | kind, outcome=accept,queue,ignore,reject, reason | |
lean_beacon_gossip_validation_seconds | Histogram | Time from a message’s arrival to its verdict | On every verdict reported to gossipsub | kind | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 |
lean_beacon_gossip_verdict_expired_total | Counter | Verdicts that arrived after gossipsub evicted the message, so an Accept propagated nothing | When report_message_validation_result returns false | kind |
kind is the topic kind, with every data_column_sidecar_{subnet} sharing the
label data_column_sidecar, every beacon_attestation_{subnet_id} sharing
beacon_attestation and every sync_committee_{subnet_id} sharing
sync_committee. The gloas topics execution_payload,
payload_attestation_message, execution_payload_bid and
proposer_preferences are labelled by their own name. queue means
IGNORE to gossipsub while the chain actor still receives the object and parks
it; of the gloas topics only execution_payload answers it, for an envelope
whose block is not known yet (block_unknown), has no post-state yet
(block_not_ready), or has one that is not in the cache the checks read
(state_not_cached, as when the block was imported moments ago; the actor
verifies the signature itself, so the envelope is forwarded rather than dropped). The aggregate, attestation,
payload_attestation_message, execution_payload_bid and proposer_preferences
topics never answer queue, since the vote
block’s post-state is either cached or it is not
(IgnoreReason::UnknownBlock/StateUnavailable), with nothing to hold the
message for. verdict_expired_total should stay at
zero: a rising count means validation is too slow for gossipsub’s message
cache.
The two topics add reasons the others do not, all reason label values on
lean_beacon_gossip_validation_total: outside_epoch_window, covered_bits
(aggregate only), unknown_block, state_unavailable,
finalized_not_ancestor, ancestry_unknown, payload_envelope_unseen and
payload_optimistic (gloas only) on the ignore side;
epoch_mismatch, no_participants (aggregate only), non_zero_data_index,
committee_bits (aggregate only), committee_index, bits_length (aggregate
only), not_aggregator (aggregate only), not_in_committee,
unknown_validator, selection_proof (aggregate only),
aggregator_signature (aggregate only), aggregate_signature (aggregate
only), target_not_ancestor, wrong_subnet (attestation only), and gloas’s
data_index_out_of_range, same_slot_payload_flag and payload_invalid on the
reject side. The builder market topics add, on the ignore side,
pre_gloas_slot, not_current_or_next_slot, not_highest_bid,
beyond_lookahead, preferences_unseen, fee_recipient_mismatch,
parent_payload_unknown, gas_limit_incompatible, not_on_head_branch,
builder_cannot_cover, builder_may_exit, slot_started and
impossible_dependent_root, and on the reject side execution_payment_nonzero,
block_hash_equals_parent, prev_randao, unknown_builder,
not_payload_builder, inactive_builder and dependent_root_too_late; a bid
over its size cap is malformed.
The sync committee topics add sync_committee_unavailable (the head
state’s committees cannot answer for the message’s period) on the ignore side
and subcommittee_index (contribution only) on the reject side; they reuse
not_current_slot, already_seen, covered_bits, no_participants,
not_aggregator, not_in_committee, unknown_validator, selection_proof,
aggregator_signature, aggregate_signature, wrong_subnet and
bad_signature. already_seen, overloaded and unsupported_fork are shared with the
other topics: unsupported_fork is an ignore reason for a message of a fork this
build has no gossip rules for, so an honest peer past the fork epoch is not
scored as a bad decoder. Gloas blocks, data columns, aggregates and attestations
are validated, so they carry their own verdict reasons instead.
Three permit pools bound the blocking-thread half of validation:
gossip_validation_permits for blocks, columns and envelopes,
attestation_validation_permits for aggregates, subnet attestations and
payload votes, and builder_validation_permits for bids and proposer
preferences. They are deliberately separate: a mainnet slot’s worth of aggregates and backbone
attestations arrives every slot, not only during a range sync, and sharing one
pool would let that burst answer Ignore(Overloaded) for a block or a column
instead. Neither pool has a metric of its own yet; a permit exhausted on
either shows up as outcome="ignore",reason="overloaded" on
lean_beacon_gossip_validation_total, for the topics that draw from it.
Beacon Peer Scoring
Gossipsub peer scoring runs on the beacon wire only; see beacon_wire.md for the parameters and the mesh-delivery gate. These are ethlambda-specific, not part of the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_gossipsub_peers_by_score | Gauge | Gossipsub peers by score band | Every 10 s, in the swarm task | band=non_negative,negative,below_gossip,below_publish,below_graylist | |
lean_gossipsub_score_disconnects_total | Counter | Peers disconnected for a gossipsub score below the graylist (-16000) | When the 10 s check disconnects one | ||
lean_gossipsub_mesh_delivery_scoring | Gauge | 1 while mesh message deliveries (P3) are scored, 0 while the head lags the wall clock or is warming up after catching up | Every slot, on the scoring refresh |
The bands are mutually exclusive and split at the gossip (-4000), publish
(-8000) and graylist (-16000) thresholds, so they add up to the peers
gossipsub knows. below_graylist counts the peers that same pass is about to
disconnect, so it should not hold a value across passes. A persistent
below_gossip/below_publish population points at slow peers first: a full
send queue costs a peer 10 points per dropped message.
Beacon Block Publication
POST /eth/v2/beacon/blocks computes every blob’s cells and batch-verifies all
its cell proofs on a blocking thread before it gossips anything, then builds the
block’s data column sidecars; at the blob cap that is thousands of cells on the
proposal’s critical path. See rpc.md. This is
ethlambda-specific, not part of the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_beacon_publish_data_columns_seconds | Histogram | Time to compute and verify a published block’s cells and build its data column sidecars | On every published block that carries blobs and whose cells verify | 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5 |
Beacon Committee Cache
ethlambda beacon derives an epoch’s attester committees with one whole-epoch
shuffle and keeps the result in CommitteeCache, keyed by epoch and the block
that decided the shuffling. The cache itself lives in the Store
(crates/storage/src/committee_cache.rs), shared by both actors: the chain
actor’s own attestation processing and p2p’s gossip validation for
beacon_aggregate_and_proof/beacon_attestation_{subnet_id} read the same
entries rather than each keeping a copy, which is what lets a validation task
and the actor race a shuffle at an epoch boundary and have only the first one
pay for it. CommitteeCacheExt in
crates/blockchain/state_transition/src/beacon/helpers/accessors.rs is the
consensus-logic wrapper (ShufflingKey derivation, the shuffle itself) around
the storage-side container. This is ethlambda-specific, not part of the
leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_beacon_committee_cache_lookups_total | Counter | Committee lookups, by whether the cache served them | On every CommitteeCacheExt::committees call: the state transition’s, fork choice’s and p2p’s gossip validation’s attestation processing | result=hit,miss,unkeyable |
Read the miss rate against the epoch rate, not the hit rate. A follower on
one chain misses about once per epoch, when the first block of a new epoch asks
for that epoch’s shuffling, and hits on everything else. Misses that track
imports instead mean entries are being evicted and rebuilt, which happens when
more branches are being imported at once than the cache has room for; each miss
is a registry scan plus a whole-epoch shuffle on the import thread. unkeyable
is a lookup the cache could not key at all, mostly the genesis state asking
about its own first epochs, and should be zero on a checkpoint-synced follower.
Beacon Total Active Balance Cache
The total active balance (the divisor of every attestation’s base reward and of
the sync aggregate’s rewards) is one registry pass, and an ordinary block asks
for it about nine times. ActiveBalanceCache in the Store keeps it per epoch,
keyed by the epoch and the block root that fixes it. This is ethlambda-specific,
not part of the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_beacon_total_active_balance_lookups_total | Counter | Total active balance lookups, by whether the cache served them | On every ActiveBalanceCacheExt::total_active_balance call: block processing’s attestations and sync aggregate | result=hit,miss,unkeyable |
Expect one miss per epoch per chain, and hits otherwise. unkeyable is the
genesis state asking about its own epoch, and should be zero on a
checkpoint-synced follower.
Beacon Justified Balances
Beacon fork choice weighs every vote by the voter’s effective balance at the
justified checkpoint. justified_balances (in
crates/blockchain/state_transition/src/beacon/fork_choice.rs) flattens that
state into one array, keyed by the justified checkpoint and rebuilt on the first
get_head after the checkpoint moves. This is ethlambda-specific, not part of
the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_beacon_justified_balances_lookups_total | Counter | Snapshot lookups, by whether the cached snapshot served them | On every justified_balances call: get_head’s weights, the proposer boost, and the reorg helpers | result=hit,miss |
lean_beacon_justified_balances_build_seconds | Histogram | Time to build one snapshot from the checkpoint state | On every miss, around the registry pass (the checkpoint state lookup is not included) |
Read the miss rate against the justified-checkpoint rate: about one miss
per justified checkpoint change, so a handful per hour on a healthy chain.
Misses that track get_head calls mean the justified checkpoint is flapping
between branches, or the snapshot is being replaced between two readers.
Beacon Epoch Precompute
The beacon chain actor advances the head state across the next epoch boundary
ahead of time, so the first block of an epoch does not run process_epoch on
the import path. See “Epoch-transition precompute” in
beacon_stf.md. This is ethlambda-specific, not part of the
leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_beacon_epoch_precompute_lookups_total | Counter | Epoch-crossing imports, by whether a precomputed boundary state was cached | On each import whose parent is before the block’s epoch start and whose block is at or after it | result=hit,miss | |
lean_beacon_epoch_precompute_seconds | Histogram | Worker time to advance, flush and hash one state | When a precompute worker finishes | 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 8 | |
lean_beacon_epoch_precompute_started_total | Counter | Workers started, by trigger | When a worker is spawned | trigger=head,timer |
Read the hit ratio against epochs. On a synced follower nearly every
epoch-crossing import should hit. Misses mean the worker had not finished (the
block raced it, see lean_beacon_epoch_precompute_seconds against the slot
duration), the node was syncing, or the entry was evicted from the state cache.
A timer start with no matching head start means the last slot was skipped.
Beacon Pubkey Cache
Every BLS signature check ethlambda beacon runs (block import, fork choice,
gossip validation) resolves its signers’ compressed public keys to validated
curve points through one process-wide cache, keyed by the compressed bytes. See
PubkeyCache in crates/blockchain/state_transition/src/beacon/bls.rs. This is
ethlambda-specific, not part of the leanMetrics spec.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_beacon_pubkey_cache_lookups_total | Counter | Public keys resolved for a signature check, by whether the cache already held them | Once per signature check, counting every signer | result=hit,miss |
lean_beacon_pubkey_cache_entries | Gauge | Validated public keys the cache holds | On each key added; the cache never removes one |
Misses should fall to near zero within an epoch of a start, once every
active validator has signed something the node checked. A steady miss rate
after that means keys the cache is refusing to hold: either the entry bound is
reached (the gauge stops rising), or the keys are invalid, which are never
cached and pay the full check every time. entries is also the cache’s memory
footprint, roughly a decompressed point plus its compressed key per entry.
Data Column Sidecars (Fulu DAS)
ethlambda beacon is a fulu data-availability-sampling custodian: it derives
a slice of the column matrix from its own discv5 node id (see
beacon_wire.md), verifies what gossip
and req/resp deliver for that slice, stores what verifies, serves it back to
peers, and refuses to import a fulu block until every column it owes for that
block is on hand. These are ethlambda-specific, not part of the leanMetrics
spec.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_data_columns_stored_total | Counter | Data column sidecars verified and written to Table::DataColumns | On the chain actor storing a sidecar the p2p layer verified | ||
lean_data_columns_rejected_total | Counter | Sidecars the p2p layer’s chain checks dropped, by reason | On each drop in beacon::column_checks, except an already stored sidecar | reason=malformed,future_slot,finalized,not_after_parent,unknown_proposer,bad_signature,wrong_proposer,finalized_not_ancestor,parent_not_ready,inclusion_proof,kzg,internal | |
lean_data_column_kzg_verify_seconds | Histogram | Time spent batch-verifying one sidecar’s cells against its own commitments | On each KZG batch verification, in gossip validation or the chain checks | 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0 | |
lean_data_column_fetch_failures_total | Counter | DataColumnsByRoot lookups this node gave up on, by reason | On lookup abandonment | reason=no_peers,max_retries | |
lean_blocks_held_for_columns | Gauge | Blocks held out of fork choice pending their custody columns | On every hold, release, and finality eviction of the held-block set | ||
lean_sidecars_awaiting_parent | Gauge | Sidecars parked until their block’s parent has a post-state | On every park, replay, and finality eviction of the parked set | ||
lean_envelopes_awaiting_block | Gauge | Gloas execution payload envelopes held until their block is imported | On every hold, release, and finality eviction of the held envelopes | ||
lean_envelopes_awaiting_columns | Gauge | Gloas execution payload envelopes held until every sampled column is stored | On every hold, release, and finality eviction of the held envelopes | ||
lean_blocks_awaiting_parent_payload | Gauge | Gloas blocks held until their FULL parent’s payload envelope is verified | On every hold, release, and finality eviction of the held blocks |
lean_data_columns_rejected_total counts the chain checks, which run in the
p2p layer on every sidecar gossip did not accept: every fetched sidecar, a
gossiped one reported Queue or Ignore(Overloaded), and a parked one sent
back once its parent imported. A gossiped sidecar is judged first by the
gossip rules and counted in
lean_beacon_gossip_validation_total{kind="data_column_sidecar"}; the reasons
here are that counter’s reason labels. The chain actor stores what reaches it
without checking it again.
Watch lean_blocks_held_for_columns. It is the first symptom of a stalled
availability gate, and a healthy node returns it to zero within a slot or two
of a hold: fetch or gossip should complete well inside the retry ladder
lean_data_column_fetch_failures_total counts down. A gauge that sits above
zero for several slots means either the fetch is failing — check whether
lean_data_column_fetch_failures_total’s no_peers or max_retries reason
is climbing — or no reachable peer actually serves the missing columns; short
of that, only finality passing the held block’s slot clears it, which can be
minutes away.
Read lean_sidecars_awaiting_parent beside it. A sidecar whose block’s
parent has no post-state yet is parked rather than dropped, and a held block is
precisely a block with no post-state, so the two gauges rise together while the
gate waits: held blocks on one, their children’s columns on the other. Both
returning to zero is a recovered hold. lean_sidecars_awaiting_parent climbing
without coming back down while lean_blocks_held_for_columns stays above zero
is the signature of a gate that is not recovering. The parked queue is
uncapped, so that gauge is also the only warning that a peer is parking
sidecars under parents it never means to supply: each one holds a
Table::PendingDataColumns row until finality passes its slot.
Beacon Aggregate Attestations
ethlambda beacon applies beacon_aggregate_and_proof to fork choice, which is
how it sees votes for the current head rather than only the votes a block body
carries (see
beacon_wire.md). These are
ethlambda-specific, not part of the leanMetrics spec, and lean nodes never emit
them.
| Name | Type | Usage | Sample collection event | Labels | Buckets |
|---|---|---|---|---|---|
lean_beacon_aggregate_decode_seconds | Histogram | Time the p2p actor spent decoding one aggregate off the wire | On each successful decode in the gossip handler | 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05 | |
lean_beacon_aggregate_mailbox_wait_seconds | Histogram | Time an already-verified aggregate spent in the chain actor’s mailbox | On the chain actor taking one off the mailbox | 0.0005 … 2.5 | |
lean_beacon_aggregate_processing_seconds | Histogram | Time the chain actor spent applying one already-verified aggregate (apply_verified_aggregate plus the applied-bits gate) | On each aggregate the actor processed, applied or not | 0.0005 … 2.5 | |
lean_beacon_aggregate_end_to_end_seconds | Histogram | Wire to fork choice, for aggregates applied on arrival | On applying an aggregate that was not deferred | 0.0005 … 2.5 | |
lean_beacon_aggregate_total | Counter | Aggregates by outcome | On each aggregate reaching a verdict | outcome=applied,invalid,known_subset,queue_full | |
lean_beacon_aggregates_deferred | Gauge | Aggregates held until their own slot has passed | On every defer and every per-slot drain |
decode is the only one of the four histograms still timing what it always
did. The other three no longer include gossip validation: committees, all
three signature checks and the specification’s own seen sets moved to p2p
(lean_beacon_gossip_validation_seconds{kind="beacon_aggregate_and_proof"}
covers that half now), so processing and end_to_end read a great deal
lower than before this change, and a comparison against an older deployment’s
values is comparing two different things. What is left for these three to
measure is real, though: validate_on_attestation_indexed and the applied-bits
gate still cost a Table::LiveChain scan and a hash-map lookup per aggregate,
so a slow chain-actor number still points at the store rather than at
cryptography.
Watch lean_beacon_aggregate_mailbox_wait_seconds. It is the failure mode
this path introduces and the one no other timing can show: a mainnet slot
carries up to MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE
aggregates, and if they queue behind block imports their votes arrive too late
to move the head while every per-aggregate timing still looks healthy.
Read lean_beacon_aggregate_total{outcome} as a ratio, not a rate.
known_subset is the actor’s own applied-bits gate now, not the specification’s
seen set (that one is p2p’s, and a duplicate or already-covered aggregate is
refused there, under lean_beacon_gossip_validation_total, before it ever
reaches this counter). known_subset dominating is still the design working:
a committee’s aggregators mostly converge on the same votes, and each one the
running union already covers is dropped before another
apply_verified_aggregate call. applied falling toward zero while
known_subset stays high means the node is seeing only aggregates it has
already covered, which is normal; invalid climbing means aggregates gossip
already accepted are failing apply_verified_aggregate regardless, most often
because this node has not imported the block being voted for, or has since
finalized past its target.
queue_full should be zero. The deferral queue holds two slots’ worth, and
reaching its cap means aggregates are arriving faster than the once-per-slot
drain clears them. Read it beside lean_beacon_aggregates_deferred, which sits
near one slot’s worth in the steady state and near the cap when the drain is
falling behind.
Attestation Aggregate Coverage
Observability into how many validators/subnets are covered by the attestations the node has aggregated, broken down by pipeline section (the section label). The slot is the X-axis. These are sampled roughly once per slot, but emission is gated by the section’s source data, so a gauge can retain its previous value:
timely,late,block,combinedand thediff_validatorsdirections are emitted on block import, and only when the canonical head block carries that round’s votes (otherwise the round is skipped and prior values are kept).agg_start_newis emitted at interval 2, right before fork-choice aggregation runs.proposal_combinedis emitted only when this node proposes a block.
| Name | Type | Usage | Sample collection event | Labels |
|---|---|---|---|---|
lean_attestation_aggregate_coverage_validators | Gauge | Validator coverage in attestation aggregate reports | Per round, per section (see note above) | section=timely,late,block,combined,agg_start_new,proposal_combined subnet=combined,subnet_0,subnet_1,…,subnet_N-1 |
lean_attestation_aggregate_coverage_subnets | Gauge | Number of covered subnets in attestation aggregate reports | Per round, per section (see note above) | section=timely,late,block,combined,agg_start_new,proposal_combined |
lean_attestation_aggregate_coverage_diff_validators | Gauge | Validators in the symmetric difference between block-included aggregates and locally-aggregated timely aggregates for the same slot | On block import, when the head carries the round’s votes (see note above) | direction=block_only,timely_only |
✅(*) Partial support: These metrics are implemented but not collected “on scrape” as the spec requires. They are updated on specific events (e.g., on tick, on block processing) rather than being computed fresh on each Prometheus scrape.
Validator client
Served by ethlambda validator on its own --metrics-port, separate from the
node’s. Prefixed ethlambda_validator_ rather than lean_, because this
process follows the beacon chain and a lean_ series here would be misleading
on a shared dashboard.
Every series is registered at startup rather than on first use, so each reads zero from the moment the process is up. That matters for alerting: a rule on “attestations stopped” cannot fire against a series that does not exist yet, and absent is not the same as zero.
| Metric | Type | Description |
|---|---|---|
ethlambda_validator_validators_loaded | Gauge | Validator keys loaded from the keystores |
ethlambda_validator_validators_resolved | Gauge | Loaded keys that have an index on chain, out of validators_loaded. A persistent gap means validators are deposited but not yet activated |
ethlambda_validator_duties_held | Gauge | Attester duties currently scheduled |
ethlambda_validator_attestations_published_total | Counter | Attestations a beacon node accepted |
ethlambda_validator_attestation_failures_total | Counter | Slots whose attestation duty failed and returned |
ethlambda_validator_attestation_deadline_missed_total | Counter | Slots whose duty never returned in time and was abandoned. Points at a slow or hung beacon node rather than a rejected attestation |
ethlambda_validator_attestations_refused_total | Counter | Signatures deliberately not attempted, because slashing protection judged the attestation unsafe or could not record it. Should normally read zero |
ethlambda_validator_signing_failures_total | Counter | Signatures attempted and failed |
ethlambda_validator_blocks_proposed_total | Counter | Blocks signed and accepted by a beacon node |
ethlambda_validator_blocks_broadcast_not_imported_total | Counter | Blocks a node broadcast but could not import into its own database, which is what a 202 means. A subset of blocks_proposed_total, not a failure: the block reached the network. Points at that node’s execution layer |
ethlambda_validator_blocks_refused_total | Counter | Blocks deliberately not signed, because slashing protection judged the block unsafe or could not record it. Should normally read zero |
ethlambda_validator_block_proposal_failures_total | Counter | Proposal duties that did not end in a published block, including ones abandoned for overrunning |
ethlambda_validator_envelopes_published_total | Counter | Execution payload envelopes a beacon node accepted for blocks this client proposed (gloas) |
ethlambda_validator_envelope_failures_total | Counter | Proposed gloas blocks whose self-built envelope was not published. The block is out and the proposal counts as made, but the slot’s payload is withheld, so this is the failure that costs the payload. Should read zero |
ethlambda_validator_payload_attestations_published_total | Counter | Payload timeliness committee votes a beacon node accepted (gloas) |
ethlambda_validator_payload_attestation_failures_total | Counter | Slots whose payload timeliness committee duty did not result in published votes |
ethlambda_validator_sync_committee_messages_published_total | Counter | Sync committee messages a beacon node accepted |
ethlambda_validator_sync_committee_failures_total | Counter | Slots whose sync committee message duty ended in an error or ran past its slot. A head the node reports optimistic is not a failure: nothing is signed |
ethlambda_validator_sync_contributions_published_total | Counter | Sync committee contributions this client published as an aggregator. Bursty, like aggregates_published_total |
ethlambda_validator_sync_contribution_failures_total | Counter | Sync aggregation duties that ended in an error or ran past their slot. A subnet with no contribution to fetch is skipped and not counted |
ethlambda_validator_sync_duties_held | Gauge | Sync committee duties held for the current period |
ethlambda_validator_aggregates_published_total | Counter | Aggregates accepted by a beacon node. Bursty rather than steady: a validator is selected a few times a day, so hours at zero are normal for a small deployment |
ethlambda_validator_aggregation_failures_total | Counter | Aggregation duties that ended in no published aggregate, including ones abandoned for overrunning the slot |
ethlambda_validator_fee_recipient_mismatches_total | Counter | Blocks paying execution rewards to an address this client did not request. Should read zero forever; a non-zero value means every proposal is paying somewhere else |
ethlambda_validator_block_publication_delay_seconds | Histogram | Slot start to block accepted. Its buckets are tighter than the attestation histogram’s, because a block is due at the slot boundary rather than a third of the way in |
ethlambda_validator_beacon_node_available | Gauge | 1 when a beacon node answered the last duty refresh or attestation |
ethlambda_validator_signing_duration_seconds | Histogram | Time to sign one slot’s batch, store read lock included |
ethlambda_validator_slashing_protection_write_seconds | Histogram | Time to durably write the slashing-protection record, once per slot’s attestation batch and once per block, before signing. Milliseconds on a local disk; worth an alert above about a second, since the duty waits for it and a write held past 3 s is refused |
ethlambda_validator_slashing_protection_write_failures_total | Counter | Slashing-protection writes that failed, each leaving its messages unsigned. Should read zero; anything else points at the disk under --validators-dir |
ethlambda_validator_publication_delay_seconds | Histogram | Slot start to attestations accepted. The headline health number: it should sit near the one-third-slot duty offset |
Three of these distinguish failures that look alike on a dashboard but have
different causes, and the distinction is the reason they are separate series:
attestation_failures_total is a duty that ran and failed,
attestation_deadline_missed_total is one that never finished, and
attestations_refused_total is one deliberately not attempted. A rise in the
second points at the beacon nodes; a rise in the third points at the clock or
the duty schedule and is worth investigating even though the attestation was
correctly suppressed.
The proposal series split the same way, plus one that is neither a success nor
a failure. blocks_proposed_total counts blocks a node accepted;
block_proposal_failures_total counts duties that produced none;
blocks_refused_total counts blocks deliberately not signed. Between them,
blocks_broadcast_not_imported_total counts blocks that did reach the network
but that the node answering could not import, which is a beacon-node fault
rather than a validator one and is why it is not folded into either.
Gloas adds two pairs. A gloas proposal ends in a block and then an envelope,
and the envelope’s failure does not fail the proposal: blocks_proposed_total
counts the block, and envelope_failures_total the reveal that did not follow.
The committee vote has the same split as the attestation’s: payload_attestations_published_total
counts votes accepted and payload_attestation_failures_total the slots whose
duty produced none. A slot where the node knows no block for the committee to
vote on is neither, since there was nothing to sign.
Two series should read zero for the life of a healthy deployment, and are the
ones worth alerting on at any non-zero value rather than on a rate:
attestations_refused_total and blocks_refused_total mean this client’s own
guards caught a duty they judged unsafe, and fee_recipient_mismatches_total
means blocks are being proposed that pay someone else. envelope_failures_total
belongs with them on a gloas network.
Troubleshooting
Docker Desktop on MacOS
lean-quickstart uses the host network mode for Docker containers, which is a problem on MacOS. To work around this, enable the “Enable host networking” option in Docker Desktop settings under Resources > Network.
Checkpoint Sync
Overview
Checkpoint sync allows a new consensus node to skip replaying the entire chain from genesis. Instead, it downloads a recent finalized state from a running peer and starts from there. This mitigates long-range attacks by starting from a recent trusted checkpoint.
Both ethlambda node (the lean consensus chain) and ethlambda beacon (an Ethereum Beacon Chain gossip follower, mainnet by default, or another network via --network) use it. The two paths share the retry loop and the URL fan-out, and now share the same fetch order, but talk to different endpoints and verify a different container shape; where they differ, this document says so.
Usage
ethlambda node
Checkpoint sync still requires the network config files (genesis, validators, bootnodes, etc.). The genesis config is needed to verify the downloaded state: checkpoint sync only replaces the starting state, not node configuration.
Pass the --checkpoint-sync-url flag when starting ethlambda:
ethlambda \
--checkpoint-sync-url <URL> \
--genesis ./network-config/config.yaml \
--validators ./network-config/annotated_validators.yaml \
--bootnodes ./network-config/nodes.yaml \
--validator-config ./network-config/validator-config.yaml \
--hash-sig-keys-dir ./network-config/hash-sig-keys \
--node-key ./node.key \
--node-id ethlambda_0
Where <URL> is the address of a checkpoint source (see Checkpoint Sources below).
State already on disk takes precedence over both checkpoint sync and genesis: if the data directory holds a previous run’s chain state for this network, the node resumes from it. --checkpoint-sync-url is the fallback for when there is nothing resumable on disk, or when what is there has fallen too far behind (see Restarts and Existing State). With no resumable state and no URL, the node initializes from genesis.
ethlambda beacon
beacon takes no genesis config, validator registry, or bootnode file of its own: it takes --network instead, naming a built-in network or a directory of published network files (see cli.md). genesis_time, genesis_validators_root, and the bootnode list all come from whichever network --network resolves to; for the built-in networks (mainnet, the default, plus sepolia, hoodi and plataberget), they are built into the binary (see CLAUDE.md’s “Built-in networks”).
ethlambda beacon --network <network> --checkpoint-sync-url <URL>
The anchor precedence is, in order: a resumable data directory, then --checkpoint-sync-url, then, for a loaded network only, that directory’s own genesis.ssz, then abort. The URL is therefore required on a fresh data directory only for a built-in network, unlike on node: this follower imports nothing past its anchor, so anchoring a built-in network at genesis would leave it parked at slot 0 while claiming to follow a live chain. A loaded network’s own genesis state is a legitimate anchor instead, since a freshly started devnet has no checkpoint provider at slot 0 and this is the only way to join one. With neither a resumable DB, a URL, nor (for a loaded network) a genesis fallback, startup aborts with CheckpointSyncError::BeaconGenesisSync. A data directory already anchored from a previous run resumes exactly as node’s does, without a URL, subject to the same resume window (see Restarts and Existing State).
Checkpoint Sources
Direct peer (lean)
Any running node that serves the finalized state as SSZ can be used as a checkpoint source, not just ethlambda. For ethlambda nodes, the endpoint is /lean/v0/states/finalized.
This is the simplest option, with no additional infrastructure needed. The trade-off is that you trust a single peer to provide a correct finalized state.
Leanpoint (lean)
Leanpoint is a dedicated checkpoint sync provider. It polls multiple nodes and only serves state when 50%+ agree on finality, adding a layer of consensus validation.
This is the recommended option for production deployments since it reduces trust in any single peer.
Any standard Beacon API provider (beacon)
ethlambda beacon speaks the standard Beacon API against --checkpoint-sync-url, so any conforming server works as a source: a public checkpoint-sync provider, an infrastructure endpoint, or another beacon client’s own API port. Nothing ethlambda-specific is required of the peer; it only has to answer the two endpoints in How It Works below the way the spec describes them.
How It Works
-
Fetch, sequentially: the node downloads the finalized state first, decodes and verifies it, then downloads the anchor block. State before block, on both chains, because the block is addressed by a slot that only the state carries; the two fetches used to run concurrently on lean (
tokio::try_join!), but the beacon path cannot do that (it cannot address its block request until it has read the anchor block’s slot off the state), so both now run sequentially. This is lighthouse’s order.node(lean)beaconFinalized state GET /lean/v0/states/finalizedGET /eth/v2/debug/beacon/states/finalizedAnchor block GET /lean/v0/blocks/finalizedGET /eth/v2/beacon/blocks/{slot}, whereslotis read offstate.latest_block_header.slotBoth endpoints on both chains mean “whatever is finalized right now”, so the peer can advance finalization between the state and block requests; a mismatched pair is retried rather than treated as a hard failure (see Anchor Pairing below).
Timeouts, shared by both chains:
- Connect: 15 seconds (fail fast if peer is unreachable)
- Read: 15 seconds of inactivity that resets on each successful read, so large states can download as long as data keeps flowing
-
Fork resolution (beacon only): SSZ carries no type tag, so decoding the downloaded state first requires knowing which fork’s container shape to decode it as.
BeaconState::slot_from_sszreads the slot straight off its fixed byte offset, without decoding the rest of the container; the configured fork schedule then names the fork at that slot. TheEth-Consensus-Versionresponse header is not consulted at all. This matches lighthouse’s checkpoint-sync client, and removes any dependence on the peer setting that header correctly: a self-describing slot cannot lie about its own fork the way a header can. The anchor block is decoded the same way, reusing the gossip path’s own slot-peeking decoder. -
Initialize: the node stores the anchor block’s header, its body (present unless the fetched block’s own body happens to be empty, same as any other block), and the full state from the checkpoint. On
node, persisting the block itself also means it can be served overBlocksByRoot; without that, peers requesting the anchor by root would get a synthetic block whose hash differs fromlatest_finalized.rootand would score-penalize this node.beacon’s req/resp protocol has noBlocksByRoothandler yet, so that benefit doesn’t apply there today; the block is stored anyway, since the pairing check above needs it.
Gloas anchor: the payload envelope (beacon only)
A gloas block’s execution payload is not in the block, so a node anchored on
one holds the block and state but not the payload its children may build on.
Once the chain actor is running, a node that anchored on a gloas block with no
stored envelope asks the checkpoint URLs for it, in order:
GET /eth/v1/beacon/execution_payload_envelopes/{anchor_root} as SSZ. A
success is handed to the chain actor as new_execution_payload_envelope, the
same entry a gossiped envelope takes, so the bid check, the column check and
the verification all apply. The fetch runs in the background and never delays
startup.
It is a fast path and nothing depends on it. A 404 means the peer does not
know the payload, not that the payload is empty, so the node concludes
nothing from it; any failure (network, decoding, an envelope naming another
block) is logged at info and ignored. Without the envelope, the by-root
request that fires when a FULL child arrives recovers it.
An anchor whose bid carries blobs also needs its sampled columns before its
envelope verifies, and those are fetched by root like any other block’s. They
sit at the finalized slot, or below it for an anchor state advanced past empty
slots, which is where the chain checks drop every other block’s columns. So
the finalized block’s own columns are exempt from that rule
(column::chain_checks_gloas); without the exemption such an anchor never gets
its payload, and the node never imports past it.
Failure and success
If any step fails (network error, decoding error, verification failure), the node logs the error and exits. There is no automatic retry; restart the node to try again. The database is not modified until verification succeeds, so a failed checkpoint sync leaves the data directory clean.
After successful initialization, the node starts normally: node connects to the P2P network and begins participating from the checkpoint slot; beacon joins the resolved network’s gossip and follows the chain from the anchor, importing blocks (a gloas anchor starts EMPTY, see the envelope).
Restarts and Existing State
A node restarted against a populated data directory resumes from disk rather than re-initializing, so no flag is needed to preserve the chain across a redeploy. The decision is made before any download, and is the same shape on both chains except for one row:
| State in data directory | --checkpoint-sync-url | node | beacon |
|---|---|---|---|
| None | omitted | Initialize from genesis | Built-in network: abort, CheckpointSyncError::BeaconGenesisSync (no genesis-sync path). Loaded network: initialize from the directory’s own genesis.ssz |
| None | set | Checkpoint sync | Checkpoint sync |
| Present, head within the resume window | either | Resume from disk (no download) | Resume from disk (no download) |
| Present, head beyond the resume window | set | Checkpoint sync | Checkpoint sync |
| Present, head beyond the resume window | omitted | Resume from disk anyway, with a warning | Resume from disk anyway, with a warning |
| Wrong network, or the other chain | either | Startup aborts (see Foreign State) | Startup aborts (see Foreign State) |
The resume window is MAX_RESUMABLE_DB_STATE_AGE (450 slots) measured as current_slot - head_slot. Staleness is measured against the head, not the finalized checkpoint, so a node whose head is current still resumes during a finality stall. The same 450-slot window means a different wall-clock budget on each chain: ~30 minutes at lean’s four-second slots, ~90 minutes at beacon’s twelve-second ones.
Beyond that window, node prefers a checkpoint when one is offered, since catching up over P2P costs more than downloading a recent state. With no URL configured there is no anchor to switch to, so the node simply runs against the data directory it was given: that is the setup that was asked for. The warning is there because range sync may not be able to close a gap this large. Peers prune block signatures past SIGNATURE_PRUNING_RANGE (21600 slots, ~1 day), so beyond that horizon they cannot serve the history the node is missing and it needs a checkpoint URL to catch up at all. The warning logs the gap so this is visible in the boot log. beacon follows the same preference, though it imports nothing past its anchor today, so there is no backfill cost yet to weigh against a fresh download.
When a checkpoint URL is set and every URL fails, the node exits rather than falling back to the stale state on disk. This is intentional, on either chain: configuring the flag asks for a specific anchor, so an unreachable source is a misconfiguration worth surfacing at boot instead of quietly starting a node that is hours behind. Omitting the flag is how you ask for “resume whatever is on disk”; that path never exits.
To deliberately discard existing state and start over, remove the data directory first. node then starts over from genesis or from a checkpoint, whichever --checkpoint-sync-url says; beacon does the same, except that a built-in-network run still needs the URL, since the built-in networks alone have no genesis-sync path. Checkpoint sync itself writes its anchor state on top without clearing existing data.
Foreign State
Persisted state is accepted only after its genesis identity is verified against the network the node is configured for: the pair (genesis_time, genesis_validators_root). Lean has no genesis_validators_root field of its own, but its validator registry is fixed at genesis and never mutates afterward, so the root of the registry a lean state carries today is the root it had at genesis; that is the value the Lean variant’s genesis_validators_root() accessor answers with. Both fields are compared by one function, verify_state_genesis, shared by the resume path on either chain and by both checkpoint-sync paths. The slot duration is compared separately, against the persisted config row rather than the state: a lean network sets it in its config file and no state carries it, so a data directory built at another cadence is caught there.
Store::from_db_state no longer runs this check itself: it loads whatever chain a data directory holds and hands back a Store, without judging whether that is the chain or the network the operator configured. The caller (fetch_initial_state for node, fetch_initial_beacon_state for beacon) checks Store::chain() against the sub-command it is running under, then runs verify_state_genesis against the loaded finalized state. Either check failing aborts startup: a chain mismatch as CheckpointSyncError::WrongChain, a genesis mismatch as CheckpointSyncError::Genesis (wrapping GenesisMismatch::GenesisTime or GenesisMismatch::GenesisValidatorsRoot). Both used to be raised from inside from_db_state itself, as ethlambda_storage::Error::GenesisMismatch/::WrongChain; those two variants have been removed from the storage crate now that the check runs one layer up, in the caller that actually knows which network and which chain it wants.
Either failure is not treated as an empty directory, because initializing a new anchor on top would leave the foreign chain’s rows in place, and the slot-indexed reads behind BlocksByRange would then serve those rows to peers. Point --data-dir at the right directory, or remove it.
Note that a genesis-time-only comparison still would not catch a network that was regenerated with the same genesis_time but a different validator set, which is why the registry root is compared too; that reasoning is unchanged; only its mechanism is, since a list’s root already commits to the count, each validator’s index, and both pubkeys, where this used to be four separate comparisons.
Verification Checks
All checks are performed before a downloaded checkpoint anchor is accepted. The genesis-identity checks (marked (shared)) are the ones the resume-from-disk path also runs, on either chain, through the same verify_state_genesis function described in Foreign State above.
Lean
| Check | What it catches |
|---|---|
| Slot > 0 | Checkpoint state cannot be genesis (slot 0) |
| Validators non-empty | State must contain validators |
| Genesis time matches (shared) | Wrong network or misconfigured peer |
| Genesis validators root matches (shared) | Wrong validator set: the root commits to the count, each validator’s index, and both pubkeys, so this one comparison subsumes what used to be four |
| Finalized slot <= state slot | Finalized checkpoint cannot be in the future |
| Justified slot >= finalized slot | Justified must be at or after finalized |
| Same-slot checkpoints have matching roots | If justified and finalized are at the same slot, they must agree on the root |
| Block header slot <= state slot | Block header cannot be ahead of the state |
| Block header root matches finalized | If header is at finalized slot, its root must match the finalized root |
| Block header root matches justified | If header is at justified slot, its root must match the justified root |
Beacon
| Check | What it catches |
|---|---|
| Slot > 0 | Checkpoint state cannot be genesis (slot 0) |
| Validators non-empty | State must contain validators |
| Genesis time matches (shared) | Wrong network or misconfigured peer |
| Genesis validators root matches (shared) | Wrong validator set |
| Finalized epoch <= current epoch | Finalized checkpoint cannot be in the future |
| Justified epoch >= finalized epoch | Justified must be at or after finalized |
| Block header slot <= state slot | Block header cannot be ahead of the state |
| Block’s fork matches state’s fork | The fetched block must decode as the same fork the state resolved to |
Anchor’s fork is followed (refuse_unfollowable_fork) | A fork the node cannot follow is refused with UnsupportedFork before any store is built. Every fork is followed today, gloas included, so this refuses nothing; a gloas anchor starts with no payload known, so its head is EMPTY and its first FULL child is held until the parent’s envelope is fetched. Runs on both anchor sources, the checkpoint provider’s state and a loaded network’s own genesis state |
| Anchor pairing | See Anchor Pairing below |
Beacon has no same-slot-checkpoints-matching-roots check: nothing here computes two separate checkpoint roots to compare, since a trusted checkpoint-synced anchor is finalized by fiat, and the beacon spec’s own construction applies that single checkpoint to all four of the store’s justified/finalized slots at once.
HTTP errors and SSZ decoding failures are caught before verification runs, on both chains.
Anchor Pairing
Beacon: paired on the header root, not the state root
The consensus specification’s get_forkchoice_store asserts anchor_block.state_root == hash_tree_root(anchor_state): the anchor state is meant to be the anchor block’s own post-state. A checkpoint-synced anchor is not, in general: the Beacon API’s states/finalized endpoint resolves to the state at finalized_checkpoint.epoch.start_slot(), and when that boundary slot was empty (no block was proposed there), the state has advanced past its own latest_block_header by one or more empty slots, so its own root no longer matches what the header committed to. This is routine on mainnet, not a transient race: an empty slot at an epoch boundary is a normal outcome, not a fault to work around.
Both get_forkchoice_store (ethlambda_state_transition::beacon::fork_choice, the specification’s own construction rules) and the checkpoint-sync path’s own pairing check instead verify that the state’s latest_block_header hashes to the anchor block’s own root, substituting the state’s own root for the header’s state_root field when that field still holds its zero placeholder. (That field is left zero only for the duration of the header’s own slot; process_slot fills it in with the real value the moment the slot moves past it, so once the slot has advanced the header already carries the real value and is trusted as-is.) The header root pins the pair just as precisely as the specification’s check does, since it names exactly one block, and unlike the state’s own root it is unaffected by the state having advanced past that block.
This is a documented deviation from the consensus specification, not an oversight: lighthouse’s checkpoint-sync client (beacon_node/beacon_chain/src/builder.rs, weak_subjectivity_state) makes the same one, for the same reason.
Lean’s own anchor pairing (anchor_pair_is_consistent) needs no such deviation: lean’s finalized-state endpoint always names the state at the same slot as the anchor block itself, so there is no epoch-boundary snapping and no empty-slot gap between them to account for.
Security Considerations
Trust model
Checkpoint sync operates under a weak subjectivity assumption. In proof of work, any node can objectively determine the canonical chain by verifying the most cumulative work. Proof of stake doesn’t have this property: validators can costlessly sign multiple forks, so a node that wasn’t online to observe the chain in real time cannot distinguish the real chain from a fabricated one using protocol rules alone.
Weak subjectivity resolves this: a new node obtains a recent trusted state through a social channel (a peer, a checkpoint provider, a block explorer) and starts from there. Nodes that are always online are unaffected because they continuously track the chain and don’t need external trust.
What you are trusting:
- The checkpoint source is honest about which state is finalized
- The state hasn’t been crafted to put you on a fork that diverged within the weak subjectivity period
What verification does protect against:
- Wrong network (genesis time or genesis validators root mismatch)
- Structurally invalid states (impossible slot or epoch orderings, inconsistent checkpoints, a block that does not pair with its state)
- Corrupted data (SSZ decode failures)
What verification does not protect against:
- A checkpoint source that serves a structurally valid state on a minority fork. It will pass all checks but put you on the wrong chain. This is why the choice of checkpoint source matters.
Fork Choice Visualization
A browser-based real-time visualization of the LMD GHOST fork choice tree, served from the existing RPC server with no additional dependencies.
Endpoints
| Endpoint | Description |
|---|---|
GET /lean/v0/fork_choice/ui | Interactive D3.js visualization page |
GET /lean/v0/fork_choice | JSON snapshot of the fork choice tree |
Both endpoints are served on the API port (--api-port, default 5052), by ethlambda node
only: they show the Lean Chain’s fork choice tree, and on ethlambda beacon every /lean/v0
path returns 404.
Quick Start
Local devnet
make run-devnet
The local devnet runs 3 ethlambda nodes with metrics ports 8085, 8086, and 8087. Open any of them:
- http://localhost:8085/lean/v0/fork_choice/ui
- http://localhost:8086/lean/v0/fork_choice/ui
- http://localhost:8087/lean/v0/fork_choice/ui
Standalone node
cargo run --release -- \
--genesis ./config/config.yaml \
--validators ./config/annotated_validators.yaml \
--bootnodes ./config/nodes.yaml \
--validator-config ./config/validator-config.yaml \
--hash-sig-keys-dir ./config/hash-sig-keys \
--node-key ./keys/node.key \
--node-id 0 \
--api-port 5052
Then open http://localhost:5052/lean/v0/fork_choice/ui.
Visualization Guide
Color coding
| Color | Meaning |
|---|---|
| Green | Finalized block |
| Blue | Justified block |
| Yellow | Safe target block |
| Orange | Current head |
| Gray | Default (no special status) |
Layout
- Y axis: slot number (time flows downward)
- X axis: fork spreading — branches appear when competing chains exist
- Circle size: scaled by
weight / validator_count— larger circles have more attestation support
Interactive features
- Tooltips: hover any block to see root hash, slot, proposer index, and weight
- Auto-polling: the page fetches fresh data every 2 seconds
- Auto-scroll: the view follows the head as the chain progresses
What to look for
- Single vertical chain: healthy consensus, no forks
- Horizontal branching: competing chains — check attestation weights to see which branch validators prefer
- Color transitions: blocks turning green as finalization advances
- Stalled finalization: if justified/finalized slots stop advancing, check validator attestation activity
JSON API
curl -s http://localhost:5052/lean/v0/fork_choice | jq .
Response schema:
{
"nodes": [
{
"root": "0x...",
"slot": 42,
"parent_root": "0x...",
"proposer_index": 3,
"weight": 5
}
],
"head": "0x...",
"justified": { "root": "0x...", "slot": 10 },
"finalized": { "root": "0x...", "slot": 5 },
"safe_target": "0x...",
"validator_count": 8
}
| Field | Description |
|---|---|
nodes | All blocks in the live chain (from finalized slot onward) |
nodes[].weight | Number of latest-message attestations whose target is this block or a descendant |
head | Current fork choice head root |
justified | Latest justified checkpoint |
finalized | Latest finalized checkpoint |
safe_target | Block root selected with a 2/3 validator threshold |
validator_count | Total validators in the head state |
💾 Storage: What Lives Where
This doc explains how ethlambda saves data. Especially,
the split between the fork choice Store and the StorageBackend trait,
what each of the thirteen tables holds, and which data is in-memory only.
Overview
All chain data flows through a single high-level type, the Store
(crates/storage/src/store.rs), which persists it through a small pluggable
key-value abstraction, the StorageBackend trait
(crates/storage/src/api/traits.rs). Two backends implement the trait:
RocksDB for production and an in-memory backend for tests.
Everything persisted is SSZ-encoded bytes.
LAYERED ARCHITECTURE
────────────────────
┌──────────────────┐ ┌──────────────────┐
│ BlockChain actor │ │ P2P actor │
└────────┬─────────┘ └────────┬─────────┘
│ (cloned Store: shared │
│ backend + buffers) │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Store │
│ crates/storage/src/store.rs │
│ │
│ • table selection, key encoding, SSZ codec │
│ • snapshot-vs-diff decisions, pruning │
│ • in-memory attestation buffers + state cache │
└────────────────────────┬────────────────────────┘
│ begin_read() / begin_write()
▼
┌─────────────────────────────────────────────────┐
│ StorageBackend trait │
│ crates/storage/src/api/traits.rs │
│ │
│ raw bytes in, raw bytes out │
└───────────┬─────────────────────────┬───────────┘
│ (production) │ (test)
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ RocksDBBackend │ │ InMemoryBackend │
│ (production, one │ │ (tests, HashMap per │
│ column family per │ │ table, lost on │
│ table) │ │ drop) │
└───────────────────────┘ └───────────────────────┘
The Store / StorageBackend Split
StorageBackend: dumb bytes
The StorageBackend trait knows nothing about consensus types. It moves raw
bytes in and out of named tables:
begin_read()returns aStorageReadView. Its one required lookup isread(table, key, read_fn), which lends the value toread_fnwithout copying it;get(an owned copy) andcontainsare built on it, andStorageReadViewExt::read_withdecodes straight from the borrowed bytes. It also hasprefix_iterator(table, prefix).begin_write()returns aStorageWriteBatchwithput_batch,delete_batch, andcommit(). A batch stages puts and deletes across multiple tables and applies them atomically on commit.
The two implementations live in crates/storage/src/backend/:
| Backend | Details |
|---|---|
RocksDBBackend | One column family per table. Writes go through a native WriteBatch with sync=false (no fsync per commit). States and StateDiffs keep values of 4 KiB and up in LZ4-compressed blob files, so compaction never rewrites a snapshot and a lookup miss never reads one. |
InMemoryBackend | A HashMap per table behind an RwLock. Its prefix_iterator sorts keys lexicographically to match RocksDB’s iteration order, because pruning relies on slot-ordered early-stop scans (see Key encoding). |
Store: all the semantics
The Store owns everything the backend doesn’t: which table each datum goes
to, how keys are built, SSZ encoding/decoding, when to write a full state
snapshot versus a diff, and when to prune. It is the only writer to the
backend.
A naming subtlety: the Store struct lives in the storage crate
(crates/storage/src/store.rs), while the fork choice logic that drives it
(on_block, on_tick, update_head, …) lives in
crates/blockchain/src/store.rs as free functions taking &mut Store.
Store is Clone, and every field is an Arc, so clones are cheap and all
clones share the same backend and the same in-memory pools. At startup
(bin/ethlambda/src/main.rs) one Arc<RocksDBBackend> is opened, one Store
is built from it, and clones are handed to the BlockChain and P2P actors.
INSIDE THE STORE
────────────────
┌───────────────────────────────── Store ──────────────────────────────────┐
│ │
│ PERSISTED (via backend) IN-MEMORY ONLY │
│ ─────────────────────── ────────────── │
│ ┌────────────────────────────────┐ ┌──────────────────────┐ │
│ │ BlockHeaders │ │ new_payloads │ │
│ │ BlockBodies │ │ (pending aggregated │ │
│ │ BlockProof │ │ attestations) │ │
│ │ BlockRoots │ │ known_payloads │ │
│ │ States │ │ (fork-choice-active │ │
│ │ StateDiffs │ │ attestations) │ │
│ │ Metadata │ │ gossip_signatures │ │
│ │ LiveChain │ │ (raw XMSS sigs │ │
│ │ DataColumns │ │ awaiting │ │
│ │ PendingDataColumns │ │ aggregation) │ │
│ │ ExecutionPayloadEnvelopes │ │ state_cache (LRU) │ │
│ │ BlockTimeliness │ └──────────────────────┘ │
│ │ BeaconUnrealizedJustifications │ │
│ └────────────────────────────────┘ │
│ Survives restarts, except │
│ PendingDataColumns, which Lost on restart. │
│ is cleared at startup. │
└──────────────────────────────────────────────────────────────────────────┘
The Tables
The thirteen variants of the Table enum (crates/storage/src/api/tables.rs):
| Table | Key | Value | Pruned? |
|---|---|---|---|
BlockHeaders | root | BlockHeader, or a whole beacon block | never |
BlockBodies | root | BlockBody (lean only) | never |
BlockProof | slot ‖ root | aggregate proof (MultiMessageAggregate) | yes: finalized older than ~1 day |
BlockRoots | slot | block root (H256) | never |
States | slot ‖ root | full State snapshot | yes: finalized snapshot intervals |
StateDiffs | slot ‖ root | StateDiff | yes: finalized snapshot intervals |
Metadata | string | SSZ scalars | never |
LiveChain | slot ‖ root | parent_root | yes: below finalized |
DataColumns | slot ‖ root ‖ column_index | DataColumnSidecar (SSZ-encoded) | no (see DataColumns below) |
PendingDataColumns | slot ‖ root ‖ column_index | DataColumnSidecar (SSZ-encoded), unverified | yes: on replay, below finalized, and wholly at startup |
ExecutionPayloadEnvelopes | slot ‖ root | gloas SignedExecutionPayloadEnvelope (SSZ), verified only | no: beacon block rows are kept too |
BlockTimeliness | root | one byte per timeliness deadline (0 or 1), gloas blocks only | no |
BeaconUnrealizedJustifications | root | (slot, Checkpoint) (beacon only) | yes: below the finalized block’s own slot (see below) |
Key encoding
Four key layouts are used:
- Root-keyed tables use the 32-byte SSZ encoding of the block root
(
root.to_ssz()). - Slot-prefixed tables (
BlockProof,LiveChain,States,StateDiffs) useencode_slot_root_key: an 8-byte big-endian slot followed by the 32-byte root. Big-endian means lexicographic key order equals numeric slot order, so pruning can iterate from the start of the table and stop at the first key past its cutoff, or drop a whole slot range with one range delete. ForStatesandStateDiffsthe slot is the slot of the block the state belongs to (the slot in that root’sBlockHeadersrow, equal to the state’slatest_block_header.slot), notstate.slot(): a beacon anchor state can be advanced past its block. A reader that only has a root looks the slot up inBlockHeaders; a root with no block row has no state. - Slot-prefixed, then column (
DataColumns) extends the sameslot ‖ rootprefix with an 8-byte big-endiancolumn_index, viadata_column_key. A block’s own sidecars therefore share one lexicographic run under theirslot ‖ root, so a prefix scan over just that pair (whatdata_column_indices_forand the by-range handler both do; see DataColumns) recovers every column of one block without touching any other block’s. - Slot-only (
BlockRoots) usesencode_block_root_key: just the 8-byte big-endian slot, since the value already holds the root. This table is never pruned, so the ordering buys nothing here; it is kept only for consistency with the other slot-prefixed keys. - Root-keyed with the slot in the value (
BeaconUnrealizedJustifications) is the inverse trade from the slot-prefixed tables above: both of this table’s readers (get_voting_source,is_ffg_competitive) look up a root with no slot in hand, so keying by root alone keeps that lookup a single point read, and the slot rides along in the value purely for the pruner to read back. The cost is on the pruner’s side instead: with no slot prefix to stop early on, it decodes every row’s value to compare its slot, cheap here since the table only ever holds the unfinalized window’s worth of leaves between two finalizations. See BeaconUnrealizedJustifications below.
BlockHeaders
root → BlockHeader. Written for every block, including the genesis/anchor
block, and never pruned: headers are the permanent record of the chain.
Headers are also read back during state reconstruction (see
State Storage).
On a beacon directory this row holds the whole SignedBeaconBlock
instead, prefixed with a one-byte fork selector the way a States value is,
since a beacon block’s shape varies by fork and SSZ carries no type tag. The
two shapes never coexist in one table: a data directory holds one chain for
its whole life (see Chain).
Store::block_entry is the chain-agnostic reader for a stored block’s slot
and parent root, and is what the fork-choice tree walk and the BlockRoots
index diff share. On the beacon arm it decodes the whole block to reach those
two fields, so a caller walking a chain of them should build
Store::block_index once instead, which reads the same links out of
LiveChain.
BlockBodies
root → BlockBody, lean only. Written for every block except those
with an empty body: if header.body_root == EMPTY_BODY_ROOT (the hash tree
root of BlockBody::default()), nothing is stored and reads synthesize
BlockBody::default(). This covers the genesis block and checkpoint sync
anchors, whose bodies are either empty or unavailable. Never pruned.
The split exists so a header-only query need not pay for the body, and so an empty body can be left out entirely. A beacon block has neither property, and nothing reads a beacon header without its block, so the beacon arm writes no row here at all: a second row would only add a write and a way for the two to disagree.
BlockProof
slot ‖ root → MultiMessageAggregate. This table stores the block’s merged
aggregate proof blob. It is keyed by slot ‖ root so that pruning can scan in
slot order and stop early.
Stored separately from headers/bodies because the genesis block has no proof.
get_signed_block synthesizes an empty proof for the slot-0 anchor only; for
any other block a missing entry (a pruned finalized block) surfaces as None
rather than a fabricated block.
This is the one block table that is pruned; see Pruning.
BlockRoots
slot → H256, the canonical block root at each slot. Rewritten on every head
update inside update_checkpoints: block_root_index_changes walks the old
and new head’s branches back to their common ancestor, deleting the slots
that leave the canonical chain and writing the ones that join it. A reorg
therefore touches only the affected slot range, not the whole table. Never
pruned.
That walk never writes the common ancestor itself, and at bootstrap the head
is the anchor, so the anchor’s own slot is written separately: by init_store
on a lean directory, and by index_beacon_anchor (called from the beacon
get_forkchoice_store) on a beacon one, keyed by the anchor block’s slot
rather than the anchor state’s. A beacon directory bootstrapped before
index_beacon_anchor existed has no row for its anchor.
Backs get_signed_blocks_by_slot_range, which serves BlocksByRange requests
over req/resp (crates/net/p2p/src/req_resp/handlers.rs). It does not
back the RPC GET /lean/v0/blocks/:slot endpoint: that handler resolves a
slot through the head state’s historical_block_hashes instead
(resolve_slot in crates/net/rpc/src/blocks.rs), so a block on a side fork
is reachable there only by root, never by slot.
States
slot ‖ root → State (full SSZ snapshot). Holds full-state snapshots
only: the bootstrap anchor written at initialization, plus one anchor
whenever a block crosses a SNAPSHOT_ANCHOR_INTERVAL-slot boundary. These
anchors are the base every diff chain resolves against, so reconstruction
always terminates. Pruned below the finalized block’s snapshot interval (see
Pruning), which never removes a snapshot a kept state’s chain
still needs.
The genesis validator registry is constant for the life of the chain
(validators is fixed at genesis; the lean STF never mutates it), but it has
no table of its own: it rides inside every States snapshot alongside
config, which StateDiff reconstruction relies on (see
State Storage).
StateDiffs
slot ‖ root → StateDiff. A diff written for every non-anchor state,
pruned together with States. Lean’s is parent-linked; a beacon diff is a
byte delta against the nearest snapshot on its branch, so a beacon state is one
snapshot plus one delta. See State Storage for what a
diff contains and how states are rebuilt.
Metadata
String keys mapping to (mostly) SSZ-encoded scalars — the Store’s own
persistent fields. Four are written on every directory, whichever chain it
holds; the rest are chain-specific, since lean and a beacon directory keep
different checkpoints.
| Key | Type | Chain | Meaning |
|---|---|---|---|
db_version | u64 | both | On-disk format version this directory was written at |
chain | raw byte | both | Which consensus protocol this directory holds (Chain) |
preset | raw byte | both | Which SSZ preset the writing build used (Preset) |
time | u64 | both | The store clock, as a UNIX timestamp in milliseconds |
config | Config | both | The node’s runtime configuration |
head | H256 | both | Current fork choice head |
anchor_slot | u64 | both | The slot this directory’s chain begins at |
states_pruned_below | u64 | both | Exclusive upper bound of the States/StateDiffs slots already range-deleted; absent means 0 |
safe_target | H256 | lean | Current safe target (see lmd_ghost.md) |
latest_justified | Checkpoint | both | Latest justified checkpoint |
latest_finalized | Checkpoint | both | Latest finalized checkpoint |
beacon_unrealized_justified | Checkpoint | beacon | Unrealized justified checkpoint (beacon Checkpoint) |
beacon_unrealized_finalized | Checkpoint | beacon | Unrealized finalized checkpoint (beacon Checkpoint) |
Rows marked for one chain are never written on the other, so reaching one through the wrong chain’s accessor panics naming the key rather than reading a zero. That is deliberate: a data directory holds one chain for its whole life.
One clock, three readings
time is the only clock either chain keeps, and it is a UNIX millisecond.
Milliseconds because the row has to be fine enough for the finest grid either
chain schedules on, which is lean’s interval: with INTERVALS_PER_SLOT
intervals to a slot, most interval boundaries fall strictly between two whole
seconds, and a second-resolution row could not name them.
Everything coarser is derived from it, exactly and in one direction:
| Reading | Method | Used by |
|---|---|---|
| Milliseconds past genesis | Store::ms_since_genesis | placing a moment within the current slot, against the beacon basis-point deadlines |
| Intervals past genesis | Store::intervals_since_genesis | lean’s tick pipeline, its future-slot guards, its fork-choice fixtures and the Hive driver |
| Slot | Store::current_slot | both chains, including the beacon get_slots_since_genesis |
Nothing stores a derived reading, so none of them can fall out of step with
the row or with each other. The beacon specification denominates its own
Store.time in seconds, so on_tick and on_tick_per_slot convert on the
way in and the fixture harness divides on the way out; that conversion lives
at those edges rather than in a second row.
Lean’s on_tick writes the row only at interval boundaries, since the
boundary is what it has actually processed: it walks forward one interval at a
time running that interval’s duty, and the leftover milliseconds up to the
tick’s own timestamp carry no duty that has run. The one time it moves the
clock backwards is the deliberate rewind that replays a slot after a gap.
Format and shape tags
db_version, chain and preset are what from_db_state checks before it
decodes anything else, and the two tags are raw bytes rather than SSZ for that
reason: they have to be readable by a build that would decode the rest of the
directory into the wrong shape. db_version catches a layout change this build
knows about; chain catches lean rows being opened as beacon or the reverse;
preset catches a preset-minimal build opening a mainnet directory, which
the other two cannot, since both presets write the same layout at the same
version while bounding every SSZ container differently. None of the three has a
migration path.
config and chain are the odd ones out among the mutable rows: written at
bootstrap (init_store for lean, init_beacon for beacon) and not mutated as
the chain progresses. chain is never rewritten (it has a getter,
Store::chain, and no setter). config is rewritten in one place,
Store::replace_config, by a beacon resume whose config file changed values
the directory does not contradict (see below), and that method panics unless
its Store holds the only handle to the cached copy. Because neither changes
once a Store is shared, the Store keeps a copy of each in memory, and
reads of them never reach the backend. Every other Metadata key is mutated
in place as the chain progresses.
config used to double as the DB’s fingerprint on its own, with
from_db_state refusing to resume a data directory belonging to another
network. from_db_state no longer judges the network or the chain (see
Startup and Restore): it reads config and chain
back and hands both to the caller, which compares config’s genesis_time
and slot duration (and genesis_validators_root, computed off the anchored
state itself rather than stored in Metadata) against the network it was
configured for, and chain against the sub-command it is running. A beacon
resume goes further, comparing every chain value in config (the slot time,
PRESET_BASE, the churn limits, …) against the config.yaml it was started
with, in first_config_difference. CONFIG_NAME is exempt: a label with no
consensus effect, so a changed one is only warned about.
The fork schedule and BLOB_SCHEDULE are compared against what the directory
holds instead, because scheduling a fork is an edit to exactly those values,
made after every existing node initialized its directory. A fork’s version or
epoch may change as long as both the stored and the new epoch lie past the
latest epoch the directory has processed: the highest LiveChain slot, or the
finalized state’s slot when that is later (a checkpoint-sync anchor advanced
through empty slots). So a fork that was unset and is now scheduled ahead of
the chain resumes, while one moved onto or off a processed epoch is refused.
A resume that passes then writes the config file over config with
Store::replace_config: the node runs on the stored config, so keeping the
old one would leave it on the old schedule. From then on the file’s values,
CONFIG_NAME included, are the ones the node reports.
A beacon directory shares head, latest_justified and latest_finalized
rather than keeping parallel ones. init_beacon seeds all three from one
trusted anchor, exactly as init_store seeds them on a lean directory, so
update_checkpoints is a single writer for both chains instead of growing an
absent-key branch per chain. The specification gives a trusted anchor’s
finality the same starting value as its justification, rather than one actually
reached through the FFG rules, which is why both start equal.
The two chains denominate a checkpoint differently, and the stored form is
lean’s: a beacon epoch is written as its own start slot. That is what makes the
conversion exact in both directions, through
Store::beacon_checkpoint_as_stored on the way in and
Store::beacon_finalized_checkpoint on the way out, and it is what lets the
shared finalization-advance comparison read a beacon checkpoint with no second
rule for it.
Note what is not in the table. There is no stored beacon head row:
beacon_head() derives its pair from head plus the head block’s own entry,
because a second row denominated in slot ‖ root would be a value that could
drift from the first.
init_beacon writes the two beacon-only keys plus db_version, chain,
preset, config, time, head, anchor_slot, latest_justified and
latest_finalized in one atomic batch.
anchor_slot is the odd one among these: every other row is either a format
tag or something a later writer moves, while this one is written once at
bootstrap and never again, like config and chain. It has to be persisted
rather than derived because it is the store’s only record of where its chain
starts. latest_finalized is seeded to the anchor as well, but climbs away
from it at the first finalization, so after that nothing else on disk can
answer the question. Both bootstrap paths take it from the anchor block’s own
slot — anchor_state.latest_block_header.slot on lean, anchor_state.slot()
on beacon — rather than from the anchor checkpoint, whose beacon form is
epoch-denominated and would name the epoch’s start slot instead. from_db_state
reads it back into a Store field, so Store::anchor_slot() costs no backend
round trip; the Status message’s earliest_available_slot and the
data_column_sidecars_by_range/1 floor are both that one value, which is what
keeps a refusal from contradicting an advertisement. A directory’s finalized state root, whichever chain it
holds, is read through Store::finalized_state_root. That needs no
chain-specific branch: both chains keep their finalized checkpoint in
latest_finalized, and the epoch-to-slot conversion above touches only the
slot, never the root. It treats a zero root as Error::UnanchoredDirectory
rather than a valid answer, since both init_store and init_beacon always
anchor at a real block root, so a zero root means the directory was built some
other way or its metadata was corrupted at rest.
Note that this is not the SSZ StateConfig carried inside State. That one is
merkleized into the state root, so its layout is fixed by the spec and holds only
genesis_time; the runtime Config adds the slot duration and the beacon fork
schedule, which the node needs to schedule duties but which never enter a state
root.
LiveChain
slot ‖ root → parent_root. A pure index for fork choice: it lets
get_live_chain() build the root → (slot, parent_root) block tree without
deserializing a single block. It contains the finalized anchor plus all
non-finalized blocks, and is pruned as finalization advances (the finalized
block itself is kept) on both chains.
The pruning horizon differs by chain because the two store their finalized
checkpoint’s slot differently. Lean’s is always a real block’s own slot, so
update_checkpoints prunes to it directly. A beacon checkpoint’s stored slot
is its epoch’s start slot (Store::beacon_checkpoint_as_stored), which can
name a slot nobody ever built a block for; pruning to it directly would
delete the finalized block’s own row whenever that start slot was skipped,
since the block then sits strictly below it, and every fork-choice walk that
asks for the ancestor at that start slot (get_checkpoint_block) would fall
through the gap into a hard SpecAssert instead of finding it. So the beacon
arm looks up the finalized root’s own slot (from BlockHeaders, which is
never pruned) and prunes to that instead: lower than or equal to the epoch
start slot, and always a slot some row actually occupies.
Presence in LiveChain is what makes a block visible to fork choice:
insert_pending_block deliberately writes a block’s header/body/proof
without a LiveChain entry, persisting the heavy proof data (~3 KB+)
while the block waits for its parent. When the block is later processed,
insert_signed_block overwrites the same keys (idempotent) and adds the
LiveChain entry.
DataColumns
slot ‖ root ‖ column_index → DataColumnSidecar (SSZ-encoded). Beacon only:
this is where a fulu data-availability-sampling follower keeps the column
sidecars its own node id assigned it custody of, so it can verify a block’s
availability and answer data_column_sidecars_by_{root,range}/1 for peers;
see beacon_wire.md for the wire side
and how a node’s custody set is selected.
Keyed slot-first, ahead of the root, for the same reason BlockProof is: it
gives a by-range answer a prefix scan per slot instead of a full-table scan,
and gives a future pruner a scan that stops early once it passes its cutoff.
Every writer and reader already has the slot in hand — an arriving sidecar
carries signed_block_header.message.slot, and the availability check and the
held-block release path both start from the block itself — so the slot prefix
costs nothing to supply.
Sidecars are written on arrival, once the p2p layer has verified one (gossip
validation for a sidecar gossip accepted, the chain checks in
beacon::gossip::column::chain_checks for any other), rather than at block
import. That ordering is what lets the availability
gate read a block’s columns before the block itself is allowed to import (a
column has to exist first for the gate to find it), and what lets a restart
keep every sidecar this node already paid a KZG batch to verify rather than
re-fetching and re-verifying them. One consequence: a sidecar is stored for
any block whose header, parent and proposer check out, whether or not that
block ever actually imports — an equivocation or an orphaned fork’s sidecars
land here too, since gossip only requires a known, unfinalized parent, not a
canonical one. data_column_sidecars_in_range (the by-range handler’s source)
compensates by restricting each slot’s answer to that slot’s canonical root
via BlockRoots, so a losing sibling’s columns are stored but never served.
DataColumns is the one table in the enum with no pruning rule at all,
not even the finalized-window pruning LiveChain and BlockProof get: every
sidecar this node has ever custodied is kept forever, by design, until a
pruner is written (MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS is the
epoch depth spec gives a future one permission to prune below). That makes it
the one table whose size a long-running node must actually watch rather than
assume bounded: at the blob cap this is roughly 360 KB per slot across the
columns this node custodies, nearer 1 GB per day at current mainnet blob
counts. lean_table_bytes{table="data_columns"} (see metrics.md)
is that growth made visible; watch it, and size the disk against however long
the node runs unattended before a pruner exists.
ExecutionPayloadEnvelopes and BlockTimeliness
Gloas only. A row in ExecutionPayloadEnvelopes exists for an envelope that
passed verification, keyed by the slot of the block it fulfills (the envelope
has no slot of its own) so a by-range handler can scan a slot window. The
table is the durable form of the store’s verified_payloads set. Losing that
set on restart would silently drop every full payload branch from fork choice.
BlockTimeliness is the durable form of the gloas part of block_timeliness.
Pre-gloas blocks write no row, since nothing reads their timeliness after a
restart.
Store::from_db_state reloads both for every LiveChain block at or above the
finalized block’s slot, and reseeds the in-memory payload-committee vote
vectors empty for each gloas block in that window. The votes themselves are not
persisted: the next block’s payload attestations refill them. Neither table is
pruned; rows outside the window are simply not read.
The execution client’s verdict on each reloaded payload is in-memory scratch
too, so the chain actor seeds one at startup: SYNCING when an execution
client is configured, so its first forkchoiceUpdated VALID for the head
promotes them, and VALID without one, as the follower trusts every envelope it
applies then. The head node’s payload status (Store::head_payload_status) is
likewise not stored; it is unknown until the first head computation after a
restart, and the envelope by-range server withholds the head’s envelope until it
is known.
PendingDataColumns
Same key and same encoding as DataColumns, holding sidecars that have not
been verified yet. A sidecar lands here when its block’s parent has no
post-state for the chain checks to check the proposer against: the parent may
still be in flight, or it may be a block the availability gate is itself
holding. The specification’s gossip rule for that case is [IGNORE] with an
explicit licence to come back to it, so the sidecar is parked rather than
dropped, and sent back through the chain checks when the parent gains a
post-state.
Two tables rather than one, and that is the whole point of this one. A parked
sidecar has passed only the cheap structural checks — not its inclusion proof,
not its KZG batch, not its proposer signature, which are held back so a replay
pays for them once rather than once per attempt. data_column_indices_for
reads DataColumns and nothing else, and that read is what the data
availability gate believes; an unverified row there would let a peer satisfy
the gate with a column nothing ever judged. A row moves from here to
DataColumns only by passing every check when it is sent back.
The chain actor keeps one key per parked row in memory
(sidecars_awaiting_parent, keyed by the parent root it waits on) and the
bytes here, because a sidecar carries a cell per blob and the queue’s size is
chosen by whichever peer is gossiping.
Nothing caps that queue. The finality sweep that evicts held blocks also
deletes the rows of parked sidecars at or below the finalized slot, and that is
the only thing reclaiming them, so it bounds how long a row lives but not how
fast rows arrive. The chain checks do not require a sidecar’s
parent_root to name a block this node knows. Every parked sidecar, gossiped
or fetched, has had its header’s signature checked against the head state
before it gets this far, but only when a head state is already cached and
the header’s proposer_index names a validator in it: with no cached head
state, or a proposer index that names none (u64::MAX, say), the checks skip
that step and a made-up header still reaches here. A peer exploiting either
gap can still park rows as fast as it can invent a slot, proposer and index.
Watch
lean_sidecars_awaiting_parent: it is the only signal that this is
happening, and unlike DataColumns these rows were written on a peer’s
say-so.
That in-memory map is also the only index into this table, and it does not
survive a restart, so start_actor clears the table outright before building
an empty one. Nothing is lost: a parked sidecar had passed no check worth
preserving, and the block it belongs to asks for its columns again. Without
that, every crash would leave every row it had parked unreadable, kept until
the directory was deleted.
BeaconUnrealizedJustifications
root → (slot, Checkpoint), beacon only. Persists the beacon fork
choice’s per-block unrealized justified checkpoint — consensus-specs’
store.unrealized_justifications[block_root] — which used to live only in
BeaconScratch::unrealized_justifications, an in-memory HashMap with no
backing table. A restart emptied that map with nothing to refill it: once a
pre-restart block was a leaf from an epoch older than the store’s clock,
get_voting_source (and, through it, filter_block_tree/get_head) hit a
hard SpecAssert("block_root in store.unrealized_justifications") on every
tick, freezing the head. This table is what makes the value survive a
restart; Store::unrealized_justification/Store::set_unrealized_justification
keep the in-memory map too, as a write-through cache in front of it, since
get_voting_source reads this for every block from a prior epoch and a cache
hit costs no backend round trip.
Keyed by root alone rather than slot ‖ root, unlike LiveChain/BlockProof:
see Key encoding above for why. The slot travels in the value
instead, written alongside the checkpoint by Store::set_unrealized_justification.
Two writers: the anchor, at bootstrap (get_forkchoice_store), and
compute_pulled_up_tip, called from on_block for every newly imported
block. Both call Store::set_unrealized_justification after the block’s own
insert_signed_block/insert_state have already run — compute_pulled_up_tip
needs the block’s post-state, which does not exist until those have — so the
write is not in the same atomic batch as the block’s import. A crash between
the two leaves that one block without an unrealized-justification entry;
has_state then skips re-importing it on resume, so nothing ever fills the
row in afterward either. get_voting_source no longer treats that miss as
fatal: it falls back to store.beacon_justified_checkpoint(), the way Prysm
seeds a rebuilt node’s forkchoice store, and logs at debug rather than
raising. That is safe because the fallback only affects whether the block
counts as viable in filter_block_tree (voting_source.epoch == store.justified.epoch is always true for it), never how much weight it
casts once it does. is_ffg_competitive still raises on a miss: nothing in
production calls it.
Pruned on finalization, on the same horizon LiveChain is (the finalized
block’s own slot, read from BlockHeaders via block_entry, not the stored
checkpoint’s epoch-start slot — see LiveChain above for why):
Store::prune_unrealized_justifications deletes every entry below that slot
and evicts the same roots from the in-memory cache, keeping the finalized
block’s own entry. That entry has to survive: after a restart with nothing
yet imported past the anchor, the anchor itself is the only leaf get_head
has to work with.
State Storage: Snapshots + Diffs
Storing a full State per block would be wasteful: most fields never change
or change predictably. Instead, insert_state writes:
- Always a
StateDiffkeyed by the block’s slot and root, linked to its parent viabase_root(the block’sparent_root). - Only at anchors a full snapshot into
States. A block is an anchor when it crosses aSNAPSHOT_ANCHOR_INTERVALslot boundary relative to its parent (~68 minutes at 4-second slots). This bounds any reconstruction walk to at mostSNAPSHOT_ANCHOR_INTERVALdiff applications.
This is the lean arm. A beacon state is also a diff or, at an anchor, a
snapshot, but the diff is a byte delta (beacon_state_delta) taken against the
nearest snapshot on its branch, not against its parent, and its frame names
that snapshot as base_root. Reading a beacon state is therefore one snapshot
plus exactly one delta, however far into the interval the state sits; the cost
is that a delta carries every change since its snapshot, so it grows across
the interval. The reader follows each frame’s base_root and does not assume
one hop, so a directory whose diffs were written parent-linked by an earlier
build still reads correctly (no DB_VERSION bump was needed), and a diff after
a restart or on a fork finds its snapshot by walking StateDiffs frames until
a root with no diff row.
A StateDiff stores only what cannot be recovered elsewhere: the target slot,
justified/finalized checkpoints, and the justification fields
(justified_slots, justifications_roots, justifications_validators,
stored in full — they are bounded by the non-finalized window, so they stay
small under healthy finality). The rest is deliberately omitted:
| Omitted field | Recovered from |
|---|---|
config, validators | The snapshot (they never change) |
latest_block_header | The BlockHeaders table |
historical_block_hashes | Regenerated from base_root + the slot gap |
The historical_block_hashes append is checked rather
than trusted blindly: validate_history_append
(crates/storage/src/state_diff.rs) rejects a diff whose appended hashes
don’t match the expected slot gap or aren’t zero-filled for skipped slots,
so a broken append surfaces at diff-creation time instead of corrupting a
later reconstruction.
Reads go through get_state, which tries three levels:
- An in-memory LRU cache (
STATE_CACHE_CAPACITY = 32states, keyed by block root). States are content-addressed and immutable, so the cache never needs invalidation. The common case — reading the parent state right after importing its block — is a cache hit. - A full snapshot in
States. - Reconstruction: walk
base_rootpointers back throughStateDiffsuntil a snapshot is found, then replay the diffs forward. On lean that is a chain of parent-linked diffs; on beacon it is one hop, to the interval’s snapshot.
STATE RECONSTRUCTION
────────────────────
get_state(D): not in the cache and no snapshot → rebuild in two passes.
Pass 1: walk backward from D, following each diff's base_root pointer
and collecting diffs, until a block with a snapshot is found:
┌────────┐ base=C ┌────────┐ base=B ┌────────┐ base=A ┌──────────┐
│ diff D │ ───────▶ │ diff C │ ───────▶ │ diff B │ ───────▶ │ snapshot │
└────────┘ └────────┘ └────────┘ │ at A │
(target) (StateDiffs table) └──────────┘
(States table)
Pass 2: starting from the snapshot, apply the diffs oldest-first:
state A ──apply B──▶ state B ──apply C──▶ state C ──apply D──▶ state D ✓
The rebuilt state D gets its latest_block_header from the BlockHeaders
table and is memoized in the LRU cache before being returned.
If the diff chain is broken or the target’s header is missing, get_state
returns None rather than a partial state.
Write Paths: What a Block Import Persists
Block import (on_block in crates/blockchain/src/store.rs) commits a
sequence of independent write batches:
BLOCK IMPORT WRITE SEQUENCE
───────────────────────────
on_block(signed_block)
│
├─ 1. update_checkpoints() Metadata: head,
│ (only if the post-state latest_justified
│ justified a higher slot) (+ triggers pruning)
│
├─ 2. insert_signed_block() ┐ BlockHeaders[root]
│ │ BlockBodies[root] (lean, if non-empty)
│ ├─one batch─ BlockProof[slot‖root]
│ │ LiveChain[slot‖root]
│ ┘
│
├─ 3. insert_state() cache + PendingStates insert (synchronous),
│ (hands the state to the then a hand-off to the background writer
│ storage crate's own writer thread, which commits
│ thread; see below) StateDiffs[slot‖root]
│ States[slot‖root] (anchors only)
│ on its own schedule, not this one
│
└─ 4. update_head() Metadata: head
(re-runs fork choice) (+ justified/finalized if advanced,
+ BlockRoots diff (canonical index),
+ pruning on finalization)
Each numbered step is atomic on its own, but the import as a whole is not
one transaction, and step 3 is no longer one write at all: insert_state
returns once the state is cached and buffered (see crates/storage/src/state_writer.rs),
not once the writer thread has actually committed it. That thread drains a
FIFO one state behind the importer at most (STATE_WRITE_QUEUE_CAPACITY = 2,
plus the one write it can be holding), so steps 1, 2 and 4 can all reach disk
before step 3’s own commit does. An unclean shutdown inside that window can
therefore leave head (from step 4), or latest_justified (from step 1, for
an even earlier block whose own step 3 might itself still be mid-flight), or
the finalized checkpoint update_head derives from the head state, naming a
root this directory has no state for yet — the exact thing “never leave
metadata referencing missing data” used to rule out, before the state write
moved off the importer’s thread.
Store::repair_head, called by a resuming caller (see
Startup and Restore below), is what restores that
property for the head: it walks back to the newest ancestor with a persisted
state and rewinds there, dropping the LiveChain rows of whatever it hops
over so fork choice does not walk straight back to the stateless tip.
Justified and finalized are never rewound the same way — they are consensus
statements, not a pointer a repair may quietly move to an earlier one — so
Store::verify_anchor_states checks them instead, and a directory that fails
it is treated as needing a fresh anchor. Re-importing a block whose LiveChain
row was dropped this way is idempotent the same way any duplicate is: the skip
check is keyed on has_state, not on whether the block is already on record,
so a stateless block is reprocessed rather than skipped.
Pruning
Pruning is driven by finalization and splits into a cheap immediate phase and a deferred heavy phase.
Immediately, when finalization advances (inside update_checkpoints):
prune_live_chain: deletesLiveChainentries below a horizon, keeping the finalized block itself. This keeps the fork choice working set bounded to the non-finalized chain. The horizon isfinalized.sloton lean, and the finalized block’s own slot (read fromBlockHeaders) on beacon — see LiveChain for why the two differ.prune_gossip_signatures: drops buffered in-memory gossip signatures at or below the finalized slot.prune_stale_aggregated_payloads: drops in-memory aggregated payloads (both pending and known) whose target slot is at or below the finalized slot.prune_unrealized_justifications: beacon only, and only alongsideprune_live_chainin the sameChain::Beaconarm, on the same horizon (the finalized block’s own slot) — see BeaconUnrealizedJustifications above. Deletes entries below that slot from the table and evicts the same roots from the in-memory cache, keeping the finalized block’s own entry.
Deferred (prune_old_data, called after a batch of blocks has been
processed):
-
prune_old_block_proofs: deletesBlockProofentries belowcutoff = tip_slot − BLOCK_PROOF_PRUNING_RANGE(21,600 slots, ~1 day at 4-second slots) — but only whencutoff ≤ finalized_slot, i.e. the entire pruned range lies within finalized history. Non-finalized proofs are never touched. Finalized blocks can never revert, so their proofs are not needed for fork choice, reorg safety, or re-aggregation once outside the window. -
prune_finalized_states: range-deletesStatesandStateDiffsover[watermark, cut), wherecutis the start of the snapshot interval that holds the finalized block (its own slot fromBlockHeaders, not the checkpoint’s epoch-start slot) andwatermarkisstates_pruned_below, the previous call’scut. The watermark is written in the same atomic batch as the deletes, so consecutive ranges are adjacent and never overlap, even across restarts. Safe because a diff-chain walk stops at the first snapshot on its branch, and the first block of any branch inside an interval is a snapshot, so every walk from a kept state stops at or aftercut; blocks below finality are never imported again, so nothing is written below the watermark afterwards. A state handed to the writer thread before finality moved can still be queued, though, so a call that has a range to delete first flushes that thread (StateWriterHandle::flush), and every earlier write commits before the tombstones do. Lean runs it insideprune_old_data; beacon runs it from the chain actor’s finality horizon, after every tick and import, where the common case is one metadata read.
Never pruned, by design: BlockHeaders, BlockBodies, BlockRoots, and
Metadata. Headers, bodies and the canonical slot index are the full
historical record (and the slot lookup every state read depends on); only the
proof blobs, the finalized states and the (non-finalized) fork choice index are
disposable.
Not pruned yet, unlike the six above: DataColumns. It is not part of the
historical-record set above; it simply has no pruning rule written for it,
which is a gap rather than a decision — see DataColumns for
what bounds it in the meantime (lean_table_bytes{table="data_columns"}) and
where a future pruner is meant to land
(MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS).
In-Memory Only (Lost on Restart)
Five Store fields never touch the backend directly, beacon with the
exceptions its row below names. All are bounded buffers (or, for
beacon, bounded in practice by the validator set and the unfinalized
window) shared across Store clones:
| Buffer | Capacity | Contents |
|---|---|---|
new_payloads | 64 messages | Pending aggregated attestation proofs, not yet active for fork choice |
known_payloads | 512 messages | Fork-choice-active aggregated proofs |
gossip_signatures | 2048 signatures | Raw per-validator XMSS signatures awaiting aggregation (each ~3 KB, so ~6 MB worst case) |
state_cache | 32 states | LRU memoization of block and checkpoint post-states (either chain), each held behind an Arc so a hit is not a copy; one bound covers both kinds, keyed apart by a small enum, and a miss is just a reconstruction rather than an error |
beacon | unbounded | Beacon fork-choice scratch: proposer boost root, block timeliness, equivocating validator indices, latest messages, PoW blocks, and unrealized justifications. Most of it is not persisted: proposer boost resets every slot, pre-gloas timeliness is read only by the same-slot reorg helpers, equivocators come back from replaying attester slashings on sync, latest messages from one epoch of attestations, and PoW blocks stand in for an execution-client call a restarted node would simply make again. The exceptions are a gloas block’s timeliness and verified payload (see ExecutionPayloadEnvelopes and BlockTimeliness) and unrealized justifications: this map is a write-through cache in front of BeaconUnrealizedJustifications (see that table above), so it is not lost, only refilled lazily on the next miss |
The payload buffers evict FIFO when full, and redundant proofs (whose participants are a subset of an existing proof for the same attestation data) are skipped on insert.
Note that the per-validator “latest attestation” maps used by fork choice are
not stored anywhere — they are derived on demand from these buffers via
extract_latest_known_attestations and friends. See the attestation pipeline
section of lmd_ghost.md for how attestations move between the
pools.
After a restart these buffers start empty: pending attestations and
un-aggregated gossip signatures are lost and must be re-collected from the
network. Everything persisted in the thirteen tables survives, except PendingDataColumns, which is cleared outright: its only index is in memory.
Startup and Restore
A Store is created through one of four constructors in
crates/storage/src/store.rs:
| Constructor | When | What it does |
|---|---|---|
from_anchor_state | Lean genesis boot | Initializes from the genesis state (no anchor block body) |
get_forkchoice_store | Lean checkpoint sync | Initializes from a downloaded finalized state + anchor block, after validating they are consistent |
init_beacon | Beacon checkpoint sync | Writes a beacon directory’s Metadata in one atomic batch, all seeded to one trusted anchor. The anchor block and state themselves are written separately, by ethlambda_state_transition::beacon::fork_choice::get_forkchoice_store (the specification’s own construction rules, which this crate cannot depend on) |
from_db_state | Resume from an existing data directory | Loads whichever chain the directory holds, without judging whether it is the right one |
The first two funnel into init_store, which writes the anchor in one
atomic batch: the Metadata keys a lean directory needs (the format version
and the chain and preset tags, time, config, head = safe_target = anchor root,
justified = finalized = anchor checkpoint), the anchor header, its
BlockRoots entry, the body if non-empty, a full snapshot into States keyed by the anchor’s slot and root (the
base of every future diff chain), and the anchor’s LiveChain entry. time
starts at genesis rather than at zero because it is an absolute timestamp, so
genesis is the value that means “the clock has not moved yet”; every derived
reading is zero there.
init_beacon’s own atomic batch is the beacon-directory keys listed under
Metadata above; the anchor’s States/BlockHeaders/BlockBodies
entries are written by its caller instead, through the same insert_state and
insert_signed_block an ordinary block import uses, followed by the anchor’s
own BeaconUnrealizedJustifications row (set_unrealized_justification), the
same order every later block’s import follows too — see
BeaconUnrealizedJustifications above. The
one row an import does not write is the anchor’s BlockRoots entry, which
index_beacon_anchor adds right after the block (see BlockRoots).
from_db_state is the restore path: it reads db_version, preset, chain,
config and anchor_slot from Metadata, returning None for an empty DB. A format
mismatch is still fatal, failing with Error::DbVersionMismatch or
Error::PresetMismatch rather than reading on, but from_db_state no longer
judges the network or the chain, and it does not mutate anything either: it
hands back whichever chain the directory holds, without comparing either
against anything or repairing anything. Both are now the caller’s job, in a
fixed order: fetch_initial_state (lean) and fetch_initial_beacon_state
(beacon) each check Store::chain() against the sub-command they are running
under, then call Store::verify_anchor_states — which reads back both the
justified and finalized checkpoints (see Metadata above) and
confirms each has a persisted state, returning the finalized state — before
running verify_state_genesis against it. Only once both of those pass does
either caller call Store::repair_head, described under
Write Paths above: it is a
mutation, so it waits until the store it would mutate has been judged fit to
resume at all, and it leans on verify_anchor_states having already confirmed
finalized has a state, which is what lets it treat reaching finalized’s slot
during its own walk as a plain stopping point rather than something it has to
guard against.
A chain mismatch aborts with CheckpointSyncError::WrongChain; a genesis
mismatch aborts with CheckpointSyncError::Genesis, wrapping
GenesisMismatch::GenesisTime or ::GenesisValidatorsRoot; a missing anchor
state (storage::Error::AnchorStateLost) is treated exactly like a stale DB,
described next. All three used to be raised (the first two) or were not yet
possible to raise (the third) from inside from_db_state itself; none of that
judgment lives in the storage crate anymore, now that the checks that need it
moved one layer up, to the caller that actually knows which network and which
chain it wants. None of these failures are treated as an empty directory:
writing a fresh anchor on top would leave the foreign (or merely incomplete)
chain’s rows in place, to be served to peers.
At startup, each chain prefers this path but only accepts the on-disk store if
its head is at most MAX_RESUMABLE_DB_STATE_AGE = 450 slots behind the
current slot: ~30 minutes at lean’s four-second slots, ~90 minutes at beacon’s
twelve-second ones. A staler DB, or one whose anchor checkpoint state is
missing, falls through to checkpoint sync, which writes a fresh anchor on top
of the existing data.
Key Files
| File | Component |
|---|---|
crates/storage/src/store.rs | Store: persistence logic, in-memory buffers, pruning, constructors |
crates/storage/src/api/traits.rs | StorageBackend, StorageReadView, StorageWriteBatch |
crates/storage/src/api/tables.rs | The Table enum |
crates/storage/src/state_diff.rs | StateDiff: diff creation and state reconstruction |
crates/storage/src/backend/rocksdb.rs | Production RocksDB backend |
crates/storage/src/backend/in_memory.rs | Test backend |
crates/blockchain/src/store.rs | Fork choice logic driving the Store (on_block, on_tick, …) |
Peer discovery (discv5)
ethlambda can find peers over discv5 instead of relying only on the static bootnode list. The implementation reuses ethrex’s discovery stack, with discv4 disabled.
Discovery is always on for beacon and opt-in for node. beacon
never had a choice: published mainnet bootnode ENRs carry no quic entry, so
none of them is statically dialable and a crawl is the only way to reach a peer.
On lean it stays behind --discovery.enable (off by default), because nothing
else on that network speaks discv5 yet, so a crawl there finds only other
ethlambda nodes. Without the flag a lean node binds no discovery socket and
peers from its --bootnodes list alone, which is also what lets several devnet
nodes share one host without a --discovery.port each.
Configuring it
| Flag | Default | Meaning |
|---|---|---|
--discovery.enable | false | node only: run discv5. beacon has no such flag and always runs it |
--discovery.port | 9000 (DEFAULT_DISCOVERY_PORT) | UDP port for the discv5 socket |
--discovery.advertise-ip | bind address (0.0.0.0) | IP address to advertise in the ENR |
--discovery.target-peers | 200 | Peers this node holds: the dial loop’s cutoff, and on beacon the connection limits too |
All but --discovery.enable are common flags with the same default on node
and beacon; on a node without --discovery.enable they are accepted and
ignored. See the CLI reference.
--discovery.port and --gossipsub-port (default 9001, libp2p QUIC) are both
UDP and so cannot share a port. --gossipsub-port also binds a libp2p TCP
listener on the same number, which collides with neither: TCP and UDP are
separate namespaces. The defaults are one apart, so a default invocation works;
pointing either flag at the other’s port is rejected at startup, before the node
touches its data directory. Co-located nodes on one host that run discovery need
an explicit --discovery.port each, the same way they already need an explicit
--gossipsub-port. Neither check applies to a node without
--discovery.enable, since it binds no discovery socket and publishes no ENR.
The discv5 socket always binds the wildcard 0.0.0.0, since that is where we
listen, not where peers should dial us. Without --discovery.advertise-ip the
published ENR inherits that same 0.0.0.0, which is not a dialable address:
set the flag to 127.0.0.1 for a local devnet or to the host’s public address
so the ENR is usable as soon as it is published. discv5’s PONG-based IP voting
may still replace the advertised address later, once a peer’s response tells
the node what its external address looks like.
The ENR
The layout follows the discovery domain of the beacon-chain phase0 p2p interface spec.
| Entry | Value |
|---|---|
id | v4 |
ip | --discovery.advertise-ip, or the bind address (0.0.0.0) if unset |
udp | --discovery.port |
quic | --gossipsub-port, the libp2p QUIC listener; omitted when 0 |
tcp | --gossipsub-port, the libp2p TCP listener; omitted when 0 |
secp256k1 | compressed public key from --node-key |
eth2 | SSZ ENRForkID, 16 bytes |
attnets | subscribed attestation subnet bitfield |
cgc | --custody-group-count, on beacon only; omitted on lean, which has no data-availability domain |
tcp and quic share the same port number: TCP and UDP are separate
namespaces, so build_swarm binds both without a collision. Advertising both
is what lets a peer whose quic port does not answer still reach this node
over TCP. It also gets us past lighthouse’s discovery predicate, which requires
enr.tcp4().is_some() || enr.tcp6().is_some() on top of the spec’s
fork_digest comparison; the lean fork digest is still the cross-client dummy
0x12345678, so a beacon-chain client rejects us on that instead.
Both ports come from configuration rather than from the bound listeners, so a
--gossipsub-port 0 would name neither of the two real OS-assigned ports.
Startup rejects that combination when discovery is enabled, and the writer omits
a 0 either way, matching every reader’s rule that 0 means absent.
The local ENR is logged once at startup.
This same record is handed to ethrex’s DiscoveryServer, so it is what answers
discv5 queries: what we report and what peers see are the same bytes. If IP
voting later changes our external address, ethrex edits and re-signs that record
rather than rebuilding one, so the consensus entries survive the bump; only the
sequence number and ip move, which the reported ENR then lags.
Which peers get dialed
A discovered peer is admitted only if:
- its ENR carries a decodable
eth2entry, and - that entry’s
fork_digestequals ours, and - it advertises a
quicport, atcpport, or both.
The digest compared against is the caller’s, not a constant: lean passes its
hardcoded dummy and beacon passes the digest it derived at startup and moves
it at each fork-digest boundary, so one admission policy serves both chains. The
record this node serves is not refreshed at a boundary (ethrex’s
DiscoveryServer cannot replace it at runtime), so its eth2 entry keeps the
startup digest until a restart; see beacon_wire.md.
A differing next_fork_version or next_fork_epoch is not grounds for
rejection: the spec permits connecting to a peer that is incompatible with an
upcoming fork but compatible now.
A peer advertising both transports is dialed with a QUIC-first address list, so
libp2p races them within one attempt and a quic entry that does not answer
falls back to TCP rather than ending the dial. Most mainnet beacon nodes are in
exactly that state, which is why a QUIC-only dialer peered so poorly with them.
These checks are handed to ethrex’s peer table as a PeerFilter, so each record
is judged the moment it arrives and a peer that fails is not offered for dialing.
No rejection is final: the peer table runs the filter again as soon as the peer
publishes a higher-seq ENR, so a node that adds a transport entry, or gains an
address through discv5’s IP voting, is reconsidered without a restart.
A peer’s dial list carries every address it advertises, quic and tcp both,
in one dial attempt, quic first. libp2p walks that list dial_concurrency_factor
addresses at a time, and ethlambda pins the factor to one, so the tcp address is
reached only after the QUIC attempt ahead of it has failed. A peer whose quic
port does not answer still connects over tcp with no separate retry, but it
waits out libp2p_quic’s handshake timeout first.
The list order is therefore a preference and should be read as one. It was a race
until the mainnet follower was profiled: both handshakes started at once, TCP won
most of them, and every TCP win is a connection carrying mplex, whose waker
bookkeeping cost more CPU than the entire beacon state transition. QUIC multiplexes
natively and reaches none of that code, so the cheaper transport is now asked for
first instead of merely offered. A lean peer advertises quic alone and is
unaffected either way.
Admitted peers are ranked by how many attestation subnets they advertise that no
currently connected peer covers, so discovery preferentially fills gaps in subnet
coverage. A peer advertising no attnets is ranked last but never dropped.
Dialing stops once --discovery.target-peers peers are connected, and resumes
if that count drops. Nothing in ethrex’s peer table or discv5’s own pacing
enforces it (see below).
On beacon the flag is more than the dial loop’s cutoff: the swarm’s connection
limits are derived from it too, so what this node refuses and what it goes
looking for are two readings of one number.
| Derived from the target | At the default 200 |
|---|---|
| Ceiling on established connections | 200 |
| Of those, the most inbound demand may hold (70%) | 140 |
| The rest, reserved for peers this node dials | 60 |
The reservation is what the dial loop chases as its second shortfall, alongside
the plain “are we at target” one, so inbound saturation cannot suppress dialing:
outbound peers are the only ones this node chooses, and so the only lever it has
on which custody columns its peer set covers. Deriving both from one number is
what keeps the loop from chasing slots the swarm would refuse, or stopping while
slots it reserved sit empty. --discovery.target-peers 0 therefore holds no
peers at all, dialing none and admitting none.
Lean is unaffected: it runs unlimited connections, so it has nothing to reserve against and paces on the total peer count alone.
The loop opens one dial per tick and varies the tick instead of the batch, on
ethrex’s own easeInOutCubic curve, so this node’s dialing, its discv5 lookups and
ethrex’s RLPx dialing all ramp the same way. A node with no peers dials at
MAX_DIAL_RATE_PER_SECOND; the gap eases out to ethrex’s LOOKUP_INTERVAL_MS
as the table fills, and dialing ends outright at the target. A tick that opens no
dial waits that same ceiling regardless of the curve, which is what keeps a
network with fewer peers to offer than --discovery.target-peers — every lean
devnet, against a default target of 200 — from holding the loop at its floor for
the life of the process, re-drawing candidates it is already connected to.
Bootnodes
The three entries a bootnode ENR can carry are read independently, because they answer different questions:
| Entry | Absent means |
|---|---|
quic | Not part of the static dial list over QUIC |
tcp | Not part of the static dial list over TCP |
udp | Not seeded into the discv5 routing table |
A bootnode is dropped only when it has none of the three: neither transport to
dial nor a udp port to seed discv5 from. Any other combination is kept,
including one with only quic, only tcp, only udp, or any pair. The ENRs
lean-quickstart generates today carry ip/quic/secp256k1 and no udp,
so they stay reachable but contribute nothing to discovery. The built-in
mainnet bootnode list is close to the mirror image, udp and no quic, and a
tcp entry is what makes such a record statically dialable rather than a
discv5 seed only: four of the seventeen carry one. A record missing an ip or
a secp256k1 key is dropped regardless of its transports.
The ENR a node logs at startup is only useful to a peer if that node was
started with a real --discovery.advertise-ip.
Copying an ENR built from the default 0.0.0.0 into another node’s bootnode
list produces a udp/quic/tcp target that cannot be dialed, since 0.0.0.0
names no reachable host. Set --discovery.advertise-ip before pointing other
nodes at this one’s ENR: 127.0.0.1 on a local devnet, or the host’s public
address otherwise.
Known limitations
Static bootnodes bypass admission and are redialed indefinitely
Everything under Which peers get dialed applies to
discovered peers. A static bootnode reaches the swarm by a different path:
parse_enr reads ip, secp256k1 and the three port entries and never looks at
eth2, so a --bootnodes list is dialed as given. Now that a tcp-only record
is dialable, a beacon-chain ENR is a valid static target, and the noise+yamux
handshake to a lighthouse node succeeds: the peer occupies a
--discovery.target-peers slot, contributes no attestation subnets, and is
eventually dropped by the remote for sharing no protocols. Each drop re-arms the
redial timer, which runs at a flat interval with no backoff and no cap for the
life of the process.
Accepted rather than fixed. Bootnodes are operator-supplied, so a list naming another network’s infrastructure is a configuration mistake, and the unbounded redial is what keeps a devnet’s own bootnode reachable across its restarts. Splitting the flag into initial peers, dialed once, and bootnodes, seeded into discv5, is the real fix and is left to a follow-up.
An upgrade’s new ENR entries are invisible to peers that stayed up
The record is signed at ethrex’s INITIAL_ENR_SEQ, a constant. A peer
identifies a record by (node id, seq) and accepts a replacement only at a
strictly higher seq, and ethrex’s WHOAREYOU responder does not even send the
record when the requester’s enr_seq already matches. So when an ethlambda
release changes which entries it publishes, as adding tcp did, a peer holding
the previous record under the same seq keeps it: the new entries reach only
peers that meet this node for the first time.
A fixed local floor above INITIAL_ENR_SEQ does not close this. ethrex re-signs
the record at seq + 1 whenever discv5’s IP voting moves the advertised
address, so a node behind NAT may already be serving a seq above any constant
the code could pick, which is exactly the case the floor was meant to cover.
Closing it needs a seq that grows without bound across restarts: a persisted
counter bumped on every content change, or one derived from the wall clock at
startup, which is what several beacon clients do.
One lean devnet is not separated from another
The spec’s fork_digest is derived from genesis, so it separates one chain from
another. Lean’s is the hardcoded cross-client dummy 0x12345678, and lean
defines no fork schedule, so every ENRForkID field it publishes is a constant.
The eth2 check therefore separates lean from non-lean but not one lean
devnet from another: two devnets running this code will peer with each other.
Closing that gap requires lean adopting a genesis-derived fork digest, which is
a cross-client change to gossip topic names.
This is a lean limitation only. ethlambda beacon derives a real digest from
mainnet’s fork schedule and genesis_validators_root, so its eth2 check
separates mainnet from every other beacon network; see
beacon_wire.md.
discv5 lookups run at the startup rate
ethrex paces its discv5 iterative lookups by how full its own peer table is,
easing from one lookup every 500ms at startup to one every 10s once the table
reaches its target. That table only counts peers registered through
NewConnectedPeer, which carries an RLPx connection; ethlambda connects over
libp2p and registers nothing, so the count is permanently zero and the pacing
never eases off the startup rate. A lean node therefore keeps looking up every
500ms rather than settling at 10s, roughly 20x the intended steady-state
FindNode traffic, for the life of the process.
--discovery.target-peers deliberately does not feed that computation, since a
target of 0 would make it divide by zero and re-fire the lookup timer with no
delay at all. Closing the gap properly means ethrex learning about non-RLPx
connections, which is an upstream change.
attnets is not a fixed-width SSZ Bitvector
The spec’s attnets is Bitvector[ATTESTATION_SUBNET_COUNT], a constant every
conformant client shares, which is what makes an undelimited bitfield decodable.
ethlambda derives the width from attestation_committee_count, which is runtime
configuration, so two nodes can legitimately exchange bitfields of different
lengths. The bit-packing convention is identical to the spec’s; only the width
is negotiable. Readers tolerate a foreign length by treating bits past the end
as unset, and a peer’s advertised subnets are clamped to the local committee
count before they influence anything.
Validator Key Generation
ethlambda keygen writes the validator XMSS key set and manifest that
--hash-sig-keys-dir reads, in the layout hash-sig-cli generate produces.
# One validator's attester and proposer pair
ethlambda keygen --output-dir keys
# A genesis for a three-node devnet
ethlambda keygen --num-validators 3 --output-dir local-devnet/genesis/hash-sig-keys
--output-dir is the only required flag.
Why the client generates its own keys
An XMSS key file is only usable by a client built against the same signature scheme, and the scheme lives in leanVM, which ethlambda pins to one revision.
The trap is that nothing in the file layout changes when the scheme does. leanVM’s move from Poseidon over KoalaBear to BLAKE2s over binary fields kept the public key at 32 SSZ bytes and the secret key in postcard, so a key set from the wrong revision satisfies every check a genesis generator makes: the file names match, the sizes match, and the manifest parses. It fails only later, when a signature is verified, and then it looks like a consensus bug rather than a provisioning one.
Generating here removes the second pin. These keys come from the same
ethlambda-crypto types the node loads them with, so the generator and the
loader cannot disagree about the format.
Flags
| Flag | Default | Meaning |
|---|---|---|
--num-validators <N> | 1 | Validators to generate a key pair for |
--log-num-active-epochs <N> | 18 | Log2 of the slots each key can sign at, from slot 0 |
--output-dir <DIR> | required | Where to write; created if absent |
--create-manifest <bool> | true | Write validator-keys-manifest.yaml |
--distributed | off | Name validators by public key rather than by index |
--force | off | Replace key files already in --output-dir |
Each validator gets two independent keys, an attester and a proposer, so it can sign an attestation and a block in the same slot without spending one slot’s one-time leaf twice.
--log-num-active-epochs is the network’s lifetime, not a tuning knob: a key
cannot sign past its range, and the range is fixed at generation. At the default
cadence 2^18 slots is about 12 days, after which every validator holding such a
key stops signing.
--force is off by default because a key is one-time-use material. Replacing a
set that validators are still signing with makes each of them sign twice at the
same slot, under a key someone else now holds.
Output
hash-sig-keys/
├── validator_0_attester_key_pk.ssz 32 bytes, SSZ
├── validator_0_attester_key_sk.ssz postcard
├── validator_0_proposer_key_pk.ssz
├── validator_0_proposer_key_sk.ssz
├── validator_1_...
└── validator-keys-manifest.yaml
The .ssz extension on a secret key is a misnomer kept for the tooling’s sake:
SSZ has no encoding for one, so it is postcard.
hash-sig-cli had an --export-format both that additionally dumped each key as
serde JSON. Its own help called that legacy and nothing reads it, so this writes
only the form above and takes no format flag. A caller carrying
--export-format ssz from the old invocation has to drop it.
The manifest
key_scheme: XmssTargetSumLifetime32Dim42Base8
hash_function: BLAKE2s
encoding: TargetSum
pubkey_bytes: 32
lifetime: 4294967296
leanvm_rev: 48a904208d682848dac0e18ef8b01ebfc40df9ad
log_num_active_epochs: 18
num_active_epochs: 262144
num_validators: 3
validators:
- index: 0
proposer_key_pubkey_hex: 0x...
proposer_key_privkey_file: validator_0_proposer_key_sk.ssz
attester_key_pubkey_hex: 0x...
attester_key_privkey_file: validator_0_attester_key_sk.ssz
leanvm_rev is ethlambda’s addition, and it is the field to trust. Read against
the scheme parameters alone, two key sets from either side of the BLAKE2s rewrite
are indistinguishable: key_scheme is built from the lifetime, V and
CHAIN_LENGTH, and all three came through that change unaltered. hash_function
does separate them but is a hardcoded label, since leanVM’s facade exports no
name for its hash. The revision is resolved from Cargo.lock at build time and
cannot drift from what the binary actually links.
generate-genesis.sh reads key_scheme and cross-checks pubkey_bytes against
the pubkeys the manifest holds; the rest is informational.
Cost
Key generation is O(sqrt(range)) in memory and fans out over the bottom Merkle
subtrees, so a large range is cheaper than it looks: about 3 seconds per
validator at 2^18 epochs on an M4 Max, generated serially. A 1024-validator
genesis is therefore tens of minutes, and worth doing once and keeping.
Building on aarch64 without the crypto extensions makes this, and every other
leanVM operation, far slower. See the aarch64 entry in .cargo/config.toml.
Benchmarking
ethlambda benchmark measures two things the node does in production, one per
chain, each against a reproducible offline workload, with no devnet running:
synthetic: Lean Chain block building, the wayethlambda nodeperforms it when it proposes, on a synthetic in-memory chain built for the run.import: Beacon Chain block import, replaying a corpus of real blocks throughethlambda beacon’s own import path. See Import workload below.
Both are otherwise only observable through the Prometheus histograms a live node exports. Those are noisy, depend on whatever the network happened to be doing, and cannot be diffed against a baseline, which makes them a poor instrument for tracking performance. Each benchmark trades some realism for repeatability: the same parameters (or the same corpus) produce the same result every run, so two reports differ only where the code differs.
Synthetic workload (block building)
Running it
make bench # defaults, mock crypto
BENCH_ARGS="synthetic" make bench # real XMSS/leanVM crypto
BENCH_ARGS="synthetic --iterations 50" make bench
make bench is a thin wrapper. The binary takes the same arguments directly:
ethlambda benchmark synthetic --num-validators 8 --iterations 10 --key-cache ~/.cache/ethlambda-bench-keys
ethlambda benchmark synthetic --mock-crypto --num-validators 8 --iterations 10
Without --mock-crypto the run uses real cryptography end to end: seed-derived
XMSS keys, real attestation signatures aggregated into leanVM type-1 proofs, and
the proposer’s real seal (block-root signature, singleton type-1 wrap, type-2
merge), with every built block imported through the verifying on_block path.
A default real run takes a few minutes; --key-cache saves the seed-derived
keys so reruns skip key generation. A default mock run finishes in well under a
second, which is why CI can afford to run one on every pull request.
| Flag | Default | Meaning |
|---|---|---|
--num-validators | 8 | Validators in the synthetic genesis |
--warmup-slots | 8 | Unmeasured slots built first, so measured builds run on a state with realistic historical roots and justifications |
--iterations | 10 | Measured builds, one block each |
--proofs-per-data | 1 | Aggregates seeded per AttestationData, mimicking committee aggregators over disjoint validator subsets |
--seed | 42 | Seed for the validator set and its XMSS keys; fixes the whole run |
--key-cache <dir> | — | Cache the seed-derived XMSS keys on disk (keyed by leanVM revision, seed, validator index and run length). Real crypto only |
--mock-crypto | off | Placeholder proofs instead of real XMSS/leanVM signatures, and no seal. Measures selection, compaction and the state transition only |
--enable-proposer-aggregation | off | Mirrors the node flag: collapse same-data proofs via recursive leanVM aggregation |
--max-attestations-per-block | 3 | Mirrors the node flag: distinct AttestationData per block |
--format | human | human or json |
--output <path> | — | Also write the JSON report to a file |
Logs go to stderr and the report to stdout, so --format json pipes straight
into jq.
What it measures
Each iteration enters produce_block_with_signatures and then seal_block —
the same functions BlockChainServer::propose_block calls — and the harness
reports the phases inside them:
| Phase | Work |
|---|---|
select_payloads | Choosing which attestations go in the block |
compact | Collapsing or picking among proofs for the same data; with --enable-proposer-aggregation this is a real recursive leanVM aggregation |
stf_simulate | The state transition that seals state_root |
sign_proposer | The proposer’s XMSS signature over the block root (real crypto only) |
wrap_proposer | Wrapping that signature into a singleton type-1 proof (real crypto only) |
merge_type2 | Merging every type-1 proof into the block’s type-2 proof (real crypto only) |
overhead | The rest of the measured span: tick processing, attestation promotion, fork-choice head, pool clone, pubkey resolution |
wall | The whole span |
overhead is wall minus the sum of the phases, so the columns add up by
construction. In mock mode there is nothing to sign with, so the seal is skipped
and its three phases are absent.
Deliberately outside the measured span, matching the boundary of the node’s
own lean_block_building_time_seconds metric: gossip publish, the
slot-alignment sleep, and importing the block that was just built. The import
still happens between iterations — otherwise every iteration would build on the
same head and process_slots would get more expensive as the run went on. Two
such costs are reported anyway, because they are real crypto worth watching:
| Column | Work |
|---|---|
aggregate | Producing the slot’s pool entries: every validator’s attestation signature plus their type-1 aggregation. Aggregator-side work a proposer never does; zero in mock mode |
import | Importing the built block; in real mode this includes verifying its type-2 proof |
Phase times come from the sample sums of the existing
lean_block_proposal_attestation_build_phase_seconds histogram, read before and
after each build. Histogram sums accumulate raw f64 seconds, so the difference
between two readings is the elapsed phase time and bucket boundaries play no
part. Nothing is added to the hot path for the benchmark’s benefit. The harness
asserts each phase was observed exactly once per build and fails the run
otherwise, because a mis-attributed report is worse than no report.
Reading a report
Block-building benchmark — synthetic workload (real crypto)
validators=2 warmup_slots=1 iterations=2 proofs_per_data=1 seed=42
enable_proposer_aggregation=false max_attestations_per_block=3
ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leanvm=48a90420 os=macos arch=aarch64 threads=14
iter compact merge_type2 select_payloads sign_proposer stf_simulate wrap_proposer overhead wall aggregate import root
1 0.001ms 550.641ms 0.007ms 0.461ms 0.011ms 65.127ms 0.103ms 616.350ms 103.548ms 19.205ms 0x77465b33
2 0.001ms 1175.326ms 0.015ms 2.024ms 0.015ms 73.124ms 0.119ms 1250.623ms 94.691ms 20.472ms 0xf7e48c73
phase count min mean p50 p90 max
compact 2 0.001ms 0.001ms 0.001ms 0.001ms 0.001ms
merge_type2 2 550.641ms 862.983ms 1175.326ms 1175.326ms 1175.326ms
...
wall 2 616.350ms 933.487ms 1250.623ms 1250.623ms 1250.623ms
outside the measured span:
aggregate 2 94.691ms 99.119ms 103.548ms 103.548ms 103.548ms
import 2 19.205ms 19.838ms 20.472ms 20.472ms 20.472ms
Every measured iteration gets its own row, and the summary follows below it. Outliers are never discarded: XMSS signing and its Merkle-subtree cache misses produce legitimate heavy tails, and hiding them would misrepresent the thing being measured. A coefficient of variation above 10% is flagged so a noisy run is not mistaken for a result.
Percentiles are nearest-rank, without interpolation. Sample counts here are small, so an actual observed value is more informative than a blend of two neighbours.
The root column is the block root of each built block. It is what makes a
before/after comparison trustworthy: if an optimization leaves the root
sequence unchanged, it changed only speed and not which attestations were
selected. If the roots move, the change altered block contents and the timing
comparison means something different than intended.
Comparing two runs
Same seed and same parameters produce identical root sequences, so a baseline and a candidate can be diffed directly. The header line exists to tell you when they cannot be compared:
leanvmis the resolved revision the binary was built against, read fromCargo.lockat build time. leanVM owns the whole signature stack (XMSS and aggregation), so a rev bump changes the measured crypto. Real-mode roots also depend on the seed-derived keys, so the same seed on the same leanVM revision reproduces the same signatures and the same roots.os,archandthreadschange results across machines.
Two reports that disagree on any of those are not measuring the same thing.
Limitations
- Synthetic chain, not a production state. This workload builds on a genesis constructed for the run, not a deep chain. The import workload below covers real production states instead, by replaying real blocks rather than building synthetic ones.
- Short-lived keys. Real-mode XMSS keys are generated for exactly the slots the run signs, so key generation is cheap but the OTS window advancement a long-lived validator key performs every 65,536 slots is never exercised.
- Mock mode skips the seal. Without keys there is nothing to sign, so the three seal phases only appear in real runs.
In CI
The Test job runs a short mock benchmark and asserts the JSON report’s shape
(schema_version, one sample per iteration). It costs seconds, and it means a
change to the report contract cannot land unnoticed.
Import workload
ethlambda benchmark import measures block import: the state transition,
attestation processing, persistence and fork-choice work a node does for every
block it receives, whether proposed locally or gossiped in. It replays a
corpus of real blocks through the node’s own import path, offline.
Why a corpus
Measuring import on a live mainnet follower is possible, but the numbers it produces are hard to trust:
- Each comparison leg needs a restart, so a run costs about ten minutes before a single block is measured.
- The denominator moves between legs. How much of that ten minutes was spent actually importing, versus holding a block for data availability or waiting on a column fetch, differs run to run with whatever the network happened to be doing.
- A live follower’s process is not only importing. It is simultaneously serving gossip, req/resp, discovery and column custody, all competing for the same CPU the import path is trying to use.
The work being measured, one block’s state transition and its consequences, is deterministic. Capturing a range of real blocks once and replaying it offline removes all three problems: no restart between legs, no held-block noise in the denominator, and nothing else running in the process.
The two phases
fetch pulls a slot range from a running beacon node’s standard Beacon
API into a corpus directory: a manifest, the anchor state and block the range
builds on, and one SSZ file per non-empty slot from the anchor to the end of
the range. The anchor sits on the first slot of an epoch (see
Requirements and limits), so the blocks between it
and --from are fetched too, and recorded as warm-up blocks the replay imports
without sampling. It prints a progress line to stderr every hundred slots.
A 404 from the Beacon API means an empty slot, a slot past the source’s head, or
one before its history, and fetch cannot tell those apart from the status
alone. So it refuses a --to past the source’s head, and checks that every
block names the previous one as its parent: a block missing from the middle of
the range, or a reorg between two requests, stops the fetch instead of turning
into a replay that fails many minutes later. A fetch that fails removes the
corpus directory if it was the one that created it.
ethlambda benchmark import fetch \
--url http://127.0.0.1:5052 \
--from 9123456 --to 9133456 \
--corpus ./corpus/9123456-9133456 \
--network mainnet
replay drives that corpus’s blocks, in manifest order, through
BlockChainServer::import_block on a freshly bootstrapped RocksDB store, and
reports per-block, per-phase timings for the blocks in the range. Each block
prints a progress line to stderr as it imports, since a mainnet block takes
seconds and a long corpus would otherwise run silent for hours.
ethlambda benchmark import replay \
--corpus ./corpus/9123456-9133456 \
--data-dir ./replay-data \
--network mainnet \
--format json --output report.json
--block-delay <MILLISECONDS> (default 0) sleeps after each block’s import
before the next block is fed. Use it to measure import work when the build
outpaces the state writer: the replay feeds blocks back to back, while a live
node gets one per slot, so a fast build can fill the writer’s queue and
writer_wait then measures the writer rather than the import. A delay longer
than one state write (about a second on mainnet) lets the writer drain fully
between blocks. The sleep runs after the block’s phases and wall_seconds are
recorded, so it appears in no per-block number and no summary row; the report
has no end-to-end figure to adjust. The value is recorded as
params.block_delay_ms in the JSON report and printed in the human header, so
compare only reports made with the same delay.
What is measured
The measured span is one BlockChainServer::import_block call per block, the
same on_block entry a live node’s own cascade uses. That covers the state
transition, block-borne attestation processing, state persistence to RocksDB,
and the beacon head recomputation: on_block recomputes the head itself after
every import rather than waiting for a tick to do it, so that cost is inside
the span too.
Per-block, per-phase numbers come from the lean_block_import_phase_seconds
histogram, read before and after each import the same way the synthetic
workload reads its own histogram. A replayed block reports under
source="replay", a label no node ever writes, so it reaches the histogram
without passing for a gossip or sync arrival. The phases are the
BLOCK_IMPORT_PHASES labels: decode, queue, defer, admit, guards,
preamble, parent_wait, cascade_wait, da_check, columns_wait, engine,
verify_struct, verify_crypto, stf, writer_wait, db_write, fc_head,
block_atts; plus the per-arrival sections that are not spans around the others, prune,
get_head and fcu, since each import_block call is one arrival. get_head
is where the head recomputation after every import is charged.
On beacon, writer_wait is the importer’s blocking hand-off of the post-state
to the storage crate’s background writer, which waits while the writer’s queue
is full. It happens inside fork_choice::on_block, so stf excludes it: stf
is state-transition work and the two phases do not overlap. When blocks import
faster than the writer persists them, the wait shows up here rather than in
stf.
What is excluded, and why
Replay runs nothing that a corpus already answers for:
- No execution client, so
enginereports a near-zero section with nothing to call andfcunever runs. On a live follower paired with one,enginewas 31ms p50, about 0.5% of an import; a replay’s numbers are complete without it. - An empty custody set, so
columns_waitnever runs and no block is ever held waiting on data availability. A corpus supplies every block directly; there are no columns to wait for. - No gossip, req/resp or discovery.
replaydrivesimport_blockdirectly on the caller’s task: no mailbox, no tick loop, no p2p, so none of that traffic competes with import for CPU.
A phase that did not run on a given block is absent from that block’s report, not zero. That is what keeps a phase that genuinely took no time distinguishable from one that never ran at all.
Requirements and limits
- The anchor sits on the first slot of an epoch. Fork choice makes the
anchor its finalized checkpoint at the anchor state’s own epoch, and every
import walks back to that epoch’s first slot to find the checkpoint’s block.
From an anchor past that slot the walk steps below the anchor, onto a block
the store never held, and the first import fails with
spec assertion failed: root in store.blocks. Sofetchanchors on the block at the first slot of the epoch holding--from - 1, stepping back an epoch at a time while that slot is empty, and fetches that block’s own post-state.replayrefuses a corpus whose anchor is anywhere else, which only a corpus fetched before this rule can have. - The source node must still hold a state at the anchor slot. Most beacon
nodes serve only recent states, so an old range fails at
fetchwith a 404 naming the state endpoint it tried. - The range is deliberately not capped.
fetchstreams: the anchor state is the only state it ever holds, decoded once to read the genesis fingerprint, written to disk and dropped before the block loop starts, and each block is written as its response completes rather than accumulated. A long range costs disk and time, not memory. - A replay’s RAM floor is the store’s own state cache.
replayshares the sameSTATE_CACHE_CAPACITY-bounded LRU every node runs with. With the registry and balances in persistent trees, cached states share every unchanged subtree, so the cache costs far less than its capacity times one state: a 128-block mainnet replay at 2.4M validators measured 2.9 GiB of RSS once every block was in, against 11.6 GiB when each state held flat copies. That ceiling belongs to the store, not to this harness. --networkselects the decoder, not just a genesis check.decode_blockresolves each block’s fork from its own slot through that network’s fork schedule, so replaying against the wrong network can decode a block at the wrong fork. The manifest records the genesis validators root the corpus was fetched against, andreplayrefuses a mismatch against--networkbefore decoding a single block.
How to A/B two revisions
Fetch one corpus, then replay it twice, once per revision, against separate
--data-dirs:
ethlambda benchmark import replay --corpus ./corpus/9123456-9133456 \
--data-dir ./replay-baseline --format json --output baseline.json
ethlambda benchmark import replay --corpus ./corpus/9123456-9133456 \
--data-dir ./replay-candidate --format json --output candidate.json
The corpus is the fixed input both legs share, so baseline.json and
candidate.json differ only where the code differs. --format json --output <file> is what makes that diffable; the human table on stdout is for reading
a single run, not for diffing two.
Determinism
Two replays of one corpus, on the same revision, import identical block roots
in the same order. Each sample’s block_root is a per-block checksum for
exactly that: if a change alters the sequence of roots a corpus produces, it
changed what got imported, not just how fast, and a timing comparison against
that run means something different than intended.
Era replay
ethlambda replay-era replays real chain history from .era files through the
Beacon Chain state transition. Era files carry every block of 8192 slots plus
the state at the end of them, so they are history that no node has to still
hold, unlike benchmark import, which reads a running node’s Beacon API. The
command exists to catch state-transition divergences on real data across every
fork, including the upgrade itself.
What a run checks
For era N, file N holds the blocks of slots [(N-1)*8192, N*8192) and the
state at slot N*8192. The command decodes the state stored in file N-1,
then applies each block of file N in slot order with
stf::state_transition(.., validate_result = true, ..) and one CommitteeCache
for the whole run:
| Check | Where |
|---|---|
| Proposer signature, randao, attestations, sync aggregate, deposits, exits | every block, always on |
Block state_root equals the post-state’s root | every block |
Block root equals block_roots[slot % 8192] of the stored end state | after the era |
state_roots[N*8192 - 1] of the stored state equals our state’s root at the last slot before the boundary | after the era |
Our full state root at N*8192 equals the stored state’s | after the era, only when slot N*8192 is empty |
The execution payload is not run: the engine answers valid, so structural
payload checks (parent hash, prev_randao, timestamp, commitment limit) run and
the EL verdict does not.
The block at the end slot
An era state is the state with the block at its own slot applied, and that
block is stored in the next file. Two consequences: a starting state may
already contain the first block of file N (blocks at or below the state’s slot
are skipped and counted as blocks_already_in_state), and the closing state
may include a block we do not have. The state_roots check needs nothing from
file N+1, which is why it is the primary end-of-era check; the full-state
comparison runs only when the stored state’s latest_block_header is older than
N*8192, i.e. the slot is empty.
Chaining and fork upgrades
Consecutive --era values continue from the same in-memory state without
re-decoding. A fork upgrade is exercised by our own process_slots: replaying
era 1607 (mainnet) from file 1606’s electra state, advancing to slot
1607*8192 runs the fulu upgrade, and the result is compared with the fulu state
stored in file 1607 when that slot is empty. Era 1608 then applies the first
fulu blocks. A non-consecutive era starts fresh from its predecessor’s file.
Fork files are F = fork_epoch / 256. File F holds the last pre-fork
blocks (slots up to the fork epoch), and the upgrade happens in our closing
process_slots, so replaying F exercises the upgrade at the very end. The
first post-fork blocks are in file F+1, which is why CI chains F+F+1:
| Network | altair | bellatrix | capella | deneb | electra | fulu | gloas |
|---|---|---|---|---|---|---|---|
| mainnet | 290 (not in CI, see Known limits) | 566 | 758 | 1053 | 1422 | 1607 | n/a |
| sepolia | in era 0 | in era 0 | 222 | 518 | 869 | 1065 | 1379 |
| hoodi | genesis | genesis | genesis | genesis | 8 | 198 | n/a |
An era whose closing state is at or past a fork this build cannot process
(Sepolia gloas, era 1379 and later) is refused up front with the last
replayable era named. The check reads GLOAS_FORK_EPOCH from the network’s
config.yaml, which Config itself drops.
Command line
ethlambda replay-era --network <mainnet|sepolia|hoodi|dir> --era-dir <dir> \
--era <N> [--era <N2> ...] [--download] [--max-blocks K] [--report <path.json>]
| Flag | Meaning |
|---|---|
--network | Resolved like beacon’s: a built-in name or a directory of published files. Its CONFIG_NAME names the era files and the host |
--era-dir | Where <config-name>-<NNNNN>-<8 hex>.era files are read from and downloaded to |
--era | Repeatable. A number, or latest (newest era the host lists; needs --download) |
--download | Fetch missing files from https://<network>.era.nimbus.team/, matching the <network>-<NNNNN>- prefix in the index page. Written to .part, renamed on success |
--max-blocks | Smoke mode: stop the era after K applied blocks. Per-block checks still run; the end-of-era checks are skipped and reported as skipped. Takes a single --era; combining it with several is a CLI error, since a smoke run stops mid-era and cannot chain |
--report | JSON array with one entry per era: network, era, blocks_applied, blocks_already_in_state, skipped_empty_slots, elapsed_secs, apply_secs, mean_ms_per_block, the oracle checks with their outcome, and error |
Every era needs two files, its own and its predecessor’s: about 1.5 GB for a recent mainnet era. The command logs one line per era start and end and a progress line every 512 blocks, and exits non-zero on any failure, after writing the report. A mismatch names the era, slot, expected and actual roots.
The e2store reader (bin/ethlambda/src/era/e2store.rs) reads the trailing slot
indices first and then seeks to single records, so a file is never held in memory
whole; only the one state being decoded is.
CI
.github/workflows/era_replay.yml runs on pushes to main that touch the
state transition, the types, the ssz-tree or the era module, and on manual
dispatch. It builds nothing: it waits for docker_publish.yaml’s
ghcr.io/lambdaclass/ethlambda:sha-<7> tag and runs replay-era in it, one job
per entry:
| Network | Entries |
|---|---|
| mainnet | 566, 758, 1053, 1422, 1607+1608, latest (full eras) |
| sepolia | 222, 518, 869, 1065+1066, 1378 (full eras) |
| hoodi | 8, 198+199, latest (full eras, need the #648 cache, see Known limits) |
Hoodi era 8 is the last deneb blocks, with the electra upgrade (epoch 2048) in
our closing process_slots; 198+199 is the fulu upgrade (epoch 50688) plus
the first fulu era.
Each job uploads its report.json; a failed push run posts the commit, the
failing entries and the run URL to Slack. Manual dispatch takes an image and
an eras override of space-separated network:era[+era...][@K] entries, where
@K adds --max-blocks K (single era only), e.g.
mainnet:290 hoodi:199 hoodi:9@512.
Known limits
- Phase0 is quadratic. Mainnet era 290 is excluded from the matrix, see “Phase0 eras are not practical yet” below. The override can still request it.
- Full Hoodi eras depend on #648. Without the per-epoch
total-active-balance cache from #648, a deneb-era block costs about 4.6 s
against 0.65 s on mainnet, because
get_base_reward_per_incrementscans the whole registry once per attestation (stf/deneb.rs:359,stf/electra.rs:764). A full era would then exceed the job limit. - Smoke runs do not cross fork boundaries. A
--max-blocksrun stops mid-era, so it never reaches the closingprocess_slotswhere the upgrade runs, and it skips the end-of-era checks. The CI matrix has no smoke entries; they are available through theerasoverride (@K).
Measured runtimes
Measured on an Apple-silicon laptop with the release-fast binary, one core
busy (mean ms/block covers block decode, the state transition and epoch
processing; downloads and the end-of-era checks are excluded):
| Run | Result |
|---|---|
| Sepolia era 1, full (7627 blocks, crosses altair and bellatrix) | 116 s, 15.2 ms/block, all three end-of-era checks as below |
| Sepolia eras 2+3+4, chained | about 120 s each, 15 ms/block, state_roots and block_roots passed; Continuing from the previous era's in-memory state logged |
| Sepolia era 31, full (slot 253952 empty) | 108 s, 15.9 ms/block, state_roots, block_roots and full_state_root all passed |
Mainnet era 1888 (latest, fulu blocks), --max-blocks 64 | 653 ms/block, every proposer signature and state_root passed; 1.5 GB download in about 2 min |
Hoodi era 8, --max-blocks 256 | 4.6 s/block (1178 s; a second replay was running on the machine) |
A full recent era is 8192 blocks, so mainnet at 0.65 s/block is about 90 minutes and Hoodi at 4.6 s/block is about 10 hours: a full Hoodi era does not fit the 330 minute job limit of a hosted runner at that speed, which is why the matrix relies on #648.
Phase0 eras are not practical yet
The beacon STF’s phase0 epoch processing is quadratic in the validator count:
get_base_reward calls get_total_active_balance, which walks the whole
registry, once per validator per reward component
(stf/epoch/rewards.rs). Mainnet era 2 (about 20k validators) spends roughly
30 s per epoch boundary, 512 blocks in 484 s, and mainnet era 290 (about 280k
validators) did not finish its first epoch boundary in 30 minutes. Phase0 eras
past the first few hundred need that total hoisted out of the per-validator
loop first; the spec fixtures are far too small to show it. Eras from altair
on do not have the problem.
The end-of-era checks, as observed on real files: slot N*8192 held a block in
every boundary checked except era 31 on Sepolia, so full_state_root is usually
skipped and state_roots[last slot] plus block_roots carry the check.
File format notes
Verified against real nimbus files: the slot index marks an empty slot by pointing at byte 0 (the Version record) rather than by offset 0 as the specification says, so both are read as empty. Block and state records are snappy framed; offsets are relative to the index record’s own start.
Spec Deviations
ethlambda diverges from its reference specifications in a few places. This page lists those deviations; each will be fleshed out with rationale, implementation notes, and trade-offs over time.
There are two references, because this repository builds for two chains. The lean node is measured against leanSpec, and its deviations below are mainly for performance. The beacon node is measured against consensus-specs and the Beacon API, and the validator client against consensus-specs and the keymanager API. Their deviations are scope decisions rather than optimizations.
Read the validator client’s deviation before running it with real keys. It is the only entry on this page that can cost you money rather than performance.
Asynchronous signature aggregation with an early start and an early stop
Aggregation runs off the main BlockChainServer actor loop, may start before its interval, and stops early once it runs out of time.
- ethlambda: the actor snapshots everything aggregation needs (
snapshot_aggregation_inputs,crates/blockchain/src/aggregation.rs) and spawns atokio::task::spawn_blockingworker (run_aggregation_worker,aggregation.rs). Candidates are the store’s gossip-signature groups plus payload-only groups (new_payload_keys, which need at least two existing proofs to merge). A tiered greedy selector orders them by consensus value (current-slot before stale, thenFinalize > Justify > Build, mirroring the block builder) and emits at mostMAX_AGGREGATION_JOBSjobs, dropping to a single job in the slot before one of our validators proposes. The worker streams each finished group back as anAggregateProducedmessage; the actor loop is never blocked on XMSS work. - Early start: a session normally fires at interval 2, but may start up to
EARLY_AGGREGATION_WINDOWearlier once the 2/3 signature threshold is already met (maybe_start_early_aggregation,crates/blockchain/src/lib.rs), so the proof lands earlier in the slot. - Early stop: a
send_after(AGGREGATION_DEADLINE, ...)timer cancels the session that long after session start, so a session that started early also ends early (AGGREGATION_DEADLINE,aggregation.rs). The worker checkscancel.is_cancelled()before each job (aggregation.rs); in-flight jobs finish, remaining jobs are dropped. - leanSpec:
aggregate()is called inline and synchronously fromtick_interval, at interval 2 only. It walks every attestation data with fresh evidence, with no job cap, no time budget, no worker, and no cancellation. - Equivalence: on cancellation the worker emits only the groups that finished, so a slot may pack fewer aggregates than the synchronous path would; any such subset still yields a valid block, affecting how many votes are included rather than signature validity. The job cap has the same character: it bounds prover work per slot, not what a block may carry.
Attestation scoring on block building
Attestations are scored and selected when packing a block, rather than taken in target-slot order as they are scanned.
- ethlambda:
select_attestations(crates/blockchain/src/block_builder.rs) ranks candidateAttestationDataentries by tierFinalize > Justify > Build(enum Tier,block_builder.rs). The within-tier order is tier-dependent (EntryScore::ordering_key,block_builder.rs):Finalize/Justifyentries already cross 2/3, so newer chain progress leads (target slot, attestation slot, then new-voter count);Buildentries only add marginal voters, so coverage leads (new-voter count, target slot, then attestation slot).data_rootis the final deterministic tiebreak in both tiers. Each round picks the best candidate against a projected post-state. - Proposer budget: rounds stop at
max_attestations_per_blockdistinctAttestationDataentries (--max-attestations-per-block, default 3), clamped toMAX_ATTESTATIONS_DATA. The consensus cap isMAX_ATTESTATIONS_DATA, the same value leanSpec enforces in its state transition; only the proposer-side budget differs, and it is configurable. - Collapsing duplicate data: a winning entry may carry several proofs, which must collapse to one proof per
AttestationDatabefore the block is valid. By default ethlambda keeps only the best-coverage proof and drops the rest (keep_best_proof_per_data,block_builder.rs), skipping the leanVM merge at the cost of the voters those proofs carried. With--enable-proposer-aggregation,compact_attestations(block_builder.rs) instead merges them through recursive proof aggregation, which is what leanSpec always does. - leanSpec:
build_blockscans candidates sorted by(target.slot, data_root), oldest target first, and includes the first ones that pass its filters (greedy, no scoring), re-running the scan as a fixed point when justification/finalization advances. Its proposer budget isMAX_ATTESTATIONS_DATAitself. - Equivalence: both produce a valid block. ethlambda front-loads the attestations that advance justification and finality, and within those tiers prefers the newest target where leanSpec takes the oldest; combined with the smaller default budget, an older entry can be outranked by newer ones round after round, so which votes reach peers through blocks differs even though every block stays valid. The smaller budget yields smaller blocks and lower build times.
- Upstream status: the tiered strategy is proposed upstream as leanSpec PR #1149 (open at the time of writing), so this deviation may converge; the recursive-merge collapse follows leanSpec #510.
The validator client keeps EIP-3076’s minimal watermarks, not full history
ethlambda validator records, per validator, only the highest block slot and
the highest attestation source and target epochs it has signed, and refuses
anything that does not advance past them.
- The specification: EIP-3076 forbids signing two distinct attestations with the same target epoch (a double vote), one whose source and target surround or are surrounded by another’s, and two distinct blocks for one slot. It allows enforcing those either against the full signing history or with the stricter minimal rule over watermarks.
- ethlambda: the minimal rule. An attestation is signed only if its target
is above the recorded target and its source is not below the recorded source;
a block only if its slot is above the recorded slot. The record is
<validators-dir>/slashing_protection.json, an EIP-3076 version 5 minimal interchange naming the chain’sgenesis_validators_root. It is written to a temporary file, flushed, renamed into place and the directory flushed, all before the signature exists, and a write that fails leaves the message unsigned. A file that is empty, malformed, of another version or for another chain stops the client at startup rather than being read as “nothing signed”. An exclusiveflockon the validators directory itself, the one inode nothing replaces or deletes, keeps a second process away from it. Seecrate::slashing_protection. - What the strictness costs: a byte-identical re-sign is refused. A client that recorded an attestation, crashed before publishing it, and restarted into the same slot loses that attestation rather than publishing it; the same holds for a block. An honest validator’s messages otherwise advance this way, so nothing else is refused. Lighthouse and Prysm keep signing roots to allow the identical re-sign; Teku keeps the same watermarks as this client.
- A wedged record: the client refuses attestation data whose source is at
or past its target, which is never valid outside genesis, so an impossible
answer from a beacon node is refused rather than recorded. What it cannot
tell is a possible one: a source below the target but ahead of what the
chain has justified passes, the record keeps it, and every honest
attestation after it is refused as
SourceRegresseduntil justification reaches it, which during non-finality may be never. That is trust in the beacon node, the same trust every attestation already places in it; the symptom is theSourceRegressedrefusal in the log and a non-zeroattestations_refused_total. There is no tool that lowers a watermark, by design: stop the client, editslashing_protection.jsonby hand, and start it again. Lowering a watermark is only safe for a key that has not signed past the value written. The same holds for a clock that jumps forward: the target is pinned to the slot the local clock asked for, so a beacon node that answers for a slot far ahead lets the record run ahead of the chain. A bound against the clock would be circular; the guard is the beacon node’s own view of the head, which doppelganger protection will bring. - No checksum, and rewritten minimal at every start: the record is an EIP-3076 minimal interchange and nothing more, by design: readable by other tools and editable by the operator, which the hand edit above relies on. A checksum or a generation counter would make it this client’s private format and refuse that edit, to guard against a flipped digit that still parses, which the disk’s and the filesystem’s own integrity checks are there for. Every start rewrites the file in the minimal shape, so signing roots and extra entries dropped in by hand collapse to their maxima.
- Keys with no record: refused at startup.
--init-slashing-protectionregisters them as never having signed, and a key imported through the keymanager API with no history is registered the same way before it is loaded. Nothing here stops such a key from signing again what it signed elsewhere a moment ago, so the flag is only for keys that have signed nothing anywhere for at least two epochs; the migration window is doppelganger protection’s to close, which this client does not have yet. - Messages outside the rule: sync committee messages and contributions
are not slashable, and are not recorded. A gloas proposer also signs an
execution payload envelope, and a payload timeliness committee member signs
a payload attestation; neither is slashable, so neither is recorded. An envelope is
published once per proposal, right after its block, and a failure to publish it
is logged at error and counted without failing the proposal. A PTC vote has an
in-memory dedup of
(validator, slot)pairs recorded at signing time (PayloadAttestationService), so a duty abandoned between signing and submission is lost rather than re-signed. A second, different vote for one slot is equivocation that peers penalize in gossip, not a slashing. - What is not covered: the same keys running in another client on another machine. No local record can see that.
- Interchange: an import (
ethlambda slashing-protection import, or the keymanager API’sPOST /eth/v1/keystores) reduces each validator to its highest slot, source and target and merges them with the record by maximum, so slashable data in the file is absorbed rather than refused, as Lighthouse and Nimbus do. The one exception is an attestation whose source is at or past its target outside genesis, which fails the whole import: it is never valid, and read into the record it would refuse every honest attestation until the chain justified past that source. An export (ethlambda slashing-protection export, orDELETE /eth/v1/keystores) is the record itself, in the minimal format. Both pass the sharedeth-clients/slashing-protection-interchange-testsv5.3.0 vectors under theirshould_succeed(minimal) expectations.
Builder bids: gossip and API only (no builder API)
A gloas proposer served by this node can take a builder’s bid from gossip or from the Beacon API, and no other way.
- The specification: a gloas proposer may take a builder’s signed bid
(
SignedExecutionPayloadBid, from gossip or a builder API) instead of building its own payload.produceBlockV4takes aBuilderConfig(min_bid,builder_boost_factor,builders) to steer that choice. - ethlambda: bids seen on
execution_payload_bidor posted toPOST /eth/v1/beacon/execution_payload_bidsare pooled, andproduceBlockV4compares the best one with the local build under the top-levelmin_bidandbuilder_boost_factor: the bid must reachmin_bid, and wins whenbuilder_boost_factor * bid_value > local_value / 10^7(in Gwei against Wei, so a factor of 100 is parity). The local build wins a tie, andshouldOverrideBuilderfrom the execution client is honored. With no execution client, or a failed local build, the best viable bid is taken. - What it does not do: the
buildersentries are decoded and ignored, so no builder is asked for a bid over HTTP and noEth-Builder-Urlis returned. Gossip bids are not filtered bybuilder_pubkeyseither, since the Beacon API puts that list on each entry and it governs only that entry’s own bid.Eth-Consensus-Block-Valuestays0, because the consensus reward is not computed. - A bid win returns no envelope. The block commits to the builder’s bid, the
builder reveals the payload, and nothing is cached for
GET .../execution_payload_envelopes, so that endpoint answers404andEth-Execution-Payload-Includedisfalse. The node never signs or publishes an envelope for such a block, and the proposer-signed self-build envelope is refused for it, since itsbuilder_indexdiffers from the bid’s. - Why: gossip and API bids need only a pool and the gossip rules. A builder API client is a separate crate with its own failure modes and timeouts, and is left for a later phase.
Fee recipient and gas target come from proposer preferences, with fallbacks
- The specification: the payload is built toward the
target_gas_limitof the proposer’sSignedProposerPreferences, which also names the fee recipient builders must pay, andbid.gas_limitmust be compatible with the target (is_gas_limit_target_compatible). - ethlambda: the self-build reads the signed preferences for
(slot, dependent_root)that name the proposer, from gossip or the Beacon API. The fee recipient is theirs, elseprepare_beacon_proposer’s, else zero with a warning. The target gas limit is theirs, else thegas_limitoflatest_execution_payload_bidof the state being built on, so the execution client holds the gas limit where it is. - Equivalence: the bid is built from the payload the execution client
returns, so
bid.gas_limitis whatever that payload carries and is always consistent with the block. A proposer that wants the limit to move without preferences cannot say so through this node: it follows the previous block’s.
Bid and preference gossip never queues
- The specification: several rules say the message “MAY be queued”: an unknown parent block, an unimported parent, an unseen dependent block.
- ethlambda: every one of them is IGNORE. A bid or preferences message that names a block or state this node lacks is dropped, never parked, so a burst of them holds no memory and nothing is replayed later.
- Why: both topics are only useful for the next slot or two, and a bid that arrives after its parent was imported would be stale by the time a replay ran. A proposer that missed a message builds locally.
Bid gossip judges against the recorded head and cached states
- The specification:
validate_execution_payload_bid_gossipreadsget_head(store),store.block_states[parent]and the parent state advanced withprocess_slotsto the bid’s slot. - ethlambda: the head node is the one the chain actor recorded
(
Store::headandhead_payload_status), with a freshget_head_nodewalk only when no status is recorded. The dependent root is read from the parent state’sblock_roots, which the lookahead rule keeps in range. The parent state stands in for the advanced one when the bid is in the parent’s own epoch, since gloas’sprocess_slottouches none ofbuilders,finalized_checkpoint,builder_pending_*,forkorlatest_execution_payload_bid, which are all the later rules read. Across an epoch the cachedCheckpointStateof the bid’s epoch is used and filled, the same entry attestation target states use. A state that is not cached is IGNORE and is never rebuilt from disk. - Why: gossip verdicts are waited on by gossipsub, and the state cache holds 32 states, so a spec-literal read of a parent about an epoch old would miss often. The head record and the equivalence above give the same verdict without a replay.
Known execution payloads are gossip-accepted or self-published envelopes only
- The specification:
seen.execution_payloadsholds a payload for every envelope accepted from gossip. - ethlambda: the known payloads are the market’s, a 256-entry LRU filled by envelopes that passed gossip validation and by envelopes this node publishes (gossip never echoes a node’s own messages). They are not persisted, and an envelope that was queued and verified later, or fetched by request and response, does not count. After a restart bids on a pre-restart payload are IGNORE until new envelopes arrive.
- Exception: a pre-gloas parent’s own payload counts as known, with its execution payload header’s gas limit, see the fork boundary entry below.
Proposer preferences are judged off cached states only
- The specification: the lookahead is read from
store.block_states[dependent_root]advanced to the epoch before the proposal’s. - ethlambda: the cached head state is used when it shares the dependent root
and is in the epoch before the proposal’s or the proposal’s own (the whole
canonical case), else the cached
CheckpointStateof the epoch before the proposal’s, else IGNORE. Nothing is rebuilt from disk. - Why: a dependent block about an epoch old is usually out of the 32-state cache, so reading it would IGNORE most honest preferences.
The fulu-to-gloas boundary for bids and preferences
- The specification: says nothing about a parent that is not a gloas block.
- ethlambda: a pre-gloas parent’s payload counts as known, with its header’s
gas_limit. A pre-gloas head’s payload hashes come from its payload header, and a bid is compatible with it when it builds on that head and its payload. The builder-exit check is skipped for a pre-gloas parent, which carries no envelope. - Preference signatures are accepted under the lookahead state’s own
DOMAIN_PROPOSER_PREFERENCESdomain (the specification’s), the fork version of the epoch before the proposal’s, or the proposal epoch’s. They coincide outside the first epoch of a fork. Lighthouse signs with the proposal epoch’s version and the specification gives the earlier one, so accepting both avoids rejecting an honest client’s messages around the gloas upgrade. - Consequence: builders onboarded at the fork are inactive until their
deposit epoch is finalized, so gossip bids are rejected for the first epochs of
gloas and
produceBlockV4self-builds.
Beacon API answers for bids and preferences
- An IGNORE verdict is a
400, the same as a REJECT, because the API has no separate status for it. The message names the verdict and the reason, such asignore: preferences_unseen. - An identical resubmission is a
200and is not published again. - The prose and the rules disagree on a mismatched fee recipient or gas limit: the Beacon API text says the bid is rejected, while consensus-specs IGNOREs it. ethlambda follows consensus-specs.
Eth-Consensus-Versionmay be absent on the bid and preferences posts. If present it must name a gloas-compatible fork.
No minimum bid increment or rate limit
- The specification: a note says implementations SHOULD guard against builders spamming bids with minimal increments, for example with a minimum threshold or by forwarding only the best bid at intervals.
- ethlambda: neither is implemented. Spam is bounded by one bid per builder
per
(slot, parent_hash, parent_root), a strictly higher value than the best seen, a funded active registered builder with a valid signature, a cap on keys per slot and on bids pooled per parent, and a separate permit pool for validating them. A configurable minimum increment is a follow-up.
skip_randao_verification is ignored
- The specification:
produceBlockV4’sskip_randao_verificationlets a caller skip the node’s check ofrandao_reveal; when it is set, the reveal must be the point at infinity. - ethlambda: the parameter is accepted and not honored. The block’s state
root comes from running it through the state transition, which verifies the
reveal, so the endpoint checks the reveal against the slot’s proposer up front
and answers
400(invalid randao_reveal) for one that does not verify, infinity included. - Consequence: a caller cannot get a block without a real reveal. Every caller this client knows of (including its own validator client) sends one.
A pre-gloas head counts as FULL in attestation_data.index
- The specification: at a gloas slot
data.indexis0when the attested block is from the attestation’s own slot, and otherwise0for anEMPTYand1for aFULLpayload status in the validator’s fork choice. It does not say which a pre-gloas block is. - ethlambda: at a gloas slot
data.indexis0when the head block is from the requested slot,1when fork choice holds the head’s payload as FULL (Store::head_payload_status, or oneget_head_nodewalk when none is recorded), and1for a pre-gloas head, the same boundary rule fork choice applies. So the first gloas slots vote for the last fulu block’s payload. - Why: the vote should name the node fork choice itself walks to, and that rule makes a pre-gloas block’s one node FULL.
payload_present is judged by the envelope’s arrival time
- The specification: a PTC member sets
payload_presentwhen it has received the block’s envelope byget_payload_due_ms()into the slot. - ethlambda:
GET payload_attestation_datacompares the time the envelope reached the node (Store::beacon_envelope_seen_ms, recorded by the chain actor from the arrival time of the envelope, not the time it finished verifying; the earliest arrival wins) with the slot’s start plusget_payload_due_ms(). An envelope held for its block or columns is therefore judged by when it came, as the rule intends. The record lives in memory only, pruned with the execution block hash cache. - Consequence: a restart forgets it. Only the slot in flight can be wrong
(a member asked after a restart answers
payload_present = falsefor an envelope that arrived before it), and the next slot’s record starts fresh.
Sync duties are not served for a period before the head’s
POST /eth/v1/validator/duties/sync/{epoch} refuses a period before the head
state’s own with a 400. Later periods follow the wall clock, as the Beacon
API defines: up to the clock’s current period plus one, read from the head or,
when the head lags a period boundary, from a copy advanced across it.
- Beacon API: allows any period up to the current one plus one, so an earlier period is valid to ask about.
- ethlambda: the head state carries only
current_sync_committeeandnext_sync_committee. Answering an earlier period means loading the state at the start of that period, which is what Lighthouse does; Prysm also serves it. A validator client only asks about the current and next period, so this refuses rather than add a historical state lookup to a duty endpoint (sync_duties,crates/net/rpc/src/beacon/validator.rs).
block_id cannot name genesis, and state_id cannot be a state root
Two id forms the Beacon API defines return 404 here.
genesis, on either id.Table::BlockRootsis the slot-to-root index every slot lookup reads, andStore::update_checkpointsis its only writer. That writer computes its delta by walking from the old head to the new one (block_root_index_changes,crates/storage/src/store.rs) and returns early when the two are the same root, which is exactly the situation at bootstrap:Store::init_beaconseedsKEY_HEADwith the anchor. So the anchor’s own slot is never written to that index, on either chain, and a checkpoint-synced directory has no genesis block to serve in any case.BlockId::Genesisrefuses outright rather than resolving to the anchor and calling that genesis.- A
state_idgiven as a0x…root. That id is a state root, and states are stored keyed by block root (Store::get_state) with no reverse index. Refusing is better than answering with a state that is right only when the two roots happen to coincide. The refusal names the ids that do work.
The anchor’s own slot is not among these. It is missing from BlockRoots
for the reason above, but it is the slot checkpoint_sync.rs asks a peer for
immediately after reading that peer’s finalized state, so a 404 there would
make this node unusable as a checkpoint-sync source for any client.
anchored_root_at_slot (crates/net/rpc/src/shared/block_id.rs) covers it: on
an index miss it tries the roots the store can name (finalized, justified,
head) and accepts one only when Store::block_entry confirms that root’s block
really sits at the slot asked for. A slot the store holds nothing at still
answers 404, since no candidate matches.
This was found by running a mainnet follower and pointing a second one at its
API: before the fallback existed, the second died with peer served no block at the anchor slot 15265888.
The inactivity-leak check runs once per epoch step, not once per validator
A state whose finalized checkpoint is past its previous epoch fails epoch processing here even where the specification never reaches the failing check.
- ethlambda:
get_finality_delay(crates/blockchain/state_transition/src/beacon/helpers/finality.rs) returnsArithmeticOverflowwhenprevious_epoch - finalized_checkpoint.epochunderflows. From altair on, the inactivity-score update andRewardContext::newread the leak flag once, before their loop over validators, so either fails whenever the delay does. altair’sget_inactivity_penalty_deltasalso builds aRewardContext, so it fails too. phase0 reads the flag where the specification does. - consensus-specs: the subtraction is the same, and a
uint64underflow makes the transition invalid. Butprocess_inactivity_updatesandget_flag_index_deltascallis_in_inactivity_leakinside their loops, for eligible (and, for rewards, participating) validators only, and altair’sget_inactivity_penalty_deltasnever calls it. A state where no validator reaches the check passes. - Consequence: none on a real chain. Justification only finalizes an epoch
behind the current one, so the finalized epoch never passes the previous
epoch, and only a crafted pre-state tells the two apart. Lighthouse reads
the leak once per epoch as well (
process_epoch_single_pass), failing on the samesafe_sub. The randomized equivalence tests keep the finalized epoch behind the previous one for this reason: past it, the fast path and the specification-shaped reference fail at different points.
The fulu-to-gloas fork-choice boundary
The gloas fork-choice text describes only a store anchored at a gloas block, so it says nothing about a tree that crosses from fulu into gloas, which is the tree every live follower holds at the fork epoch.
- The specification:
get_parent_payload_statusreads the parent block’s execution payload bid, which a fulu parent does not have. Consensus-specs issue #5096 asked what a pre-gloas parent should answer and was closed as not planned. Pull request #5125 is open, as of this writing, and proposesPENDING. - ethlambda: a pre-gloas block is a single node whose payload status is
FULL, since its payload ran inside the block and never had an empty branch. Concretely,get_parent_payload_statusof a gloas block with a pre-gloas parent answersFULL; a pre-gloas block’s only child (get_node_children) is itsFULLnode; a vote for one (get_supported_node) supports that node whatever itspayload_presentsays; andgloas_get_ancestorstepping onto one lands onFULL. Its payload also counts as verified, timely and available (is_payload_verified,payload_timeliness,payload_data_availability), so the first gloas block passeson_blockand anindex == 1vote for the last fulu block passesvalidate_on_attestation. - Which rules the head uses:
get_head_noderuns one bottom-up walk for every fork, and the current slot’s fork, not the head block’s or the justified checkpoint’s, selects its two fork-dependent rules. FromGLOAS_FORK_EPOCHon, blocks carry the payload dimension their bids give them and the proposer boost is gated byshould_apply_proposer_boost; before it, every block is a single full node and the boost applies whenever it is set.compute_headandgloas_get_headare the references the walk is tested against. - Other clients: Lodestar and Prysm also treat a pre-gloas parent as
FULL. Lighthouse (v8.2.2) answersEMPTYfor a pre-gloas parent, although #5125 describes its handling asPENDING. - Consequence: if #5125 lands, only the answers above change, and each is
named in the header comment of
fork_choice.rs’s gloas section.
The gloas head tolerates state the specification asserts on
Three places in the gloas head read something the specification would abort on,
because a live node holds gaps a fixture tree never has. Each is marked
“Implementation choice, not spec text” at its definition. The head the node
runs is walk_head (through get_head_node) over the weights of
compute_node_weights; the spec-literal references carry the same tolerances so
that the tests compare like with like.
- Pruned votes (
compute_node_weights; in the references,get_attestation_score, and through itis_head_weakandis_parent_strong, the latter reached fromget_proposer_head). A vote for a root missing from the block index contributes nothing, whereget_ancestorwould raise. Pruning removes only entries below the finalized slot, and every root scored is indexed at or above it, so such a vote cannot descend from the scored root and the specification’s own contribution for it is zero too. - Unknown voters (
compute_node_weights;gloas_get_attestation_scorein the reference). A vote for a root the store never held is skipped, whereget_supported_nodewould raise, so one stale voter cannot abort a head computation. The walk drops a block pruned from the index but still in the store the same way, with the pruned votes above; the reference does not skip it, and its vote walks the block table and contributes nothing. - Missing timeliness (
should_apply_proposer_boost). A candidate with noblock_timelinessentry reads as not timely by either deadline, where the specification indexes the entry directly. A gloas block’s entry is persisted (Table::BlockTimeliness) and reloaded on resume; a pre-gloas block’s is in memory only, so a restart empties it. The reading can only miss withholding a boost from an early equivocation, never withhold one wrongly. - Equivalence: each is the specification’s answer wherever the specification has one; they differ only where it would have raised.
The envelope check reads the cached state root
verify_execution_payload_envelope compares envelope.beacon_block_root with
the root of the state’s latest block header.
- The specification: sets
header.state_root = hash_tree_root(state)on a copy of the header. That is exact for a state fresh out of block processing, whoselatest_block_header.state_rootis still zero. - ethlambda: uses
BeaconState::compute_state_root. The function’s caller hands it a stored state, and this repository writes the real root into that header field as soon as the block’s transition returns. Hashing such a state would hash a header whosestate_rootis already set, a different value from the one the header committed to, and every stored state’s envelope would fail.compute_state_rootreturns the cached value only while the state is still at that block’s slot and the field is set, and hashes otherwise, so both shapes give the same root. - Equivalence: the same check, on both kinds of state.
The attestation deadline takes the epoch that picks the fork’s rule
- The specification:
get_attestation_due_ms()takes no argument, and gloas replaces it with a function that readsATTESTATION_DUE_BPS_GLOAS. - ethlambda: one
get_attestation_due_ms(epoch, config)(fork_choice.rs) that reads the basis points of the forkepochfalls in (ForkRules::of), so both sides of the fulu-to-gloas boundary share it. - Equivalence: the same deadline for every slot, since the epoch is the one the specification’s own per-fork function would have been selected for.
The head is one bottom-up walk, not the specification’s per-node get_weight
The specification’s gloas get_head calls get_weight for every candidate
node, and each call walks every latest message and every ancestor step.
- ethlambda:
get_head_nodecomputes every node’s attestation score in one pass over the latest messages and one over the blocks (highest slot first), folding each block’s total into the payload node of its parent that it builds on, then descends from the justified checkpoint over that table. The proof that the fold equalsget_attestation_scorefor every node, fromis_ancestor’s definition, is in thefork_choice.rssection “The head computation”. The proposer boost is added along the boosted block’s chain aftershould_apply_proposer_boosthas read the parent’s score from the same table, so that gate no longer re-walks the votes either. - Few decodes: a block’s fork and parent payload status are recorded as a
BlockPayloadLinkwhenon_blockimports it, and the head’s slot reads come from the block index, so a head computation decodes no block in the ordinary case. Two cases still decode. A block imported before a restart has no entry (the scratch is in memory), and the walk derives it once by decoding the block and its parent and records it; the actor does the same for each new finalized root before pruning links. And a weak boosted-block parent from the previous slot scans same-slot candidates for an early equivocation, which needs their proposer indexes; that scan is rare and bounded by the candidates at one slot. - Bounded at the finalized block: vote placement, the fold and the boost chain stop at the least of the finalized block’s slot, the justified block’s slot and the boosted block’s parent’s slot, which on a live node is the finalized block’s. The other two terms make the bound safe without assuming that the justified block and the boosted block’s parent are at or above the finalized block. Finality pruning drops the block index rows below the finalized block’s own slot, so on a live node the index already starts there; the bound holds the walk to that window whatever the index still keeps below it. Links below the finalized block are pruned because nothing reads them.
- No boost outside the justified subtree: the specification evaluates
should_apply_proposer_boostwhenever a boost root is set. The walk skips the boost, gate included, when the boosted block is not under the justified root: the boost reaches only that block’s ancestors, none of which the descent compares, so the head is the same. It also keeps the gate from reading a parent that finality pruning removed after the boost was set, which would otherwise fail the head computation until the next slot. - Cost: work is proportional to the latest messages plus the indexed blocks at or above the finalized block, the unfinalized window. The spec-literal form is proportional to the messages times the candidate nodes, with block decodes at each ancestor step and for each voter, which would wedge the chain actor on a large tree.
- Equivalence: the walk names the head and gives every node at or above the
finalized block the weight the specification’s
get_headandget_weightdo, wherever they do not raise; the tolerances above are the only differences. The fixture harness checks the head at everyheadstep and every viable leaf’s weight, and a seeded randomized test checks both over trees that cross the fork boundary. - Why still tested against the spec-literal form:
gloas_get_headandgloas_get_weightremain, as the references the walk is compared with, at everyheadcheck of every fork-choice fixture and on randomized trees;compute_headis the pre-gloas reference.
The committee fraction leaves slashed validators out, ahead of the pinned release
The proposer boost and both reorg thresholds follow a consensus-specs change newer than the release the fixtures are pinned to.
- The pinned release (
v1.7.0-beta.2):calculate_committee_fractiondividesget_total_active_balance, which counts a validator that is slashed but still active, andget_proposer_scoredoes the same arithmetic inline. - ethlambda: both divide the total balance of the active, unslashed
validators, as consensus-specs #5679 does (first released in
v1.7.0-beta.4). Fork choice weighs no vote from a slashed validator, so during a mass slashing a boost sized against their stake outweighs a larger share of the votes that still count, which makes a reorg easier. TheJustifiedBalancessnapshot carries that total (total_balance) beside each validator’s raw effective balance, whichis_head_weakstill adds back for equivocators in the head slot’s committees, slashed or not. - Other clients: Lighthouse’s
JustifiedBalances::from_justified_statealready sums active, unslashed validators only. - Consequence: none while no active validator in the justified state is
slashed. No
v1.7.0-beta.2fork-choice fixture distinguishes the two: the one that slashes validators in its anchor state ticks past the boosted slot before it checks the head. Remove this entry when the pin reachesv1.7.0-beta.4.
The execution client judges a gloas payload before the envelope is applied
verify_execution_payload_envelope asks the execution engine last, as part of
one boolean function. The follower runs the pure consensus checks first
(check_execution_payload_envelope: signature, bid, state, data availability),
then asks the engine with engine_newPayloadV5 only about an envelope that
passed them, and records it with accept_execution_payload_envelope. The
engine’s part is answered “valid” inside the checks because its answer has
three outcomes and the specification’s ExecutionEngine has two.
VALID: applied; the payload is recordedVALID.SYNCING/ACCEPTED: applied, since the consensus checks passed and the payload isNOT_VALIDATED(optimistic-sync.md); recordedSYNCING, which the attestation gossip rules already read as not yet validated. A laterforkchoiceUpdatedVALIDfor the head, or for a descendant payload, moves it and every optimistic payload on that chain toVALID. Unlike a pre-gloas block there is nois_optimistic_candidate_blockgate: a payload is applied to a block the consensus layer already imported, so there is no merge-transition poisoning to guard against.INVALID: not applied, so the FULL node of the block never exists; the payload is recordedINVALID, children held for it are dropped and later blocks that name it as their FULL parent are dropped instead of held. The block itself and its EMPTY branch stay.latestValidHashcan reach further back, in which case the first payload after it is condemned the same way (beacon_payloads::resolve_invalid_payload, which skips blocks that carry no payload, unlike the pre-gloasresolve_invalid_block). AforkchoiceUpdatedINVALIDfor a payload already applied removes it again (its FULL node, its stored envelope, and every child built on it).- A hash mismatch (
INVALID_BLOCK_HASH, orINVALIDwith a nulllatestValidHash): the envelope’s contents do not hash to the block hash it claims, so only that envelope (by its own root) is refused; the root’s payload is not condemned, since the builder’s real envelope may still arrive. ethrex has noINVALID_BLOCK_HASHstatus: it answers a block hash mismatch withINVALIDand a nulllatestValidHash, and an executed-and-failed payload withINVALIDand its last valid ancestor. So a null one is read as a mismatch and a non-null one as the payload being invalid. A payload that failed with no valid ancestor to name is read as a mismatch too, which only costs a refused envelope. - No answer after the retry ladder: the payload is not applied, as for a block, but unlike a block the envelope is consensus-valid and already held, so it is kept and the per-slot redrive asks again, about the oldest held envelope only and the rest once that ask is answered, so a down client costs one retry ladder per slot however many wait. It is not fetched again: that would repeat the ladder for every copy that arrives.
latestValidHash and VALID follow the execution-layer chain, not every
verified ancestor: a gloas block’s EL parent is the ancestor whose payload hash
is its bid.parent_block_hash, skipping blocks it built past on their EMPTY
node, and a pre-gloas block’s is its parent. Both walks stop at finality.
forkchoiceUpdated for a gloas head
- The head hash follows the head node:
bid.block_hashfor a FULL head,bid.parent_block_hashfor an EMPTY one. A head whose payload status has not been computed yet is skipped. finalized_block_hashis the finalized block’sbid.parent_block_hash, as the specification has it.safe_block_hashis the justified block’sbid.parent_block_hash. The specification’sget_safe_execution_block_hashis built on fast confirmation, which the follower does not run, so this is the fallback. It can only be older than the confirmed block, which keeps it safe.- A pre-gloas checkpoint block, including one beneath a gloas head, keeps its own payload hash.
- The call is V4 with the node’s custody columns when the head block is gloas,
and V3 before;
engine_newPayloadV5is for envelopes and V4 for pre-gloas blocks. - A hash of zero is “nothing to say” on either fork, so a payload whose hash is
zero (the fixtures’ placeholder) sends no
forkchoiceUpdated.
Sync committee gossip resolves the committee by the message’s slot and the domain by the fork schedule
sync_committee_{subnet_id} and sync_committee_contribution_and_proof
validate against the cached head state, choosing the committee from the
message’s slot and the signing domain from the config’s fork schedule.
- Specification:
p2p-interface.md’sget_sync_subcommittee_pubkeysandvalidator.md’scompute_subnets_for_sync_committeepick the committee bystate.slot + 1, andget_domain(state, ..)takes the fork version fromstate.fork. - ethlambda: the committee is chosen by
message.slot + 1(current_sync_committeein the head’s own period,next_sync_committeein the one after), and the domain comes fromConfig’s schedule (helpers::sync_committee). The validator client signs with the same domain. When the head is in the message’s period and fork the answer is the specification’s. When it lags across a period or fork boundary, the specification would reject honest messages and this does not. - Unanswerable cases are IGNORE: a head state that is not cached is
IGNORE(state_unavailable), like every other topic, and a period the head state’s two committees cannot cover isIGNORE sync_committee_unavailable, since neither is the sender’s fault.
Sync committee subnets are joined on request, immediately, and advertised in MetaData only
- Specification:
validator.md(“Sync committee subnet stability”) has a validator join its subnets a random 1 toSYNC_COMMITTEE_SUBNET_COUNTepochs before its period starts, and has the node advertise them in the ENR’ssyncnets. - ethlambda: a subnet is joined only when a validator client posts
POST /eth/v1/validator/sync_committee_subscriptions, at once, untiluntil_epoch(exclusive) clamped to the end of the next period. The node never subscribes without a request, because validating every subnet would cost each follower up toSYNC_COMMITTEE_SIZEBLS verifications a slot.syncnetsappears in MetaData, never in the ENR: ethrex’sDiscoveryServercannot replace the served record at runtime, the same known gap as theeth2entry. Peers therefore find this node’s sync subnets by chance, while publishing still reaches the mesh, because a joined subnet is subscribed. - The
sync_committee_contribution_and_prooftopic is the exception: it is subscribed under every digest and always validated, and accepted contributions are relayed and pooled for block production.
Block production packs only the parent’s sync committee votes
validator.md (“Sync committee”) describes block packing in terms of
contributions only.
- ethlambda: a block at slot
Npacks only what is pooled for(N - 1, parent_root). For each subcommittee it takes the best pooled contribution and adds every pooled direct message at a position that contribution does not cover. The result is verified exactly asprocess_sync_aggregatewill check it (block_production::verified_sync_aggregate); a failure, or a proposer whose parent is not the root the committee signed, gets the empty aggregate. Every “retry without operations” fallback also drops it. That costs rewards, never the block. - Messages from earlier slots over the same root are not packed.
The validator client signs a sync committee message at the deadline only
validator.md (“Prepare sync committee message”) has a member sign as soon as
it sees the block for the slot, or at the sync-message deadline, whichever
comes first. The validator client signs once, at the deadline
(SYNC_MESSAGE_DUE_BPS, or its gloas variant), over the head root it then
reads, and refuses to when that root is optimistic
(specs/bellatrix/optimistic-sync.md).