diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 7bd3d4279db..7a71ac236a9 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -486,6 +486,10 @@ def should_apply_proposer_boost(store: Store) -> bool: if store.proposer_boost_root == Root(): return False + # Withhold boost if the boosted block's proposer has equivocated + if is_proposer_equivocation(store, store.proposer_boost_root): + return False + block = store.blocks[store.proposer_boost_root] parent_root = block.parent_root parent = store.blocks[parent_root] diff --git a/specs/gloas/p2p-interface.md b/specs/gloas/p2p-interface.md index f55ae7d499a..0efeb4983f2 100644 --- a/specs/gloas/p2p-interface.md +++ b/specs/gloas/p2p-interface.md @@ -563,7 +563,9 @@ def validate_beacon_block_gossip( if block.slot <= finalized_slot: raise GossipIgnore("block is not from a slot greater than the latest finalized slot") - # [IGNORE] The block is the first block with valid signature received for the slot and proposer + # [IGNORE] The block is the first block with valid signature received for the slot and proposer. + # Note: Implementations SHOULD still pass this block to `on_block` so both blocks enter + # `store.blocks` and `is_proposer_equivocation()` can observe the equivocation. proposer_slot_key = (block.slot, block.proposer_index) if proposer_slot_key in seen.proposer_slots: raise GossipIgnore("block is not the first valid block for this slot and proposer") diff --git a/specs/phase0/p2p-interface.md b/specs/phase0/p2p-interface.md index ff2dfb1e640..fbb3e9afe96 100644 --- a/specs/phase0/p2p-interface.md +++ b/specs/phase0/p2p-interface.md @@ -636,7 +636,9 @@ def validate_beacon_block_gossip( if block.slot <= finalized_slot: raise GossipIgnore("block is not from a slot greater than the latest finalized slot") - # [IGNORE] The block is the first block with valid signature received for the slot and proposer + # [IGNORE] The block is the first block with valid signature received for the slot and proposer. + # Note: Implementations SHOULD still pass this block to `on_block` so both blocks enter + # `store.blocks` and `is_proposer_equivocation()` can observe the equivocation. proposer_slot_key = (block.slot, block.proposer_index) if proposer_slot_key in seen.proposer_slots: raise GossipIgnore("block is not the first valid block for this slot and proposer") diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_should_apply_proposer_boost.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_should_apply_proposer_boost.py index 739bf4a5004..1eb3c06d4aa 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_should_apply_proposer_boost.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_should_apply_proposer_boost.py @@ -234,3 +234,52 @@ def test_should_apply_proposer_boost_withheld(spec, state): output_store_checks(spec, store, test_steps, with_viable_for_head_weights=True) yield "steps", test_steps + + +@with_gloas_and_later +@with_presets([MINIMAL], reason="too slow") +@spec_state_test +def test_should_apply_proposer_boost_proposer_equivocation(spec, state): + """ + The boosted block's own proposer equivocated: a second block from the same + proposer at the boosted block's slot. Boost is withheld regardless of the + parent-adjacency and weakness conditions checked later in the function. Here the + parent is two slots back, so absent the equivocation the "not adjacent" escape + would apply the boost; the equivocation overrides it. + """ + store, state, test_steps = yield from setup_finalized_store(spec, state) + + # Leave a gap so the boosted block's parent is two slots back (non-adjacent), + # which on its own would make should_apply_proposer_boost return True. + next_slot(spec, state) + + pre_state = state.copy() + block = build_empty_block_for_next_slot(spec, state) + signed_block = state_transition_and_sign_block(spec, state, block) + block_root = signed_block.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_block, test_steps) + assert store.proposer_boost_root == block_root + # The boosted block's parent is two slots back (empty slot from next_slot above). + assert store.blocks[block.parent_root].slot + 2 == block.slot + + # Baseline: without an equivocation, the boost applies (non-adjacent escape). + assert spec.should_apply_proposer_boost(store) is True + _assert_weight_reflects_boost(spec, store, block_root, boost_applied=True) + + # Build a same-slot same-proposer equivocation of the boosted block. + equivocation_state = pre_state.copy() + equivocation_block = build_empty_block(spec, equivocation_state, slot=block.slot) + equivocation_block.body.graffiti = spec.Bytes32(b"\x01" * 32) + signed_equivocation = state_transition_and_sign_block( + spec, equivocation_state, equivocation_block + ) + assert signed_equivocation.message.proposer_index == signed_block.message.proposer_index + assert signed_equivocation.message.hash_tree_root() != block_root + yield from add_block(spec, store, signed_equivocation, test_steps) + + # The boosted block's proposer has now equivocated -> boost is withheld. + assert spec.should_apply_proposer_boost(store) is False + _assert_weight_reflects_boost(spec, store, block_root, boost_applied=False) + + output_store_checks(spec, store, test_steps, with_viable_for_head_weights=True) + yield "steps", test_steps