diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 5fae6619583..9de630c96a8 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,29 @@ 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 newly +computed proposer indices only contain 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 = [ + 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) +``` + #### Modified `get_next_sync_committee_indices` *Note*: `get_next_sync_committee_indices` is modified to use 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 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..012e1099933 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork/test_gloas_fork_proposer_lookahead.py @@ -0,0 +1,53 @@ +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_slashed_validators_carried_over(spec, phases, state): + """ + 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 half of the validator set + for validator_index in range(len(state.validators) // 2): + state.validators[validator_index].slashed = True + + 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)