Skip to content

fix(lstar): keep pending proofs that an aggregation round skips - #1208

Open
pucedoteth wants to merge 1 commit into
leanEthereum:mainfrom
pucedoteth:fix/keep-skipped-pending-proofs
Open

pucedoteth wants to merge 1 commit into
leanEthereum:mainfrom
pucedoteth:fix/keep-skipped-pending-proofs

Conversation

@pucedoteth

Copy link
Copy Markdown

🗒️ Description

aggregate() rebuilds the pending pool from the proofs it built this round:

new_aggregated_payloads = {
    signed_attestation.data: {signed_attestation.proof}
    for signed_attestation in new_aggregates
}

A vote the loop continues over builds no proof, so it is absent from the rebuilt map and the proof it already had is dropped.

The skip is the one just above:

# Aggregation needs fresh material: one raw signature, or two child proofs to merge.
# A lone child proof is already valid, so there is nothing to do.
if not raw_signatures and len(child_proofs) < 2:
    continue

"A lone child proof is already valid" is true, but the pool rebuild then throws it away. That proof is in latest_new_aggregated_payloads — pending, not yet promoted — and the round that produced it already dropped the raw signatures it absorbed. Nothing else holds those votes, so they are gone for good.

How a node hits this

A gossiped aggregate lands in the pending pool via on_aggregated_attestation. If it arrives after the acceptance tick that last drained the pool and before the aggregator's interval-2 run, it sits there alone with no raw signatures beside it — exactly the skip condition. The next aggregation round discards it.

The raw-signature bookkeeping below the loop does not save it; that filter keeps attestation_signatures, a different pool.

This is also the assumption #747 rests on when it proposes widening the skip:

Bookkeeping below the loop already keeps gossip sigs that were not consumed by an aggregation

That holds for raw signatures, not for pending payloads. The 0 raw + 1 child case the skip already covers loses its proof today.

Reproduction

Against main, with one gossiped aggregate pending and no raw signatures for it:

before aggregate(): pending has 1 vote(s), proof covers validators [0, 1]
after  aggregate(): pending has 0 vote(s), returned 0 aggregate(s)
known pool still has 0 vote(s)
raw signatures left: 0
BUG REPRODUCED: the proof for validators [0, 1] is gone from every pool

The change

Carry skipped votes' proofs forward:

new_aggregated_payloads = {
    attestation_data: proofs
    for attestation_data, proofs in store.latest_new_aggregated_payloads.items()
    if attestation_data not in aggregated_payloads
} | aggregated_payloads

Votes that did aggregate still keep only this round's proof, which subsumes the children it was built from. Proofs the greedy set cover left unselected are redundant by construction, so they are still dropped.

The raw-signature filter is deliberately keyed off aggregated_payloads — the proofs built this round — so signature retention behaves exactly as before.

Tests

tests/consensus/lstar/fork_choice/test_pending_pool_retention.py gossips one aggregate into the pending pool after the acceptance tick, runs the aggregation interval, and asserts the proof is still there and still reaches a block.

Reverting only aggregation.py and keeping the test:

E   AssertionError: Step 3: new_pool_proof_participants[1] = set(), expected {0, 1}
FAILED tests/consensus/lstar/fork_choice/test_pending_pool_retention.py::test_lone_pending_proof_survives_aggregation[fork_Lstar]

The nearest existing vector, test_fallback_pool_set_cover.py, does not catch this: it has two proofs in the pools, so it merges rather than skipping.

Full suites with the fix:

495 passed, 35 deselected      # fill --fork Lstar tests/consensus
2102 passed                    # pytest tests/

ruff check and ruff format --check are clean on both files.

🔗 Related Issues or PRs

Related to #747 / #748, which propose widening this same skip. They are independent of this change, but the wider skip inherits the same drop unless this lands — worth a look together.

✅ Checklist

  • Ran local quality checks to avoid unnecessary CI fails
  • Considered adding appropriate tests for the changes.
  • Considered updating the online docs in the ./docs/ directory.

AI disclosure

Written with AI assistance (Claude Code): the investigation, the fix, the test, and this description.

🤖 Generated with Claude Code

aggregate() rebuilds latest_new_aggregated_payloads from the proofs it
built this round. A vote the loop skips builds no proof, so it is absent
from the rebuilt pool and its existing proof is dropped.

The loop skips a vote when it has no raw signatures and fewer than two
child proofs, on the grounds that a lone child proof is already valid.
That proof is pending, not yet promoted, and the round that produced it
already dropped the raw signatures it absorbed, so nothing else holds
those votes. Dropping it loses them.

A gossiped aggregate that lands between the acceptance tick and the
aggregation interval hits this: it sits alone in the pending pool with no
raw signatures beside it, and the next round discards it.

Carry skipped votes' proofs forward. Votes that did aggregate still keep
only this round's proof, which subsumes the children it was built from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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