Skip to content

feat(blockchain): gossip candidate block bodies for proposers to adopt - #604

Draft
MegaRedHand wants to merge 2 commits into
feat/always-on-aggregation-workerfrom
feat/block-body-proof-gossip
Draft

feat(blockchain): gossip candidate block bodies for proposers to adopt#604
MegaRedHand wants to merge 2 commits into
feat/always-on-aggregation-workerfrom
feat/block-body-proof-gossip

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #603 (feat/always-on-aggregation-worker). Review that one first; this PR's base is that branch, so the diff here is only the two commits on top.

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):

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)

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:

struct BlockBodyProof { block_body: BlockBody, proof: MultiMessageAggregate }
/leanconsensus/{fork_digest}/block_body_proof/ssz_snappy

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

slot N,   interval 4  ┌ store promotes the round's votes
                      ├ worker packs + merges the candidate body   ~2.4s
                      └ actor gossips it the moment it is done
slot N+1, interval 0  ┌ store accepts attestations
                      ├ proposer scores the candidates it holds, verifies one, signs
                      └ publishes                                  ~t+200ms
          t+1210ms      the candidate packed during slot N lands here, too late
                        for this proposer — slot N+2 is the one that adopts it

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

  • A candidate is dropped if any of its votes does not sit on the chain the block extends (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.
  • A candidate whose attestations that transition rejects is dropped, and state_root is computed from the transition rather than trusted.
  • A gossiped candidate's aggregate is verified before signing. XMSS keys are one-time, so a proposer gets one signature per slot and cannot try a candidate, fail the import, and try another. Our own worker's candidates skip that check.
  • A candidate is adopted only if it justifies more, finalizes more, or adds voters the state does not already hold, so stale candidates lose to the empty body rather than bloating the block.

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

Area Change
types/block.rs BlockProof { proposer_signature, attestation_proof }; new BlockBodyProof
blockchain/body_proof.rs New: build_body_proof (worker side), BodyProofBuffer, choose_body + its scoring
blockchain/block_builder.rs select_and_compact and seal_block split out of build_block so the worker and the proposer share them; extended_chain_view extracted
blockchain/store.rs verify_block_signatures verifies the raw proposer sig then the attestation Type-2; new produce_block_from_candidates
blockchain/lib.rs Proposal moves to the interval-0 arm; pending_body_proofs + body_proof_candidates buffers
net/api, net/p2p publish_block_body_proof / new_block_body_proof, topic, subscription, decode path
docs slots_and_intervals.md, architecture.md, spec_deviations.md, metrics.md, data_storage.md, benchmarking.md, CLAUDE.md

Metrics

lean_block_building_time_seconds now 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:

  • the candidate buffer is never cleared on a tick and nothing is aged out of it. Clearing at the head-update boundary raced the batch it made room for and cost 11 empty blocks out of 27; a stale candidate cannot win anyway, since it adds no voters the state lacks;
  • when the buffer is empty at the boundary the proposer waits PROPOSAL_CANDIDATE_GRACE (400 ms) before settling for empty.

Empty blocks went 11 → 7 → 1 across three runs.

Open questions

  • Is a slot of vote latency the right price? The alternative is packing the candidate before the promote, from new_payloads as well — a different vote set than the block builder has ever used.
  • Wire divergence. Both commits diverge from leanSpec deliberately, so signature/SSZ fixtures no longer apply and a node running this cannot interop with one that does not. Needs a matching leanSpec change before it can go on a mixed devnet.
  • BlockBodyProof carries 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.
  • Where the candidate is built. The worker uses the fork-choice head as the parent, not 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.
  • No aggregator, no bodies. Body-proof production is gated on the aggregator role, so on a chain where no aggregator gossips them every block is empty.

Test status

  • make fmt, make lint (clippy -D warnings): clean
  • cargo test --workspace --lib: 257 passed, 0 failed (6 new body_proof tests covering adoption, the off-chain screen, the empty fallback, the ranking and the candidate ring)
  • Spec tests not run: this diverges from the fixtures by design
  • Local 3-node devnet, 1 aggregator: 26 of 27 proposals adopted a candidate, no errors, finality trailing head by 8 slots (results)

🤖 Draft — opened for review of the shape.

`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.
@MegaRedHand
MegaRedHand force-pushed the feat/block-body-proof-gossip branch from 96eb4d0 to 7c3f376 Compare September 2, 2026 19:43
@MegaRedHand
MegaRedHand force-pushed the feat/always-on-aggregation-worker branch from 18e53b0 to abce001 Compare September 2, 2026 19:43
@MegaRedHand
MegaRedHand force-pushed the feat/block-body-proof-gossip branch from 7c3f376 to b15af5a Compare September 2, 2026 20:12
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Local devnet: 3 ethlambda nodes, 1 aggregator, 20 slots

Same setup as #603's run: attestation_committee_count = 1, one aggregator, so only that node builds candidates and the other two can only adopt from gossip.

The topic works end to end, and the run turned up a timing problem worth reading before the diff.

Where it ended up

ethlambda_0 (aggregator) ethlambda_1 ethlambda_2
blocks imported 18 18 18
body proofs built / gossiped 22 / 22 0 0
body proofs received 0 20 20
body proofs adopted 9 8 9
empty-block fallbacks 0 1 0
ERROR / panic 0 0 0

26 of 27 proposals adopted a candidate. The one empty block is slot 1, where no votes exist yet.

The problem the run exposed

The 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 known_payloads. So a candidate packed during slot N reaches peers at a median of t+1210 ms of slot N+1, long after that slot's proposer assembled at t+0.

Measured, from a non-aggregator's log:

event n median min max
body proof received 20 t+1210ms t+272ms t+3908ms
block published 18 t+199ms t+132ms t+714ms

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:

  • the buffer is never cleared on a tick and nothing is aged out of it. A candidate that lands a slot late is still the newest anyone has, and while the block in between was empty its votes have not been included yet. A genuinely stale candidate cannot win regardless: it adds no voters the state lacks, so it scores below an empty body;
  • when the buffer is empty at the boundary the proposer waits PROPOSAL_CANDIDATE_GRACE (400 ms) before settling for empty.

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 remains

Because candidates are a slot late by construction, blocks carry votes one slot older than they could. Finality reflects that:

finalized head gap
#603 alone 15 18 3 slots
this PR 19 27 8 slots

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:

  1. Is a slot of vote latency an acceptable price? If not, the candidate has to be packed before the head-update promote, which means packing from new_payloads as well — a different set of votes than the block builder has ever used.
  2. BlockBodyProof carries no slot, so a rebuilt-but-identical candidate hashes to the same gossipsub message id and the republish is dropped (Not publishing a message that has already been published, 3 times in the final run). Harmless as it stands, since peers already hold that candidate, but it means a candidate cannot be refreshed without changing its content.
How it was run
DOCKER_TAG=pr604 make docker-build
.claude/skills/devnet-runner/scripts/run-devnet-with-timeout.sh 140

Genesis keys from blockblaz/hash-sig-cli:latest (52-byte pubkeys); the lean-quickstart default emits 32-byte leanVM-main keys, which no main-based image can parse.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant