Skip to content

EIP-7917: Deterministic proposer lookahead #4190

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e73b5d8
EIP-XXXX Stabilize next epoch proposer lookahead
linoscope Mar 23, 2025
fb3cd63
EIP-XXXX add simple happy case tests
linoscope Mar 23, 2025
3fbe39c
fixup: Code clean up
linoscope Mar 24, 2025
0a37fe7
fixup: Target correct fork
linoscope Mar 24, 2025
a4dd9eb
Merge branch 'dev' into stabalize-next-epoch-lookahead
jtraglia Mar 24, 2025
9b82cb4
Clean up comment
linoscope Mar 25, 2025
7485399
refactor based on review comments
linoscope Mar 25, 2025
6a1d923
fix fork check in genesis helper
linoscope Mar 25, 2025
8fb1a58
improve comments
linoscope Mar 25, 2025
208f368
fix off-by-one error
linoscope Mar 25, 2025
af1892b
fix bug in seed calculation
linoscope Mar 26, 2025
53df021
add proper EIP number (EIP7917)
linoscope Mar 26, 2025
94d9dde
fix lint errors
linoscope Mar 27, 2025
83daa1e
add test to check lookahead consistency before/after Fulu fork
linoscope Mar 29, 2025
b1b0144
add tests for lookahead changes due to EB changes
linoscope Apr 1, 2025
9610b02
fix test folder
linoscope Apr 4, 2025
898432d
fix import
linoscope Apr 4, 2025
0414e6c
fix imports
linoscope Apr 7, 2025
78b3810
add generator EIP-7917 tests
linoscope Apr 7, 2025
291bed7
Merge branch 'dev' into stabalize-next-epoch-lookahead
jtraglia Apr 7, 2025
a54040e
Run make lint
jtraglia Apr 7, 2025
cede1ae
better yielding of vectors
linoscope Apr 8, 2025
3c9cf75
move helper and fix type
linoscope Apr 17, 2025
ec0243d
Merge branch 'dev' into stabalize-next-epoch-lookahead
jtraglia Apr 21, 2025
835df3d
Run make lint
jtraglia Apr 21, 2025
1860d06
move state accessors outside of function
linoscope May 23, 2025
faaf52b
use helper in test
linoscope May 23, 2025
dfce6e4
Move some tests to the `sanity` format to generate vectors for EIP-7917
leolara May 26, 2025
016b45d
Merge remote-tracking branch 'origin/dev' into stabalize-next-epoch-l…
linoscope May 27, 2025
98adbee
Merge branch 'dev' into stabalize-next-epoch-lookahead
jtraglia May 30, 2025
a53408e
Run make lint
jtraglia May 30, 2025
ef950a6
Make test less reliant on specific RANDAO
linoscope May 30, 2025
9d5a6be
Break up two long lines
jtraglia May 30, 2025
c17c9dd
Use double backticks in docstrings
jtraglia May 30, 2025
ac6b213
fix yield statement in test
linoscope May 31, 2025
c8811a0
Change proposer_lookahead type from List to Vector
linoscope Jun 2, 2025
e9266b2
Compute seed and indicies inside compute_proposer_indicies
linoscope Jun 2, 2025
79bf80d
refactor and introduce get_beacon_proposer_indices
linoscope Jun 3, 2025
5874370
remove trailing whitespaces
linoscope Jun 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions specs/fulu/beacon-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@
- [Block processing](#block-processing)
- [Execution payload](#execution-payload)
- [Modified `process_execution_payload`](#modified-process_execution_payload)
- [Containers](#containers)
- [Extended Containers](#extended-containers)
- [`BeaconState`](#beaconstate)
- [Helper functions](#helper-functions)
- [Misc](#misc)
- [New `compute_proposer_indices`](#new-compute_proposer_indices)
- [Beacon state accessors](#beacon-state-accessors)
- [Modified `get_beacon_proposer_index`](#modified-get_beacon_proposer_index)
- [New `get_beacon_proposer_indices`](#new-get_beacon_proposer_indices)
- [Epoch processing](#epoch-processing)
- [Modified `process_epoch`](#modified-process_epoch)
- [New `process_proposer_lookahead`](#new-process_proposer_lookahead)

<!-- mdformat-toc end -->

Expand Down Expand Up @@ -71,3 +83,162 @@ def process_execution_payload(state: BeaconState, body: BeaconBlockBody, executi
excess_blob_gas=payload.excess_blob_gas,
)
```

## Containers

### Extended Containers

#### `BeaconState`

*Note*: The `BeaconState` container is extended with the `proposer_lookahead`
field, which is a list of validator indices covering the full lookahead period,
starting from the beginning of the current epoch. For example,
`proposer_lookahead[0]` is the validator index for the first proposer in the
current epoch, `proposer_lookahead[1]` is the validator index for the next
proposer in the current epoch, and so forth. The length of the
`proposer_lookahead` list is `(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH`,
reflecting how far ahead proposer indices are computed based on the
`MIN_SEED_LOOKAHEAD` parameter.

```python
class BeaconState(Container):
# Versioning
genesis_time: uint64
genesis_validators_root: Root
slot: Slot
fork: Fork
# History
latest_block_header: BeaconBlockHeader
block_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
state_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
historical_roots: List[Root, HISTORICAL_ROOTS_LIMIT]
# Eth1
eth1_data: Eth1Data
eth1_data_votes: List[Eth1Data, EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH]
eth1_deposit_index: uint64
# Registry
validators: List[Validator, VALIDATOR_REGISTRY_LIMIT]
balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT]
# Randomness
randao_mixes: Vector[Bytes32, EPOCHS_PER_HISTORICAL_VECTOR]
# Slashings
slashings: Vector[Gwei, EPOCHS_PER_SLASHINGS_VECTOR] # Per-epoch sums of slashed effective balances
# Participation
previous_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT]
current_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT]
# Finality
justification_bits: Bitvector[JUSTIFICATION_BITS_LENGTH] # Bit set for every recent justified epoch
previous_justified_checkpoint: Checkpoint
current_justified_checkpoint: Checkpoint
finalized_checkpoint: Checkpoint
# Inactivity
inactivity_scores: List[uint64, VALIDATOR_REGISTRY_LIMIT]
# Sync
current_sync_committee: SyncCommittee
next_sync_committee: SyncCommittee
# Execution
latest_execution_payload_header: ExecutionPayloadHeader
# Withdrawals
next_withdrawal_index: WithdrawalIndex
next_withdrawal_validator_index: ValidatorIndex
# Deep history valid from Capella onwards
historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT]
deposit_requests_start_index: uint64
deposit_balance_to_consume: Gwei
exit_balance_to_consume: Gwei
earliest_exit_epoch: Epoch
consolidation_balance_to_consume: Gwei
earliest_consolidation_epoch: Epoch
pending_deposits: List[PendingDeposit, PENDING_DEPOSITS_LIMIT]
pending_partial_withdrawals: List[PendingPartialWithdrawal, PENDING_PARTIAL_WITHDRAWALS_LIMIT]
pending_consolidations: List[PendingConsolidation, PENDING_CONSOLIDATIONS_LIMIT]
proposer_lookahead: Vector[ValidatorIndex, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH] # [New in Fulu:EIP7917]
```

## Helper functions

### Misc

#### New `compute_proposer_indices`

```python
def compute_proposer_indices(state: BeaconState, epoch: Epoch, seed: Bytes32, indices: Sequence[ValidatorIndex]) -> List[ValidatorIndex, SLOTS_PER_EPOCH]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All calls to this function explicitly compute seed at the callsite, it is cleaner to have a different signature only taking the state and epoch and computing the seed within this function.

Moreover, the same applies to the indices argument, this function should be defined as follows

def compute_proposer_indices(state: BeaconState, epoch: Epoch) -> List[ValidatorIndex, SLOTS_PER_EPOCH]:
    """
    Return the proposer indices for the given ``epoch``.
    """
    start_slot = compute_start_slot_at_epoch(epoch)
    seed = get_seed(state, epoch, DOMAIN_BEACON_PROPOSER)
    seeds = [hash(seed + uint_to_bytes(Slot(start_slot + i))) for i in range(SLOTS_PER_EPOCH)]
    indices = get_active_validator_indices(state, epoch)
    return [compute_proposer_index(state, indices, seed) for seed in seeds]
``

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current interface was from a suggestion to make it more in line with the interface of compute_proposer_index, which takes the seed and indices in the argument. Personally don't have strong preference either way, but I do see the benefit of reducing repeated logic in callsites, so right now slightly in favor of reverting back to compute_proposer_indices(state: BeaconState, epoch: Epoch) interface as @potuz suggests. What do you think @gfukushima ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current spec, compute_proposer_index() is meant to be a private/internal/utility function that is only used by get_beacon_proposer_index().

The usage of compute_proposer_indices() is more similar to get_beacon_proposer_index() than compute_proposer_index(). Delegating the seed calculation to the caller is not really necessary imo.

If we really want to be consistent with the current spec,
we should have get_beacon_proposer_indices(state, epoch) which calls compute_proposer_indices (state, epoch, seed)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks both for the input!

The usage of compute_proposer_indices() is more similar to get_beacon_proposer_index() than compute_proposer_index(). Delegating the seed calculation to the caller is not really necessary imo.

Makes sense.

Made the call to circle back to having seeds and indicies in compute_proposer_indicies here: e9266b2

Copy link

@gfukushima gfukushima Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with @ensi321 what I was trying to tell you with my initial comment is that the initial implementation was introducing a circular dependency, having a method in the misc helpers (compute_proposer_indices) depending on methods of the beacon state accessor (get_seed,get_active_validator_indices) would be unusual (if not a bad practice). It is usually the other way around, beacon state accessor can call misc helpers methods. Having said that I think @ensi321 suggestion is the closest I would agree with but with a slight modification: get_beacon_proposer_indices(state, epoch) should call compute_proposer_indices (state, epoch, seed, indices)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having a method in the misc helpers (compute_proposer_indices) depending on methods of the beacon state accessor (get_seed,get_active_validator_indices) would be unusual (if not a bad practice)

Thanks for the explanation, I see your point, makes sense. Will make this change to remove the circular dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor and introduction of get_beacon_proposer_indices accessor done here:
79bf80d

Thanks everyone for the input!

"""
Return the proposer indices for the given ``epoch``.
"""
start_slot = compute_start_slot_at_epoch(epoch)
seeds = [hash(seed + uint_to_bytes(Slot(start_slot + i))) for i in range(SLOTS_PER_EPOCH)]
return [compute_proposer_index(state, indices, seed) for seed in seeds]
```

### Beacon state accessors

#### Modified `get_beacon_proposer_index`

*Note*: The function `get_beacon_proposer_index` is modified to use the
pre-calculated `current_proposer_lookahead` instead of calculating it on-demand.

```python
def get_beacon_proposer_index(state: BeaconState) -> ValidatorIndex:
"""
Return the beacon proposer index at the current slot.
"""
return state.proposer_lookahead[state.slot % SLOTS_PER_EPOCH]
```

#### New `get_beacon_proposer_indices`

```python
def get_beacon_proposer_indices(state: BeaconState, epoch: Epoch) -> List[ValidatorIndex, SLOTS_PER_EPOCH]:
"""
Return the proposer indices for the given ``epoch``.
"""
indices = get_active_validator_indices(state, epoch)
seed = get_seed(state, epoch, DOMAIN_BEACON_PROPOSER)
return compute_proposer_indices(state, epoch, seed, indices)
```

### Epoch processing

#### Modified `process_epoch`

*Note*: The function `process_epoch` is modified in Fulu to call
`process_proposer_lookahead` to update the `proposer_lookahead` in the beacon
state.

```python
def process_epoch(state: BeaconState) -> None:
process_justification_and_finalization(state)
process_inactivity_updates(state)
process_rewards_and_penalties(state)
process_registry_updates(state)
process_slashings(state)
process_eth1_data_reset(state)
process_pending_deposits(state)
process_pending_consolidations(state)
process_effective_balance_updates(state)
process_slashings_reset(state)
process_randao_mixes_reset(state)
process_historical_summaries_update(state)
process_participation_flag_updates(state)
process_sync_committee_updates(state)
process_proposer_lookahead(state) # [New in Fulu:EIP7917]
```

#### New `process_proposer_lookahead`

*Note*: This function updates the `proposer_lookahead` field in the beacon state
by shifting out proposer indices from the earliest epoch and appending new
proposer indices for the latest epoch. With `MIN_SEED_LOOKAHEAD` set to `1`,
this means that at the start of epoch `N`, the proposer lookahead for epoch
`N+1` will be computed and included in the beacon state's lookahead.

```python
def process_proposer_lookahead(state: BeaconState) -> None:
last_epoch_start = len(state.proposer_lookahead) - SLOTS_PER_EPOCH
# Shift out proposers in the first epoch
state.proposer_lookahead[:last_epoch_start] = state.proposer_lookahead[SLOTS_PER_EPOCH:]
# Fill in the last epoch with new proposer indices
last_epoch_proposers = get_beacon_proposer_indices(state, Epoch(get_current_epoch(state) + MIN_SEED_LOOKAHEAD + 1))
state.proposer_lookahead[last_epoch_start:] = last_epoch_proposers
```
19 changes: 19 additions & 0 deletions specs/fulu/fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Helper functions](#helper-functions)
- [Misc](#misc)
- [Modified `compute_fork_version`](#modified-compute_fork_version)
- [New `initialize_proposer_lookahead`](#new-initialize_proposer_lookahead)
- [Fork to Fulu](#fork-to-fulu)
- [Fork trigger](#fork-trigger)
- [Upgrading the state](#upgrading-the-state)
Expand Down Expand Up @@ -54,6 +55,23 @@ def compute_fork_version(epoch: Epoch) -> Version:
return GENESIS_FORK_VERSION
```

#### New `initialize_proposer_lookahead`

```python
def initialize_proposer_lookahead(
state: electra.BeaconState
) -> List[ValidatorIndex, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH]:
"""
Return the proposer indices for the full available lookahead starting from current epoch.
Used to initialize the ``proposer_lookahead`` field in the beacon state at genesis and after forks.
"""
current_epoch = get_current_epoch(state)
lookahead = []
for i in range(MIN_SEED_LOOKAHEAD + 1):
lookahead.extend(get_beacon_proposer_indices(state, Epoch(current_epoch + i)))
return lookahead
```

## Fork to Fulu

### Fork trigger
Expand Down Expand Up @@ -129,6 +147,7 @@ def upgrade_to_fulu(pre: electra.BeaconState) -> BeaconState:
pending_deposits=pre.pending_deposits,
pending_partial_withdrawals=pre.pending_partial_withdrawals,
pending_consolidations=pre.pending_consolidations,
proposer_lookahead=initialize_proposer_lookahead(pre), # [New in Fulu:EIP7917]
)

return post
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from eth2spec.test.context import spec_state_test, with_fulu_and_later
from eth2spec.test.helpers.epoch_processing import run_epoch_processing_with
from eth2spec.test.helpers.state import next_epoch


@with_fulu_and_later
@spec_state_test
def test_next_epoch_proposer_lookahead_shifted_to_front(spec, state):
"""Test that the next epoch proposer lookahead is shifted to the front at epoch transition."""
# Transition few epochs to pass the MIN_SEED_LOOKAHEAD
next_epoch(spec, state)
next_epoch(spec, state)
# Get initial lookahead
initial_lookahead = state.proposer_lookahead.copy()

# Run epoch processing
yield "pre", state
next_epoch(spec, state)
yield "post", state

# Verify lookahead was shifted correctly
assert (
state.proposer_lookahead[: spec.SLOTS_PER_EPOCH]
== initial_lookahead[spec.SLOTS_PER_EPOCH :]
)


@with_fulu_and_later
@spec_state_test
def test_proposer_lookahead_in_state_matches_computed_lookahead(spec, state):
"""Test that the proposer lookahead in the state matches the lookahead computed on the fly."""
# Transition few epochs to pass the MIN_SEED_LOOKAHEAD
next_epoch(spec, state)
next_epoch(spec, state)

# Run epoch processing
yield "pre", state
next_epoch(spec, state)
yield "post", state

# Verify lookahead in state matches the lookahead computed on the fly
computed_lookahead = spec.initialize_proposer_lookahead(state)
assert state.proposer_lookahead == computed_lookahead
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from eth2spec.test.context import (
spec_test,
with_phases,
with_state,
)
from eth2spec.test.helpers.constants import (
ELECTRA,
FULU,
)
from eth2spec.test.helpers.fulu.fork import (
FULU_FORK_TEST_META_TAGS,
run_fork_test,
)
from eth2spec.test.helpers.state import next_slot
from eth2spec.test.utils import with_meta_tags
from tests.core.pyspec.eth2spec.test.helpers.state import simulate_lookahead


@with_phases(phases=[ELECTRA], other_phases=[FULU])
@spec_test
@with_state
@with_meta_tags(FULU_FORK_TEST_META_TAGS)
def test_lookahead_consistency_at_fork(spec, phases, state):
"""
Test that lookahead is consistent before/after the Fulu fork.
"""

# Calculate the current and next epoch lookahead by simulating the state progression
# with empty slots and calling `get_beacon_proposer_index` (how it was done pre-Fulu)
pre_fork_proposers = simulate_lookahead(spec, state)

# Upgrade to Fulu
spec = phases[FULU]
state = yield from run_fork_test(spec, state)

# Check if the pre-fork simulation matches the post-fork `state.proposer_lookahead`
assert pre_fork_proposers == state.proposer_lookahead
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from eth2spec.test.context import (
spec_state_test,
with_phases,
)
from eth2spec.test.helpers.attestations import (
state_transition_with_full_block,
)
from eth2spec.test.helpers.constants import ELECTRA, FULU
from eth2spec.test.helpers.state import (
next_epoch,
simulate_lookahead,
)
from eth2spec.test.helpers.withdrawals import (
set_compounding_withdrawal_credential,
)


def run_test_effective_balance_increase_changes_lookahead(
spec, state, randao_setup_epochs, expect_lookahead_changed
):
# Advance few epochs to adjust the RANDAO
for _ in range(randao_setup_epochs):
next_epoch(spec, state)

# Set all active validators to have balance close to the hysteresis threshold
current_epoch = spec.get_current_epoch(state)
active_validator_indices = spec.get_active_validator_indices(state, current_epoch)
for validator_index in active_validator_indices:
# Set compounding withdrawal credentials for the validator
set_compounding_withdrawal_credential(spec, state, validator_index)
state.validators[validator_index].effective_balance = 32000000000
# Set balance to close the next hysteresis threshold
state.balances[validator_index] = 33250000000 - 1

# Calculate the lookahead of next epoch
next_epoch_lookahead = simulate_lookahead(spec, state)[spec.SLOTS_PER_EPOCH :]

blocks = []
yield "pre", state

# Process 1-epoch worth of blocks with attestations
for _ in range(spec.SLOTS_PER_EPOCH):
block = state_transition_with_full_block(
spec, state, fill_cur_epoch=True, fill_prev_epoch=True
)
blocks.append(block)

yield "blocks", blocks
yield "post", state

# Calculate the actual lookahead
actual_lookahead = simulate_lookahead(spec, state)[: spec.SLOTS_PER_EPOCH]

if expect_lookahead_changed:
assert next_epoch_lookahead != actual_lookahead
else:
assert next_epoch_lookahead == actual_lookahead


def run_test_with_randao_setup_epochs(spec, state, randao_setup_epochs):
if spec.fork == ELECTRA:
# Pre-EIP-7917, effective balance changes due to attestation rewards
# changes the next epoch's lookahead
expect_lookahead_changed = True
else:
# Post-EIP-7917, effective balance changes due to attestation rewards
# do not change the next epoch's lookahead
expect_lookahead_changed = False

yield from run_test_effective_balance_increase_changes_lookahead(
spec, state, randao_setup_epochs, expect_lookahead_changed=expect_lookahead_changed
)


@with_phases(phases=[ELECTRA, FULU])
@spec_state_test
def test_effective_balance_increase_changes_lookahead(spec, state):
# Since this test relies on the RANDAO, we adjust the number of next_epoch transitions
# we do at the setup of the test run until the assertion passes.
# We start with 4 epochs because the test is known to pass with 4 epochs.
for randao_setup_epochs in range(4, 20):
try:
state_copy = state.copy()
yield from run_test_with_randao_setup_epochs(spec, state_copy, randao_setup_epochs)
return
except AssertionError:
# If the randao_setup_epochs is not the right one to make the test pass,
# then try again in the next iteration
pass
assert False, "The test should have succeeded with one of the iterations."
Loading