Skip to content

refactor(forkchoice): extract AttestationPool from Store#685

Closed
tcoratger wants to merge 1 commit intoleanEthereum:mainfrom
tcoratger:refactor/attestation-pool
Closed

refactor(forkchoice): extract AttestationPool from Store#685
tcoratger wants to merge 1 commit intoleanEthereum:mainfrom
tcoratger:refactor/attestation-pool

Conversation

@tcoratger
Copy link
Copy Markdown
Collaborator

Summary

Three parallel dict fields lived directly on Store:

attestation_signatures: dict[AttestationData, set[AttestationSignatureEntry]]
latest_new_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]]
latest_known_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]]

Each was deep-copied with {k: set(v) for k, v in d.items()} in five different Store methods. This PR collapses all three into a single immutable AttestationPool value object that owns the gossip → new → known lifecycle, and rewrites those five methods to delegate to pool methods.

Net diff: store.py shrinks by ~80 lines; one new module (attestation_pool.py); one new test file with 17 cases covering each pool method, including the edge cases flagged as test gaps during research (lone-child early-exit drop, gossip retention by data key).

Pool API

Method Replaces Why a method
prune_finalized(slot) prune_stale_attestation_data body Encapsulates atomic three-stage prune at finalization advances
add_signature(data, entry) on_gossip_attestation deep-copy block Aggregator inbox arrival
add_new_proof(data, proof) on_gossip_aggregated_attestation deep-copy block Direct-to-new gossip arrival
add_block_proofs(by_data) on_block body-attestation merge Block-included proofs bypass the new stage
migrate_new_to_known() accept_new_attestations merge step Slot-rollover stage promotion
replace_after_aggregation(new) aggregate end-of-method bookkeeping Wholesale new-stage swap + consumed-signature drop

Read paths stay direct: aggregate, compute_block_weights, update_head, update_safe_target, and produce_block_with_signatures access pool.signatures, pool.new_proofs, pool.known_proofs as plain attributes — every read site already knows which stage it cares about and the docstrings explain why (see e.g. the safe-target rationale).

Field renames on the pool

  • attestation_signaturessignatures
  • latest_new_aggregated_payloadsnew_proofs
  • latest_known_aggregated_payloadsknown_proofs

The AttestationPool prefix already conveys "attestation"; the latest_ prefix was misleading (the maps don't store latest-vote-per-validator). No back-compat aliases — every call site is updated in this PR.

Behavior preserved exactly

The design phase ran through both consensus-correctness and Pythonic-API reviews before any code was written. Specifically:

  • Pruning predicate stays strict-greater (target.slot > finalized_slot); test boundaries verified.
  • Aggregation still replaces the new pool wholesale (not a merge): pre-existing entries that did not produce a fresh proof this round still disappear (lone-child early-exit). Gossip retention predicate is keyed on data, not produced-proofs.
  • on_block ordering preserved: persist + advance checkpoints → process body attestations → update_head → prune. Pruning runs on the post-update store as before.
  • AttestationSignatureEntry stays a NamedTuple (must be hashable for sets) and moves to attestation_pool.py since it has no consumer outside the pool.

Tests

  • 17 new unit tests in tests/lean_spec/subspecs/forkchoice/test_attestation_pool.py covering each pool method, including the two edges flagged as gaps in the existing test suite during research:
    • test_replace_after_aggregation_overwrites_new_pool (lone-child entries drop)
    • test_replace_after_aggregation_keeps_unconsumed_signatures (gossip retention)
  • Existing tests/lean_spec/subspecs/forkchoice/ and tests/consensus/devnet/fc/ test suites all still pass.
  • 3178 total unit tests pass.

Test plan

  • uvx tox -e all-checks (ruff, format, ty, codespell, mdformat)
  • uv run pytest tests/lean_spec/subspecs/forkchoice/ tests/lean_spec/subspecs/containers/test_state_aggregation.py tests/lean_spec/subspecs/validator/test_service.py — 150 passed
  • Full uv run pytest tests/lean_spec --no-cov — 3178 passed
  • Reviewer to spot-check replace_after_aggregation semantics against the existing aggregator behavior
  • Reviewer to spot-check that no test pierces the renamed fields with stale references

🤖 Generated with Claude Code

Three parallel dict fields on Store — attestation_signatures,
latest_new_aggregated_payloads, latest_known_aggregated_payloads — collapse
into a single immutable AttestationPool value object that owns the gossip →
new → known lifecycle.

The pool exposes one method per protocol step that previously lived as
deep-copy boilerplate inline on Store:

- prune_finalized(slot) — atomic three-stage prune at finalization advances
- add_signature(data, entry) — gossip arrival into the aggregator inbox
- add_new_proof(data, proof) — gossip arrival of an aggregated proof
- add_block_proofs(by_data) — block-included proofs land directly in known
- migrate_new_to_known() — slot-rollover stage promotion
- replace_after_aggregation(new_proofs) — wholesale new-stage swap, drops
  consumed gossip signatures keyed by data

Five Store methods (prune_stale_attestation_data, on_gossip_attestation,
on_gossip_aggregated_attestation, on_block, accept_new_attestations,
aggregate) shed their {k: set(v) for k, v in d.items()} deep-copy patterns
and call the pool methods instead.

Read paths stay direct: aggregate, compute_block_weights, update_head,
update_safe_target, and produce_block_with_signatures access pool.signatures,
pool.new_proofs, and pool.known_proofs as plain attributes — there's no need
to encapsulate iteration when the call site already knows which stage it cares
about and the docstring explains why.

Field renames on the pool: latest_new_aggregated_payloads → new_proofs,
latest_known_aggregated_payloads → known_proofs, attestation_signatures →
signatures (the AttestationPool prefix already conveys "attestation").

Behavior preserved exactly:

- Pruning predicate is strict-greater (target.slot > finalized_slot)
- Aggregation replaces the new pool wholesale; lone-child early-exit drops
  apply, gossip retention predicate is data-keyed
- on_block keeps its order: persist + advance checkpoints → process body →
  update_head → prune
- accept_new_attestations migrates new → known and clears new in one shot
- AttestationSignatureEntry stays a NamedTuple (must be hashable for sets)

AttestationSignatureEntry moves from store.py to the new attestation_pool.py
since it has no consumer outside the pool.

Net: store.py shrinks by ~80 lines; one new module (attestation_pool.py)
and one new test file (test_attestation_pool.py, 17 cases covering each
method including the lone-child drop and gossip-retention edges flagged
as test gaps during research). All 3178 unit tests pass; all linter
checks pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tcoratger tcoratger closed this Apr 26, 2026
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