diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 6731919605..3d190e2bac 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) @@ -66,6 +67,7 @@ - [Modified `process_slot`](#modified-process_slot) - [Epoch processing](#epoch-processing) - [Modified `process_epoch`](#modified-process_epoch) + - [New `process_ptc_update`](#new-process_ptc_update) - [New `process_builder_pending_payments`](#new-process_builder_pending_payments) - [Block processing](#block-processing) - [Withdrawals](#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] + previous_epoch_last_ptc: Vector[ValidatorIndex, PTC_SIZE] ``` ## Dataclasses @@ -630,6 +634,28 @@ def compute_proposer_indices( ] ``` +#### New `compute_ptc` + +```python +def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: + """ + Compute the payload timeliness committee for the given ``slot`` + using the state's current effective balances. + """ + epoch = compute_epoch_at_slot(slot) + assert epoch <= get_current_epoch(state) + 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 +731,23 @@ def get_attestation_participation_flag_indices( #### New `get_ptc` +*Note*: `get_ptc` uses the cached `previous_epoch_last_ptc` for the last slot of +the previous epoch. This is sufficient because `process_payload_attestation` +requires `data.slot + 1 == state.slot`, and the only case where effective +balance updates can change the computed PTC occurs at the epoch boundary. + ```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) + assert epoch + 1 >= state_epoch and epoch <= state_epoch + if epoch + 1 == state_epoch: + assert slot % SLOTS_PER_EPOCH == SLOTS_PER_EPOCH - 1 + return state.previous_epoch_last_ptc + return compute_ptc(state, slot) ``` #### New `get_indexed_payload_attestation` @@ -817,6 +844,8 @@ def process_slot(state: BeaconState) -> None: ```python def process_epoch(state: BeaconState) -> None: + # [New in Gloas:EIP7732] + process_ptc_update(state) process_justification_and_finalization(state) process_inactivity_updates(state) process_rewards_and_penalties(state) @@ -836,6 +865,17 @@ def process_epoch(state: BeaconState) -> None: process_proposer_lookahead(state) ``` +#### New `process_ptc_update` + +```python +def process_ptc_update(state: BeaconState) -> None: + """ + Cache the PTC for the current slot (last slot of the ending epoch) + before effective balance updates alter the weighted selection. + """ + state.previous_epoch_last_ptc = compute_ptc(state, Slot(state.slot)) +``` + #### New `process_builder_pending_payments` ```python diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index c1dfe470bb..e7e995501b 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -854,11 +854,11 @@ def on_payload_attestation_message( data = ptc_message.data # PTC attestation must be for a known block. If block is unknown, delay consideration until the block is found state = store.block_states[data.beacon_block_root] - ptc = get_ptc(state, data.slot) # PTC votes can only change the vote for their assigned beacon block, return early otherwise if data.slot != state.slot: return # Check that the attester is from the PTC + ptc = get_ptc(state, data.slot) assert ptc_message.validator_index in ptc # Verify the signature and check that its for the current slot if it is coming from the wire diff --git a/specs/gloas/fork.md b/specs/gloas/fork.md index 88184ae982..dbb0a010d1 100644 --- a/specs/gloas/fork.md +++ b/specs/gloas/fork.md @@ -159,6 +159,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] + previous_epoch_last_ptc=[ValidatorIndex(0)] * PTC_SIZE, ) # [New in Gloas:EIP7732] diff --git a/specs/gloas/validator.md b/specs/gloas/validator.md index d1d95f72a0..c22756caa7 100644 --- a/specs/gloas/validator.md +++ b/specs/gloas/validator.md @@ -49,9 +49,9 @@ 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 == current_epoch`, as PTC committee selection is only stable within the +context of the current epoch. ```python def get_ptc_assignment( @@ -62,12 +62,11 @@ 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 + assert epoch == get_current_epoch(state) start_slot = compute_start_slot_at_epoch(epoch) for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH): - if validator_index in get_ptc(state, Slot(slot)): + if validator_index in compute_ptc(state, Slot(slot)): return Slot(slot) return None ``` @@ -75,8 +74,8 @@ def get_ptc_assignment( ### Lookahead `get_ptc_assignment` should be called at the start of each epoch to get the -assignment for the next epoch (`current_epoch + 1`). A validator should plan for -future assignments by noting their assigned PTC slot. +assignment for the current epoch. A validator should note their assigned PTC +slot for that epoch. ## Beacon chain responsibilities @@ -242,8 +241,9 @@ def prepare_execution_payload( ### Payload timeliness attestation Some validators are selected to submit payload timeliness attestations. -Validators should call `get_ptc_assignment` at the beginning of an epoch to be -prepared to submit their PTC attestations during the next epoch. +Validators should call `get_ptc_assignment` at the beginning of the epoch to +determine the slot during the current epoch in which they should submit their +PTC attestation. A validator should create and broadcast the `payload_attestation_message` to the global execution attestation subnet within the first diff --git a/specs/heze/beacon-chain.md b/specs/heze/beacon-chain.md index a82797401f..a5da3713e3 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] + previous_epoch_last_ptc: Vector[ValidatorIndex, PTC_SIZE] ``` ## Helpers diff --git a/specs/heze/fork.md b/specs/heze/fork.md index 01333da7a5..48026b6f63 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, + previous_epoch_last_ptc=pre.previous_epoch_last_ptc, ) return post diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_update.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_update.py new file mode 100644 index 0000000000..7d8af1e836 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_ptc_update.py @@ -0,0 +1,85 @@ +from eth_consensus_specs.test.context import ( + expect_assertion_error, + 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_update_caches_last_slot_ptc(spec, state): + """ + Test that process_ptc_update caches the PTC for the current slot + (last slot of the epoch) into state.previous_epoch_last_ptc. + """ + # Advance to last slot of the epoch + spec.process_slots(state, state.slot + spec.SLOTS_PER_EPOCH - 1) + + # Compute expected PTC for this slot (last slot of epoch, before balance updates) + expected_ptc = spec.compute_ptc(state, spec.Slot(state.slot)) + + yield from run_epoch_processing_with(spec, state, "process_ptc_update") + + assert list(state.previous_epoch_last_ptc) == list(expected_ptc) + + +@with_phases([GLOAS]) +@spec_state_test +@single_phase +def test_get_ptc_returns_cached_previous_for_epoch_boundary(spec, state): + """ + Test that after crossing an epoch boundary, get_ptc returns the cached + previous_epoch_last_ptc for the last slot of the previous epoch. + """ + # Advance to first slot of next epoch + target_slot = spec.SLOTS_PER_EPOCH + spec.process_slots(state, target_slot) + + # Now state.slot = SLOTS_PER_EPOCH (first slot of epoch 1) + # Query PTC for slot SLOTS_PER_EPOCH - 1 (last slot of epoch 0) + last_slot_prev_epoch = spec.Slot(spec.SLOTS_PER_EPOCH - 1) + ptc = spec.get_ptc(state, last_slot_prev_epoch) + + # Should match previous_epoch_last_ptc cached during epoch processing + assert list(ptc) == list(state.previous_epoch_last_ptc) + # Should not be all zeros (real PTC was cached) + assert any(v != 0 for v in ptc) + + +@with_phases([GLOAS]) +@spec_state_test +@single_phase +def test_get_ptc_computes_current_epoch_on_demand(spec, state): + """ + Test that get_ptc computes current epoch PTCs on demand via compute_ptc. + """ + ptc_slot_0 = spec.get_ptc(state, spec.Slot(0)) + computed_ptc_slot_0 = spec.compute_ptc(state, spec.Slot(0)) + + assert list(ptc_slot_0) == list(computed_ptc_slot_0) + + +@with_phases([GLOAS]) +@spec_state_test +@single_phase +def test_compute_ptc_next_epoch_asserts(spec, state): + """ + Test that compute_ptc does not allow next-epoch computation. + """ + next_epoch_slot = spec.Slot(spec.SLOTS_PER_EPOCH) + expect_assertion_error(lambda: spec.compute_ptc(state, next_epoch_slot)) + + +@with_phases([GLOAS]) +@spec_state_test +@single_phase +def test_get_ptc_next_epoch_asserts(spec, state): + """ + Test that get_ptc does not allow next-epoch lookups. + """ + next_epoch_slot = spec.Slot(spec.SLOTS_PER_EPOCH) + expect_assertion_error(lambda: spec.get_ptc(state, next_epoch_slot)) 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..e69de29bb2 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..6b8df8e6be --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/unittests/validator/test_validator.py @@ -0,0 +1,62 @@ +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 _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__previous_epoch(spec, state): + next_epoch(spec, state) + + epoch = spec.Epoch(spec.get_current_epoch(state) - 1) + expect_assertion_error(lambda: spec.get_ptc_assignment(state, epoch, spec.ValidatorIndex(0))) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__current_epoch(spec, state): + epoch = spec.get_current_epoch(state) + assignments = _compute_first_ptc_assignments(spec, state, epoch) + _assert_get_ptc_assignments(spec, state, epoch, assignments) + + +@with_phases([GLOAS]) +@spec_test +@with_state +@single_phase +def test_get_ptc_assignment__next_epoch(spec, state): + epoch = spec.Epoch(spec.get_current_epoch(state) + 1) + expect_assertion_error(lambda: spec.get_ptc_assignment(state, epoch, spec.ValidatorIndex(0))) 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 c3b507b6dd..7775d1e873 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/genesis.py @@ -235,6 +235,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.previous_epoch_last_ptc = [spec.ValidatorIndex(0)] * spec.PTC_SIZE if is_post_fulu(spec): # Initialize proposer lookahead list