diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 6731919605..3c6d040406 100644 --- a/specs/gloas/beacon-chain.md +++ b/specs/gloas/beacon-chain.md @@ -54,6 +54,7 @@ - [New `compute_balance_weighted_selection`](#new-compute_balance_weighted_selection) - [New `compute_balance_weighted_acceptance`](#new-compute_balance_weighted_acceptance) - [Modified `compute_proposer_indices`](#modified-compute_proposer_indices) + - [New `compute_ptc`](#new-compute_ptc) - [Beacon state accessors](#beacon-state-accessors) - [Modified `get_next_sync_committee_indices`](#modified-get_next_sync_committee_indices) - [Modified `get_attestation_participation_flag_indices`](#modified-get_attestation_participation_flag_indices) @@ -67,6 +68,7 @@ - [Epoch processing](#epoch-processing) - [Modified `process_epoch`](#modified-process_epoch) - [New `process_builder_pending_payments`](#new-process_builder_pending_payments) + - [New `process_ptc_window`](#new-process_ptc_window) - [Block processing](#block-processing) - [Withdrawals](#withdrawals) - [New `get_builder_withdrawals`](#new-get_builder_withdrawals) @@ -384,6 +386,8 @@ class BeaconState(Container): latest_block_hash: Hash32 # [New in Gloas:EIP7732] payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + # [New in Gloas:EIP7732] + ptc_window: Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] ``` ## Dataclasses @@ -630,6 +634,26 @@ def compute_proposer_indices( ] ``` +#### New `compute_ptc` + +```python +def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: + """ + Get the payload timeliness committee for the given ``slot``. + """ + epoch = compute_epoch_at_slot(slot) + seed = hash(get_seed(state, epoch, DOMAIN_PTC_ATTESTER) + uint_to_bytes(slot)) + indices: List[ValidatorIndex] = [] + # Concatenate all committees for this slot in order + committees_per_slot = get_committee_count_per_slot(state, epoch) + for i in range(committees_per_slot): + committee = get_beacon_committee(state, slot, CommitteeIndex(i)) + indices.extend(committee) + return compute_balance_weighted_selection( + state, indices, seed, size=PTC_SIZE, shuffle_indices=False + ) +``` + ### Beacon state accessors #### Modified `get_next_sync_committee_indices` @@ -705,22 +729,21 @@ def get_attestation_participation_flag_indices( #### New `get_ptc` +*Note*: `get_ptc` uses the cached `ptc_window` for lookups. + ```python def get_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: """ Get the payload timeliness committee for the given ``slot``. """ epoch = compute_epoch_at_slot(slot) - seed = hash(get_seed(state, epoch, DOMAIN_PTC_ATTESTER) + uint_to_bytes(slot)) - indices: List[ValidatorIndex] = [] - # Concatenate all committees for this slot in order - committees_per_slot = get_committee_count_per_slot(state, epoch) - for i in range(committees_per_slot): - committee = get_beacon_committee(state, slot, CommitteeIndex(i)) - indices.extend(committee) - return compute_balance_weighted_selection( - state, indices, seed, size=PTC_SIZE, shuffle_indices=False - ) + state_epoch = get_current_epoch(state) + if epoch < state_epoch: + assert epoch + 1 == state_epoch + return state.ptc_window[slot % SLOTS_PER_EPOCH] + assert epoch <= state_epoch + MIN_SEED_LOOKAHEAD + offset = (epoch - state_epoch + 1) * SLOTS_PER_EPOCH + return state.ptc_window[offset + slot % SLOTS_PER_EPOCH] ``` #### New `get_indexed_payload_attestation` @@ -815,6 +838,9 @@ def process_slot(state: BeaconState) -> None: #### Modified `process_epoch` +*Note*: The function `process_epoch` is modified in Gloas to call the new +helpers `process_builder_pending_payments` and `process_ptc_window`. + ```python def process_epoch(state: BeaconState) -> None: process_justification_and_finalization(state) @@ -834,6 +860,8 @@ def process_epoch(state: BeaconState) -> None: process_participation_flag_updates(state) process_sync_committee_updates(state) process_proposer_lookahead(state) + # [New in Gloas:EIP7732] + process_ptc_window(state) ``` #### New `process_builder_pending_payments` @@ -853,6 +881,23 @@ def process_builder_pending_payments(state: BeaconState) -> None: state.builder_pending_payments = old_payments + new_payments ``` +#### New `process_ptc_window` + +```python +def process_ptc_window(state: BeaconState) -> None: + """ + Update the cached PTC window. + """ + # Shift all epochs forward by one + state.ptc_window[: len(state.ptc_window) - SLOTS_PER_EPOCH] = state.ptc_window[SLOTS_PER_EPOCH:] + # Fill in the last epoch + next_epoch = Epoch(get_current_epoch(state) + MIN_SEED_LOOKAHEAD + 1) + start_slot = compute_start_slot_at_epoch(next_epoch) + state.ptc_window[len(state.ptc_window) - SLOTS_PER_EPOCH :] = [ + compute_ptc(state, Slot(slot)) for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH) + ] +``` + ### Block processing ```python diff --git a/specs/gloas/fork.md b/specs/gloas/fork.md index 88184ae982..ea0970fb11 100644 --- a/specs/gloas/fork.md +++ b/specs/gloas/fork.md @@ -7,6 +7,7 @@ - [Introduction](#introduction) - [Configuration](#configuration) - [Helpers](#helpers) + - [New `initialize_ptc_window`](#new-initialize_ptc_window) - [New `onboard_builders_from_pending_deposits`](#new-onboard_builders_from_pending_deposits) - [Fork to Gloas](#fork-to-gloas) - [Fork trigger](#fork-trigger) @@ -29,6 +30,31 @@ Warning: this configuration is not definitive. ## Helpers +### New `initialize_ptc_window` + +```python +def initialize_ptc_window( + state: BeaconState, +) -> Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH]: + """ + Return the cached PTC window starting from the current epoch. + Used to initialize the ``ptc_window`` field in the beacon state at genesis and after forks. + """ + empty_previous_epoch = [ + Vector[ValidatorIndex, PTC_SIZE]([ValidatorIndex(0) for _ in range(PTC_SIZE)]) + for _ in range(SLOTS_PER_EPOCH) + ] + + ptcs = [] + current_epoch = get_current_epoch(state) + for e in range(1 + MIN_SEED_LOOKAHEAD): + epoch = Epoch(current_epoch + e) + start_slot = compute_start_slot_at_epoch(epoch) + ptcs += [compute_ptc(state, Slot(start_slot + i)) for i in range(SLOTS_PER_EPOCH)] + + return empty_previous_epoch + ptcs +``` + ### New `onboard_builders_from_pending_deposits` ```python @@ -159,6 +185,8 @@ def upgrade_to_gloas(pre: fulu.BeaconState) -> BeaconState: latest_block_hash=pre.latest_execution_payload_header.block_hash, # [New in Gloas:EIP7732] payload_expected_withdrawals=[], + # [New in Gloas:EIP7732] + ptc_window=initialize_ptc_window(pre), ) # [New in Gloas:EIP7732] diff --git a/specs/gloas/validator.md b/specs/gloas/validator.md index d1d95f72a0..b898a15c4e 100644 --- a/specs/gloas/validator.md +++ b/specs/gloas/validator.md @@ -49,9 +49,10 @@ validator" to implement Gloas. A validator may be a member of the new Payload Timeliness Committee (PTC) for a given slot. To check for PTC assignments, use -`get_ptc_assignment(state, epoch, validator_index)` where `epoch <= next_epoch`, -as PTC committee selection is only stable within the context of the current and -next epoch. +`get_ptc_assignment(state, epoch, validator_index)` where +`epoch <= get_current_epoch(state) + MIN_SEED_LOOKAHEAD`, as PTC committee +selection is only stable within the context of the current and next epochs in +the lookahead. ```python def get_ptc_assignment( @@ -62,8 +63,8 @@ def get_ptc_assignment( index ``validator_index`` is a member of the PTC. Returns None if no assignment is found. """ - next_epoch = Epoch(get_current_epoch(state) + 1) - assert epoch <= next_epoch + max_epoch = Epoch(get_current_epoch(state) + MIN_SEED_LOOKAHEAD) + assert epoch <= max_epoch start_slot = compute_start_slot_at_epoch(epoch) for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH): diff --git a/specs/heze/beacon-chain.md b/specs/heze/beacon-chain.md index a82797401f..a3a2a63f38 100644 --- a/specs/heze/beacon-chain.md +++ b/specs/heze/beacon-chain.md @@ -150,6 +150,7 @@ class BeaconState(Container): builder_pending_withdrawals: List[BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT] latest_block_hash: Hash32 payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + ptc_window: Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] ``` ## Helpers diff --git a/specs/heze/fork.md b/specs/heze/fork.md index 01333da7a5..7aef37d7df 100644 --- a/specs/heze/fork.md +++ b/specs/heze/fork.md @@ -103,6 +103,7 @@ def upgrade_to_heze(pre: gloas.BeaconState) -> BeaconState: builder_pending_withdrawals=pre.builder_pending_withdrawals, latest_block_hash=pre.latest_block_hash, payload_expected_withdrawals=pre.payload_expected_withdrawals, + ptc_window=pre.ptc_window, ) return post diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_payload_attestation.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_payload_attestation.py index 15532e76dd..eef256e508 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_payload_attestation.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_payload_attestation.py @@ -375,6 +375,8 @@ def test_process_payload_attestation_sampling_not_capped(spec, state): low_balance = spec.EFFECTIVE_BALANCE_INCREMENT for validator in state.validators: validator.effective_balance = low_balance + # Direct balance mutations bypass epoch processing, so refresh the cached current-epoch PTC. + state.ptc_window = spec.initialize_ptc_window(state) chosen_slot = None chosen_index = None diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_window.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_window.py new file mode 100644 index 0000000000..8ff53b843a --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_window.py @@ -0,0 +1,38 @@ +from eth_consensus_specs.test.context import ( + single_phase, + spec_state_test, + with_phases, +) +from eth_consensus_specs.test.helpers.constants import GLOAS +from eth_consensus_specs.test.helpers.epoch_processing import run_epoch_processing_with + + +@with_phases([GLOAS]) +@spec_state_test +@single_phase +def test_process_ptc_window__shifts_all_epochs(spec, state): + """ + Verify that process_ptc_window shifts prev/curr/next correctly + and that get_ptc returns the right committees afterwards. + """ + spec.process_slots(state, state.slot + 2 * spec.SLOTS_PER_EPOCH - 1) + + SPE = spec.SLOTS_PER_EPOCH + # Save current and next epoch sections before the shift + curr_epoch_ptc = list(state.ptc_window[SPE : 2 * SPE]) + next_epoch_ptc = list(state.ptc_window[2 * SPE : 3 * SPE]) + + yield from run_epoch_processing_with(spec, state, "process_ptc_window") + + # After shift: [curr, next, new_next] + assert list(state.ptc_window[:SPE]) == curr_epoch_ptc + assert list(state.ptc_window[SPE : 2 * SPE]) == next_epoch_ptc + + # run_epoch_processing_with does not increment the slot, so do it manually + state.slot += 1 + + # Now state_epoch = current_epoch + 1 + # Previous epoch lookup (current_epoch) should hit the first section + assert spec.get_ptc(state, spec.Slot(state.slot - 1)) == curr_epoch_ptc[-1] + # Current epoch lookup should hit the second section + assert spec.get_ptc(state, state.slot) == next_epoch_ptc[0] diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/__init__.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/test_validator.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/test_validator.py new file mode 100644 index 0000000000..e828f938b1 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/test_validator.py @@ -0,0 +1,102 @@ +from eth_consensus_specs.test.context import ( + expect_assertion_error, + single_phase, + spec_test, + with_phases, + with_state, +) +from eth_consensus_specs.test.helpers.constants import GLOAS +from eth_consensus_specs.test.helpers.state import next_epoch + + +def _compute_first_ptc_assignments(spec, state, epoch): + assignments = {} + start_slot = spec.compute_start_slot_at_epoch(epoch) + for slot in range(start_slot, start_slot + spec.SLOTS_PER_EPOCH): + for validator_index in spec.compute_ptc(state, spec.Slot(slot)): + assignments.setdefault(validator_index, spec.Slot(slot)) + return assignments + + +def _run_get_ptc_assignments(spec, state, epoch, valid=True, assignments=None): + if not valid: + expect_assertion_error( + lambda: spec.get_ptc_assignment(state, epoch, spec.ValidatorIndex(0)) + ) + return + + if assignments is None: + assignments = _compute_first_ptc_assignments(spec, state, epoch) + _assert_get_ptc_assignments(spec, state, epoch, assignments) + + +def _assert_get_ptc_assignments(spec, state, epoch, assignments): + assert len(assignments) > 0 + + for validator_index, expected_slot in assignments.items(): + assert spec.get_ptc_assignment(state, epoch, validator_index) == expected_slot + + unassigned_validator = next( + (spec.ValidatorIndex(i) for i in range(len(state.validators)) if i not in assignments), + None, + ) + if unassigned_validator is not None: + assert spec.get_ptc_assignment(state, epoch, unassigned_validator) is None + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch_minus_2(spec, state): + next_epoch(spec, state) + next_epoch(spec, state) + + epoch = spec.Epoch(spec.get_current_epoch(state) - 2) + _run_get_ptc_assignments(spec, state, epoch, valid=False) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch_minus_1(spec, state): + previous_epoch = spec.get_current_epoch(state) + previous_assignments = _compute_first_ptc_assignments(spec, state, previous_epoch) + + next_epoch(spec, state) + + _run_get_ptc_assignments( + spec, + state, + previous_epoch, + valid=True, + assignments=previous_assignments, + ) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch(spec, state): + epoch = spec.get_current_epoch(state) + _run_get_ptc_assignments(spec, state, epoch, valid=True) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch_plus_1(spec, state): + epoch = spec.Epoch(spec.get_current_epoch(state) + 1) + _run_get_ptc_assignments(spec, state, epoch, valid=True) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch_plus_2(spec, state): + epoch = spec.Epoch(spec.get_current_epoch(state) + 2) + _run_get_ptc_assignments(spec, state, epoch, valid=False) diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py index 45d25251b0..2130c8eff0 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py @@ -252,6 +252,7 @@ def create_genesis_state(spec, validator_balances, activation_threshold): spec.BuilderPendingPayment() for _ in range(2 * spec.SLOTS_PER_EPOCH) ] state.builder_pending_withdrawals = [] + state.ptc_window = spec.initialize_ptc_window(state) if is_post_fulu(spec): # Initialize proposer lookahead list