feat(blockchain): gossip candidate block bodies for proposers to adopt - #604
MegaRedHand wants to merge 10 commits into
Conversation
`SignedBlock.proof` becomes a two-field `BlockProof`:
before: block-proof = aggregate([prop-sig, att0, att1])
after: block-proof = (prop-sig, aggregate([att0, att1]))
before (no atts): aggregate([prop-sig])
after (no atts): (prop-sig, empty-proof)
The proposer signature was wrapped as a singleton Type-1 and merged into
one block Type-2 alongside every attestation, which had two costs: even a
block with zero attestations needed a prover call, and the merge could
only run once the block root was known. Splitting the two:
- lets the attestation aggregate be built without the block root, which is
what makes a gossiped block body proof possible;
- removes all prover work from the empty-attestation case;
- verifies the proposer signature with the hash-based XMSS verifier
directly, so it never enters the lean-multisig prover or verifier.
The signature reuses the existing fixed-size `XmssSignature` already
carried by `SignedAttestation`, and `sign_block_root` already returns one,
so the proposer carries it verbatim; genesis anchors use the existing
`blank_xmss_signature()` placeholder.
This diverges from leanSpec's single-merged-proof wire format, so the
signature and SSZ fixtures no longer apply.
Ported from #467.
96eb4d0 to
7c3f376
Compare
18e53b0 to
abce001
Compare
7c3f376 to
b15af5a
Compare
Local devnet: 3 ethlambda nodes, 1 aggregator, 20 slotsSame setup as #603's run: The topic works end to end, and the run turned up a timing problem worth reading before the diff. Where it ended up
26 of 27 proposals adopted a candidate. The one empty block is slot 1, where no votes exist yet. The problem the run exposedThe merge that produces a candidate takes ~2.4 s, and it cannot start before the head-update promote at t+3200 ms — that promote is the first moment the slot's own votes are in Measured, from a non-aggregator's log:
The first cut of this PR cleared the candidate buffer at the head-update tick, which raced that arrival and produced 11 empty blocks out of 27 — every third slot on the aggregator, whose own candidate landed on the wrong side of its own clear. Two changes fixed it:
Empty blocks went 11 → 7 → 1 across the three runs. The grace period fired once in the final run, so the buffer is doing the work. The cost that remainsBecause candidates are a slot late by construction, blocks carry votes one slot older than they could. Finality reflects that:
It finalizes, and it finalizes in bigger, lumpier steps. On a 3-validator chain with one committee every late vote shows up directly in the justification chain, so read the direction rather than the number, but the direction is real: this trades finality latency for taking the merge off the proposer's critical path. Worth deciding before this leaves draft:
How it was runDOCKER_TAG=pr604 make docker-build
.claude/skills/devnet-runner/scripts/run-devnet-with-timeout.sh 140Genesis keys from |
The costly part of proposing was never picking attestations, it was merging
their proofs into the single aggregate a block body carries. With the
proposer signature out of that aggregate, the merge no longer involves the
block root — so it does not need the proposer, and does not need the slot.
Aggregators now do it instead. During the head-update interval the
aggregation worker packs a candidate body for the next slot out of the pool
as it stands, merges its attestation Type-1s into one Type-2, and the actor
gossips the pair as a `BlockBodyProof` on a new topic:
struct BlockBodyProof { block_body: BlockBody, proof: MultiMessageAggregate }
/leanconsensus/{fork_digest}/block_body_proof/ssz_snappy
The proposer packs nothing. It keeps a bounded buffer of the candidates that
arrive — its own worker's included — and at the slot boundary adopts the
most valuable one, or signs an empty block. What is left of proposing is a
state transition, one aggregate verification and one signature, so the
proposal moves back to the interval-0 tick the protocol puts it in; the
interval-4 prebuild existed only to give the merge headroom.
The proposer keeps the last word on what it signs:
- a candidate voting for a block this node cannot place on the chain it is
extending is dropped. The state transition does not check those roots, so
a body packed against another node's view would otherwise be carried
verbatim: valid, and worthless;
- a candidate whose attestations its own state transition rejects is
dropped, and the state root is computed from that transition rather than
trusted;
- a gossiped candidate's aggregate is verified before signing, not after.
XMSS keys are one-time, so a proposer gets one signature per slot and
cannot try a candidate, fail the import, and try another;
- a candidate is adopted only if it justifies more, finalizes more, or adds
voters the state does not already hold. Otherwise the empty body wins,
which keeps stale candidates out of blocks rather than merely valid.
An empty block is a real option, not a failure: with the proposer signature
outside the proof, an attestation-less block carries no aggregate and needs
no prover call at all.
Two details a devnet run dictated. The candidate buffer is never cleared on
a tick and nothing is aged out of it: the merge that produces a candidate
takes seconds, so a candidate routinely lands a slot after the one it was
packed for, and clearing at an interval boundary races the very batch it
makes room for. A stale candidate cannot win anyway — it adds no voters the
state lacks, so it scores below an empty body. And when the buffer is empty
at the boundary the proposer waits PROPOSAL_CANDIDATE_GRACE before settling
for an empty block, since that batch is usually still in flight.
`lean_block_building_time_seconds` now covers assembly only. The merge it
used to include is `lean_block_body_proof_building_time_seconds` on
whichever node built the candidate, and `lean_block_body_source_total`
reports how often adoption actually happens.
b15af5a to
d8714f3
Compare
…-proof-gossip This branch taught the worker to build candidate body proofs while the branch it merges reworked what the worker does with an aggregate, so every interesting conflict is in `aggregation.rs`: - the worker now stores each aggregate itself and announces it by participant set, so `EmittedCoverage` is gone and `run_aggregate_job` writes through its own `Store` handle before sending; - `PauseReason` replaces the single pause flag, and the block-build pause is taken where assembly now lives, at interval 0; - interval offsets follow the configured slot duration, so `HEAD_UPDATE_OFFSET_MS` becomes `head_update_offset_ms(config)`, and the worker takes its slot from the store clock with only the position inside that slot from the wall clock. Interval 4 keeps this branch's candidate-body publication: the prebuild it replaced is what the other branch was still pausing the worker around. Outside the conflicts, two things the merged config type forces: `wall_clock_slot` reads the slot duration from `ChainConfig` instead of the deleted `MILLISECONDS_PER_SLOT`, and `body_proof.rs`'s tests build a `StateConfig`. In CLAUDE.md, the summary paragraph added here is dropped: it called the worker a `spawn_blocking` task, which the worker section the other branch added contradicts.
count_new_voters scored score_entry() directly with no pre-filter, unlike the other two callers of score_entry, which both gate on entry_passes_filters first. Once a target justifies, the state transition drops its justifications entry, so score_entry sees no prior voters and credits the candidate's entire coverage as new. A stale candidate then outscores the empty-body baseline and wins BodyValue ordering, so the proposer republishes a duplicate body instead of falling back to empty. Extract the already-justified check into ProjectedState::target_already_justified, shared by entry_passes_filters and count_new_voters, so the two predicates cannot drift apart again.
Selection valued an entry only by the justification voters it added for its target. That misses half of what an attestation carries: its head vote moves LMD-GHOST whether or not its target still needs voters. Two entries bringing the same justification voters were treated as interchangeable even when one moved far more validators' latest head, and an entry whose target was already fully covered was dropped outright despite carrying the freshest head votes anyone had. `ProjectedState` now optionally holds the per-validator latest votes fork choice weighs, seeded from `Store::extract_latest_known_attestations` and advanced as entries are selected so a validator is not credited twice across rounds. `score_entry` reports the new head voters alongside the new justification voters, and `EntryScore::ordering_key` places them immediately after `new_voters` in both tier arms, so head votes break a tie on justification value and never outrank it. An entry that adds only head votes is now kept, at `Build` tier, rather than returned as `None`. It stays at `Build` regardless of the prior vote count, since an entry adding no justification voter cannot push its target past the threshold. The head-vote map is `Option`, not a possibly-empty map. The aggregation worker shares this scorer to pick which group to prove next and leaves it `None`: with no recorded vote every validator in an entry's coverage reads as newly covered, so an empty map would score every entry as maximally valuable and silently disable the worker's zero-value skip. `Store::should_replace_vote` moves to `AttestationData::supersedes` so the scorer applies the same latest-message rule fork choice does rather than a second copy of it. It lands in `ethlambda-types` rather than beside fork choice because the vote map is maintained in the storage layer, which does not depend on the fork choice crate. `build_block` and `select_attestations` take a `ProposalInputs` struct instead of growing another loose parameter each.
The candidate ring was never cleared. `MAX_BODY_PROOF_CANDIDATES` is 8 and devnet-5 runs 8 aggregators, so one slot's batch exactly fills it and it then stays full forever. Two costs followed. The proposer re-scored dead entries every slot, and `PROPOSAL_CANDIDATE_GRACE` never fired even once, because it waits only on an empty buffer — measured at 0 of 100 proposals on devnet-5, while 11% of candidates land within its 400ms window. A block import is exactly when a candidate can become worthless: it folds the block's attestations into the state and records them as latest votes, so a candidate carrying that same body now adds nothing. `prune_scoreless` drops every candidate that adds neither a justification voter nor a head vote, run from `process_block` — the one point both the gossip cascade and the proposer's own `process_and_publish_block` pass through. It is skipped while syncing, since a node that is behind proposes nothing and the scan has no business on the backfill path. Age is deliberately not the criterion. The merge that produces a candidate takes seconds, so candidates routinely arrive a slot late and a slot-old one may still hold the newest votes anyone has. Value is what decides. `BodyValue` gains a `new_head_voters` term, directly after `new_voters` and matching `EntryScore::ordering_key`, so it breaks a tie on justification value without outranking it. Without this the prune would be pointless: a candidate kept for its fresh head votes would still score `new_voters = 0` in `choose_body`, tie the empty body on checkpoints, lose on `fewer_attestations` and be discarded anyway. `count_new_voters` becomes `count_body_voters`, reporting both axes. An already-justified target still contributes no justification voters — the state transition drops its `justifications` entry, so scoring it would miscount the whole bitfield as new — but its head votes are counted, which is the point: a body whose targets have all settled can still be the freshest fork-choice weight on the network. Corrects two doc comments this invalidates: the buffer field claimed it was "Cleared once a proposal has been assembled", which nothing did, and `iter`'s doc argued staleness was harmless because a stale candidate could not win, which is now enforced by pruning rather than left to scoring.
…r head votes Selection dropped every entry whose target was already justified, mirroring `is_valid_vote`. But the two disagree about what that verdict means. The state transition SKIPS such a vote (`is_valid_vote` returns `Ok(false)` and `process_attestations` does `continue`) without rejecting the block, while `insert_signed_block` records every attestation a block carries as a fork-choice vote regardless of that verdict. So the vote is worthless for justification and still moves LMD-GHOST. That makes it a question of value, not validity, and it is now answered by scoring: `entry_passes_filters` admits the entry, and `score_entry` zeroes its justification axis while keeping its head-vote value, so it can only ever win at `Build` tier. Zeroing that axis is what keeps the earlier fix intact: the transition drops a justified target's `justifications` entry, so `current_votes` holds no prior voters for it and a naive score would credit the entire aggregation bitfield as new. Why this matters: on a chain whose `justified - finalized` sits at 6, the justifiable rungs are 3 slots apart (above delta 5 only squares and pronics qualify), so three consecutive slots of validators all vote for the same rung. Once it is justified, every pooled entry hit this filter, `select_attestations` returned an empty list on round 0, and every aggregator built no candidate body at all. Measured on devnet-5: 48% of slots had zero candidates built fleet-wide, and ~50% of blocks were empty, in a clean 3-on/3-off cycle. The aggregation worker now seeds head votes into its projection too. It shares this scorer to choose which group to prove, and without the seed it would score every settled-target group at zero on both axes and prove none of them, leaving the pool empty on exactly the slots this is meant to cover. Adds an STF test pinning the property the packing side depends on: a block carrying a vote for an already-justified target applies cleanly, moves no justification, and opens no tally. If the transition ever started erroring there instead, every proposer packing a settled target would build blocks the network rejects. `snapshot_skips_group_whose_target_is_already_justified` is renamed to `..._is_at_or_behind_finalized`, which is what it actually pins: its target sits below the finalized slot, so `target_not_justifiable` rejects it, and that is still correct. The justified-but-above-finalized case it appeared to cover now has its own test asserting the opposite.
…s we have seen Head-vote scoring measured the wrong thing, and measured it as exactly zero every time. The baseline was `extract_latest_known_attestations`, the map of every vote this node has seen. But that map and the aggregated-payload pool advance in lockstep from the same data, at both stages: `insert_new_aggregated_payload` writes `new_votes` and `new_payloads` in one call, and `promote_new_aggregated_payloads` then drains `new_votes` into `known_votes` and `new_payloads` into `known_payloads`, also in one call. A candidate body is built out of that pool, so it can never carry a vote that supersedes the map it is scored against. `supersedes` is irreflexive, so the answer was always zero. Measured on devnet-5 before this change: `new_head_voters=0` on 54 of 54 adopted candidates, including ones carrying 864 new justification voters. The axis was dead, so the tie-breaker never broke a tie, `score_entry` never kept a head-only entry, and relaxing `entry_passes_filters` to admit already-justified targets admitted entries that scored zero and were dropped one step later. "Have I seen this vote?" is the right question for fork choice and the wrong one for deciding what to PACK. The question that matters there is whether the CHAIN already carries the vote. `ForkChoiceState` gains `on_chain_votes` to answer it, written only by `record_known_attestation_votes`, which is reached only from `insert_signed_block`. Nothing on a gossip, pool or attestation-processing path touches it, which is the entire property that makes it a usable baseline. It is bounded by the validator set (one entry per validator, replaced in place) and needs no pruning. `update_head` and the fork-choice API keep the seen-votes map: fork choice must weigh every vote it knows, not only the ones a block happened to carry. Also fixes two defects the review surfaced in the aggregation worker, both of which this change would otherwise have amplified: - The worker credited head voters again on every selection round, because `pick_best_candidate` discarded them and only `advance` was called. Harmless while the axis was dead; now that it decides ordering, it made later candidates over-score. `pick_best_candidate` carries the voters out and the round loop calls `advance_head_votes`. - The comment claiming the worker leaves `head_votes` at `None` has been false since the projection was seeded, and told a reader the zero-new-voters skip still meant "no justification voters" when it now means "nothing on either axis". Tests pin the property rather than the implementation. At the storage layer, `aggregated_payloads_move_known_votes_but_never_on_chain_votes` fails if the new map ever starts tracking the pool, and `a_pooled_vote_newer_than_the_chain_supersedes_the_on_chain_baseline` asserts both directions: new against the chain, NOT new against the seen-votes map, which is the bug itself written down. At the call site, `snapshot_scores_head_votes_against_the_chain_not_against_seen_votes` promotes a payload so the seen-votes map holds the very vote under test, then requires a job to still be selected; swapping that call site back makes it fail. The accessor-level tests alone would not have caught a reverted call site.
…ated Follow-up from review of the previous commit. No behaviour change except the added test. - `new_head_voters`' doc comment had been concatenated onto `target_already_justified` by an earlier conflict resolution, leaving `new_head_voters` undocumented and attributing head-vote reasoning to a justification predicate. Split back apart, and the surviving text now says the count is measured against what the chain carries. - `supersedes` justified its total-order tiebreak by the vote map having several writers. Still true of the seen-votes map, not of the on-chain one, which has exactly one; it relies on the same total order for a different reason, namely independence from import interleaving. - `reaggregate` skips attestations whose target is at or behind the justified checkpoint, and said it does so because such votes "carry no fork-choice value". Selection now packs exactly those votes for their fork-choice value, so that reason is the opposite of what the code elsewhere relies on. The skip is correct and stays: the vote is already on chain in the block being imported, so splitting it back into the pool would let it be repacked indefinitely, paying a SNARK per round. Only the stated reason changes. Adds `select_skips_a_group_whose_vote_the_chain_already_carries`, covering the suppression direction. Every other test in this area asserts that a group IS selected, and with an empty on-chain baseline everything scores its full coverage, so "always selects" and "correctly selects" were indistinguishable. Note this test passes under either baseline, since a block import writes both maps; the guard against a reverted call site is `snapshot_scores_head_votes_against_the_chain_not_against_seen_votes`.
…voters Head weight had no tier of its own. An entry could bring 2/3 of the validator set's latest head votes onto a root and still be scored `Build`, indistinguishable from one adding a single marginal vote below every threshold. `Justify` exists because crossing 2/3 on a target is categorically different from approaching it; the same is true on the head axis, and nothing expressed it. `TargetAdvance` sits between `Justify` and `Build`: below `Justify` because finality beats head weight, above `Build` because crossing a threshold beats approaching one. It is claimed only when the entry itself moves head votes AND the projected post-state puts 2/3 of validators on this entry's head root. The "itself moves" half matters: without it an entry that shifts nobody could claim a threshold that was already met, which is exactly the miscount the justification axis had, where a settled target's whole coverage read as new. `head_crosses_2_3` counts over the post-state, the same way `crosses_2_3` does. Ordering, per tier: Finalize/Justify newer_target > newer_att > head_votes > voters > root TargetAdvance newer_att > head_votes > root Build voters > head_votes > newer_target > newer_att > root Two changes from before. In the justify arm head votes now outrank justification voters: past the 2/3 target threshold the marginal justification voter buys little, while the head weight riding along with it still moves fork choice. `TargetAdvance` ranks on recency first and does not consult `newer_target` at all, since the target is by definition not moving at that tier, nor `more_new_voters`, since an entry there was chosen for head weight. The two `OrderingKey` slots it leaves unranked take a constant, which cannot discriminate. `Build` is unchanged. `advance` keys on `tier <= Tier::Justify`, so inserting a variant below `Justify` leaves justification bookkeeping untouched. Two existing tests asserted `Tier::Build` for entries that now legitimately reach `TargetAdvance`: both move a supermajority of heads. The invariant each was written to guard is that an entry adding no justification voter must not be tiered as if it justified, which still holds and is now asserted directly (`tier > Tier::Justify`) rather than implied by a `Build` literal. Neither was weakened.
What
The costly part of proposing is not picking attestations, it is merging their proofs into the single aggregate a block body carries. Two commits move that merge off the proposer entirely.
1. Proposer signature outside the block proof (a port of #467):
2. Candidate block bodies, gossiped. During the head-update interval the aggregation worker packs a body for the next slot, merges its attestation Type-1s, and the actor gossips the pair on a new topic:
The proposer packs nothing. It buffers the candidates that arrive (its own worker's included) and, at the slot boundary, adopts the most valuable one that survives its checks — or signs an empty block.
Why
With the proposer signature out of the aggregate, the merge no longer involves the block root. So it does not need the proposer, and does not need the slot: whoever holds the proofs can do it, an interval ahead. What is left of proposing is a state transition, one aggregate verification and one signature.
That also makes the empty case free. An attestation-less block used to still need a prover call to wrap the proposer signature as a singleton Type-1; now it carries no aggregate at all, which is what makes "propose empty" a real option rather than a failure.
Timing
Proposal moves back to the interval-0 tick the protocol puts it in: the interval-4 prebuild existed only to give the merge headroom, and the merge is no longer on that path. Publication lands ~200ms into the slot rather than exactly at
t+0.What the measurement below shows is that the merge does not fit in the interval it starts in, so in steady state a proposer adopts the previous slot's candidate. See below.
Safety: the proposer keeps the last word
attestation_data_matches_chain). The state transition does not check those roots, so a body packed against another node's view would otherwise be carried verbatim: valid, and worthless.state_rootis computed from the transition rather than trusted.The screen deliberately stops there. A body is all-or-nothing — its proof binds exactly those attestations — so dropping one for a merely stale entry would cost the whole block.
Changes
types/block.rsBlockProof { proposer_signature, attestation_proof }; newBlockBodyProofblockchain/body_proof.rsbuild_body_proof(worker side),BodyProofBuffer,choose_body+ its scoringblockchain/block_builder.rsselect_and_compactandseal_blocksplit out ofbuild_blockso the worker and the proposer share them;extended_chain_viewextractedblockchain/store.rsverify_block_signaturesverifies the raw proposer sig then the attestation Type-2; newproduce_block_from_candidatesblockchain/lib.rspending_body_proofs+body_proof_candidatesbuffersnet/api,net/p2ppublish_block_body_proof/new_block_body_proof, topic, subscription, decode pathslots_and_intervals.md,architecture.md,spec_deviations.md,metrics.md,data_storage.md,benchmarking.md,CLAUDE.mdMetrics
lean_block_building_time_secondsnow covers assembly only. New:lean_block_body_proof_building_time_seconds(the merge, on whichever node built the candidate),lean_block_body_proof_candidates,lean_block_body_source_total{source=body_proof,empty},lean_block_body_proof_rejected_total{reason=off_chain_vote,state_transition,verification},lean_gossip_block_body_proof_size_bytes.The candidate is a slot late, measured
The merge takes ~2.4 s and cannot start before the head-update promote at t+3200 ms, which is the first moment the slot's own votes are in
known_payloads. On a 3-node devnet a candidate packed during slot N therefore reached peers at a median of t+1210 ms of slot N+1 — past that proposer's assembly. It is the next proposer that adopts it, so blocks carry votes one slot older than they could, and finality trails the head by ~8 slots where #603 alone trails by 3. Numbers in the comment below.Two things follow from that, both in the diff:
PROPOSAL_CANDIDATE_GRACE(400 ms) before settling for empty.Empty blocks went 11 → 7 → 1 across three runs.
Open questions
new_payloadsas well — a different vote set than the block builder has ever used.BlockBodyProofcarries no slot, so a rebuilt-but-identical candidate hashes to the same gossipsub message id and the republish is dropped. Harmless as it stands — peers already hold that candidate — but a candidate cannot be refreshed without changing its content.get_proposal_head(it must not mutate the store). If the head moves between the head-update interval and the proposer's assembly, every candidate fails the chain screen and the block goes out empty.Test status
make fmt,make lint(clippy-D warnings): cleancargo test --workspace --lib: 257 passed, 0 failed (6 newbody_prooftests covering adoption, the off-chain screen, the empty fallback, the ranking and the candidate ring)🤖 Draft — opened for review of the shape.