Development reference for ethlambda - minimalist Lean Ethereum consensus client. Not to be confused with Ethereum consensus clients AKA Beacon Chain clients AKA Eth2 clients.
Main branch: main
Rust version: 1.97.1 (edition 2024)
Test fixtures release: Download latest production fixtures from leanSpec releases
bin/ethlambda/ # Entry point, CLI, orchestration
├─ src/main.rs # run_node: one entry point for both chains (see below)
├─ src/cli.rs # Options { common, network: Lean | Mainnet }
├─ src/command.rs # Sub-command dispatch + default-subcommand injection
├─ src/beacon.rs # Beacon wire params derived from a resolved network: epoch, fork digest
├─ src/checkpoint_sync.rs # Checkpoint sync for both chains (lean's `/lean/v0/...`, beacon's Beacon API)
├─ src/network/ # --network resolution: built-in name vs. directory of published files
│ └─ built_in.rs # Built-in chains (mainnet, sepolia, hoodi, plataberget) and their genesis constants
├─ assets/{mainnet,sepolia,hoodi,plataberget}/ # config.yaml + bootstrap_nodes.yaml (each publisher's files)
├─ tests/fixtures/networks/mainnet/genesis.ssz # Mainnet genesis state, test-only
└─ src/version.rs # Build-time version info (vergen-git2)
crates/
blockchain/ # State machine actor (GenServer pattern)
├─ src/lib.rs # BlockChain actor, tick events, validator duties
├─ src/store.rs # Fork choice store, block/attestation processing
├─ src/block_builder.rs # Block assembly (pre-built at previous slot's interval 4)
├─ src/aggregation.rs # Interval-2 signature aggregation worker
├─ src/reaggregate.rs # Re-aggregation of block-borne votes on import
├─ src/sync_status.rs # Sync-gate tracker (suppresses duties while syncing)
├─ src/key_manager.rs # Validator key management and signing
├─ src/metrics.rs # Blockchain-level Prometheus metrics
├─ fork_choice/ # [crate] LMD GHOST implementation (3SF-mini)
└─ state_transition/ # [crate] STF: process_slots, process_block, attestations
├─ src/justified_slots_ops.rs # Relative-index helpers for justified_slots
└─ src/metrics.rs # State transition timing + counters
common/
├─ types/ # Core types (State, Block, Attestation, Checkpoint)
├─ crypto/ # XMSS sign/verify + aggregation (leanVM wrapper)
├─ ssz-tree/ # Persistent Merkle tree (List/Vector) behind the beacon registry and balances
├─ metrics/ # Prometheus re-exports, TimingGuard, gather utilities
└─ test-fixtures/ # Spec-fixture loading (prod dep of rpc's Hive test driver)
net/
├─ api/ # Actor protocol traits wiring BlockChain ↔ P2P
├─ p2p/ # libp2p: gossipsub + req-resp (Status, BlocksByRoot, BlocksByRange, gloas envelopes)
│ ├─ src/gossipsub/ # Topic encoding, message handling
│ ├─ src/req_resp/ # Request/response codec and handlers
│ └─ src/metrics.rs # Peer connection/disconnection tracking
└─ rpc/ # Axum HTTP: API server + metrics server (independent ports)
storage/ # RocksDB backend, in-memory for tests
└─ src/api/ # StorageBackend trait + Table enum
- BlockChain: Main state machine (GenServer pattern)
- P2P: Network event loop with libp2p swarm
- Communication via
mpsc::unbounded_channel - Shared storage via
Arc<dyn StorageBackend>(clone Store, share backend)
Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 4 (see below) and aligned to publish here; no attestation acceptance happens at interval 0.
Interval 1: Attestation production (all validators, including proposer)
Interval 2: Aggregation (aggregators create proofs from gossip signatures)
Interval 3: Safe target update (fork choice)
Interval 4: Accept accumulated attestations; build the NEXT slot's block and publish it aligned to that slot's interval 0 (build and publish merged into this tick)
Gossip → Signature verification → new_payloads (pending)
↓ (intervals 0/4)
promote → known_payloads (fork choice active)
↓
Fork choice head update
(Store buffer fields are new_payloads/known_payloads; the accessors are named
extract_latest_new_attestations/extract_latest_known_attestations.)
- process_slots(): Advance through empty slots, update historical roots
- process_block(): Validate header → process attestations → update justifications/finality
- Justification: 3SF-mini rules (delta ≤ 5 OR n² OR n(n+1))
- Finalization: Source with no unjustifiable gaps to target
make fmt # Format code (cargo fmt --all)
make lint # Clippy with -D warnings
make test # All tests + forkchoice spec testsmake test is test-consensus plus test-node, two halves CI runs as separate
jobs because together they no longer fit a runner's disk. CONSENSUS_CRATES in
the Makefile names the first; test-node is the workspace minus it. Run one half
directly when iterating on it.
rm -rf leanSpec && make leanSpec/fixtures # Download latest released test fixtures
make update UPDATE_ARGS="-p <crate>" # Bump deps under the 14-day publish-age cooldown (nightly resolver)
make cooldown-check # Fail if a lockfile pins crates younger than the cooldown (same as CI)
make docker-build # Build Docker image (DOCKER_TAG=local)
make run-devnet # Run local devnet with lean-quickstartSee .claude/skills/devnet-runner/SKILL.md for running a local multi-client devnet
(node roster, image tags, pause/unpause instability testing) and
.claude/skills/devnet-log-review/SKILL.md for analyzing the dumped logs.
The kurtosis + assertoor battery (ethlambda beacon and validator next to lighthouse, run on
every push to main) is in tooling/kurtosis-assertoor/README.md.
// Prefer From/Into traits over custom from_x/to_x methods
impl From<u8> for ResponseCode { fn from(code: u8) -> Self { Self(code) } }
impl From<ResponseCode> for u8 { fn from(code: ResponseCode) -> Self { code.0 } }
// Enables idiomatic .into() usage
let code: ResponseCode = byte.into();
let byte: u8 = code.into();// Prefer taking ownership to avoid cloning large data (signatures ~2.5KB)
pub fn insert_signed_block(&mut self, root: H256, signed_block: SignedBlock) { ... }
// Add .clone() at call site if needed - makes cost explicit
store.insert_signed_block(block_root, signed_block.clone());// Extract long arguments into variables so formatter can join lines
// Instead of:
batch.put_batch(Table::X, vec![(key, value)]).expect("msg");
// Prefer:
let entries = vec![(key, value)];
batch.put_batch(Table::X, entries).expect("msg");Use inspect and inspect_err for side-effect-only error handling:
// ✅ GOOD: Use inspect_err when only logging or performing side effects on error
result
.inspect_err(|err| warn!(%err, "Operation failed"));
// Extract complex expressions to variables for cleaner formatting
let response = Response::success(ResponsePayload::BlocksByRoot(blocks));
server.swarm.behaviour_mut().req_resp.send_response(channel, response)
.inspect_err(|err| warn!(%peer, ?err, "Failed to send response"));
// ✅ GOOD: Use inspect + inspect_err when both branches need side effects
operation()
.inspect(|_| metrics::inc_success())
.inspect_err(|_| metrics::inc_failed());
// ❌ AVOID: Using if let Err when only performing side effects
if let Err(err) = result {
warn!(%err, "Operation failed");
}
// ❌ AVOID: Using if/else for both success and error side effects
if let Err(err) = operation() {
metrics::inc_failed();
} else {
metrics::inc_success();
}When NOT to use inspect_err:
// Use if let Err or match when:
// 1. Early return needed
if let Err(err) = operation() {
error!(%err, "Fatal error");
return false;
}
// 2. Error needs transformation (use map_err + ?)
let result = operation()
.map_err(|err| CustomError::from(err))?;Registration with LazyLock:
// Module-scoped statics (preferred for state_transition metrics)
static LEAN_STATE_TRANSITION_TIME_SECONDS: LazyLock<Histogram> = LazyLock::new(|| {
register_histogram!("lean_metric_name", "Description", vec![...]).unwrap()
});
// Function-scoped statics (used in blockchain metrics)
pub fn update_head_slot(slot: u64) {
static LEAN_HEAD_SLOT: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("lean_head_slot", "Latest slot").unwrap()
});
LEAN_HEAD_SLOT.set(slot.try_into().unwrap());
}RAII timing guard (auto-observes duration on drop):
let _timing = metrics::time_state_transition();All metrics use ethlambda_metrics::* re-exports — the ethlambda-metrics crate re-exports
prometheus types (IntGauge, IntCounter, Histogram, etc.) and provides TimingGuard + gather_default_metrics().
Naming convention: All metrics use lean_ prefix (e.g., lean_head_slot, lean_state_transition_time_seconds).
Use tracing shorthand syntax for cleaner logs:
// ✅ GOOD: Shorthand for simple variables
let slot = block.slot;
let proposer = block.proposer_index;
info!(
%slot, // Shorthand for slot = %slot (Display)
proposer, // Shorthand for proposer = proposer
block_root = %ShortRoot(&block_root.0), // Named expression
"Block imported"
);
// ❌ BAD: Verbose
info!(
slot = %slot,
proposer = proposer,
...
);Standardized field ordering (temporal → identity → identifiers → context → metadata):
// Block logs
info!(%slot, proposer, block_root = ..., parent_root = ..., attestation_count, "...");
// Attestation logs
info!(%slot, validator, target_slot, target_root = ..., source_slot, source_root = ..., "...");
// Consensus events
info!(finalized_slot, finalized_root = ..., previous_finalized, justified_slot, "...");
// Peer events
info!(%peer_id, %direction, peer_count, our_finalized_slot, our_head_slot, "...");Root hash truncation:
use ethlambda_types::ShortRoot;
// Always use ShortRoot for consistent 8-char display (4 bytes)
info!(block_root = %ShortRoot(&root.0), "...");// Bounded storage: index relative to finalized_slot
actual_slot = finalized_slot + 1 + relative_index
// Helper ops in justified_slots_ops.rsXMSS (eXtended Merkle Signature Scheme):
- Post-quantum signature scheme
- Wire sizes:
PUBLIC_KEY_SIZE(common/types/src/state.rs) andSIGNATURE_SIZE(common/types/src/attestation.rs), static-asserted against leanVM's scheme constants incommon/crypto/src/signature.rs - Epoch-based to prevent reuse
- Signing, verification, and aggregation all come from leanVM, which internalized
XMSS in its own
xmsscrate; there is no external leanSig dependency - BLAKE2s over binary fields, not Poseidon over KoalaBear: a leanVM
mainbump across that rewrite invalidates every genesis key and every stored proof, even when the wire sizes happen to match ethlambda_crypto::init_leanvm(use_arena)must run once at startup, before any proving or proof decoding.--prover-arenaopts into leanVM's bump arena, which recycles the prover's large buffers across proofs instead of re-faulting them, so its pages stay resident for the node's lifetime- Every proof runs on one dedicated
leanvm-proverthread (proveinethlambda-crypto). The arena pins a slab to each thread that ever drives a proof, so proving from the calling thread (tokio's blocking pool, the actors) grew RSS one proof peak per new thread until aggregators were OOM-killed.tests/arena_slabs.rsguards this ethlambda keygengenerates genesis validator keys through the sameValidatorSecretKeythe node loads them with, so a key set cannot be built against a different leanVM than the client reading it. Keys are only usable by a client on the matching revision, and no file size changes when the scheme does, so the manifest recordsleanvm_rev. Seedocs/keygen.md
Aggregation shape (one leanVM AggregateSignature, grouped by (epoch, message)):
- Type-1 and Type-2 are the same object: one
XmssGroupper(epoch, message)pair, carrying that group's sorted, deduplicated keys - A slot can carry several messages. Validators attesting moments apart
disagree within a slot, so two distinct
AttestationDataat one slot is ordinary rather than equivocation and a block routinely carries both. The groups are sorted on the whole pair: with two of them at one slot, sorting on the epoch alone rebuilds a different signer set and fails a valid proof - The binding is off the wire.
to_bytes_without_pubkeys()carries neither the keys nor the(slot, message)pairs, so every decode rebuilds the whole signer set from aSignerSetper claim. A wrong set, message or slot decodes fine and fails inside the SNARK verifier, so there is no cheap binding check - Narrowing replaces splitting: re-aggregate the parent with a
declarenaming the group to keep (split_type_2_by_message)
Signature Aggregation (Two-Phase):
- Gossip signatures: Fresh XMSS from network → aggregate via leanVM
- Fallback to proofs: Reuse previous block proofs for missing validators
- Transport: QUIC over UDP (TLS 1.3), plus TCP (noise, then yamux or mplex) on the same port number as a fallback: a peer whose advertised
quicdoesn't answer can still be reached over TCP. Both addresses go into one dial,quicfirst, andDIAL_ADDRESS_CONCURRENCYpinsdial_concurrency_factorto one, so the order is a real preference and TCP is tried only after the QUIC attempt fails. Mainnet beacon peers answernato a yamux-only proposal, so TCP connections negotiate mplex (see themuxersmodule doc), which is expensive: preferring QUIC is how that cost is avoided where the peer allows it- Binding TCP puts
--gossipsub-portin the HTTP servers' namespace, so it must now differ from--api-port/--metrics-porttoo.Options::validate_portsrejects every clash before anything binds
- Binding TCP puts
- Gossipsub: Blocks + Attestations (snappy raw compression)
- Topic:
/leanconsensus/{fork_digest}/{block|aggregation|attestation_N}/ssz_snappy fork_digestis a 4-byte hex string (no0xprefix); currently the dummy12345678agreed across clients- Mesh size: 8 (6-12 bounds), heartbeat: 700ms
- Beacon wire:
validate_messages()is on, so every beacon message waits for a verdict (~4.2s before gossipsub's cache evicts it). Rules instate_transition::beacon::gossip(cheap half inline, stateful half on a boundedspawn_blockingtask); plumbing inp2p/src/beacon/verdict.rs. Lean gossip still auto-forwards - Beacon peer scoring: lighthouse's parameters ported in
p2p/src/beacon/scoring.rs(block, aggregate, 64 attestation subnets, exits, slashings; columns and sync contributions unscored), refreshed every slot from the head's active validator count. Mesh-delivery scoring (P3) is gated off while the head lags more than 4 slots and for an epoch after, because a catching-up nodeIgnores every aggregate and would otherwise graylist its own mesh. Every held fork digest is parametrized beforetransition::applyjoins it (gossipsub never scores a mesh grafted before its topic had parameters); the previous digest's weights are zeroed at the switch, and the switch restarts the P3 warmup. The swarm disconnects peers below the graylist (-16000) every 10 s. Lean runs unscored. Seedocs/beacon_wire.md - Data columns: every check runs in p2p. A column gossip did not accept (
Queue/Overloaded), every fetched column, and parked columns replayed after their parent imports go throughcolumn::chain_checksinp2p/src/beacon/column_checks.rs. The chain actor stores what it gets unchecked; only debug builds re-runchain_checksthere - Beacon subscribes seven global topics plus two node-id-derived subnet families: custody
columns and backbone attestation subnets. One of the seven is
sync_committee_contribution_and_proof; the foursync_committee_{0..3}subnets are joined on demand only (a validator client'ssync_committee_subscriptions, held until itsuntil_epoch, under every held digest) and advertised in MetaDatasyncnets, never the ENR. Messages and contributions validate in p2p on their own permit pool (gossip::sync_committee,p2p/src/beacon/sync_committee.rs), and accepted ones go into the sharedSyncCommitteePool. Gloas digests add four more topics,execution_payload,payload_attestation_message,execution_payload_bidandproposer_preferences(BeaconTopics::for_fork; earlier digests never carry them). Bids and preferences are validated in p2p against one sharedBuilderMarket(state_transition::beacon::builder_market) and never reach the chain actor. Gloas has its own rules forbeacon_block,data_column_sidecar(fork enumDataColumnSidecar, fork from the topic's digest), aggregates (SignedAggregateAndProof::Gloas, aggregation-bits length bounded before expansion) and attestations (verify_attestation_payload_status). Deliberate departures: with no bad-block cache a block seen without a post-state is queued or ignored where the spec rejects; an envelope whose block state is not cached is queued and forwarded to the actor (which verifies every envelope) rather than dropped; payload votes are applied without a second signature check.beacon_aggregate_and_proofandbeacon_attestation_{subnet_id}validate in p2p like blocks/columns, with their own permit pool. The actor applies only accepted aggregates (gossip's, plus the Beacon API's own validatedaggregate_and_proofssubmissions), attesting indices already verified, held one slot pervalidate_on_attestation's `current_slot >= data.slot- 1
; subnet attestations are verified and relayed but never applied. See [docs/beacon_wire.md`](docs/beacon_wire.md)
- 1
- Fork-digest rollover happens at runtime (
ForkSchedule,beacon::transition): the next digest's topics are joinedSUBSCRIBE_LEAD_EPOCHSbefore its boundary and the previous one's leftUNSUBSCRIBE_LAG_EPOCHSafter, blob-parameter (BPO) boundaries included. Known gap: ethrex'sDiscoveryServercannot replace the served ENR at runtime, so itseth2entry keeps the startup digest until a restart; peers discovering the node after a boundary reject the record voluntary_exit,proposer_slashing,attester_slashingandbls_to_execution_changevalidate in p2p like aggregates (gossip::operations, against the unadvanced head state, which must be electra or fulu: under a gloas head every operation is IGNOREd) and feedStore::operation_pool; the attestation pool is inStoretoo. Both are in memory, and block production packs the operation pool throughpack_operations
- Topic:
- Req/Resp: Status, BlocksByRoot, BlocksByRange (snappy frame compression + varint length)
- Beacon adds
beacon_blocks_by_{range,root}/2alongside its Status/Ping/MetaData/Goodbye set. Both serve from the checkpoint-anchored store, andbuild_statusadvertises it (head, finalized checkpoint, and the anchor's slot asearliest_available_slot), so peers have a reason to ask. Asking runs too: a peer's Status starts a range session from the higher ofP2PServer::beacon_fetched_through(the highest slot a range answer handed to the chain actor, since the store's head trails a delivered batch by the whole actor mailbox) and the store's head (which gossip imports move and the watermark never sees). Fetched blocks reach the actor asBlockSource::Sync. - Gloas adds
execution_payload_envelopes_by_{range,root}/1. Server: canonical FULL envelopes only, and the head's envelope only when the head node is FULL. Client: by root onneeds_envelope(same dedup and retry ladder as block fetches), by range after a gloas span's blocks were handed to the actor, with range sync holding the next batch until the envelope answer is forwarded (the actor keeps unmatched envelopes only briefly) Their chunks carry four<context-bytes>(the block's own epoch's fork digest), which is whyBeaconWireand the codec carrygenesis_validators_root. Seedocs/beacon_wire.md
- Beacon adds
- Always on for
beacon(mainnet bootnode ENRs are not statically dialable, so a crawl is its only way to find a peer); opt-in fornodebehind the lean-only--discovery.enable, off by default, so a lean node peers from--bootnodesalone and binds no discovery socket.Network::discovery_enabledis the one answer;P2P::spawntakesOption<DiscoverySpawnConfig>andP2PServer.discoveryis anOption,Noneleaving the dial loop unscheduled - Where it runs:
DEFAULT_DISCOVERY_PORT(9000) unless--discovery.portsays otherwise (own UDP socket, must differ from--gossipsub-port; checked once byOptions::validate_ports, which skips the discovery rules when it is off). Co-located nodes that run discovery must each pass--discovery.port - Reuses ethrex's
DiscoveryServer+PeerTablewith discv4 disabled;spawntakes the prepared lean ENR, so the record ethrex serves is the one we report - ENR follows the beacon phase0 spec:
ip/udp/quic/tcp/secp256k1/eth2/attnets - Admission mirrors lighthouse:
eth2.fork_digestmust match,next_fork_*may differ, aquicortcpentry required. Handed to the peer table asLeanFilter: PeerFilter, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-seqENR - Candidates ranked by uncovered attestation subnets. See
docs/discovery.md
- Exponential backoff: doubling from
INITIAL_BACKOFF_MS(5ms → 2560ms) - Max
MAX_FETCH_RETRIES(10) attempts, random peer selection on retry
- 20-byte truncated SHA256 of: domain (valid/invalid snappy) + topic + data
ethlambda node and ethlambda beacon are the same entry point. Each parses
into one cli::Options { common, network }, where network is
Network::Lean(LeanOptions) or Network::Mainnet { mainnet, execution }. Each
variant carries that chain's own flags, so a flag one chain does not take is
unreachable on the other's path by construction rather than by an Option
nobody unwraps. MainnetOptions holds the three flags beacon has of its own:
--custody-group-count, --safe-slots-to-import-optimistically, and
--network (default mainnet). The last is carried unresolved: run_node is
where a bad value is reported.
run_node owns everything that is not chain-specific, in order: the discv5
port check, metrics registration, the banner and version log, the
RLIMIT_NOFILE raise, the HIVE_LEAN_TEST_DRIVER early return (lean-only, and
it must still precede key loading), --node-key resolution, resolving
--network into a NetworkSource (mainnet only; must precede the next step,
since a loaded network's own bootnode list is part of its fallback), reading
--bootnodes, and building the aggregator, sync-status and event handles.
It then branches once, on network, both arms inline, each evaluating to a
ChainSetup: 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 this chain's actor with (lean's validator
keys and BlockChainConfig, or beacon's Beacon marker). The rest of the
discv5 configuration is the node key, the ports, the bootnodes and the peer
target, which are operator input and identical either way, so run_node fills
those in once below the match rather than having each arm repeat them.
Everything after that match is shared again: one build_swarm, one
P2P::spawn, one start_rpc_server, and one chain actor, since ChainActor
selects between BlockChain::spawn and BlockChain::spawn_beacon rather than
between spawning one and not. The InitP2P/InitBlockChain wiring and
wait_for_shutdown are then shared too.
ChainSetup is a data bag, not an abstraction. The match that fills it stays
inline, because moving it into a method would relocate the branch rather than
remove it.
Two consequences of "one call site" worth knowing. P2P::spawn now runs
before BlockChain::spawn, so gossip arriving in between hits a P2PServer
whose blockchain is still None and is dropped; every access is an if let Some, and the window is a handful of statements. And beacon now binds
--api-port off a real, DB-backed anchored Store, not an empty in-memory
placeholder: checkpoint sync or resume gives it one, the same way node gets
its own, and it serves the Beacon API off it rather than the lean-shaped
/lean/v0/... routes, which read metadata keys and state variants a beacon
directory never carries. Which surface a node serves follows from
Store::chain(), not from the sub-command, so the two cannot disagree; a
/lean/v0 path on a beacon run is a 404 rather than the panicked request it
used to be.
RunningNode.blockchain is a plain BlockChain, not an Option: the beacon
follower runs a chain actor too, so there is always exactly one to stop and
join, and run_node spawns and wires it before building the struct. Mainnet
therefore gets the same graceful shutdown, metrics bootstrap and fd-limit raise
lean does (it used to park on std::future::pending()).
docs/cli.md has the step-by-step table.
beacon takes a --network flag (built-in name mainnet (default),
sepolia, hoodi or plataberget, or a path to a directory of published network files)
and resolves it into a NetworkSource before doing anything else; see
bin/ethlambda/src/network/. genesis_time and genesis_validators_root,
which the fork digest keying every gossip topic, the ENR eth2 entry and
discv5 admission are computed from, come from that resolved network.
Every built-in chain is a network::built_in::EmbeddedChain: its
eth-clients repo's config.yaml and bootstrap_nodes.yaml byte for byte
(Platåberget's come from ethpandaops/glamsterdam-devnets'
network-configs/devnet-8) (bin/ethlambda/assets/<name>/), parsed through the same
ConfigFile::parse/bootnode reader a directory goes through, plus the two
genesis values as constants. None carries a genesis state: a built-in
network never anchors at genesis, and the states are 5 MB (mainnet) to 150 MB
(Hoodi). The constants are checked offline (mainnet's against
tests/fixtures/networks/mainnet/genesis.ssz, eth-clients' file, whose SHA-256
beacon::tests::the_fixture_state_is_eth_clients_file pins; Sepolia's,
Hoodi's and Platåberget's against fork digests published in their own bootnode ENRs), and at
runtime by checkpoint sync and resume, which both check the anchor state
against them. beacon::tests::the_built_in_network_derives_what_it_always_did
pins the parsed mainnet config to Config::mainnet(), so the file and the
Rust constant cannot drift apart. Sepolia's config schedules gloas: its
GLOAS_* keys are claimed (ForkName::Gloas exists, and Config carries the
fork's schedule, timing and churn fields), and the live follower follows it
(ForkName::Gloas.is_followed()): the chain actor imports gloas blocks and
their payload envelopes, the p2p layer delivers both and the payload votes, and
the follower crosses GLOAS_FORK_EPOCH without a restart (fork-digest
rollover, see Networking). refuse_unfollowable_fork in main.rs and
network::warn_if_unfollowed_fork_scheduled (shared by
BuiltInNetwork::resolve and a loaded NetworkDir) are now inert guards for
the next fork the node cannot follow: the first refuses such an anchor with
UnsupportedFork, the second warns once per scheduled fork for which
ForkName::is_followed answers no (the one place that says which forks the
node follows). The ignored-keys warning only ever names heze's keys and
GAS_LIMIT_SCHEDULE/INCLUSION_LIST_DUE_BPS.
Platåberget (the Glamsterdam testnet) is already past its GLOAS_FORK_EPOCH,
so its checkpoint anchor is a gloas state: it is accepted, starts with no
payload known (head EMPTY), and its first FULL child is held until the anchor's
envelope arrives (best-effort from the checkpoint URL's
execution_payload_envelopes endpoint, else the by-root request). Its
file says CONFIG_NAME: 'testnet' (a placeholder for Prysm), so
BuiltInNetwork::resolve sets every built-in's CONFIG_NAME to its
--network name.
A loaded network decodes its own genesis.ssz instead, at whatever fork its
own schedule names for epoch 0. Every config.yaml, built-in or loaded, goes
through two checks as soon as it parses, and a failure is a hard startup
error. For a directory they run before its genesis.ssz is decoded, since
the compiled preset sets that state's container bounds and a mismatch would
otherwise surface as an SSZ error:
check_preset:PRESET_BASEmust name the compiled preset.check_constants: every key the node runs on a compile-time constant for instead of readingConfig(the custody counts, subnet counts,MAX_REQUEST_*,MAX_PAYLOAD_SIZE, the snappy message domains,MAXIMUM_GOSSIP_CLOCK_DISPARITY) must equal that constant./eth/v1/config/specreports these keys off the storedConfig, so this is what keeps it from reporting a value the node does not use. A new such constant needs a line incheck_constants.
Two consequences, on the built-in arm. beacon takes no genesis
configuration of its own the way node does: genesis_time and
genesis_validators_root need nothing from the command line beyond
--network. And a build with ethlambda-types/preset-minimal on refuses every
built-in network, since each declares PRESET_BASE: mainnet; nothing enables
that feature for this binary, and failing is the right answer if anything does.
--checkpoint-sync-url is no longer merely accepted-but-unused on beacon:
the anchor work has landed, and this flag is now how beacon fetches its
finalized BeaconState and anchor block from a standard Beacon API server.
The full anchor precedence is a resumable data directory, then this URL, then,
for a loaded network only, the resolved network's own genesis.ssz, then
abort. The URL is therefore required on a fresh data directory only for the
built-in networks, since they alone have no genesis-sync path (see
docs/checkpoint_sync.md).
These genesis values used to come from a Beacon API's /eth/v1/beacon/genesis,
which made a beacon run depend on a checkpoint provider being reachable. They
are properties of the chain, not of a provider. Checkpoint sync itself is a
separate concern, and now runs on both chains: lean still fetches a
finalized anchor, which genuinely has no local source, and beacon fetches
one too. The URL cleaning, base-URL trim and first-success fan-out that the
old genesis-fetching path and lean's checkpoint sync once shared through a
checkpoint_common module live in checkpoint_sync.rs, which today serves
both chains' anchor fetches rather than lean's alone.
The RPC crate serves the API router (--api-port, default 5052) and the metrics/debug routers
(--metrics-port, default 5054). When the two ports differ it binds two independent Axum servers;
when they are equal it merges all three routers onto a single listener, so pointing both flags at
one port is supported and not a misconfiguration.
Both sub-commands bind from one site in run_node, which picks the API router by
Store::chain() rather than by sub-command, so the surface and the data behind it cannot
disagree. node calls start_rpc_server and gets /lean/v0; beacon calls
start_beacon_rpc_server and gets the Beacon API under /eth/v1 and /eth/v2
(crates/net/rpc/src/beacon/, one file per endpoint group). Both reach it with real
handles: the DB-backed anchored Store the P2PServer already holds, plus a clone of the
same SyncStatusController the chain actor writes to. The beacon arm takes no
AggregatorController, because a follower has no aggregator duty to toggle, but it does
take the same EventBus lean does, for GET /eth/v1/events. It also takes the P2P
actor's RpcToP2PRef (crates/net/api), the one path by which this node gossips on the
beacon wire: POST /eth/v2/beacon/pool/attestations validates a validator client's
attestations and publishes them through it.
The two API routers are alternatives, never merged. A /lean/v0 path on a beacon run is
a 404: those handlers read lean state variants and metadata keys a beacon directory never
carries, so serving both off one store would answer lean questions with beacon data, or
panic trying. crates/net/rpc/tests/http_servers.rs pins both halves of that.
start_http_servers(config, api_router, shutdown) still takes api_router as an Option
and that test still covers the None arm, which is now the test-driver-less shape rather
than the beacon one.
Encoding differs between the two surfaces, deliberately. The Beacon API serves JSON by
default and SSZ on Accept: application/octet-stream, quotes every integer, and tags
fork-versioned responses with Eth-Consensus-Version. The lean surface keeps SSZ as the
default on its two SSZ endpoints and bare integers in JSON, because
checkpoint_sync.rs and other clients' lean sync read those bytes and may send no
Accept at all; /lean/v0/blocks/finalized now answers JSON when asked for it by name.
See docs/rpc.md for the full reference: CLI flags and defaults, the lean API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the Beacon API endpoints and the three ids they refuse, the metrics/debug endpoints (Prometheus /metrics, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types.
ethlambda-types carries the Ethereum Beacon Chain containers (phase0
through gloas) alongside lean's own types. The Beacon Chain is a different
protocol from the Lean consensus this repo implements; the types share a crate
so that one BlockChainServer can dispatch on a single state type instead of
existing once per chain.
BeaconStatehas aLeanvariant holding lean'sState, andForkNamea matchingLean. Onlyfork_name()andfrom_ssz()handle it; every other beacon accessor answersunreachable!()naming itself. The guarantee is the singlematchat the top of each handler, not the type system, so a lean state reaching a beacon accessor should fail as a named panic rather than a silent wrong answer.ForkName::Leanis deliberately absent fromForkName::ALL.ALLis whatparse,previousandnextsearch, so its absence keepsparse("lean")atNoneandGloas.next()atNone, meaning a fork upgrade cannot walk off the end into lean. Lean is not a point on the Beacon Chain's fork timeline.Leanis declared last so the derivedOrdputs it after every beacon fork, which is whatfork >= ForkName::Xgating reads.- Preset is a compile-time choice (
preset-minimalfeature on this crate) because SSZ container bounds are const-generic arguments; fork scheduling is runtime (beacon::config) instead. Every lean crate depends onethlambda-types, so enabling the feature rebuilds it for the whole graph; lean code reads none of the beacon preset constants, so it cannot change lean behavior. - Per-fork containers are plain structs behind an enum, so SSZ stays derived:
two of phase0's fields are replaced in altair, one field changes type in
five separate forks, and the state's merkle tree gains a level at electra.
Gloas's big containers and most lists are progressive (EIP-7688), in
containers/gloas.rs, with their ownSignedBeaconBlock::Gloasbody; a progressive container has no fixed depth. beacon::primitives::Rootisprimitives::H256, not a second 32-byte hash converted at the boundary, andbeacon::primitives::HashTreeRootre-exports lean's convenience trait rather than declaring its own. The primitive family a beacon container needs beyond that is two newtypes,H160andU256, so the crate declares them instead of depending onethereum-types. Two consequences worth knowing:U256holds the 32 little-endian bytes SSZ encodes it as, so its stored byte order is the reverse of its numeric one andOrdis written out by hand. Do not derive it: a derivedOrdcompares the least significant byte first, which would silently invertterminal_total_difficultycomparisons.H160writes outlibssz_merkle::HashTreeRootbecause the derive dropsis_basic_type, and at 20 bytes wide that answer changes the merkle tree of any list or vector of addresses. Everything else here is 32 bytes, where packing and per-element padding coincide, so the derive is fine.
- Requires mutable element access on
SszList, which no published libssz release has, so all four libssz crates are git dependencies onlambdaclass/libssz(for lambdaclass/libssz#33), pinned byrevrather than trackingmain: this is the SSZ encoder and merkleizer behind everyhash_tree_root, so a routinecargo updatemust not be able to move it. Return them to a crates.io version once a release carries #33 and #37. A git dependency rather than a[patch.crates-io]override because nothing outside this workspace depends on libssz, so there is no second copy to unify; that also keeps the manifest free of a[patch]table, whichshadow/cargo-patch.tomlwould collide with. ValidatorsandBalancesareethlambda_ssz_tree::Lists (gloas:ethlambda_ssz_tree::ProgressiveList, same method set), persistent Merkle trees that cache node hashes and share unchanged subtrees between states throughArc; they have no slices and noiter_mut(a pass that rewrites most of a list uses the lazy copy-on-write cursor,iter_cow/try_update_each, withBeaconState::registry_mutfor disjoint borrows of both lists). The registry is therefore reached through element-levelBeaconStateaccessors (validator/validator_mut,balance/balance_mut,push_validator,iter_validators/iter_balances,validator_count,validators_root), so no accessor has to return two list types. A leaf holds a page-sized run of elements rather than one chunk, and an inner node a page of child pointers spanning several binary levels, so a lookup crosses a handful of nodes and a rebuilt leaf or node copies one page. Writes are buffered untilBeaconState::apply_pending_mutations, which the state transition calls before every state-root computation. A state decoded from storage is rebased onto a cached one (Store::get_state).state.validator(i)andstate.balance(i)are tree descents, not array indexing. A loop over the registry should walkiter_validators()(zipped withiter_balances()where it needs both), not index per validator: helpers that build the active-indexVecand then read each index back were the largest cost left in the import profile (docs/beacon_stf.md, "Registry and balances").
The Ethereum Beacon Chain consensus specs (phase0 through gloas), a different
protocol from the Lean consensus the rest of this repo implements, live in the
beacon module of ethlambda-state-transition beside lean's own state
transition. The module holds the behavior (state transition, fork choice,
helpers, BLS and KZG); the containers, presets, configuration and primitives it
transitions are in ethlambda-types, per the section above. Nothing above
beacon reads anything inside it, and nothing inside it reads lean's modules.
blstandc-kzgare now on the lean binary's dependency path, sinceethlambda-blockchain,ethlambda-rpcandethlambda-test-fixturesall depend on this crate. That is the cost of one crate holding both chains' rules; the module is not feature-gated.- The BLS wrapper lives in
ethlambda_crypto::blsand is re-exported asbeacon::bls, so p2p's operation validation andethlambda-rpccan verify signatures without depending on this crate:ethlambda-storage's attestation pool (now owned byStore) aggregates signatures, and storage cannot depend onethlambda-state-transition. - The
beacon_aggregate_and_proof/beacon_attestation_{subnet_id}gossip rules (committees,is_aggregator, all the signatures) live ingossip::{aggregate,attestation}, validated inethlambda-p2poff the vote block's own cached post-state, not the chain actor.aggregate.rskeeps only what the actor's applied-bits gate still needs (is_non_strict_superset,MAX_AGGREGATES_PER_SLOT);fork_choice::apply_verified_aggregateis apply-only, no committee lookup or signature check left in it.das.rsis the one module still holdingp2p-interface.mdrules here rather than in p2p: it needs no state, but thenetworkingfixture suite has handlers forget_custody_groupsandcompute_columns_for_custody_group, and that runner lives in this crate'stests/beacon_spec/. The attestation-subnet backbone's own subnet-selection math is neither, so it lives with the wire code it serves, inethlambda-p2p'sbeacon::subnets. - Tests:
make test-beacon(builds once per preset), ortest-beacon-mainnet/test-beacon-minimalfor one. CI runs the two as a job each, so they build and run concurrently.make testcovers the whole workspace, in two halves, and still needs no fixture download: thebeacon_spec_teststarget declaresrequired-features = ["beacon-spec-tests"]socargo testskips it, and the BLS and KZG fixture vectors, which are unit tests inside the module, are#[cfg_attr(not(feature = ...), ignore)]so they report as ignored rather than silently absent. Turning the feature on is whatmake test-beacondoes, and it runs--libtoo so those 15 are not missed.ethlambda-blockchainhas its ownbeacon-spec-testsfeature gating the fixture-driven chain-actor tests (the gloas envelope cases in its--libtarget, which readconsensus-spec-tests);make test-beacon-mainnetruns them, and without the feature they report as ignored, so a plaincargo testneeds no download. - Every fixture case is its own test, named
<runner>/<fork>/<handler>/<suite>/<case>, so a failure names the case and not the suite around it. The spec binary therefore supplies its own harness (harness = false), since a case is only known once the fixture tree is walked. A substring filter selects a whole suite or one case:cargo test -p ethlambda-state-transition --test beacon_spec_tests --features beacon-spec-tests -- electra/attester_slashing. - Fixtures:
make consensus-spec-tests, pinned to aconsensus-specsrelease. The tree is stamped with the version and the configs it holds, so changing either wipes and re-downloads rather than leaving the old cases in place and silently green, or marking a partial tree complete.CONSENSUS_SPEC_TESTS_CONFIGSnarrows the download: a run reads its own preset's tree and nothing else, which is what each CI job sets. The BLS and KZG vectors are a separate, preset-independent download,make cryptography-specs, pinned to anethereum/cryptography-specsrelease: consensus-specs shipped them itself, under ageneralconfig, through v1.7.0-alpha.12, but v1.7.0-alpha.13 (consensus-specs #5398) moved them out. - Preset is a compile-time choice (
preset-minimalfeature) because SSZ container bounds are const-generic arguments; fork scheduling is runtime because thetransitionsuite moves fork epochs per case. - Per-fork containers are plain structs behind an enum, so SSZ stays derived. See
docs/beacon_stf.mdfor why, including the fork-by-fork field counts and the merkle depth change at electra. - The types are re-exported at their old paths (
crate::beacon::containers,crate::beacon::preset,crate::beacon::config, ...), so a use site inside the module reads as if they were local, andethlambda_state_transition::beacon::containers::Xandethlambda_types::beacon::containers::Xname one type. Two things do move the other way:fork_choicere-exportsLatestMessageandPowBlockfrom types (ethlambda-storagepersists them), andhelpers::miscre-exportscompute_fork_data_root(the networking crate needs the fork digest built on it). BeaconStateandForkNamecarry aLeanvariant, so every match on either needs an arm for it. Nothing here can transition a lean value, so those arms panic throughlean_state_unreachable/lean_fork_unreachable(src/beacon/lean_boundary.rs, same names and wording asethlambda-types' ownpub(crate)pair) rather than widening a signature to aResultno correct caller would see. Functions, not a macro, and#[cold]+#[track_caller]so the panic still reports the arm that was reached rather thanlean_boundary.rs. In the spec tests the same arms calllean_is_not_a_fixture_fork, which is#[track_caller]for the same reason, since a case's fork is parsed from a directory name andForkName::ALLhas no lean entry. Both are named arms rather than a catch-all_, so a real new fork still breaks every match that must grow one.- Needs mutable element access on
SszList/SszVector, which no published libssz release has yet. Nothing extra is required here: the workspace already tracks all four libssz crates from git at5cb1437for the beacon containers inethlambda-types(see the section above), and that rev is the0.3.0release plusDerefMut/IndexMutonSszList(lambdaclass/libssz#33),as_chunksin the merkleize fold loop (#35), and the sameDerefMut/IndexMutforProgressiveList(#37, an unmerged PR branch head for now). This module needs theSszListcommit (#33) for the same reason, and gloas's progressive lists need #37. - Status: all eight forks (phase0 through gloas) have containers, fork
upgrades, state transitions, and epoch processing, and every fixture case
that is not ignored passes on both presets at the pinned consensus-specs
release. The counts live in the Status section of
docs/beacon_stf.md, so they are kept in one place. Fork choice is fixture-verified too, including bellatrix's terminal-PoW validation, deneb's and fulu's data availability, and gloas's payload-aware suites. - Gloas lives in
stf/gloas.rs,stf/epoch/gloas.rs,helpers/gloas.rs, the gloas section offork_choice.rs, andcontainers/gloas.rsinethlambda-types. The live follower follows gloas. The chain actor's envelope path isbeacon_envelope.rs: an envelope waits for its block's post-state, then for every sampled column when the bid has commitments (data availability moves from the block to the envelope), then goes toon_execution_payload_envelope; a block whose parent is FULL with an unverified payload is held until that envelope verifies. All queues are capped, aged out and swept at finality, a missing parent envelope is re-asked once a slot (FetchRequest.needs_envelope, asked at once when the block is held, whatever its source, sinceBlockSource::Syncalso covers by-root answers), an envelope for a block that is stored but not imported is checked against that block's bid and held outside the per-arrival-slot cap (awaiting_import, which range catch-up needs), andbeacon_columns.rsbounds the parking of gloas column sidecars (they carry no signature). Fork-choice events are timed at arrival, not at the slot tick, and the head's payload status is kept with its root (Store::head_payload_status, recomputed after a restart). The node also serves gloas validator duties and the builder market: it validates, pools and relays gossip bids and proposer preferences, servesPOST /eth/v1/beacon/execution_payload_bidsand/proposer_preferencesplus the builder endpoints, andproduceBlockV4weighs the best pooled p2p bid against the local build perBuilderConfig(min_bid,builder_boost_factor; the local build wins a tie). A winning bid returns the block only. The builder API (relay orbuilder_pubkeysbids) is phase 2; seedocs/spec_deviations.md.state_transition/src/beacon/gloas_block_production.rsassembles the block and its envelope (gloas_payload_inputs,parse_gloas_execution_requests,pack_gloas_attestations,pack_payload_attestations,assemble_gloas_block,gloas_data_column_sidecars);rpc/src/beacon/gloas_proposal.rsservesproduceBlockV4and the envelope endpoints (cache of the current and previous slot's builds; the envelope POST waits up to 4 s for its block, then gossips the envelope and all column sidecars throughRpcToP2P, relying on gossipsub fanout for subnets the node does not subscribe to) andrpc/src/beacon/ptc.rsthe PTC duties, payload attestation data and pool. OneSharedPayloadAttestationPoolis shared by p2p (filled on accepted gossip) and rpc. The store keeps an in-memory envelope-arrival record (insert_beacon_envelope_seen, keyed by root, pruned with the el-hash cache, not persisted) thatpayload_presentreads againstfork_choice::get_payload_due_ms;helpers::gloas::get_ptc_assignmentsbacks the duties.attestation_data.indexat a gloas slot comes fromStore::head_payload_status. With--execution-endpoint, an envelope that passes the consensus checks is judged byengine_newPayloadV5(INVALID with a nulllatestValidHashis a hash mismatch that refuses only that envelope; INVALID with one condemns the payload via the execution-chain walk; an engine error keeps the envelope and retries once a slot, probing with the oldest), and a gloas head sendsengine_forkchoiceUpdatedV4with the custody columns (head hash by the head node's FULL or EMPTY status, finalized and safe from the checkpoint blocks'bid.parent_block_hash, safe being a fallback for fast confirmation). Building a gloas payload forproduceBlockV4usesPayloadAttributesV4(V3's fields plusslot_numberandtarget_gas_limit, the parent bid's gas limit) throughforkchoice_updated_v4_with_attributesandget_payload_v6(BuiltGloasPayload,ExecutionPayloadV4). Seedocs/beacon_engine.md. The validator client (crates/validator) at a gloas slot runs: propose at the boundary (block published bare, then the proposer-signed envelope withEth-Blob-Data-Included: true, both inside the proposal's attester-offset budget; an envelope failure is logged and counted, not a failed proposal), attest atATTESTATION_DUE_BPS_GLOAS, aggregate atAGGREGATE_DUE_BPS_GLOAS, then the PTC vote atPAYLOAD_ATTESTATION_DUE_BPS. Sync committee duties (SyncCommitteeService,crates/validator/src/sync_committee.rs) run at every fork: one message per validator over the head root atSYNC_MESSAGE_DUE_BPS(_GLOAS)(refused while the head is optimistic), then contributions for each selected (validator, subnet) pair atCONTRIBUTION_DUE_BPS(_GLOAS), with subscriptions re-sent every epoch for the current and next period. The node pools the messages and a block at slot N packs only(N-1, parent_root), replaced by the empty aggregate whenverified_sync_aggregatefails (seedocs/spec_deviations.md).SlotClockpicks the offsets by each slot's own fork, and/eth/v1/config/specsupplies them. It still keeps no slashing-protection record; PTC votes have an in-memory(validator, slot)dedup only, and envelopes are unguarded.get_head_nodeis one bottom-up walk for every fork, with the current slot's fork selecting the payload rules and the boost gate, so a follower on any network that schedules gloas (Sepolia does) pays work proportional to its votes plus blocks every slot and decodes no block in the ordinary case (each block's payload link is recorded at import; only the rare weak-parent equivocation scan and the one-time derivation of a link lost to a restart decode, seedocs/spec_deviations.md).compute_headand the spec-literalgloas_get_headare the references its tests compare it with.docs/beacon_stf.mdhas the design anddocs/spec_deviations.mdthe deliberate departures from the spec, in particular the fulu-to-gloas fork-choice boundary rule. - Nothing is ignored for being unimplemented in the state transition or fork
choice. Ignored cases are the
LightClient*containers (a different layer, out of scope),networking/gossip_*cases outside what the node validates (every fork's cases for topics it has no validator for, cases from forks a validated topic's rules do not cover pervalidated_forks, gloas's operation vectors among them, and the vectors inSKIPPED, which assume a bad-block cache), and thehezefixture tree.hezedoes not parse as aForkName, socollectwould skip it silently;UNMODELED_FORKSnames it andfixture_forks/every_directory_is_accounted_forfails on any fork directory that is neither parseable nor listed, so a new fork forces a decision. - A fixture case with no
poststate asserts the input must be rejected. That rule lives incheck_transition; do not add a runner that ignores it.
Genesis: config.yaml (YAML format, cross-client compatible)
GENESIS_TIME: 1770407233
MILLISECONDS_PER_SLOT: 4000 # optional, defaults to DEFAULT_MILLISECONDS_PER_SLOT
GENESIS_VALIDATORS:
- attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a..." # XMSS pubkeys, hex, PUBLIC_KEY_SIZE bytes
proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37..."- Validator indices are assigned sequentially (0, 1, 2, ...) based on array order
MILLISECONDS_PER_SLOTmust be a multiple ofINTERVALS_PER_SLOTand at leastMIN_MILLISECONDS_PER_SLOT: the knob slows a network down, it does not speed one up, since timings fixed in milliseconds (EARLY_AGGREGATION_WINDOW) are sized for the spec cadence. It is persisted in the DB'sMetadata["config"]and a resume with a different value is refused. Other clients ignore the key and stay at their compile-time 4s, so it only takes effect on an all-ethlambda network- All genesis state fields (checkpoints, justified_slots, etc.) initialize to zero/empty defaults
- Matches Ream/Zeam format — no extra state fields in the config file
Bootnodes: ENR records (Base64-encoded, RLP decoded for the quic/tcp/udp ports + secp256k1 pubkey). A 0 port means absent everywhere, on read and on write. build_swarm dedups by PeerId: two entries naming one key merge into a single dial
- Unit tests: Embedded in source files
- Spec tests: From
leanSpec/fixtures/consensus/crates/blockchain/tests/forkchoice_spectests.rs(useson_block_without_verificationviaspec_test_runner)crates/blockchain/tests/signature_spectests.rscrates/blockchain/state_transition/tests/stf_spectests.rs(state transition)
ethlambda replay-era replays real mainnet/Sepolia/Hoodi blocks from nimbus .era files through the beacon STF and compares the end of each era with the stored state. .github/workflows/era_replay.yml runs it on the published image after every push to main that touches the STF, types or ssz-tree. See docs/era_replay.md.
cargo test --workspace --profile release-fast # All workspace tests
cargo test -p ethlambda-blockchain --test forkchoice_spectests
cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads=1 # SequentialTests run under release-fast: release-grade opt-level (needed to avoid stack
overflows in signature verification/aggregation) but no LTO, parallel codegen,
incremental, and line-tables-only debuginfo, so rebuilds are much faster than
--release. Artifacts land in target/release-fast/, separate from
cargo build --release.
- At least one node must be started with
--is-aggregatorto finalize blocks - Without this flag, attestations pass signature verification and are logged as "Attestation processed", but the signature is never stored for aggregation (the
is_aggregatorgate inon_gossip_attestation,store.rs), so blocks are always built withattestation_count=0 - The attestation pipeline: gossip → verify signature → store gossip signature (only if
is_aggregator) → aggregate at interval 2 → promote to known → pack into blocks - Symptom:
justified_slot=0andfinalized_slot=0indefinitely despite healthy block production and attestation gossip
POST /lean/v0/admin/aggregatorwith{"enabled": bool}toggles the aggregator role at runtime without restart (ported from leanSpec PR #636)GET /lean/v0/admin/aggregatorreturns{"is_aggregator": bool}- The CLI
--is-aggregatorflag seeds the initial value; runtime toggles are in-process only (not persisted across restarts) - Runtime toggles do NOT resubscribe gossip subnets — those are frozen at startup by
build_swarm. Toggling ON at runtime only activates aggregation logic for subnets the node was already subscribed to - Operational model: standby aggregators should boot with
--is-aggregator=true(so subscriptions are in place), then use the admin endpoint to rotate duties. A node booted with--is-aggregator=falseand toggled ON later will have no extra subnets to aggregate
- Fork choice tests use
on_block_without_verification()to skip signature checks - Signature spec tests use
on_block()which always verifies - Crypto tests marked
#[ignore](slow leanVM operations)
Blocks split across BlockHeaders/BlockBodies/BlockProof; states are
snapshot (States) + diff (StateDiffs) pairs; BlockRoots and LiveChain
index by slot for range serving and fork choice. Attestations and gossip
signatures are not persisted; they live in in-memory Store buffers consumed
during the tick pipeline. See docs/data_storage.md
for the full reference: what each of the thirteen tables holds and how it's
keyed, the snapshot/diff reconstruction algorithm, the block-import write
sequence, pruning rules, what never changes at runtime, and startup/restore
behavior.
ExecutionPayloadEnvelopes(verified gloas envelopes, keyed by slot and root) andBlockTimeliness(the gloas part ofblock_timeliness) are reloaded on resume for the unfinalized window; the PTC vote vectors are reseeded empty and each payload gets a verdict seeded for the execution client to promote. Adding them did not bumpDB_VERSION.- A restart of a beacon data directory currently hits
SpecAssert("block_root in store.unrealized_justifications")in head computation until #628 (persists unrealized justifications) lands; that is not gloas-specific. BlockProofis the only pruned block table (below the finalized boundary);get_signed_blockreturnsNonefor a pruned finalized block.States/StateDiffsare pruned below the finalized block's snapshot interval byprune_finalized_states(lean: inprune_old_data; beacon: inevict_held_blocks_at_or_below_finality), watermarked byKEY_STATES_PRUNED_BELOWso ranges are adjacent and never overlap.- A
StateDiffomitsconfigandvalidators, trusting they never mutate; breaking that invariant would silently corrupt every reconstructed state. Metadata["config"]is written at bootstrap; it doubles as the DB's genesis-time fingerprint on resume. A beacon resume also refuses a config file that changes a chain value in it (first_config_difference: slot time,PRESET_BASE, churn limits, ...), but only warns about a changedCONFIG_NAME, a label with no consensus effect. The fork schedule andBLOB_SCHEDULEmay change ahead of the chain: a change is refused only when its old or new epoch is at or before the latest epoch the directory processed, so a network scheduling a fork does not cost its nodes their directories. A resume that passes writes the file's config over the stored one (Store::replace_config), since the node runs on the stored config.PendingDataColumnsholds unverified sidecars parked until their block's parent has a post-state.data_column_indices_forreadsDataColumnsonly, which is what keeps a parked column from satisfying the availability gate. Its only index is the chain actor's in-memorysidecars_awaiting_parent, sostart_actorclears the whole table at startup.BeaconUnrealizedJustifications(root ->(slot, Checkpoint)) persists the beacon store's per-block unrealized justified checkpoint (consensus-specs'store.unrealized_justifications), which used to live only in an in-memoryBeaconScratchmap. A restart emptied that map with nothing to refill it, soget_voting_sourcehit a hardSpecAsserton the first pre-restart leaf from an older epoch andget_headfroze. The in-memory map stays as a write-through cache in front of the table. Pruned on finalization to the same horizon asLiveChain(the finalized block's own slot, keeping that block's own entry).DB_VERSIONis 7:StatesandStateDiffsmoved from root keys to slot||root keys (the block's slot, looked up fromBlockHeadersby readers) soStore::prune_finalized_statescan range-delete finalized states; a directory written by an earlier version holds rows no lookup finds. (6 was the gloas schedule, timing and churn keys inConfig, which is SSZ-encoded underKEY_CONFIG; 5 was the unrealized-justifications table, since a directory written before it has no row there for any block it already imported; 4 wasPRESET_BASE/CONFIG_NAMEat the front ofConfig; 3 was the runtime keys aconfig.yamlsupplies.)Store::from_db_staterefuses any other version outright; there is no migration.
- Always computed via
hash_tree_root()after full state transition - Must match proposer's pre-computed
block.state_root
- Use
original_finalized_slotfor justifiability checks during attestation processing - Finalization updates can occur mid-processing
- Call
shift_window()when finalization advances - Prunes justifications for now-finalized slots
Critical:
leanvm: XMSS signatures and recursive aggregation, taken from leanVM's facade crate (which re-exportsxmss,rec_aggregationand itsrand) and pinned to onemainrevision (leanEthereum project)libssz/libssz-derive/libssz-types: SSZ serializationlibssz-merkle: Merkle tree hashing (hash_tree_root())spawned-concurrency: Actor modellibp2p: P2P networking (custom LambdaClass fork)vergen-git2: Build-time git commit/branch info embedded in binary
Storage:
rocksdb: Persistent backend- In-memory backend for tests
Specs: leanSpec/src/lean_spec/spec/ (Python reference implementation; fork logic under forks/<fork>/, e.g. forks/lstar/)
Devnet: lean-quickstart (github.com/blockblaz/lean-quickstart)
Docs: docs/ — rpc.md, metrics.md, checkpoint_sync.md, 3sf_mini.md, lmd_ghost.md (mdbook via make docs)
Releases: See RELEASE.md for release process documentation
- zeam (Zig): https://github.com/blockblaz/zeam
- ream (Rust): https://github.com/ReamLabs/ream
- qlean (C++): https://github.com/qdrvm/qlean-mini
- grandine (Rust): https://github.com/grandinetech/lean/tree/main/lean_client
- gean (Go): https://github.com/geanlabs/gean
- Lantern (C): https://github.com/Pier-Two/lantern