fix(devnet5): don't register block-attestation data at import (broke Type-2 merge)#395
fix(devnet5): don't register block-attestation data at import (broke Type-2 merge)#395MegaRedHand wants to merge 4 commits into
Conversation
…laceholders
Block import previously inserted T1::empty(bits) placeholders (participant
bits, empty proof bytes) into the known payload pool. Block building read the
same pool and fed those empty bytes into the Type-2 merge, where lean-multisig
decompression failed ("child proof deserialization failed at index N").
Match the spec's on_block: register each block attestation's data key with an
empty proof set instead of a proof-less placeholder. No empty-byte proof ever
enters the pool, so the merge can no longer be handed one. Block-borne votes
now contribute zero fork-choice weight at import; the weight returns once
reaggregation recovers real Type-1 proofs into the pool (deferred by up to one
slot), matching the spec.
Known follow-up: ethlambda's import-only forkchoice harness expects block votes
in "known" immediately after import (the spec's harness simulates the proposer
build to populate them). 13 forkchoice spec tests now fail on this expectation;
the harness/reaggregation wiring will be addressed separately.
Option A made on_block spec-faithful: it registers each block attestation's data key with an empty proof set, so block-borne votes contribute no fork-choice weight at import. A real node recovers that weight by SNARK-splitting the block's merged Type-2 proof into per-attestation Type-1s (reaggregation); leanSpec's forkchoice harness gets the same effect by simulating the proposer build straight into the known pool. ethlambda's harness only imports blocks (on_block_without_verification) with blank proofs, so it did neither and 13 head/reorg/safe-target fixtures lost their vote source. Reconstruct structurally after import: for each block attestation, insert a Type-1 carrying its aggregation_bits into the known pool (fork choice reads participants, not proof bytes; the harness never feeds these to a Type-2 merge). Recompute the head afterward, since on_block ran its head update before these votes existed. Expose update_head for the harness recompute. All 84 forkchoice tests pass.
🤖 Kimi Code ReviewCritical Consensus Considerations
Security Observations
Code Quality and Correctness
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
I didn’t run Rust tests here because the environment blocks Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have enough context to write a thorough review. PR Review: fix: drop proof-less Type-1 placeholders that broke block-proof mergeOverviewThis PR fixes a crash where The root-cause diagnosis is sound and the fix is logically correct. Issues1.
Bare keys do get pruned by 2. No unit tests for The new 3. Double When 4. Integration tests in #[doc(hidden)]
pub fn update_head(store: &mut Store, log_tree: bool) { ... }5. Removed The deleted comment ( Minor observations
SummaryThe core fix is correct and the spec alignment is well-justified. The most actionable items before merge are:
Automated review by Claude (Anthropic) · sonnet · custom prompt |
Greptile SummaryThis PR fixes a devnet crash where block import seeded the known payload pool with proof-less
Confidence Score: 4/5The production fix is correct and spec-faithful; the test harness reconstruction is a reasonable workaround for blank-proof fixtures. One edge case in the new The fix correctly eliminates empty-byte proofs from the merge path and all 84 fork-choice spectests pass. The one issue worth a follow-up is that bare-key entries registered via
|
| Filename | Overview |
|---|---|
| crates/storage/src/store.rs | Adds register_data (no-proof bare-key insertion) and register_known_aggregated_data_batch; bare entries skip eviction accounting since total_proofs is not incremented, potentially allowing data.len() to exceed AGGREGATED_PAYLOAD_CAP between prune calls. |
| crates/blockchain/src/store.rs | Replaces empty-proof TypeOneMultiSignature placeholder insertion with bare data-key registration in on_block_core, matching the spec's on_block; update_head promoted to pub for test-harness use. |
| crates/blockchain/tests/forkchoice_spectests.rs | Reconstructs per-attestation Type-1s from imported blocks' aggregation_bits and recomputes head after each successful block import, reproducing the proposer-view store that the fixtures encode. |
| crates/common/types/src/block.rs | Removes an outdated doc comment referencing leanSpec PR #717 on MAX_ATTESTATIONS_DATA; no logic change. |
Sequence Diagram
sequenceDiagram
participant Importer
participant on_block_core
participant known_payloads as known_payloads (PayloadBuffer)
participant Reaggregator
participant ForkChoice
Note over Importer,ForkChoice: Before fix (broken)
Importer->>on_block_core: on_block_without_verification(signed_block)
on_block_core->>known_payloads: insert_known_aggregated_payloads_batch([empty-proof TypeOneMultiSig])
on_block_core->>ForkChoice: update_head()
Reaggregator->>ForkChoice: build_block() → merge Type-2
ForkChoice-->>Reaggregator: ❌ child proof deserialization failed (empty bytes)
Note over Importer,ForkChoice: After fix (spec-faithful)
Importer->>on_block_core: on_block_without_verification(signed_block)
on_block_core->>known_payloads: register_known_aggregated_data_batch([bare data keys, no proofs])
on_block_core->>ForkChoice: update_head() — zero block-vote weight until reaggregation
Reaggregator->>known_payloads: push(real Type-1 proofs via gossip / SNARK-split)
known_payloads-->>ForkChoice: proofs available for fork-choice weight
Reaggregator->>ForkChoice: build_block() → merge Type-2
ForkChoice-->>Reaggregator: ✅ valid merged proof
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
crates/storage/src/store.rs:215-228
**Empty entries bypass eviction, data map can grow beyond capacity**
`register_data` adds entries to `data` and `order` without incrementing `total_proofs`. The eviction loop in `push` is gated on `total_proofs > self.capacity` (512), so bare-key entries are invisible to the eviction trigger. If a node imports many blocks but reaggregation is slow or absent, the `data` map can grow well beyond the stated 512-entry bound — up to 8 new entries per slot (per `MAX_ATTESTATIONS_DATA`). The previous code (`TypeOneMultiSignature::empty(bits)`) incremented `total_proofs` by 1 per entry, so it did trigger eviction; this PR removes that side-effect. `prune` bounds growth for finalized entries, but active entries between finalization events accumulate without limit.
Reviews (1): Last reviewed commit: "test(blockchain): deconstruct imported b..." | Re-trigger Greptile
| fn register_data(&mut self, hashed: HashedAttestationData) { | ||
| let (data_root, att_data) = hashed.into_parts(); | ||
| if self.data.contains_key(&data_root) { | ||
| return; | ||
| } | ||
| self.data.insert( | ||
| data_root, | ||
| PayloadEntry { | ||
| data: att_data, | ||
| proofs: Vec::new(), | ||
| }, | ||
| ); | ||
| self.order.push_back(data_root); | ||
| } |
There was a problem hiding this comment.
Empty entries bypass eviction, data map can grow beyond capacity
register_data adds entries to data and order without incrementing total_proofs. The eviction loop in push is gated on total_proofs > self.capacity (512), so bare-key entries are invisible to the eviction trigger. If a node imports many blocks but reaggregation is slow or absent, the data map can grow well beyond the stated 512-entry bound — up to 8 new entries per slot (per MAX_ATTESTATIONS_DATA). The previous code (TypeOneMultiSignature::empty(bits)) incremented total_proofs by 1 per entry, so it did trigger eviction; this PR removes that side-effect. prune bounds growth for finalized entries, but active entries between finalization events accumulate without limit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/src/store.rs
Line: 215-228
Comment:
**Empty entries bypass eviction, data map can grow beyond capacity**
`register_data` adds entries to `data` and `order` without incrementing `total_proofs`. The eviction loop in `push` is gated on `total_proofs > self.capacity` (512), so bare-key entries are invisible to the eviction trigger. If a node imports many blocks but reaggregation is slow or absent, the `data` map can grow well beyond the stated 512-entry bound — up to 8 new entries per slot (per `MAX_ATTESTATIONS_DATA`). The previous code (`TypeOneMultiSignature::empty(bits)`) incremented `total_proofs` by 1 per entry, so it did trigger eviction; this PR removes that side-effect. `prune` bounds growth for finalized entries, but active entries between finalization events accumulate without limit.
How can I resolve this? If you propose a fix, please make it concise.on_block registered each block attestation's data in the known pool with an empty proof set, mirroring leanSpec's on_block. That registration is inert in our weight model: fork-choice weight is derived from proof participant bits, so an empty proof set contributes nothing, and the block builder skips entries with no proofs. Its only side effect was reserving an insertion-order slot, which reaggregation re-establishes anyway. The empty entries did, however, bypass the payload buffer's eviction cap: register_data inserted into the data map without incrementing total_proofs, so a non-finalizing chain could grow known_payloads unbounded. Remove the registration entirely. Block-borne votes were already zero-weight at import; weight still arrives once reaggregation recovers real Type-1 proofs and gossip delivers them. This eliminates the unbounded-growth path by construction and deletes the now-dead register_data helpers.
Summary
Fixes the devnet error
Failed to merge Type-1s into Type-2 ... child proof deserialization failed at index N.Root cause: block import seeded the known payload pool with
TypeOneMultiSignature::empty(bits)placeholders (participant bits, empty proof bytes) so block-borne votes carried fork-choice weight. Block building read the same pool and fed those empty bytes into the Type-2 merge, where lean-multisig decompression failed.Fix: stop registering block-attestation data in the pool at import altogether.
A first pass mirrored leanSpec's
on_block— registering each attestation's data key with an empty proof set instead of a proof-less placeholder. That removed the crash, but the empty entries turned out to be inert in our weight model and introduced an unbounded-growth path (see below), so the final version drops the registration entirely.Why removing it is safe
ethlambda derives fork-choice weight from proof participant bits, not from data-key presence:
extract_latest_attestationsiteratesentry.proofs; an empty proof set yields zero voters.merge_type_1s_into_type_2.So a registered-but-empty entry contributed zero weight and zero merge input. Its only observable effect was reserving an insertion-order slot for same-slot equivocation tie-breaking, which reaggregation re-establishes in block-attestation order anyway.
Block-borne vote weight is still recovered: reaggregation SNARK-splits the block's merged Type-2 into per-attestation Type-1s (
split_type_2_by_message) into the new pool, and gossip delivers them; both migrate to the known pool at the next acceptance tick. This matches the spec's documented one-slot deferral.Bonus: closes an unbounded-growth path
The empty-proof entries bypassed the payload buffer's only capacity limit.
PayloadBufferevicts whentotal_proofs > capacity, but the bare-key insertion never incrementedtotal_proofs, so a non-finalizing chain could growknown_payloadspast its bound. Removing the registration eliminates this by construction (flagged by all four PR reviewers).Changes
crates/blockchain/src/store.rs:on_block_coreno longer registers block-attestation data keys (keeps the per-validator attestation-valid metric).crates/storage/src/store.rs: delete the now-deadPayloadBuffer::register_dataandStore::register_known_aggregated_data_batch.crates/blockchain/tests/forkchoice_spectests.rs: the harness deconstructs each imported block into per-attestation Type-1s (structurally, fromaggregation_bits) into the known pool and recomputes the head — reproducing the proposer-view store the fixtures encode, the role leanSpec's harness fills via its proposer-build simulation. These entries go throughpush, so they are properly accounted and bounded.Notes / follow-ups
newpool (deferred), which a leanSpec reviewer flagged as leaving receiving nodes with zero block-vote weight (Aggregated block proof - devnet5 leanEthereum/leanSpec#717, discussion_r3259006576). Thenew-vs-knownquestion remains open upstream.synced-gated inlib.rs), so block-borne votes during sync rely on gossip re-delivery. Accepted as an OK tradeoff for now; tracked as a follow-up.Test plan
cargo test --workspace --release— full suite passes (forkchoice 84, stf 51, signature 13, storage 38, ssz 118, …; 0 failures)cargo fmt --all -- --checkcleancargo clippy --workspace --release -- -D warningsclean