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.
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.
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.
ethlambda validator signs attestations and blocks without recording what it
has signed. It cannot detect that a previous run, or another client holding the
same keys, already signed.
- The specification: EIP-3076 defines an interchange format and the conditions a validator client must never violate. For attestations: signing two distinct attestations with the same target epoch (a double vote), and signing one whose source and target surround or are surrounded by another's. For blocks: signing two distinct ones for the same slot. Enforcing any of them requires a durable record, written before the signature leaves the process.
- ethlambda: there is no such record and no store to hold one. This is a deliberate scope decision for the client's first phase, not an oversight.
- What partially covers it: two in-memory guards, one per message kind.
crate::attestation_guard::AttestationGuardrefuses to sign a second conflicting attestation for a validator within one run, using EIP-3076's minimal rule (the target must advance, the source must not regress).crate::proposal_guard::ProposalGuarddoes the same for blocks, with the minimal rule for those (the slot must strictly advance). Between them they close the shapes reachable through ordinary operation: a backward wall-clock step, or a duty schedule replaced mid-epoch. Both are held in memory only, so a restart empties them and neither knows anything about any other process. - Why a proposer slashing is the worse of the two: a double vote needs a second validator's attestation to be caught. Two signed block headers for one slot are the whole of the evidence on their own.
- What is therefore not covered: restarting into a state where the chain has moved, and running these keys in two places at once. Both can produce a slashable double vote or a double proposal, and nothing in this client will stop them.
- Keymanager consequences:
DELETE /eth/v1/keystoresmust return an EIP-3076 interchange. This client returns a well-formed but empty one whosegenesis_validators_rootis deliberately all-zero, so a conformant importer rejects it outright rather than trusting an empty history that was never recorded.POSTaccepts theslashing_protectionfield and ignores it, with a server-side warning. A key migrated in or out through this API does not carry its history. - Operationally: do not run these keys in any other client while this one runs, and treat a restart as an event that needs the same care a manual key move would. The client warns about this at startup on every run.
The endpoint's enr, p2p_addresses and discovery_addresses are empty
rather than populated, which is not spec-valid.
- ethlambda:
get_identity(crates/net/rpc/src/beacon/node.rs) reportspeer_idand a placeholdermetadatablock and nothing else. The ENR is built for discv5 and owned by the P2P actor;BuiltSwarmhandsrun_nodeonly alocal_peer_id, so serving the record means widening theethlambda-p2psurface and threading it through startup. - Beacon API:
enris the node's base64 ENR and the two lists are its libp2p multiaddrs. - Consequence: a consumer reading
enrto dial this node gets an empty string rather than a record, so peer discovery through this endpoint does not work. Everything that readspeer_idis unaffected. Out of scope for the change that added the Beacon API surface; a follow-up exposes the record.
POST /eth/v1/validator/duties/sync/{epoch} answers an epoch in the head
state's own sync committee period or the next one, and refuses an earlier
period with a 400.
- 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).
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.
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.