diff --git a/configs/mainnet.yaml b/configs/mainnet.yaml index 252f82dbe1..e0f9128b70 100644 --- a/configs/mainnet.yaml +++ b/configs/mainnet.yaml @@ -94,6 +94,13 @@ MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 # --------------------------------------------------------------- # 40% PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# `2` epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + # Deposit contract # --------------------------------------------------------------- diff --git a/configs/minimal.yaml b/configs/minimal.yaml index a3b1a8d5ad..cdfbca3a2c 100644 --- a/configs/minimal.yaml +++ b/configs/minimal.yaml @@ -94,6 +94,12 @@ MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 4 # --------------------------------------------------------------- # 40% PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# `2` epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 # Deposit contract diff --git a/pysetup/spec_builders/bellatrix.py b/pysetup/spec_builders/bellatrix.py index c5753d7df0..1f49a0029a 100644 --- a/pysetup/spec_builders/bellatrix.py +++ b/pysetup/spec_builders/bellatrix.py @@ -27,7 +27,12 @@ def get_execution_state(_execution_state_root: Bytes32) -> ExecutionState: def get_pow_chain_head() -> PowBlock: - pass""" + pass + + +def validator_is_connected(validator_index: ValidatorIndex) -> bool: + # pylint: disable=unused-argument + return True""" @classmethod def execution_engine_cls(cls) -> str: diff --git a/specs/bellatrix/fork-choice.md b/specs/bellatrix/fork-choice.md index c8475195fc..7bf607d6e9 100644 --- a/specs/bellatrix/fork-choice.md +++ b/specs/bellatrix/fork-choice.md @@ -11,6 +11,7 @@ - [`ExecutionEngine`](#executionengine) - [`notify_forkchoice_updated`](#notify_forkchoice_updated) - [`safe_block_hash`](#safe_block_hash) + - [`should_override_forkchoice_update`](#should_override_forkchoice_update) - [Helpers](#helpers) - [`PayloadAttributes`](#payloadattributes) - [`PowBlock`](#powblock) @@ -76,6 +77,86 @@ As per EIP-3675, before a post-transition block is finalized, `notify_forkchoice The `safe_block_hash` parameter MUST be set to return value of [`get_safe_execution_payload_hash(store: Store)`](../../fork_choice/safe-block.md#get_safe_execution_payload_hash) function. +##### `should_override_forkchoice_update` + +If proposer boost re-orgs are implemented and enabled (see `get_proposer_head`) then additional care +must be taken to ensure that the proposer is able to build an execution payload. + +If a beacon node knows it will propose the next block then it SHOULD NOT call +`notify_forkchoice_updated` if it detects the current head to be weak and potentially capable of +being re-orged. Complete information for evaluating `get_proposer_head` _will not_ be available +immediately after the receipt of a new block, so an approximation of those conditions should be +used when deciding whether to send or suppress a fork choice notification. The exact conditions +used may be implementation-specific, a suggested implementation is below. + +Let `validator_is_connected(validator_index: ValidatorIndex) -> bool` be a function that indicates +whether the validator with `validator_index` is connected to the node (e.g. has sent an unexpired +proposer preparation message). + +```python +def should_override_forkchoice_update(store: Store, head_root: Root) -> bool: + head_block = store.blocks[head_root] + parent_root = head_block.parent_root + parent_block = store.blocks[parent_root] + current_slot = get_current_slot(store) + proposal_slot = head_block.slot + Slot(1) + + # Only re-org the head_block block if it arrived later than the attestation deadline. + head_late = is_head_late(store, head_root) + + # Shuffling stable. + shuffling_stable = is_shuffling_stable(proposal_slot) + + # FFG information of the new head_block will be competitive with the current head. + ffg_competitive = is_ffg_competitive(store, head_root, parent_root) + + # Do not re-org if the chain is not finalizing with acceptable frequency. + finalization_ok = is_finalization_ok(store, proposal_slot) + + # Only suppress the fork choice update if we are confident that we will propose the next block. + parent_state_advanced = store.block_states[parent_root].copy() + process_slots(parent_state_advanced, proposal_slot) + proposer_index = get_beacon_proposer_index(parent_state_advanced) + proposing_reorg_slot = validator_is_connected(proposer_index) + + # Single slot re-org. + parent_slot_ok = parent_block.slot + 1 == head_block.slot + proposing_on_time = is_proposing_on_time(store) + + # Note that this condition is different from `get_proposer_head` + current_time_ok = (head_block.slot == current_slot + or (proposal_slot == current_slot and proposing_on_time)) + single_slot_reorg = parent_slot_ok and current_time_ok + + # Check the head weight only if the attestations from the head slot have already been applied. + # Implementations may want to do this in different ways, e.g. by advancing + # `store.time` early, or by counting queued attestations during the head block's slot. + if current_slot > head_block.slot: + head_weak = is_head_weak(store, head_root) + parent_strong = is_parent_strong(store, parent_root) + else: + head_weak = True + parent_strong = True + + return all([head_late, shuffling_stable, ffg_competitive, finalization_ok, + proposing_reorg_slot, single_slot_reorg, + head_weak, parent_strong]) +``` + +*Note*: The ordering of conditions is a suggestion only. Implementations are free to +optimize by re-ordering the conditions from least to most expensive and by returning early if +any of the early conditions are `False`. + +In case `should_override_forkchoice_update` returns `True`, a node SHOULD instead call +`notify_forkchoice_updated` with parameters appropriate for building upon the parent block. Care +must be taken to compute the correct `payload_attributes`, as they may change depending on the slot +of the block to be proposed (due to withdrawals). + +If `should_override_forkchoice_update` returns `True` but `get_proposer_head` later chooses the +canonical head rather than its parent, then this is a misprediction that will cause the node +to construct a payload with less notice. The result of `get_proposer_head` MUST be preferred over +the result of `should_override_forkchoice_update` (when proposer reorgs are enabled). + ## Helpers ### `PayloadAttributes` @@ -191,11 +272,15 @@ def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: # Add new state for this block to the store store.block_states[block_root] = state - # Add proposer score boost if the block is timely + # Add block timeliness to the store time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT + is_timely = get_current_slot(store) == block.slot and is_before_attesting_interval + store.block_timeliness[hash_tree_root(block)] = is_timely + + # Add proposer score boost if the block is timely and not conflicting with an existing block is_first_block = store.proposer_boost_root == Root() - if get_current_slot(store) == block.slot and is_before_attesting_interval and is_first_block: + if is_timely and is_first_block: store.proposer_boost_root = hash_tree_root(block) # Update checkpoints in store if necessary diff --git a/specs/capella/fork-choice.md b/specs/capella/fork-choice.md index a830080c11..ded9782ccf 100644 --- a/specs/capella/fork-choice.md +++ b/specs/capella/fork-choice.md @@ -103,11 +103,15 @@ def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: # Add new state for this block to the store store.block_states[block_root] = state - # Add proposer score boost if the block is timely + # Add block timeliness to the store time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT + is_timely = get_current_slot(store) == block.slot and is_before_attesting_interval + store.block_timeliness[hash_tree_root(block)] = is_timely + + # Add proposer score boost if the block is timely and not conflicting with an existing block is_first_block = store.proposer_boost_root == Root() - if get_current_slot(store) == block.slot and is_before_attesting_interval and is_first_block: + if is_timely and is_first_block: store.proposer_boost_root = hash_tree_root(block) # Update checkpoints in store if necessary diff --git a/specs/deneb/fork-choice.md b/specs/deneb/fork-choice.md index 10cf2bacd2..101e07a0fc 100644 --- a/specs/deneb/fork-choice.md +++ b/specs/deneb/fork-choice.md @@ -107,11 +107,15 @@ def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: # Add new state for this block to the store store.block_states[block_root] = state - # Add proposer score boost if the block is timely + # Add block timeliness to the store time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT + is_timely = get_current_slot(store) == block.slot and is_before_attesting_interval + store.block_timeliness[hash_tree_root(block)] = is_timely + + # Add proposer score boost if the block is timely and not conflicting with an existing block is_first_block = store.proposer_boost_root == Root() - if get_current_slot(store) == block.slot and is_before_attesting_interval and is_first_block: + if is_timely and is_first_block: store.proposer_boost_root = hash_tree_root(block) # Update checkpoints in store if necessary diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 8b52186dda..6a54371152 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -18,6 +18,7 @@ - [`get_current_slot`](#get_current_slot) - [`compute_slots_since_epoch_start`](#compute_slots_since_epoch_start) - [`get_ancestor`](#get_ancestor) + - [`calculate_committee_fraction`](#calculate_committee_fraction) - [`get_checkpoint_block`](#get_checkpoint_block) - [`get_weight`](#get_weight) - [`get_voting_source`](#get_voting_source) @@ -26,6 +27,15 @@ - [`get_head`](#get_head) - [`update_checkpoints`](#update_checkpoints) - [`update_unrealized_checkpoints`](#update_unrealized_checkpoints) + - [Proposer head and reorg helpers](#proposer-head-and-reorg-helpers) + - [`is_head_late`](#is_head_late) + - [`is_shuffling_stable`](#is_shuffling_stable) + - [`is_ffg_competitive`](#is_ffg_competitive) + - [`is_finalization_ok`](#is_finalization_ok) + - [`is_proposing_on_time`](#is_proposing_on_time) + - [`is_head_weak`](#is_head_weak) + - [`is_parent_strong`](#is_parent_strong) + - [`get_proposer_head`](#get_proposer_head) - [Pull-up tip helpers](#pull-up-tip-helpers) - [`compute_pulled_up_tip`](#compute_pulled_up_tip) - [`on_tick` helpers](#on_tick-helpers) @@ -76,11 +86,16 @@ Any of the above handlers that trigger an unhandled exception (e.g. a failed ass ### Configuration -| Name | Value | -| ---------------------- | ------------ | -| `PROPOSER_SCORE_BOOST` | `uint64(40)` | +| Name | Value | +| ------------------------------------- | ------------ | +| `PROPOSER_SCORE_BOOST` | `uint64(40)` | +| `REORG_HEAD_WEIGHT_THRESHOLD` | `uint64(20)` | +| `REORG_PARENT_WEIGHT_THRESHOLD` | `uint64(160)`| +| `REORG_MAX_EPOCHS_SINCE_FINALIZATION` | `Epoch(2)` | -- The proposer score boost is worth `PROPOSER_SCORE_BOOST` percentage of the committee's weight, i.e., for slot with committee weight `committee_weight` the boost weight is equal to `(committee_weight * PROPOSER_SCORE_BOOST) // 100`. +- The proposer score boost and re-org weight threshold are percentage + values that are measured with respect to the weight of a single committee. See + `calculate_committee_fraction`. ### Helpers @@ -115,6 +130,7 @@ class Store(object): equivocating_indices: Set[ValidatorIndex] blocks: Dict[Root, BeaconBlock] = field(default_factory=dict) block_states: Dict[Root, BeaconState] = field(default_factory=dict) + block_timeliness: Dict[Root, boolean] = field(default_factory=dict) checkpoint_states: Dict[Checkpoint, BeaconState] = field(default_factory=dict) latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict) unrealized_justifications: Dict[Root, Checkpoint] = field(default_factory=dict) @@ -191,6 +207,14 @@ def get_ancestor(store: Store, root: Root, slot: Slot) -> Root: return root ``` +#### `calculate_committee_fraction` + +```python +def calculate_committee_fraction(state: BeaconState, committee_percent: uint64) -> Gwei: + committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH + return Gwei((committee_weight * committee_percent) // 100) +``` + #### `get_checkpoint_block` ```python @@ -225,8 +249,7 @@ def get_weight(store: Store, root: Root) -> Gwei: proposer_score = Gwei(0) # Boost is applied if ``root`` is an ancestor of ``proposer_boost_root`` if get_ancestor(store, store.proposer_boost_root, store.blocks[root].slot) == root: - committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH - proposer_score = (committee_weight * PROPOSER_SCORE_BOOST) // 100 + proposer_score = calculate_committee_fraction(state, PROPOSER_SCORE_BOOST) return attestation_score + proposer_score ``` @@ -247,7 +270,6 @@ def get_voting_source(store: Store, block_root: Root) -> Checkpoint: # The block is not from a prior epoch, therefore the voting source is not pulled up head_state = store.block_states[block_root] return head_state.current_justified_checkpoint - ``` #### `filter_block_tree` @@ -374,7 +396,113 @@ def update_unrealized_checkpoints(store: Store, unrealized_justified_checkpoint: if unrealized_finalized_checkpoint.epoch > store.unrealized_finalized_checkpoint.epoch: store.unrealized_finalized_checkpoint = unrealized_finalized_checkpoint ``` +#### Proposer head and reorg helpers + +_Implementing these helpers is optional_. + +##### `is_head_late` +```python +def is_head_late(store: Store, head_root: Root) -> bool: + return not store.block_timeliness[head_root] +``` + +##### `is_shuffling_stable` +```python +def is_shuffling_stable(slot: Slot) -> bool: + return slot % SLOTS_PER_EPOCH != 0 +``` + +##### `is_ffg_competitive` + +```python +def is_ffg_competitive(store: Store, head_root: Root, parent_root: Root) -> bool: + return (store.unrealized_justifications[head_root] == store.unrealized_justifications[parent_root]) +``` + +##### `is_finalization_ok` + +```python +def is_finalization_ok(store: Store, slot: Slot) -> bool: + epochs_since_finalization = compute_epoch_at_slot(slot) - store.finalized_checkpoint.epoch + return epochs_since_finalization <= REORG_MAX_EPOCHS_SINCE_FINALIZATION +``` + +##### `is_proposing_on_time` + +```python +def is_proposing_on_time(store: Store) -> bool: + # Use half `SECONDS_PER_SLOT // INTERVALS_PER_SLOT` as the proposer reorg deadline + time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT + proposer_reorg_cutoff = SECONDS_PER_SLOT // INTERVALS_PER_SLOT // 2 + return time_into_slot <= proposer_reorg_cutoff +``` + +##### `is_head_weak` + +```python +def is_head_weak(store: Store, head_root: Root) -> bool: + justified_state = store.checkpoint_states[store.justified_checkpoint] + reorg_threshold = calculate_committee_fraction(justified_state, REORG_HEAD_WEIGHT_THRESHOLD) + head_weight = get_weight(store, head_root) + return head_weight < reorg_threshold +``` + +##### `is_parent_strong` + +```python +def is_parent_strong(store: Store, parent_root: Root) -> bool: + justified_state = store.checkpoint_states[store.justified_checkpoint] + parent_threshold = calculate_committee_fraction(justified_state, REORG_PARENT_WEIGHT_THRESHOLD) + parent_weight = get_weight(store, parent_root) + return parent_weight > parent_threshold +``` + +##### `get_proposer_head` + +```python +def get_proposer_head(store: Store, head_root: Root, slot: Slot) -> Root: + head_block = store.blocks[head_root] + parent_root = head_block.parent_root + parent_block = store.blocks[parent_root] + + # Only re-org the head block if it arrived later than the attestation deadline. + head_late = is_head_late(store, head_root) + + # Do not re-org on an epoch boundary where the proposer shuffling could change. + shuffling_stable = is_shuffling_stable(slot) + + # Ensure that the FFG information of the new head will be competitive with the current head. + ffg_competitive = is_ffg_competitive(store, head_root, parent_root) + + # Do not re-org if the chain is not finalizing with acceptable frequency. + finalization_ok = is_finalization_ok(store, slot) + + # Only re-org if we are proposing on-time. + proposing_on_time = is_proposing_on_time(store) + + # Only re-org a single slot at most. + parent_slot_ok = parent_block.slot + 1 == head_block.slot + current_time_ok = head_block.slot + 1 == slot + single_slot_reorg = parent_slot_ok and current_time_ok + + # Check that the head has few enough votes to be overpowered by our proposer boost. + assert store.proposer_boost_root != head_root # ensure boost has worn off + head_weak = is_head_weak(store, head_root) + + # Check that the missing votes are assigned to the parent and not being hoarded. + parent_strong = is_parent_strong(store, parent_root) + + if all([head_late, shuffling_stable, ffg_competitive, finalization_ok, + proposing_on_time, single_slot_reorg, head_weak, parent_strong]): + # We can re-org the current head by building upon its parent block. + return parent_root + else: + return head_root +``` +*Note*: The ordering of conditions is a suggestion only. Implementations are free to +optimize by re-ordering the conditions from least to most expensive and by returning early if +any of the early conditions are `False`. #### Pull-up tip helpers @@ -536,11 +664,15 @@ def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: # Add new state for this block to the store store.block_states[block_root] = state - # Add proposer score boost if the block is timely + # Add block timeliness to the store time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT + is_timely = get_current_slot(store) == block.slot and is_before_attesting_interval + store.block_timeliness[hash_tree_root(block)] = is_timely + + # Add proposer score boost if the block is timely and not conflicting with an existing block is_first_block = store.proposer_boost_root == Root() - if get_current_slot(store) == block.slot and is_before_attesting_interval and is_first_block: + if is_timely and is_first_block: store.proposer_boost_root = hash_tree_root(block) # Update checkpoints in store if necessary diff --git a/specs/phase0/validator.md b/specs/phase0/validator.md index 86b230654c..533cdeabcc 100644 --- a/specs/phase0/validator.md +++ b/specs/phase0/validator.md @@ -274,15 +274,22 @@ A validator has two primary responsibilities to the beacon chain: [proposing blo A validator is expected to propose a [`SignedBeaconBlock`](./beacon-chain.md#signedbeaconblock) at the beginning of any `slot` during which `is_proposer(state, validator_index)` returns `True`. -To propose, the validator selects the `BeaconBlock`, `parent` which: - -1. In their view of fork choice is the head of the chain at the start of - `slot`, after running `on_tick` and applying any queued attestations from `slot - 1`. -2. Is from a slot strictly less than the slot of the block about to be proposed, - i.e. `parent.slot < slot`. +To propose, the validator selects a `BeaconBlock`, `parent` using this process: + +1. Compute fork choice's view of the head at the start of `slot`, after running + `on_tick` and applying any queued attestations from `slot - 1`. + Set `head_root = get_head(store)`. +2. Compute the _proposer head_, which is the head upon which the proposer SHOULD build in order to + incentivise timely block propagation by other validators. + Set `parent_root = get_proposer_head(store, head_root, slot)`. + A proposer may set `parent_root == head_root` if proposer re-orgs are not implemented or have + been disabled. +3. Let `parent` be the block with `parent_root`. The validator creates, signs, and broadcasts a `block` that is a child of `parent` -that satisfies a valid [beacon chain state transition](./beacon-chain.md#beacon-chain-state-transition-function). +and satisfies a valid [beacon chain state transition](./beacon-chain.md#beacon-chain-state-transition-function). +Note that the parent's slot must be strictly less than the slot of the block about to be proposed, +i.e. `parent.slot < slot`. There is one proposer per slot, so if there are N active validators any individual validator will on average be assigned to propose once per N slots (e.g. at 312,500 validators = 10 million ETH, that's once per ~6 weeks). diff --git a/tests/core/pyspec/eth2spec/test/bellatrix/fork_choice/test_should_override_forkchoice_update.py b/tests/core/pyspec/eth2spec/test/bellatrix/fork_choice/test_should_override_forkchoice_update.py new file mode 100644 index 0000000000..465a00f1a9 --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/bellatrix/fork_choice/test_should_override_forkchoice_update.py @@ -0,0 +1,186 @@ +from eth2spec.test.context import ( + spec_state_test, + with_bellatrix_and_later, + with_presets, +) +from eth2spec.test.helpers.constants import ( + MINIMAL, +) +from eth2spec.test.helpers.attestations import ( + get_valid_attestation_at_slot, +) +from eth2spec.test.helpers.block import ( + build_empty_block_for_next_slot, +) +from eth2spec.test.helpers.fork_choice import ( + apply_next_epoch_with_attestations, + apply_next_slots_with_attestations, + get_genesis_forkchoice_store_and_block, + on_tick_and_append_step, + output_store_checks, + tick_and_add_block, + tick_and_run_on_attestation, +) +from eth2spec.test.helpers.state import ( + state_transition_and_sign_block, + next_epoch, + next_slot, +) + + +@with_bellatrix_and_later +@spec_state_test +@with_presets([MINIMAL], reason="too slow") +def test_should_override_forkchoice_update__false(spec, state): + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving a block of `GENESIS_SLOT + 1` slot + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block, test_steps) + assert spec.get_head(store) == signed_block.message.hash_tree_root() + + # Proposer of next slot + head_root = spec.get_head(store) + + # Next slot + next_slot(spec, state) + slot = state.slot + + current_time = slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + + should_override = spec.should_override_forkchoice_update(store, head_root) + assert not should_override + + output_store_checks(spec, store, test_steps) + test_steps.append({ + 'checks': { + 'should_override_forkchoice_update': { + 'validator_is_connected': True, + 'result': should_override, + }, + } + }) + + yield 'steps', test_steps + + +@with_bellatrix_and_later +@spec_state_test +def test_should_override_forkchoice_update__true(spec, state): + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + next_epoch(spec, state) + on_tick_and_append_step(spec, store, store.genesis_time + state.slot * spec.config.SECONDS_PER_SLOT, test_steps) + + # Fill epoch 1 to 3 + for _ in range(3): + state, store, _ = yield from apply_next_epoch_with_attestations( + spec, state, store, True, True, test_steps=test_steps) + + assert spec.compute_epoch_at_slot(spec.get_current_slot(store)) == 4 + assert state.current_justified_checkpoint.epoch == store.justified_checkpoint.epoch == 3 + assert state.finalized_checkpoint.epoch == store.finalized_checkpoint.epoch == 2 + + # Make an empty block + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block, test_steps) + + # Fill a slot (parent) + state, store, signed_parent_block = yield from apply_next_slots_with_attestations( + spec, state, store, 1, True, True, test_steps) + + # Fill a slot with attestations to its parent + block = build_empty_block_for_next_slot(spec, state) + parent_block_slot = block.slot - 1 + block.body.attestations = get_valid_attestation_at_slot( + state, + spec, + parent_block_slot, + ) + signed_block = state_transition_and_sign_block(spec, state, block) + + # Make the head block late + attesting_cutoff = spec.config.SECONDS_PER_SLOT // spec.INTERVALS_PER_SLOT + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + attesting_cutoff + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + yield from tick_and_add_block(spec, store, signed_block, test_steps) + assert spec.get_current_slot(store) == block.slot + + # Check conditions + head_root = spec.get_head(store) + head_block = store.blocks[head_root] + parent_root = head_block.parent_root + assert parent_root == signed_parent_block.message.hash_tree_root() + parent_block = store.blocks[parent_root] + + # Add attestations to the parent block + temp_state = state.copy() + next_slot(spec, temp_state) + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + 1 + on_tick_and_append_step(spec, store, current_time, test_steps) + attestations = get_valid_attestation_at_slot( + temp_state, + spec, + slot_to_attest=temp_state.slot - 1, + beacon_block_root=parent_root, + ) + current_slot = spec.get_current_slot(store) + for attestation in attestations: + yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) + + current_slot = spec.get_current_slot(store) + proposal_slot = head_block.slot + 1 + + # The conditions in `get_proposer_head` + assert spec.is_head_late(store, head_root) + assert spec.is_shuffling_stable(proposal_slot) + assert spec.is_ffg_competitive(store, head_root, parent_root) + assert spec.is_finalization_ok(store, proposal_slot) + + parent_state_advanced = store.block_states[parent_root].copy() + spec.process_slots(parent_state_advanced, proposal_slot) + proposer_index = spec.get_beacon_proposer_index(parent_state_advanced) + assert spec.validator_is_connected(proposer_index) + + # Single slot re-org. + parent_slot_ok = parent_block.slot + 1 == head_block.slot + proposing_on_time = spec.is_proposing_on_time(store) + assert proposing_on_time + assert parent_slot_ok and proposal_slot == current_slot and proposing_on_time + + assert spec.is_head_weak(store, head_root) + assert spec.is_parent_strong(store, parent_root) + + should_override = spec.should_override_forkchoice_update(store, head_root) + assert should_override + + output_store_checks(spec, store, test_steps) + test_steps.append({ + 'checks': { + 'should_override_forkchoice_update': { + 'validator_is_connected': True, + 'result': should_override, + }, + } + }) + + yield 'steps', test_steps diff --git a/tests/core/pyspec/eth2spec/test/helpers/attestations.py b/tests/core/pyspec/eth2spec/test/helpers/attestations.py index 4899e62243..6cd35c5380 100644 --- a/tests/core/pyspec/eth2spec/test/helpers/attestations.py +++ b/tests/core/pyspec/eth2spec/test/helpers/attestations.py @@ -51,19 +51,21 @@ def run_attestation_processing(spec, state, attestation, valid=True): yield 'post', state -def build_attestation_data(spec, state, slot, index, shard=None): +def build_attestation_data(spec, state, slot, index, beacon_block_root=None, shard=None): assert state.slot >= slot - if slot == state.slot: - block_root = build_empty_block_for_next_slot(spec, state).parent_root + if beacon_block_root is not None: + pass + elif slot == state.slot: + beacon_block_root = build_empty_block_for_next_slot(spec, state).parent_root else: - block_root = spec.get_block_root_at_slot(state, slot) + beacon_block_root = spec.get_block_root_at_slot(state, slot) current_epoch_start_slot = spec.compute_start_slot_at_epoch(spec.get_current_epoch(state)) if slot < current_epoch_start_slot: epoch_boundary_root = spec.get_block_root(state, spec.get_previous_epoch(state)) elif slot == current_epoch_start_slot: - epoch_boundary_root = block_root + epoch_boundary_root = beacon_block_root else: epoch_boundary_root = spec.get_block_root(state, spec.get_current_epoch(state)) @@ -77,7 +79,7 @@ def build_attestation_data(spec, state, slot, index, shard=None): data = spec.AttestationData( slot=slot, index=index, - beacon_block_root=block_root, + beacon_block_root=beacon_block_root, source=spec.Checkpoint(epoch=source_epoch, root=source_root), target=spec.Checkpoint(epoch=spec.compute_epoch_at_slot(slot), root=epoch_boundary_root), ) @@ -91,6 +93,7 @@ def get_valid_attestation(spec, slot=None, index=None, filter_participant_set=None, + beacon_block_root=None, signed=False): # If filter_participant_set filters everything, the attestation has 0 participants, and cannot be signed. # Thus strictly speaking invalid when no participant is added later. @@ -99,9 +102,7 @@ def get_valid_attestation(spec, if index is None: index = 0 - attestation_data = build_attestation_data( - spec, state, slot=slot, index=index - ) + attestation_data = build_attestation_data(spec, state, slot=slot, index=index, beacon_block_root=beacon_block_root) beacon_committee = spec.get_beacon_committee( state, @@ -195,7 +196,7 @@ def add_attestations_to_state(spec, state, attestations, slot): spec.process_attestation(state, attestation) -def get_valid_attestation_at_slot(state, spec, slot_to_attest, participation_fn=None): +def get_valid_attestation_at_slot(state, spec, slot_to_attest, participation_fn=None, beacon_block_root=None): committees_per_slot = spec.get_committee_count_per_slot(state, spec.compute_epoch_at_slot(slot_to_attest)) for index in range(committees_per_slot): def participants_filter(comm): @@ -210,7 +211,8 @@ def participants_filter(comm): slot_to_attest, index=index, signed=True, - filter_participant_set=participants_filter + filter_participant_set=participants_filter, + beacon_block_root=beacon_block_root, ) diff --git a/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py b/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py index e0e3547222..ef80a3ab6f 100644 --- a/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py +++ b/tests/core/pyspec/eth2spec/test/helpers/fork_choice.py @@ -92,14 +92,11 @@ def add_attestations(spec, store, attestations, test_steps, is_from_block=False) def tick_and_run_on_attestation(spec, store, attestation, test_steps, is_from_block=False): - parent_block = store.blocks[attestation.data.beacon_block_root] - pre_state = store.block_states[spec.hash_tree_root(parent_block)] - block_time = pre_state.genesis_time + parent_block.slot * spec.config.SECONDS_PER_SLOT - next_epoch_time = block_time + spec.SLOTS_PER_EPOCH * spec.config.SECONDS_PER_SLOT - - if store.time < next_epoch_time: - spec.on_tick(store, next_epoch_time) - test_steps.append({'tick': int(next_epoch_time)}) + # Make get_current_slot(store) >= attestation.data.slot + 1 + min_time_to_include = (attestation.data.slot + 1) * spec.config.SECONDS_PER_SLOT + if store.time < min_time_to_include: + spec.on_tick(store, min_time_to_include) + test_steps.append({'tick': int(min_time_to_include)}) yield from add_attestation(spec, store, attestation, test_steps, is_from_block) diff --git a/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_get_proposer_head.py b/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_get_proposer_head.py new file mode 100644 index 0000000000..249e76b08a --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/phase0/fork_choice/test_get_proposer_head.py @@ -0,0 +1,167 @@ +from eth_utils import encode_hex +from eth2spec.test.context import ( + spec_state_test, + with_altair_and_later, +) +from eth2spec.test.helpers.attestations import ( + get_valid_attestation_at_slot, +) +from eth2spec.test.helpers.block import ( + build_empty_block_for_next_slot, +) +from eth2spec.test.helpers.fork_choice import ( + apply_next_epoch_with_attestations, + apply_next_slots_with_attestations, + get_genesis_forkchoice_store_and_block, + on_tick_and_append_step, + output_store_checks, + tick_and_add_block, + tick_and_run_on_attestation, +) +from eth2spec.test.helpers.state import ( + next_epoch, + next_slot, + state_transition_and_sign_block, +) + + +@with_altair_and_later +@spec_state_test +def test_basic_is_head_root(spec, state): + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + # On receiving a block of `GENESIS_SLOT + 1` slot + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block, test_steps) + assert spec.get_head(store) == signed_block.message.hash_tree_root() + + # Proposer of next slot + head_root = spec.get_head(store) + + # Proposing next slot + next_slot(spec, state) + slot = state.slot + + current_time = slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + proposer_head = spec.get_proposer_head(store, head_root, slot) + assert proposer_head == head_root + + output_store_checks(spec, store, test_steps) + test_steps.append({ + 'checks': { + 'get_proposer_head': encode_hex(proposer_head), + } + }) + + yield 'steps', test_steps + + +@with_altair_and_later +@spec_state_test +def test_basic_is_parent_root(spec, state): + test_steps = [] + # Initialization + store, anchor_block = get_genesis_forkchoice_store_and_block(spec, state) + yield 'anchor_state', state + yield 'anchor_block', anchor_block + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + next_epoch(spec, state) + on_tick_and_append_step(spec, store, store.genesis_time + state.slot * spec.config.SECONDS_PER_SLOT, test_steps) + + # Fill epoch 1 to 3 + for _ in range(3): + state, store, _ = yield from apply_next_epoch_with_attestations( + spec, state, store, True, True, test_steps=test_steps) + + assert spec.compute_epoch_at_slot(spec.get_current_slot(store)) == 4 + assert state.current_justified_checkpoint.epoch == store.justified_checkpoint.epoch == 3 + assert state.finalized_checkpoint.epoch == store.finalized_checkpoint.epoch == 2 + + # Make an empty block + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + yield from tick_and_add_block(spec, store, signed_block, test_steps) + + # Fill a slot (parent) + state, store, signed_parent_block = yield from apply_next_slots_with_attestations( + spec, state, store, 1, True, True, test_steps) + + # Fill a slot with attestations to its parent + block = build_empty_block_for_next_slot(spec, state) + parent_block_slot = block.slot - 1 + block.body.attestations = get_valid_attestation_at_slot( + state, + spec, + parent_block_slot, + ) + signed_block = state_transition_and_sign_block(spec, state, block) + + # Make the head block late + attesting_cutoff = spec.config.SECONDS_PER_SLOT // spec.INTERVALS_PER_SLOT + current_time = state.slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + attesting_cutoff + on_tick_and_append_step(spec, store, current_time, test_steps) + assert store.time == current_time + + yield from tick_and_add_block(spec, store, signed_block, test_steps) + + # Check conditions + head_root = spec.get_head(store) + head_block = store.blocks[head_root] + parent_root = head_block.parent_root + assert parent_root == signed_parent_block.message.hash_tree_root() + parent_block = store.blocks[parent_root] + + # Proposing next slot + next_slot(spec, state) + slot = state.slot + + # Add attestations to the parent block + current_time = slot * spec.config.SECONDS_PER_SLOT + store.genesis_time + on_tick_and_append_step(spec, store, current_time, test_steps) + attestations = get_valid_attestation_at_slot( + state, + spec, + slot_to_attest=slot - 1, + beacon_block_root=parent_root, + ) + for attestation in attestations: + yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) + + # The conditions in `get_proposer_head` + assert spec.is_head_late(store, head_root) + assert spec.is_shuffling_stable(slot) + assert spec.is_ffg_competitive(store, head_root, parent_root) + assert spec.is_finalization_ok(store, slot) + assert spec.is_proposing_on_time(store) + + parent_slot_ok = parent_block.slot + 1 == head_block.slot + current_time_ok = head_block.slot + 1 == slot + single_slot_reorg = parent_slot_ok and current_time_ok + assert single_slot_reorg + + assert spec.is_head_weak(store, head_root) + assert spec.is_parent_strong(store, parent_root) + + proposer_head = spec.get_proposer_head(store, head_root, state.slot) + assert proposer_head == parent_root + + output_store_checks(spec, store, test_steps) + test_steps.append({ + 'checks': { + 'get_proposer_head': encode_hex(proposer_head), + } + }) + + yield 'steps', test_steps diff --git a/tests/formats/fork_choice/README.md b/tests/formats/fork_choice/README.md index d23de865b3..1258a66c06 100644 --- a/tests/formats/fork_choice/README.md +++ b/tests/formats/fork_choice/README.md @@ -142,7 +142,7 @@ Optional step for optimistic sync tests. } ``` -This step sets the [`payloadStatus`](https://github.com/ethereum/execution-apis/blob/main/src/engine/specification.md#PayloadStatusV1) +This step sets the [`payloadStatus`](https://github.com/ethereum/execution-apis/blob/main/src/engine/paris.md#payloadstatusv1) value that Execution Layer client mock returns in responses to the following Engine API calls: * [`engine_newPayloadV1(payload)`](https://github.com/ethereum/execution-apis/blob/main/src/engine/paris.md#engine_newpayloadv1) if `payload.blockHash == payload_info.block_hash` * [`engine_forkchoiceUpdatedV1(forkchoiceState, ...)`](https://github.com/ethereum/execution-apis/blob/main/src/engine/paris.md#engine_forkchoiceupdatedv1) if `forkchoiceState.headBlockHash == payload_info.block_hash` @@ -159,7 +159,7 @@ The checks to verify the current status of `store`. checks: {: value} -- the assertions. ``` -`` is the field member or property of [`Store`](../../../specs/phase0/fork-choice.md#store) object that maintained by client implementation. Currently, the possible fields included: +`` is the field member or property of [`Store`](../../../specs/phase0/fork-choice.md#store) object that maintained by client implementation. The fields include: ```yaml head: { @@ -179,6 +179,16 @@ finalized_checkpoint: { proposer_boost_root: string -- Encoded 32-byte value from store.proposer_boost_root ``` +Additionally, these fields if `get_proposer_head` and `should_override_forkchoice_update` features are implemented: + +```yaml +get_proposer_head: string -- Encoded 32-byte value from get_proposer_head(store) +should_override_forkchoice_update: { -- [New in Bellatrix] + validator_is_connected: bool, -- The mocking result of `validator_is_connected(proposer_index)` in this call + result: bool, -- The result of `should_override_forkchoice_update(store, head_root)`, where head_root is the result value from get_head(store) +} +``` + For example: ```yaml - checks: @@ -187,6 +197,8 @@ For example: justified_checkpoint: {epoch: 3, root: '0xc25faab4acab38d3560864ca01e4d5cc4dc2cd473da053fbc03c2669143a2de4'} finalized_checkpoint: {epoch: 2, root: '0x40d32d6283ec11c53317a46808bc88f55657d93b95a1af920403187accf48f4f'} proposer_boost_root: '0xdaa1d49d57594ced0c35688a6da133abb086d191a2ebdfd736fad95299325aeb' + get_proposer_head: '0xdaa1d49d57594ced0c35688a6da133abb086d191a2ebdfd736fad95299325aeb' + should_override_forkchoice_update: {validator_is_connected: false, result: false} ``` *Note*: Each `checks` step may include one or multiple items. Each item has to be checked against the current store. diff --git a/tests/generators/fork_choice/main.py b/tests/generators/fork_choice/main.py index 7ff028cd80..df117ccd16 100644 --- a/tests/generators/fork_choice/main.py +++ b/tests/generators/fork_choice/main.py @@ -10,12 +10,14 @@ 'ex_ante', 'reorg', 'withholding', + 'get_proposer_head', ]} # For merge `on_merge_block` test kind added with `pow_block_N.ssz` files with several # PowBlock's which should be resolved by `get_pow_block(hash: Hash32) -> PowBlock` function _new_bellatrix_mods = {key: 'eth2spec.test.bellatrix.fork_choice.test_' + key for key in [ 'on_merge_block', + 'should_override_forkchoice_update', ]} bellatrix_mods = combine_mods(_new_bellatrix_mods, altair_mods) capella_mods = bellatrix_mods # No additional Capella specific fork choice tests