From 93ac6c840d3ace5a004e743646b31e7142b1a9a0 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 29 May 2026 16:42:24 +0200 Subject: [PATCH 1/5] Gloas: exclude slashed validators from proposing (EIP-8045) Filter slashed validators out of the candidate pool upfront in get_beacon_proposer_indices, before invoking compute_proposer_indices, so the EIP-7917 proposer_lookahead only contains active and unslashed indices. EIP-8045 is scoped to proposer selection only; no other duties are modified. --- specs/gloas/beacon-chain.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 8bd86ed5149..245b2c5d09b 100644 --- a/specs/gloas/beacon-chain.md +++ b/specs/gloas/beacon-chain.md @@ -56,6 +56,7 @@ - [Modified `compute_proposer_indices`](#modified-compute_proposer_indices) - [New `compute_ptc`](#new-compute_ptc) - [Beacon state accessors](#beacon-state-accessors) + - [Modified `get_beacon_proposer_indices`](#modified-get_beacon_proposer_indices) - [Modified `get_next_sync_committee_indices`](#modified-get_next_sync_committee_indices) - [Modified `get_attestation_participation_flag_indices`](#modified-get_attestation_participation_flag_indices) - [New `get_ptc`](#new-get_ptc) @@ -118,6 +119,8 @@ Gloas is a consensus-layer upgrade containing a number of features. Including: - [EIP-7732](https://eips.ethereum.org/EIPS/eip-7732): Enshrined Proposer-Builder Separation - [EIP-7843](https://eips.ethereum.org/EIPS/eip-7843): SLOTNUM opcode +- [EIP-8045](https://eips.ethereum.org/EIPS/eip-8045): Exclude slashed + validators from proposing - [EIP-8061](https://eips.ethereum.org/EIPS/eip-8061): Increase exit and consolidation churn @@ -682,6 +685,28 @@ def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SI ### Beacon state accessors +#### Modified `get_beacon_proposer_indices` + +*Note*: `get_beacon_proposer_indices` is modified to exclude slashed validators +from the candidate pool before invoking `compute_proposer_indices`, so the +pre-computed `proposer_lookahead` (EIP-7917) only contains active and unslashed +validators. + +```python +def get_beacon_proposer_indices( + state: BeaconState, epoch: Epoch +) -> Vector[ValidatorIndex, SLOTS_PER_EPOCH]: + """ + Return the proposer indices for the given ``epoch``. + """ + # [Modified in Gloas:EIP8045] + indices = [ + i for i in get_active_validator_indices(state, epoch) if not state.validators[i].slashed + ] + seed = get_seed(state, epoch, DOMAIN_BEACON_PROPOSER) + return compute_proposer_indices(state, epoch, seed, indices) +``` + #### Modified `get_next_sync_committee_indices` *Note*: `get_next_sync_committee_indices` is modified to use From 1d76603f09fd4ead86575cac10a842f5462da9b8 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 29 May 2026 16:42:28 +0200 Subject: [PATCH 2/5] Gloas: add EIP-8045 slashed-proposer exclusion tests proposer_lookahead (epoch_processing): - slashed validators do not appear in the newly appended lookahead epoch after process_proposer_lookahead - a full SLOTS_PER_EPOCH worth of proposers is still produced when the bulk of active validators are slashed --- .../test_process_proposer_lookahead.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_proposer_lookahead.py diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_proposer_lookahead.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_proposer_lookahead.py new file mode 100644 index 00000000000..b0d9bf561e7 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_proposer_lookahead.py @@ -0,0 +1,51 @@ +from eth_consensus_specs.test.context import spec_state_test, with_gloas_and_later +from eth_consensus_specs.test.helpers.epoch_processing import run_epoch_processing_with +from eth_consensus_specs.test.helpers.state import next_epoch + + +@with_gloas_and_later +@spec_state_test +def test_proposer_lookahead_does_not_contain_slashed_validators(spec, state): + """ + [EIP-8045] The newly appended epoch of ``proposer_lookahead`` must not + reference slashed validators. + """ + for _ in range(spec.MIN_SEED_LOOKAHEAD + 1): + next_epoch(spec, state) + + active = spec.get_active_validator_indices(state, spec.get_current_epoch(state)) + to_slash = set(active[: len(active) // 2]) + for validator_index in to_slash: + state.validators[validator_index].slashed = True + + yield from run_epoch_processing_with(spec, state, "process_proposer_lookahead") + + last_epoch_start = len(state.proposer_lookahead) - spec.SLOTS_PER_EPOCH + for validator_index in state.proposer_lookahead[last_epoch_start:]: + assert validator_index not in to_slash + assert not state.validators[validator_index].slashed + + +@with_gloas_and_later +@spec_state_test +def test_proposer_lookahead_full_with_many_slashed_validators(spec, state): + """ + [EIP-8045] The lookahead must still fill its full ``SLOTS_PER_EPOCH`` entries + when only a small subset of active validators remain unslashed. + """ + for _ in range(spec.MIN_SEED_LOOKAHEAD + 1): + next_epoch(spec, state) + + active = spec.get_active_validator_indices(state, spec.get_current_epoch(state)) + keep = max(spec.SLOTS_PER_EPOCH, 8) + assert len(active) > keep + for validator_index in active[keep:]: + state.validators[validator_index].slashed = True + + yield from run_epoch_processing_with(spec, state, "process_proposer_lookahead") + + last_epoch_start = len(state.proposer_lookahead) - spec.SLOTS_PER_EPOCH + last_epoch_proposers = list(state.proposer_lookahead[last_epoch_start:]) + assert len(last_epoch_proposers) == spec.SLOTS_PER_EPOCH + for validator_index in last_epoch_proposers: + assert not state.validators[validator_index].slashed From 026ed4541bcfe47ea7fc4cfdf361efe2dd4fead2 Mon Sep 17 00:00:00 2001 From: Justin Traglia Date: Fri, 29 May 2026 09:57:30 -0500 Subject: [PATCH 3/5] Fix two nits --- specs/gloas/beacon-chain.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 245b2c5d09b..e70867d4f89 100644 --- a/specs/gloas/beacon-chain.md +++ b/specs/gloas/beacon-chain.md @@ -689,8 +689,7 @@ def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SI *Note*: `get_beacon_proposer_indices` is modified to exclude slashed validators from the candidate pool before invoking `compute_proposer_indices`, so the -pre-computed `proposer_lookahead` (EIP-7917) only contains active and unslashed -validators. +`proposer_lookahead` only contains active and unslashed validators. ```python def get_beacon_proposer_indices( @@ -701,7 +700,9 @@ def get_beacon_proposer_indices( """ # [Modified in Gloas:EIP8045] indices = [ - i for i in get_active_validator_indices(state, epoch) if not state.validators[i].slashed + index + for index in get_active_validator_indices(state, epoch) + if not state.validators[index].slashed ] seed = get_seed(state, epoch, DOMAIN_BEACON_PROPOSER) return compute_proposer_indices(state, epoch, seed, indices) From 0cdc261e97255ba73e65e6787f78394c2003529b Mon Sep 17 00:00:00 2001 From: Justin Traglia Date: Mon, 1 Jun 2026 11:59:07 -0500 Subject: [PATCH 4/5] Add a few fork tests --- .../test_gloas_fork_proposer_lookahead.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py new file mode 100644 index 00000000000..24bbc7624ee --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py @@ -0,0 +1,130 @@ +from eth_consensus_specs.test.context import ( + spec_test, + with_phases, + with_state, +) +from eth_consensus_specs.test.helpers.constants import ( + FULU, + GLOAS, +) +from eth_consensus_specs.test.helpers.fork_transition import do_fork_generate +from eth_consensus_specs.test.helpers.gloas.fork import GLOAS_FORK_TEST_META_TAGS +from eth_consensus_specs.test.helpers.state import ( + next_epoch, + transition_to, +) +from eth_consensus_specs.test.utils import with_meta_tags + + +def _transition_to_last_slot_before_fork(spec, state): + """ + Advance a couple of epochs so the proposer lookahead is fully populated by + regular epoch processing, then stop at the last slot of the epoch right + before the fork (where ``do_fork_generate`` expects to take over). + """ + for _ in range(2): + next_epoch(spec, state) + fork_epoch = spec.get_current_epoch(state) + 1 + transition_to(spec, state, fork_epoch * spec.SLOTS_PER_EPOCH - 1) + return fork_epoch + + +@with_phases(phases=[FULU], other_phases=[GLOAS]) +@spec_test +@with_state +@with_meta_tags(GLOAS_FORK_TEST_META_TAGS) +def test_fork_proposer_lookahead_no_slashed_validators(spec, phases, state): + """ + [EIP-8045] When there are no slashed validators before the fork, the + proposer lookahead carried across the fork contains no slashed validators, + and it stays slashed-free as Gloas epoch transitions recompute it. + """ + gloas = phases[GLOAS] + fork_epoch = _transition_to_last_slot_before_fork(spec, state) + + assert not any(v.slashed for v in state.validators) + + state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) + + # Nothing was slashed, so the carried-over lookahead is clean. + for validator_index in state.proposer_lookahead: + assert not state.validators[validator_index].slashed + + # And it stays clean as the lookahead is recomputed under Gloas. + post_state = state.copy() + for _ in range(gloas.MIN_SEED_LOOKAHEAD + 1): + next_epoch(gloas, post_state) + for validator_index in post_state.proposer_lookahead: + assert not post_state.validators[validator_index].slashed + + +@with_phases(phases=[FULU], other_phases=[GLOAS]) +@spec_test +@with_state +@with_meta_tags(GLOAS_FORK_TEST_META_TAGS) +def test_fork_proposer_lookahead_slashed_validators_carried_over(spec, phases, state): + """ + [EIP-8045] Slashed validators that were already committed to the proposer + lookahead before the fork are carried across the fork verbatim. + + Both lookahead epochs are computed under the pre-fork (Fulu) rules: the + first half two epochs before the fork, and the second half by the epoch + transition that runs immediately before ``upgrade_to_gloas``. EIP-8045 only + filters *newly* computed entries, so these pre-fork assignments are not + retroactively rewritten and the slashed validators remain. + """ + gloas = phases[GLOAS] + fork_epoch = _transition_to_last_slot_before_fork(spec, state) + + # Slash every validator that appears anywhere in the pre-fork lookahead, so + # both lookahead epochs reference slashed validators. + pre_fork_lookahead = list(state.proposer_lookahead) + for validator_index in set(pre_fork_lookahead): + state.validators[validator_index].slashed = True + + # The fork-slot proposer is now slashed and cannot produce a valid block. + state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) + + # The first lookahead epoch is the pre-fork second half shifted down: it was + # committed before the fork and still references the same slashed validators. + half = gloas.SLOTS_PER_EPOCH + assert list(state.proposer_lookahead[:half]) == pre_fork_lookahead[half:] + assert all(state.validators[v].slashed for v in state.proposer_lookahead[:half]) + + +@with_phases(phases=[FULU], other_phases=[GLOAS]) +@spec_test +@with_state +@with_meta_tags(GLOAS_FORK_TEST_META_TAGS) +def test_fork_proposer_lookahead_excludes_slashed_in_new_half(spec, phases, state): + """ + [EIP-8045] Scenario where slashed validators would be selected into the + newly computed (second) half of the lookahead under the pre-fork rules. + + The validators slashed here are exactly those that the pre-fork (Fulu) + ``get_beacon_proposer_indices`` selects for that half -- the proposers that + would be included "without this change". Slashing them does not change the + Fulu selection (which ignores the slashed flag), so the pre-fork rules still + fill the half with slashed validators, while EIP-8045's modified + ``get_beacon_proposer_indices`` excludes them. + """ + gloas = phases[GLOAS] + fork_epoch = _transition_to_last_slot_before_fork(spec, state) + + # The epoch whose proposers form the newly computed half of the lookahead + # that the epoch transition fills in right before upgrade_to_gloas. + new_half_epoch = spec.get_current_epoch(state) + spec.MIN_SEED_LOOKAHEAD + 1 + + # Slash exactly the validators the pre-fork rules would select for that half. + for validator_index in set(spec.get_beacon_proposer_indices(state, new_half_epoch)): + state.validators[validator_index].slashed = True + + # Without EIP-8045, the new half is entirely slashed validators. + pre_fork_new_half = spec.get_beacon_proposer_indices(state, new_half_epoch) + assert all(state.validators[v].slashed for v in pre_fork_new_half) + + # With EIP-8045, the same computation excludes the slashed validators. + post_fork_new_half = gloas.get_beacon_proposer_indices(state, new_half_epoch) + assert not any(state.validators[v].slashed for v in post_fork_new_half) + + state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) From 52a38c40a0f9bca966fd426022ecfc8eba929fd2 Mon Sep 17 00:00:00 2001 From: Justin Traglia Date: Mon, 1 Jun 2026 13:52:58 -0500 Subject: [PATCH 5/5] Fix tests --- specs/gloas/beacon-chain.md | 4 +- .../test_gloas_fork_proposer_lookahead.py | 93 ++----------------- .../test/gloas/sanity/test_blocks.py | 52 ++++++++++- 3 files changed, 61 insertions(+), 88 deletions(-) diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 6c27b4d45fb..9de630c96a8 100644 --- a/specs/gloas/beacon-chain.md +++ b/specs/gloas/beacon-chain.md @@ -688,8 +688,8 @@ def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SI #### Modified `get_beacon_proposer_indices` *Note*: `get_beacon_proposer_indices` is modified to exclude slashed validators -from the candidate pool before invoking `compute_proposer_indices`, so the -`proposer_lookahead` only contains active and unslashed validators. +from the candidate pool before invoking `compute_proposer_indices`, so the newly +computed proposer indices only contain active and unslashed validators. ```python def get_beacon_proposer_indices( diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py index 24bbc7624ee..012e1099933 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py @@ -29,102 +29,25 @@ def _transition_to_last_slot_before_fork(spec, state): return fork_epoch -@with_phases(phases=[FULU], other_phases=[GLOAS]) -@spec_test -@with_state -@with_meta_tags(GLOAS_FORK_TEST_META_TAGS) -def test_fork_proposer_lookahead_no_slashed_validators(spec, phases, state): - """ - [EIP-8045] When there are no slashed validators before the fork, the - proposer lookahead carried across the fork contains no slashed validators, - and it stays slashed-free as Gloas epoch transitions recompute it. - """ - gloas = phases[GLOAS] - fork_epoch = _transition_to_last_slot_before_fork(spec, state) - - assert not any(v.slashed for v in state.validators) - - state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) - - # Nothing was slashed, so the carried-over lookahead is clean. - for validator_index in state.proposer_lookahead: - assert not state.validators[validator_index].slashed - - # And it stays clean as the lookahead is recomputed under Gloas. - post_state = state.copy() - for _ in range(gloas.MIN_SEED_LOOKAHEAD + 1): - next_epoch(gloas, post_state) - for validator_index in post_state.proposer_lookahead: - assert not post_state.validators[validator_index].slashed - - @with_phases(phases=[FULU], other_phases=[GLOAS]) @spec_test @with_state @with_meta_tags(GLOAS_FORK_TEST_META_TAGS) def test_fork_proposer_lookahead_slashed_validators_carried_over(spec, phases, state): """ - [EIP-8045] Slashed validators that were already committed to the proposer - lookahead before the fork are carried across the fork verbatim. - - Both lookahead epochs are computed under the pre-fork (Fulu) rules: the - first half two epochs before the fork, and the second half by the epoch - transition that runs immediately before ``upgrade_to_gloas``. EIP-8045 only - filters *newly* computed entries, so these pre-fork assignments are not - retroactively rewritten and the slashed validators remain. + Test that both proposer lookahead epochs can contain slashed validators + after the fork. """ gloas = phases[GLOAS] fork_epoch = _transition_to_last_slot_before_fork(spec, state) - # Slash every validator that appears anywhere in the pre-fork lookahead, so - # both lookahead epochs reference slashed validators. - pre_fork_lookahead = list(state.proposer_lookahead) - for validator_index in set(pre_fork_lookahead): + # Slash half of the validator set + for validator_index in range(len(state.validators) // 2): state.validators[validator_index].slashed = True - # The fork-slot proposer is now slashed and cannot produce a valid block. state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) - # The first lookahead epoch is the pre-fork second half shifted down: it was - # committed before the fork and still references the same slashed validators. - half = gloas.SLOTS_PER_EPOCH - assert list(state.proposer_lookahead[:half]) == pre_fork_lookahead[half:] - assert all(state.validators[v].slashed for v in state.proposer_lookahead[:half]) - - -@with_phases(phases=[FULU], other_phases=[GLOAS]) -@spec_test -@with_state -@with_meta_tags(GLOAS_FORK_TEST_META_TAGS) -def test_fork_proposer_lookahead_excludes_slashed_in_new_half(spec, phases, state): - """ - [EIP-8045] Scenario where slashed validators would be selected into the - newly computed (second) half of the lookahead under the pre-fork rules. - - The validators slashed here are exactly those that the pre-fork (Fulu) - ``get_beacon_proposer_indices`` selects for that half -- the proposers that - would be included "without this change". Slashing them does not change the - Fulu selection (which ignores the slashed flag), so the pre-fork rules still - fill the half with slashed validators, while EIP-8045's modified - ``get_beacon_proposer_indices`` excludes them. - """ - gloas = phases[GLOAS] - fork_epoch = _transition_to_last_slot_before_fork(spec, state) - - # The epoch whose proposers form the newly computed half of the lookahead - # that the epoch transition fills in right before upgrade_to_gloas. - new_half_epoch = spec.get_current_epoch(state) + spec.MIN_SEED_LOOKAHEAD + 1 - - # Slash exactly the validators the pre-fork rules would select for that half. - for validator_index in set(spec.get_beacon_proposer_indices(state, new_half_epoch)): - state.validators[validator_index].slashed = True - - # Without EIP-8045, the new half is entirely slashed validators. - pre_fork_new_half = spec.get_beacon_proposer_indices(state, new_half_epoch) - assert all(state.validators[v].slashed for v in pre_fork_new_half) - - # With EIP-8045, the same computation excludes the slashed validators. - post_fork_new_half = gloas.get_beacon_proposer_indices(state, new_half_epoch) - assert not any(state.validators[v].slashed for v in post_fork_new_half) - - state, _ = yield from do_fork_generate(state, spec, gloas, fork_epoch, with_block=False) + # Both halves were computed under Fulu, so both still have slashed validators + half = spec.SLOTS_PER_EPOCH + assert any(state.validators[v].slashed for v in state.proposer_lookahead[:half]) + assert any(state.validators[v].slashed for v in state.proposer_lookahead[half:]) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/sanity/test_blocks.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/sanity/test_blocks.py index bac5b1e8d20..574700ba492 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/sanity/test_blocks.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/sanity/test_blocks.py @@ -1,4 +1,10 @@ -from eth_consensus_specs.test.context import always_bls, spec_state_test, with_gloas_and_later +from eth_consensus_specs.test.context import ( + always_bls, + GLOAS, + spec_state_test, + with_gloas_and_later, + with_phases, +) from eth_consensus_specs.test.gloas.block_processing.test_process_payload_attestation import ( prepare_signed_payload_attestation, ) @@ -11,6 +17,7 @@ ) from eth_consensus_specs.test.helpers.keys import builder_privkeys, privkeys from eth_consensus_specs.test.helpers.state import ( + next_epoch, next_epoch_with_full_participation, state_transition_and_sign_block, ) @@ -590,3 +597,46 @@ def test_voluntary_exit_fails_after_parent_payload_withdrawal_request(spec, stat signed_block = state_transition_and_sign_block(spec, state, block, expect_fail=True) yield "blocks", [signed_block] yield "post", None + + +@with_phases([GLOAS]) +@spec_state_test +def test_proposer_lookahead_excludes_slashed_validators(spec, state): + """ + Test that slashed validators are excluded from the proposer lookahead. + """ + for _ in range(2): + next_epoch(spec, state) + + current_epoch = spec.get_current_epoch(state) + new_half_epoch = current_epoch + spec.MIN_SEED_LOOKAHEAD + 1 + + # Slash some validators + for validator_index in range(len(state.validators) // 2): + state.validators[validator_index].slashed = True + + # Fulu includes slashed validators + proposers_in_fulu = spec.fulu.get_beacon_proposer_indices(state, new_half_epoch) + assert any(state.validators[v].slashed for v in proposers_in_fulu) + + # Gloas excludes slashed validators + proposers_in_gloas = spec.get_beacon_proposer_indices(state, new_half_epoch) + assert not any(state.validators[v].slashed for v in proposers_in_gloas) + + # Cross into the next epoch with a block, picking a slot whose proposer is + # not one of the validators we slashed so the block is valid + epoch_n1_proposers = list(state.proposer_lookahead[spec.SLOTS_PER_EPOCH :]) + offset = next(i for i, p in enumerate(epoch_n1_proposers) if not state.validators[p].slashed) + block_slot = (current_epoch + 1) * spec.SLOTS_PER_EPOCH + offset + + yield "pre", state + + block = build_empty_block(spec, state, slot=block_slot) + signed_block = state_transition_and_sign_block(spec, state, block) + + yield "blocks", [signed_block] + yield "post", state + + # The newly appended lookahead epoch matches the Gloas selection + last_epoch_start = len(state.proposer_lookahead) - spec.SLOTS_PER_EPOCH + assert list(state.proposer_lookahead[last_epoch_start:]) == list(proposers_in_gloas)