From fc5b9034b8921f50c48931764e6af58daea8d757 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Sun, 2 Aug 2026 19:47:05 +0000 Subject: [PATCH 01/20] FFG-test payload-status variants individually in `filter_block_tree` --- specs/gloas/fork-choice.md | 125 ++++++++++++++-- .../test_filter_block_tree_variants.py | 135 ++++++++++++++++++ .../fork_choice/instantiators/block_cover.py | 7 +- 3 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 509b9f74f41..0e5b497e072 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -33,6 +33,8 @@ - [New `get_payload_status_tiebreaker`](#new-get_payload_status_tiebreaker) - [New `should_apply_proposer_boost`](#new-should_apply_proposer_boost) - [Modified `get_weight`](#modified-get_weight) + - [Modified `filter_block_tree`](#modified-filter_block_tree) + - [Modified `get_filtered_block_tree`](#modified-get_filtered_block_tree) - [Modified `get_node_children`](#modified-get_node_children) - [Modified `get_head`](#modified-get_head) - [Modified `get_latest_message_epoch`](#modified-get_latest_message_epoch) @@ -537,27 +539,130 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei: return attestation_score + proposer_score ``` -### Modified `get_node_children` +### Modified `filter_block_tree` + +*Note*: External calls to `filter_block_tree` (i.e., any calls that are not made +by the recursive logic in this function) MUST set `node` to a pending +`ForkChoiceNode` with root `store.justified_checkpoint.root`. -*Note*: This function is modified to introduce new type of children nodes -representing *full* and *empty* blocks. +*Note*: This function is modified to operate on payload-status variants instead +of blocks, so that each variant is FFG-tested independently. A variant is +identified by the tuple `(root, payload_status)`. The FFG test itself is +computed per block root and is unchanged. ```python -def get_node_children( - store: Store, blocks: Dict[Root, BeaconBlock], node: ForkChoiceNode -) -> Sequence[ForkChoiceNode]: +def filter_block_tree( + store: Store, node: ForkChoiceNode, blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] +) -> bool: + block = store.blocks[node.root] + # Expand a pending node into its empty and full variants, and an empty or + # full node into the pending nodes of its children blocks + # [Modified in Gloas:EIP7732] if node.payload_status == PAYLOAD_STATUS_PENDING: + # [New in Gloas:EIP7732] children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)] if is_payload_verified(store, node.root): children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL)) - return children else: + # [New in Gloas:EIP7732] + children = [ + ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) + for root in store.blocks + if ( + store.blocks[root].parent_root == node.root + and node.payload_status == get_parent_payload_status(store, store.blocks[root]) + ) + ] + + # If any children branches contain expected finalized/justified checkpoints, + # add to filtered block-tree and signal viability to parent. + if any(children): + filter_block_tree_result = [filter_block_tree(store, child, blocks) for child in children] + if any(filter_block_tree_result): + # [Modified in Gloas:EIP7732] + blocks[(node.root, node.payload_status)] = block + return True + return False + + current_epoch = get_current_store_epoch(store) + voting_source = get_voting_source(store, node.root) + + # The voting source should be either at the same height as the store's justified checkpoint or + # not more than two epochs ago + correct_justified = ( + store.justified_checkpoint.epoch == GENESIS_EPOCH + or voting_source.epoch == store.justified_checkpoint.epoch + or voting_source.epoch + 2 >= current_epoch + ) + + finalized_checkpoint_block = get_checkpoint_block( + store, + node.root, + store.finalized_checkpoint.epoch, + ) + + correct_finalized = ( + store.finalized_checkpoint.epoch == GENESIS_EPOCH + or store.finalized_checkpoint.root == finalized_checkpoint_block + ) + + # If expected finalized/justified, add to viable block-tree and signal viability to parent. + if correct_justified and correct_finalized: + # [Modified in Gloas:EIP7732] + blocks[(node.root, node.payload_status)] = block + return True + + # Otherwise, branch not viable + return False +``` + +### Modified `get_filtered_block_tree` + +```python +def get_filtered_block_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], BeaconBlock]: + """ + Retrieve a filtered block tree from ``store``, only returning branches + whose leaf state's justified/finalized info agrees with that in ``store``. + """ + # [Modified in Gloas:EIP7732] + base = ForkChoiceNode( + root=store.justified_checkpoint.root, + payload_status=PAYLOAD_STATUS_PENDING, + ) + # [Modified in Gloas:EIP7732] + blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] = {} + filter_block_tree(store, base, blocks) + return blocks +``` + +### Modified `get_node_children` + +*Note*: This function is modified to only return children that are present in +the filtered block tree, so that payload-status variants that are not FFG-viable +are not considered for the head. + +```python +def get_node_children( + store: Store, blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock], node: ForkChoiceNode +) -> Sequence[ForkChoiceNode]: + if node.payload_status == PAYLOAD_STATUS_PENDING: + # [New in Gloas:EIP7732] + return [ + ForkChoiceNode(root=root, payload_status=payload_status) + for root, payload_status in [ + (node.root, PAYLOAD_STATUS_EMPTY), + (node.root, PAYLOAD_STATUS_FULL), + ] + if (root, payload_status) in blocks + ] + else: + # [Modified in Gloas:EIP7732] return [ ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) - for root in blocks + for (root, payload_status), block in blocks.items() if ( - blocks[root].parent_root == node.root - and node.payload_status == get_parent_payload_status(store, blocks[root]) + block.parent_root == node.root + and node.payload_status == get_parent_payload_status(store, block) ) ] ``` diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py new file mode 100644 index 00000000000..309fabe59a3 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py @@ -0,0 +1,135 @@ +from eth_consensus_specs.test.context import ( + spec_state_test, + with_gloas_and_later, + with_presets, +) +from eth_consensus_specs.test.helpers.attestations import get_valid_attestation +from eth_consensus_specs.test.helpers.block import build_empty_block_for_next_slot +from eth_consensus_specs.test.helpers.constants import MINIMAL +from eth_consensus_specs.test.helpers.execution_payload import ( + build_signed_execution_payload_envelope, +) +from eth_consensus_specs.test.helpers.fork_choice import ( + add_execution_payload, + on_tick_and_append_step, + output_head_check, + setup_finalized_store, + tick_and_add_block, + tick_and_run_on_attestation, +) +from eth_consensus_specs.test.helpers.state import ( + next_slots, + state_transition_and_sign_block, +) + + +@with_gloas_and_later +@with_presets([MINIMAL], reason="too slow") +@spec_state_test +def test_get_head_prunes_childless_unviable_full_variant(spec, state): + """ + Reproduces issue #5496: a childless FULL payload-status variant of a block + that fails the FFG test must not be returned by get_head. + + Block B is built on the justified checkpoint from a fork of its state, so + its voting source is stale and B fails the FFG test. K builds on EMPTY(B) + and pulls up justification to the store's justified checkpoint (set below, + as in the issue's example), so EMPTY(B) is viable. FULL(B) exists because + B's envelope is delivered, but it is childless and never passes the FFG + test, so get_head must not return it. + """ + store, _, test_steps = yield from setup_finalized_store(spec, state) + + justified_epoch = store.justified_checkpoint.epoch + assert store.finalized_checkpoint.epoch == justified_epoch - 1 + + justified_state = store.block_states[store.justified_checkpoint.root] + + # B is the first block of epoch `justified_epoch + 1` on a fork of the + # justified state. The fork has no on-chain votes, so B's voting source is + # stale and B fails the FFG test. + b_slot = spec.compute_start_slot_at_epoch(justified_epoch + 1) + fork_state = justified_state.copy() + next_slots(spec, fork_state, b_slot - fork_state.slot - 1) + b_block = build_empty_block_for_next_slot(spec, fork_state) + assert b_block.slot == b_slot + signed_b = state_transition_and_sign_block(spec, fork_state, b_block) + b_root = signed_b.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_b, test_steps) + + # Deliver B's envelope so the FULL(B) variant exists + b_state = store.block_states[b_root] + envelope = build_signed_execution_payload_envelope(spec, b_state, b_root, signed_b) + yield from add_execution_payload(spec, store, envelope, test_steps) + assert spec.is_payload_verified(store, b_root) + + # Tick to the start of the next epoch so attestations for B's epoch can be + # processed + k_slot = spec.compute_start_slot_at_epoch(justified_epoch + 2) + on_tick_and_append_step( + spec, store, store.genesis_time + k_slot * spec.config.SLOT_DURATION_MS // 1000, test_steps + ) + + # Attest B's FULL variant with all committees of B's epoch, so the B branch + # outweighs the main chain in the LMD-GHOST walk + committees_per_slot = spec.get_committee_count_per_slot(b_state, justified_epoch + 1) + for slot in range(b_slot + 1, k_slot): + att_state = b_state.copy() + next_slots(spec, att_state, slot - att_state.slot) + for index in range(committees_per_slot): + attestation = get_valid_attestation( + spec, + att_state, + slot=slot, + index=index, + payload_index=1, + beacon_block_root=b_root, + signed=True, + ) + yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) + + # K builds on EMPTY(B) at the start of the next epoch + k_state = b_state.copy() + next_slots(spec, k_state, k_slot - k_state.slot - 1) + k_block = build_empty_block_for_next_slot(spec, k_state) + assert k_block.slot == k_slot + signed_k = state_transition_and_sign_block(spec, k_state, k_block) + k_root = signed_k.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_k, test_steps) + # K must claim an EMPTY parent for the B branch to stay viable + assert spec.get_parent_payload_status(store, signed_k.message) == spec.PAYLOAD_STATUS_EMPTY + + # Simulate K's chain having pulled up justification to the store's justified + # checkpoint, while B's justification remains stale + store.unrealized_justifications[k_root] = store.justified_checkpoint + + # Advance to the next epoch so that B is more than two epochs behind the + # voting source required by the FFG test + next_epoch_slot = spec.compute_start_slot_at_epoch(justified_epoch + 3) + on_tick_and_append_step( + spec, + store, + store.genesis_time + next_epoch_slot * spec.config.SLOT_DURATION_MS // 1000, + test_steps, + ) + + full_b_variant = (b_root, spec.PAYLOAD_STATUS_FULL) + empty_b_variant = (b_root, spec.PAYLOAD_STATUS_EMPTY) + + # B fails the FFG test while K passes it + assert spec.get_voting_source(store, b_root).epoch + 2 < spec.get_current_store_epoch(store) + assert spec.get_voting_source(store, b_root).epoch != store.justified_checkpoint.epoch + assert spec.get_voting_source(store, k_root).epoch == store.justified_checkpoint.epoch + + # The childless FULL(B) variant is not viable and must not be the head + head = spec.get_head(store) + assert (head.root, head.payload_status) != full_b_variant + assert head.root == k_root + assert head.payload_status == spec.PAYLOAD_STATUS_EMPTY + + filtered_tree = spec.get_filtered_block_tree(store) + assert full_b_variant not in filtered_tree + assert empty_b_variant in filtered_tree + + output_head_check(spec, store, test_steps) + yield "steps", test_steps diff --git a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py index 38145b99500..923f220c802 100644 --- a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py +++ b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py @@ -481,4 +481,9 @@ def run_sanity_checks(spec, store, model_params, target_block_root): or predicates["block_vse_eq_store_je"] or predicates["block_vse_plus_two_ge_curr_e"] ): - assert target_block_root in spec.get_filtered_block_tree(store) + filtered_tree = spec.get_filtered_block_tree(store) + if is_post_gloas(spec): + filtered_roots = [root for root, _ in filtered_tree] + else: + filtered_roots = list(filtered_tree) + assert target_block_root in filtered_roots From 3161090356b590a334240e4f35c670086fa3502c Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Mon, 3 Aug 2026 13:01:21 +0000 Subject: [PATCH 02/20] Rename `filter_block_tree` to `filter_node_tree` --- specs/gloas/fork-choice.md | 32 ++++++------ specs/phase0/fork-choice.md | 52 ++++++++++--------- sync/optimistic.md | 2 +- ...s.py => test_filter_node_tree_variants.py} | 2 +- .../test/helpers/fork_choice.py | 2 +- .../test/helpers/optimistic_sync.py | 4 +- .../test/phase0/fork_choice/test_get_head.py | 2 +- .../fork_choice/instantiators/block_cover.py | 6 +-- 8 files changed, 53 insertions(+), 49 deletions(-) rename tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/{test_filter_block_tree_variants.py => test_filter_node_tree_variants.py} (99%) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 0e5b497e072..4e9bed55374 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -33,8 +33,8 @@ - [New `get_payload_status_tiebreaker`](#new-get_payload_status_tiebreaker) - [New `should_apply_proposer_boost`](#new-should_apply_proposer_boost) - [Modified `get_weight`](#modified-get_weight) - - [Modified `filter_block_tree`](#modified-filter_block_tree) - - [Modified `get_filtered_block_tree`](#modified-get_filtered_block_tree) + - [Modified `filter_node_tree`](#modified-filter_node_tree) + - [Modified `get_filtered_node_tree`](#modified-get_filtered_node_tree) - [Modified `get_node_children`](#modified-get_node_children) - [Modified `get_head`](#modified-get_head) - [Modified `get_latest_message_epoch`](#modified-get_latest_message_epoch) @@ -539,9 +539,9 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei: return attestation_score + proposer_score ``` -### Modified `filter_block_tree` +### Modified `filter_node_tree` -*Note*: External calls to `filter_block_tree` (i.e., any calls that are not made +*Note*: External calls to `filter_node_tree` (i.e., any calls that are not made by the recursive logic in this function) MUST set `node` to a pending `ForkChoiceNode` with root `store.justified_checkpoint.root`. @@ -551,7 +551,7 @@ identified by the tuple `(root, payload_status)`. The FFG test itself is computed per block root and is unchanged. ```python -def filter_block_tree( +def filter_node_tree( store: Store, node: ForkChoiceNode, blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] ) -> bool: block = store.blocks[node.root] @@ -575,10 +575,10 @@ def filter_block_tree( ] # If any children branches contain expected finalized/justified checkpoints, - # add to filtered block-tree and signal viability to parent. + # add to filtered node tree and signal viability to parent. if any(children): - filter_block_tree_result = [filter_block_tree(store, child, blocks) for child in children] - if any(filter_block_tree_result): + filter_node_tree_result = [filter_node_tree(store, child, blocks) for child in children] + if any(filter_node_tree_result): # [Modified in Gloas:EIP7732] blocks[(node.root, node.payload_status)] = block return True @@ -606,7 +606,7 @@ def filter_block_tree( or store.finalized_checkpoint.root == finalized_checkpoint_block ) - # If expected finalized/justified, add to viable block-tree and signal viability to parent. + # If expected finalized/justified, add to viable node tree and signal viability to parent. if correct_justified and correct_finalized: # [Modified in Gloas:EIP7732] blocks[(node.root, node.payload_status)] = block @@ -616,12 +616,12 @@ def filter_block_tree( return False ``` -### Modified `get_filtered_block_tree` +### Modified `get_filtered_node_tree` ```python -def get_filtered_block_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], BeaconBlock]: +def get_filtered_node_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], BeaconBlock]: """ - Retrieve a filtered block tree from ``store``, only returning branches + Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. """ # [Modified in Gloas:EIP7732] @@ -631,14 +631,14 @@ def get_filtered_block_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], Be ) # [Modified in Gloas:EIP7732] blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] = {} - filter_block_tree(store, base, blocks) + filter_node_tree(store, base, blocks) return blocks ``` ### Modified `get_node_children` *Note*: This function is modified to only return children that are present in -the filtered block tree, so that payload-status variants that are not FFG-viable +the filtered node tree, so that payload-status variants that are not FFG-viable are not considered for the head. ```python @@ -674,8 +674,8 @@ between *full* and *empty* nodes. ```python def get_head(store: Store) -> ForkChoiceNode: - # Get filtered block tree that only includes viable branches - blocks = get_filtered_block_tree(store) + # Get filtered node tree that only includes viable branches + blocks = get_filtered_node_tree(store) # Execute the LMD-GHOST fork-choice head = ForkChoiceNode( root=store.justified_checkpoint.root, diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 2bb8d6849c6..90833f1b638 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -26,8 +26,8 @@ - [`get_proposer_score`](#get_proposer_score) - [`get_weight`](#get_weight) - [`get_voting_source`](#get_voting_source) - - [`filter_block_tree`](#filter_block_tree) - - [`get_filtered_block_tree`](#get_filtered_block_tree) + - [`filter_node_tree`](#filter_node_tree) + - [`get_filtered_node_tree`](#get_filtered_node_tree) - [`get_node_children`](#get_node_children) - [`get_head`](#get_head) - [`update_checkpoints`](#update_checkpoints) @@ -391,28 +391,32 @@ def get_voting_source(store: Store, block_root: Root) -> Checkpoint: return head_state.current_justified_checkpoint ``` -#### `filter_block_tree` +#### `filter_node_tree` -*Note*: External calls to `filter_block_tree` (i.e., any calls that are not made -by the recursive logic in this function) MUST set `block_root` to -`store.justified_checkpoint.root`. +*Note*: External calls to `filter_node_tree` (i.e., any calls that are not made +by the recursive logic in this function) MUST set `node` to +`ForkChoiceNode(root=store.justified_checkpoint.root)`. ```python -def filter_block_tree(store: Store, block_root: Root, blocks: Dict[Root, BeaconBlock]) -> bool: - block = store.blocks[block_root] - children = [root for root in store.blocks if store.blocks[root].parent_root == block_root] +def filter_node_tree(store: Store, node: ForkChoiceNode, blocks: Dict[Root, BeaconBlock]) -> bool: + block = store.blocks[node.root] + children = [ + ForkChoiceNode(root=root) + for root in store.blocks + if store.blocks[root].parent_root == node.root + ] # If any children branches contain expected finalized/justified checkpoints, - # add to filtered block-tree and signal viability to parent. + # add to filtered node tree and signal viability to parent. if any(children): - filter_block_tree_result = [filter_block_tree(store, child, blocks) for child in children] - if any(filter_block_tree_result): - blocks[block_root] = block + filter_node_tree_result = [filter_node_tree(store, child, blocks) for child in children] + if any(filter_node_tree_result): + blocks[node.root] = block return True return False current_epoch = get_current_store_epoch(store) - voting_source = get_voting_source(store, block_root) + voting_source = get_voting_source(store, node.root) # The voting source should be either at the same height as the store's justified checkpoint or # not more than two epochs ago @@ -424,7 +428,7 @@ def filter_block_tree(store: Store, block_root: Root, blocks: Dict[Root, BeaconB finalized_checkpoint_block = get_checkpoint_block( store, - block_root, + node.root, store.finalized_checkpoint.epoch, ) @@ -433,26 +437,26 @@ def filter_block_tree(store: Store, block_root: Root, blocks: Dict[Root, BeaconB or store.finalized_checkpoint.root == finalized_checkpoint_block ) - # If expected finalized/justified, add to viable block-tree and signal viability to parent. + # If expected finalized/justified, add to viable node tree and signal viability to parent. if correct_justified and correct_finalized: - blocks[block_root] = block + blocks[node.root] = block return True # Otherwise, branch not viable return False ``` -#### `get_filtered_block_tree` +#### `get_filtered_node_tree` ```python -def get_filtered_block_tree(store: Store) -> Dict[Root, BeaconBlock]: +def get_filtered_node_tree(store: Store) -> Dict[Root, BeaconBlock]: """ - Retrieve a filtered block tree from ``store``, only returning branches + Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. """ - base = store.justified_checkpoint.root + base = ForkChoiceNode(root=store.justified_checkpoint.root) blocks: Dict[Root, BeaconBlock] = {} - filter_block_tree(store, base, blocks) + filter_node_tree(store, base, blocks) return blocks ``` @@ -471,8 +475,8 @@ def get_node_children( ```python def get_head(store: Store) -> ForkChoiceNode: - # Get filtered block tree that only includes viable branches - blocks = get_filtered_block_tree(store) + # Get filtered node tree that only includes viable branches + blocks = get_filtered_node_tree(store) # Execute the LMD-GHOST fork choice head = ForkChoiceNode(root=store.justified_checkpoint.root) while True: diff --git a/sync/optimistic.md b/sync/optimistic.md index e00e6bfff54..728aad39240 100644 --- a/sync/optimistic.md +++ b/sync/optimistic.md @@ -388,7 +388,7 @@ verified or the block is older than `SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY`. These restraints are applied in order to mitigate an attack where a block which enables execution (a *transition block*) can reference a junk parent hash. This makes it impossible for honest nodes to build atop that block. If an attacker -exploits a nuance in fork choice `filter_block_tree`, they can, in some rare +exploits a nuance in fork choice `filter_node_tree`, they can, in some rare cases, produce a junk block that out-competes all locally produced blocks for the head. This prevents a node from producing a chain of blocks, therefore breaking liveness. diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py similarity index 99% rename from tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py rename to tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index 309fabe59a3..fb2ba250dde 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_block_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -127,7 +127,7 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): assert head.root == k_root assert head.payload_status == spec.PAYLOAD_STATUS_EMPTY - filtered_tree = spec.get_filtered_block_tree(store) + filtered_tree = spec.get_filtered_node_tree(store) assert full_b_variant not in filtered_tree assert empty_b_variant in filtered_tree diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py index d6889cbf651..e902555c1af 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py @@ -586,7 +586,7 @@ def get_weighed_node_checks(spec, store, node): def get_viable_for_head_checks(spec, store): - filtered_blocks = spec.get_filtered_block_tree(store) + filtered_blocks = spec.get_filtered_node_tree(store) root_node = get_fork_choice_node(spec, store.justified_checkpoint.root) pending_nodes = [root_node] leaves_viable_for_head = [] diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py index 8ed01ae93fc..a2bc0e5bbb7 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py @@ -175,8 +175,8 @@ def get_opt_head_block_root(spec, mega_store): """ store = mega_store.fc_store - # Get filtered block tree that only includes viable branches - blocks = spec.get_filtered_block_tree(store) + # Get filtered node tree that only includes viable branches + blocks = spec.get_filtered_node_tree(store) # Execute the LMD-GHOST fork choice head = store.justified_checkpoint.root while True: diff --git a/tests/core/pyspec/eth_consensus_specs/test/phase0/fork_choice/test_get_head.py b/tests/core/pyspec/eth_consensus_specs/test/phase0/fork_choice/test_get_head.py index d400cd46e66..a7ca1405b50 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/phase0/fork_choice/test_get_head.py +++ b/tests/core/pyspec/eth_consensus_specs/test/phase0/fork_choice/test_get_head.py @@ -702,7 +702,7 @@ def test_voting_source_beyond_two_epoch(spec, state): - is a descendant of store.justified_checkpoint.root The block being a descendant of store.justified_checkpoint.root is necessary because -filter_block_tree descends the tree starting at store.justified_checkpoint.root +filter_node_tree descends the tree starting at store.justified_checkpoint.root @with_altair_and_later @spec_state_test diff --git a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py index 923f220c802..a2a9c1d23f8 100644 --- a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py +++ b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py @@ -38,7 +38,7 @@ def _should_justify_epoch(parents, current_justifications, previous_justificatio return any(previous_justifications[c] for c in (b for b, p in enumerate(parents) if p == block)) -def _generate_filter_block_tree( +def _generate_filter_node_tree( spec, genesis_state, block_epochs, @@ -357,7 +357,7 @@ def gen_block_cover_test_data(spec, state, model_params, debug, seed) -> (FCTest rnd = random.Random(seed) signed_blocks, post_block_tips, target_signed_block, target_post_state = ( - _generate_filter_block_tree( + _generate_filter_node_tree( spec, state, block_epochs, @@ -481,7 +481,7 @@ def run_sanity_checks(spec, store, model_params, target_block_root): or predicates["block_vse_eq_store_je"] or predicates["block_vse_plus_two_ge_curr_e"] ): - filtered_tree = spec.get_filtered_block_tree(store) + filtered_tree = spec.get_filtered_node_tree(store) if is_post_gloas(spec): filtered_roots = [root for root, _ in filtered_tree] else: From f066d022db5bb12312c3704f1b926348a13a67f8 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Mon, 3 Aug 2026 21:56:31 +0000 Subject: [PATCH 03/20] Decouple `get_node_children` from fork-choice filtering --- specs/gloas/fork-choice.md | 82 ++++++++----------- specs/phase0/fork-choice.md | 43 +++++----- .../test_filter_node_tree_variants.py | 10 +-- .../test/helpers/fork_choice.py | 6 +- .../test/helpers/optimistic_sync.py | 10 +-- tests/formats/fork_choice/README.md | 2 +- .../fork_choice/instantiators/block_cover.py | 7 +- 7 files changed, 73 insertions(+), 87 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 4e9bed55374..25c03414dfa 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -118,6 +118,9 @@ class ForkChoiceNode: root: Root # [New in Gloas:EIP7732] payload_status: PayloadStatus # One of PAYLOAD_STATUS_* values + + def __hash__(self) -> int: + return int.from_bytes(self.root, "little") * 31 + int(self.payload_status) ``` ### Modified `PayloadAttributes` @@ -546,41 +549,23 @@ by the recursive logic in this function) MUST set `node` to a pending `ForkChoiceNode` with root `store.justified_checkpoint.root`. *Note*: This function is modified to operate on payload-status variants instead -of blocks, so that each variant is FFG-tested independently. A variant is -identified by the tuple `(root, payload_status)`. The FFG test itself is -computed per block root and is unchanged. +of blocks, so that each variant is FFG-tested independently. The FFG test itself +is computed per block root and is unchanged. ```python -def filter_node_tree( - store: Store, node: ForkChoiceNode, blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] -) -> bool: - block = store.blocks[node.root] - # Expand a pending node into its empty and full variants, and an empty or - # full node into the pending nodes of its children blocks +def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: # [Modified in Gloas:EIP7732] - if node.payload_status == PAYLOAD_STATUS_PENDING: - # [New in Gloas:EIP7732] - children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)] - if is_payload_verified(store, node.root): - children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL)) - else: - # [New in Gloas:EIP7732] - children = [ - ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) - for root in store.blocks - if ( - store.blocks[root].parent_root == node.root - and node.payload_status == get_parent_payload_status(store, store.blocks[root]) - ) - ] + children = get_node_children(store, node) # If any children branches contain expected finalized/justified checkpoints, # add to filtered node tree and signal viability to parent. if any(children): - filter_node_tree_result = [filter_node_tree(store, child, blocks) for child in children] + filter_node_tree_result = [ + filter_node_tree(store, child, viable_nodes) for child in children + ] if any(filter_node_tree_result): # [Modified in Gloas:EIP7732] - blocks[(node.root, node.payload_status)] = block + viable_nodes.add(node) return True return False @@ -609,7 +594,7 @@ def filter_node_tree( # If expected finalized/justified, add to viable node tree and signal viability to parent. if correct_justified and correct_finalized: # [Modified in Gloas:EIP7732] - blocks[(node.root, node.payload_status)] = block + viable_nodes.add(node) return True # Otherwise, branch not viable @@ -619,7 +604,7 @@ def filter_node_tree( ### Modified `get_filtered_node_tree` ```python -def get_filtered_node_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], BeaconBlock]: +def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: """ Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. @@ -630,39 +615,34 @@ def get_filtered_node_tree(store: Store) -> Dict[Tuple[Root, PayloadStatus], Bea payload_status=PAYLOAD_STATUS_PENDING, ) # [Modified in Gloas:EIP7732] - blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock] = {} - filter_node_tree(store, base, blocks) - return blocks + viable_nodes: Set[ForkChoiceNode] = set() + filter_node_tree(store, base, viable_nodes) + return viable_nodes ``` ### Modified `get_node_children` -*Note*: This function is modified to only return children that are present in -the filtered node tree, so that payload-status variants that are not FFG-viable -are not considered for the head. +*Note*: This function is modified to return all possible children of a given +node, regardless of the FFG test result. It expands a *pending* node into its +*empty* and *full* variants, and an *empty* or *full* node into the *pending* +nodes of its children blocks. ```python -def get_node_children( - store: Store, blocks: Dict[Tuple[Root, PayloadStatus], BeaconBlock], node: ForkChoiceNode -) -> Sequence[ForkChoiceNode]: +def get_node_children(store: Store, node: ForkChoiceNode) -> Sequence[ForkChoiceNode]: if node.payload_status == PAYLOAD_STATUS_PENDING: # [New in Gloas:EIP7732] - return [ - ForkChoiceNode(root=root, payload_status=payload_status) - for root, payload_status in [ - (node.root, PAYLOAD_STATUS_EMPTY), - (node.root, PAYLOAD_STATUS_FULL), - ] - if (root, payload_status) in blocks - ] + children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)] + if is_payload_verified(store, node.root): + children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL)) + return children else: # [Modified in Gloas:EIP7732] return [ ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) - for (root, payload_status), block in blocks.items() + for root in store.blocks if ( - block.parent_root == node.root - and node.payload_status == get_parent_payload_status(store, block) + store.blocks[root].parent_root == node.root + and node.payload_status == get_parent_payload_status(store, store.blocks[root]) ) ] ``` @@ -675,7 +655,7 @@ between *full* and *empty* nodes. ```python def get_head(store: Store) -> ForkChoiceNode: # Get filtered node tree that only includes viable branches - blocks = get_filtered_node_tree(store) + filtered_node_tree = get_filtered_node_tree(store) # Execute the LMD-GHOST fork-choice head = ForkChoiceNode( root=store.justified_checkpoint.root, @@ -684,7 +664,9 @@ def get_head(store: Store) -> ForkChoiceNode: ) while True: - children = get_node_children(store, blocks, head) + children = [ + child for child in get_node_children(store, head) if child in filtered_node_tree + ] if len(children) == 0: return head # Sort by latest attesting balance with ties broken lexicographically diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 90833f1b638..918945d57d6 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -151,6 +151,9 @@ This abstraction is introduced to support upgradability. @dataclass(eq=True, frozen=True) class ForkChoiceNode: root: Root + + def __hash__(self) -> int: + return int.from_bytes(self.root, "little") ``` #### `LatestMessage` @@ -398,20 +401,17 @@ by the recursive logic in this function) MUST set `node` to `ForkChoiceNode(root=store.justified_checkpoint.root)`. ```python -def filter_node_tree(store: Store, node: ForkChoiceNode, blocks: Dict[Root, BeaconBlock]) -> bool: - block = store.blocks[node.root] - children = [ - ForkChoiceNode(root=root) - for root in store.blocks - if store.blocks[root].parent_root == node.root - ] +def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: + children = get_node_children(store, node) # If any children branches contain expected finalized/justified checkpoints, # add to filtered node tree and signal viability to parent. if any(children): - filter_node_tree_result = [filter_node_tree(store, child, blocks) for child in children] + filter_node_tree_result = [ + filter_node_tree(store, child, viable_nodes) for child in children + ] if any(filter_node_tree_result): - blocks[node.root] = block + viable_nodes.add(node) return True return False @@ -439,7 +439,7 @@ def filter_node_tree(store: Store, node: ForkChoiceNode, blocks: Dict[Root, Beac # If expected finalized/justified, add to viable node tree and signal viability to parent. if correct_justified and correct_finalized: - blocks[node.root] = block + viable_nodes.add(node) return True # Otherwise, branch not viable @@ -449,26 +449,29 @@ def filter_node_tree(store: Store, node: ForkChoiceNode, blocks: Dict[Root, Beac #### `get_filtered_node_tree` ```python -def get_filtered_node_tree(store: Store) -> Dict[Root, BeaconBlock]: +def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: """ Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. """ base = ForkChoiceNode(root=store.justified_checkpoint.root) - blocks: Dict[Root, BeaconBlock] = {} - filter_node_tree(store, base, blocks) - return blocks + viable_nodes: Set[ForkChoiceNode] = set() + filter_node_tree(store, base, viable_nodes) + return viable_nodes ``` #### `get_node_children` ```python def get_node_children( - store: Store, # noqa: ARG001 - blocks: Dict[Root, BeaconBlock], + store: Store, node: ForkChoiceNode, ) -> Sequence[ForkChoiceNode]: - return [ForkChoiceNode(root=root) for root in blocks if blocks[root].parent_root == node.root] + return [ + ForkChoiceNode(root=root) + for root in store.blocks + if store.blocks[root].parent_root == node.root + ] ``` #### `get_head` @@ -476,11 +479,13 @@ def get_node_children( ```python def get_head(store: Store) -> ForkChoiceNode: # Get filtered node tree that only includes viable branches - blocks = get_filtered_node_tree(store) + filtered_node_tree = get_filtered_node_tree(store) # Execute the LMD-GHOST fork choice head = ForkChoiceNode(root=store.justified_checkpoint.root) while True: - children = get_node_children(store, blocks, head) + children = [ + child for child in get_node_children(store, head) if child in filtered_node_tree + ] if len(children) == 0: return head # Sort by latest attesting balance with ties broken lexicographically diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index fb2ba250dde..fb23809f7db 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -113,8 +113,8 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): test_steps, ) - full_b_variant = (b_root, spec.PAYLOAD_STATUS_FULL) - empty_b_variant = (b_root, spec.PAYLOAD_STATUS_EMPTY) + full_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_FULL) + empty_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_EMPTY) # B fails the FFG test while K passes it assert spec.get_voting_source(store, b_root).epoch + 2 < spec.get_current_store_epoch(store) @@ -123,13 +123,13 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): # The childless FULL(B) variant is not viable and must not be the head head = spec.get_head(store) - assert (head.root, head.payload_status) != full_b_variant + assert head != full_b_node assert head.root == k_root assert head.payload_status == spec.PAYLOAD_STATUS_EMPTY filtered_tree = spec.get_filtered_node_tree(store) - assert full_b_variant not in filtered_tree - assert empty_b_variant in filtered_tree + assert full_b_node not in filtered_tree + assert empty_b_node in filtered_tree output_head_check(spec, store, test_steps) yield "steps", test_steps diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py index e902555c1af..3d8e4a24d6a 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py @@ -586,14 +586,16 @@ def get_weighed_node_checks(spec, store, node): def get_viable_for_head_checks(spec, store): - filtered_blocks = spec.get_filtered_node_tree(store) + filtered_node_tree = spec.get_filtered_node_tree(store) root_node = get_fork_choice_node(spec, store.justified_checkpoint.root) pending_nodes = [root_node] leaves_viable_for_head = [] while len(pending_nodes) > 0: node = pending_nodes.pop() - children = spec.get_node_children(store, filtered_blocks, node) + children = [ + child for child in spec.get_node_children(store, node) if child in filtered_node_tree + ] if len(children) == 0: leaves_viable_for_head.append(node) else: diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py index a2bc0e5bbb7..99d7e2b89a9 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py @@ -176,16 +176,16 @@ def get_opt_head_block_root(spec, mega_store): store = mega_store.fc_store # Get filtered node tree that only includes viable branches - blocks = spec.get_filtered_node_tree(store) + filtered_node_tree = spec.get_filtered_node_tree(store) # Execute the LMD-GHOST fork choice head = store.justified_checkpoint.root while True: children = [ - root - for root in blocks + child.root + for child in spec.get_node_children(store, spec.ForkChoiceNode(root=head)) if ( - blocks[root].parent_root == head - and not is_invalidated(mega_store, root) # For optimistic sync + child in filtered_node_tree + and not is_invalidated(mega_store, child.root) # For optimistic sync ) ] if len(children) == 0: diff --git a/tests/formats/fork_choice/README.md b/tests/formats/fork_choice/README.md index 86068a3de8a..8e68529955c 100644 --- a/tests/formats/fork_choice/README.md +++ b/tests/formats/fork_choice/README.md @@ -252,7 +252,7 @@ finalized_checkpoint: { } proposer_boost_root: string -- Encoded 32-byte value from store.proposer_boost_root viable_for_head_roots_and_weights: [{ - root: string, -- Encoded 32-byte value of filtered_block_tree leaf blocks/nodes + root: string, -- Encoded 32-byte value of filtered_node_tree leaf blocks/nodes weight: int, -- Integer value of the weight of the block/node payload_status: int, -- Gloas and later, the payload_status of the node }] diff --git a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py index a2a9c1d23f8..0b7c4881085 100644 --- a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py +++ b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py @@ -475,15 +475,12 @@ def run_sanity_checks(spec, store, model_params, target_block_root): else: assert voting_source.epoch + 2 < current_epoch, "block_vse_plus_two_ge_curr_e not satisfied" - # Ensure the target block is in filtered blocks if it is a leaf and eligible + # Ensure the target block is in the filtered node tree if it is a leaf and eligible if predicates["block_is_leaf"] and ( predicates["store_je_eq_zero"] or predicates["block_vse_eq_store_je"] or predicates["block_vse_plus_two_ge_curr_e"] ): filtered_tree = spec.get_filtered_node_tree(store) - if is_post_gloas(spec): - filtered_roots = [root for root, _ in filtered_tree] - else: - filtered_roots = list(filtered_tree) + filtered_roots = [node.root for node in filtered_tree] assert target_block_root in filtered_roots From 019e6daa2940515708cb5838583f76ff8617d0ad Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Tue, 4 Aug 2026 15:27:04 +0000 Subject: [PATCH 04/20] Apply review feedback on fork-choice node-tree redesign --- specs/gloas/fork-choice.md | 69 +++++-------------------------------- specs/phase0/fork-choice.md | 30 ++++++++-------- 2 files changed, 23 insertions(+), 76 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 25c03414dfa..aff8852c39e 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -33,7 +33,6 @@ - [New `get_payload_status_tiebreaker`](#new-get_payload_status_tiebreaker) - [New `should_apply_proposer_boost`](#new-should_apply_proposer_boost) - [Modified `get_weight`](#modified-get_weight) - - [Modified `filter_node_tree`](#modified-filter_node_tree) - [Modified `get_filtered_node_tree`](#modified-get_filtered_node_tree) - [Modified `get_node_children`](#modified-get_node_children) - [Modified `get_head`](#modified-get_head) @@ -542,67 +541,12 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei: return attestation_score + proposer_score ``` -### Modified `filter_node_tree` +### Modified `get_filtered_node_tree` *Note*: External calls to `filter_node_tree` (i.e., any calls that are not made by the recursive logic in this function) MUST set `node` to a pending `ForkChoiceNode` with root `store.justified_checkpoint.root`. -*Note*: This function is modified to operate on payload-status variants instead -of blocks, so that each variant is FFG-tested independently. The FFG test itself -is computed per block root and is unchanged. - -```python -def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: - # [Modified in Gloas:EIP7732] - children = get_node_children(store, node) - - # If any children branches contain expected finalized/justified checkpoints, - # add to filtered node tree and signal viability to parent. - if any(children): - filter_node_tree_result = [ - filter_node_tree(store, child, viable_nodes) for child in children - ] - if any(filter_node_tree_result): - # [Modified in Gloas:EIP7732] - viable_nodes.add(node) - return True - return False - - current_epoch = get_current_store_epoch(store) - voting_source = get_voting_source(store, node.root) - - # The voting source should be either at the same height as the store's justified checkpoint or - # not more than two epochs ago - correct_justified = ( - store.justified_checkpoint.epoch == GENESIS_EPOCH - or voting_source.epoch == store.justified_checkpoint.epoch - or voting_source.epoch + 2 >= current_epoch - ) - - finalized_checkpoint_block = get_checkpoint_block( - store, - node.root, - store.finalized_checkpoint.epoch, - ) - - correct_finalized = ( - store.finalized_checkpoint.epoch == GENESIS_EPOCH - or store.finalized_checkpoint.root == finalized_checkpoint_block - ) - - # If expected finalized/justified, add to viable node tree and signal viability to parent. - if correct_justified and correct_finalized: - # [Modified in Gloas:EIP7732] - viable_nodes.add(node) - return True - - # Otherwise, branch not viable - return False -``` - -### Modified `get_filtered_node_tree` - ```python def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: """ @@ -622,10 +566,13 @@ def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: ### Modified `get_node_children` -*Note*: This function is modified to return all possible children of a given -node, regardless of the FFG test result. It expands a *pending* node into its -*empty* and *full* variants, and an *empty* or *full* node into the *pending* -nodes of its children blocks. +*Note*: This function is modified to operate on payload-status variants instead +of blocks, so that each variant is FFG-tested independently by +`filter_node_tree`. The FFG test itself is computed per block root and is +unchanged. This function returns all possible children of a given node, +regardless of the FFG test result. It expands a *pending* node into its *empty* +and *full* variants, and an *empty* or *full* node into the *pending* nodes of +its children blocks. ```python def get_node_children(store: Store, node: ForkChoiceNode) -> Sequence[ForkChoiceNode]: diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 918945d57d6..37965555e75 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -26,9 +26,9 @@ - [`get_proposer_score`](#get_proposer_score) - [`get_weight`](#get_weight) - [`get_voting_source`](#get_voting_source) + - [`get_node_children`](#get_node_children) - [`filter_node_tree`](#filter_node_tree) - [`get_filtered_node_tree`](#get_filtered_node_tree) - - [`get_node_children`](#get_node_children) - [`get_head`](#get_head) - [`update_checkpoints`](#update_checkpoints) - [`update_unrealized_checkpoints`](#update_unrealized_checkpoints) @@ -394,6 +394,20 @@ def get_voting_source(store: Store, block_root: Root) -> Checkpoint: return head_state.current_justified_checkpoint ``` +#### `get_node_children` + +```python +def get_node_children( + store: Store, + node: ForkChoiceNode, +) -> Sequence[ForkChoiceNode]: + return [ + ForkChoiceNode(root=root) + for root in store.blocks + if store.blocks[root].parent_root == node.root + ] +``` + #### `filter_node_tree` *Note*: External calls to `filter_node_tree` (i.e., any calls that are not made @@ -460,20 +474,6 @@ def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: return viable_nodes ``` -#### `get_node_children` - -```python -def get_node_children( - store: Store, - node: ForkChoiceNode, -) -> Sequence[ForkChoiceNode]: - return [ - ForkChoiceNode(root=root) - for root in store.blocks - if store.blocks[root].parent_root == node.root - ] -``` - #### `get_head` ```python From 5df3d787f9b8fab707607ea844cd6f341e0671bf Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Wed, 5 Aug 2026 12:08:26 +0000 Subject: [PATCH 05/20] Revert accidental rename of `_generate_filter_block_tree` --- .../fork_choice/instantiators/block_cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py index 0b7c4881085..855a788f970 100644 --- a/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py +++ b/tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py @@ -38,7 +38,7 @@ def _should_justify_epoch(parents, current_justifications, previous_justificatio return any(previous_justifications[c] for c in (b for b, p in enumerate(parents) if p == block)) -def _generate_filter_node_tree( +def _generate_filter_block_tree( spec, genesis_state, block_epochs, @@ -357,7 +357,7 @@ def gen_block_cover_test_data(spec, state, model_params, debug, seed) -> (FCTest rnd = random.Random(seed) signed_blocks, post_block_tips, target_signed_block, target_post_state = ( - _generate_filter_node_tree( + _generate_filter_block_tree( spec, state, block_epochs, From 93550821cc1aa6853c1a6656a2d4093b82185647 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Thu, 6 Aug 2026 12:19:22 +0000 Subject: [PATCH 06/20] Apply review feedback on filter_node_tree notes and node handling --- specs/gloas/fork-choice.md | 18 +++++------------- specs/phase0/fork-choice.md | 7 +++++-- .../test/helpers/optimistic_sync.py | 10 +++++----- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index aff8852c39e..807526aab92 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -118,6 +118,9 @@ class ForkChoiceNode: # [New in Gloas:EIP7732] payload_status: PayloadStatus # One of PAYLOAD_STATUS_* values + # The dataclass-generated ``__hash__`` would call the SHA256 ``hash`` + # function defined in this module instead of the builtin ``hash``, so + # it is defined explicitly. def __hash__(self) -> int: return int.from_bytes(self.root, "little") * 31 + int(self.payload_status) ``` @@ -543,10 +546,6 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei: ### Modified `get_filtered_node_tree` -*Note*: External calls to `filter_node_tree` (i.e., any calls that are not made -by the recursive logic in this function) MUST set `node` to a pending -`ForkChoiceNode` with root `store.justified_checkpoint.root`. - ```python def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: """ @@ -566,24 +565,17 @@ def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: ### Modified `get_node_children` -*Note*: This function is modified to operate on payload-status variants instead -of blocks, so that each variant is FFG-tested independently by -`filter_node_tree`. The FFG test itself is computed per block root and is -unchanged. This function returns all possible children of a given node, -regardless of the FFG test result. It expands a *pending* node into its *empty* -and *full* variants, and an *empty* or *full* node into the *pending* nodes of -its children blocks. +*Note*: This function is modified to introduce new type of children nodes +representing *full* and *empty* blocks. ```python def get_node_children(store: Store, node: ForkChoiceNode) -> Sequence[ForkChoiceNode]: if node.payload_status == PAYLOAD_STATUS_PENDING: - # [New in Gloas:EIP7732] children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)] if is_payload_verified(store, node.root): children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL)) return children else: - # [Modified in Gloas:EIP7732] return [ ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) for root in store.blocks diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 37965555e75..b5913faea4b 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -152,6 +152,9 @@ This abstraction is introduced to support upgradability. class ForkChoiceNode: root: Root + # The dataclass-generated ``__hash__`` would call the SHA256 ``hash`` + # function defined in this module instead of the builtin ``hash``, so + # it is defined explicitly. def __hash__(self) -> int: return int.from_bytes(self.root, "little") ``` @@ -411,8 +414,8 @@ def get_node_children( #### `filter_node_tree` *Note*: External calls to `filter_node_tree` (i.e., any calls that are not made -by the recursive logic in this function) MUST set `node` to -`ForkChoiceNode(root=store.justified_checkpoint.root)`. +by the recursive logic in this function) MUST set `node` to a `ForkChoiceNode` +with root `store.justified_checkpoint.root`. ```python def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py index 99d7e2b89a9..beccb471fdc 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py @@ -178,23 +178,23 @@ def get_opt_head_block_root(spec, mega_store): # Get filtered node tree that only includes viable branches filtered_node_tree = spec.get_filtered_node_tree(store) # Execute the LMD-GHOST fork choice - head = store.justified_checkpoint.root + head = spec.ForkChoiceNode(root=store.justified_checkpoint.root) while True: children = [ - child.root - for child in spec.get_node_children(store, spec.ForkChoiceNode(root=head)) + child + for child in spec.get_node_children(store, head) if ( child in filtered_node_tree and not is_invalidated(mega_store, child.root) # For optimistic sync ) ] if len(children) == 0: - return head + return head.root # Sort by latest attesting balance with ties broken lexicographically # Ties broken by favoring block with lexicographically higher root head = max( children, - key=lambda root: (spec.get_weight(store, spec.ForkChoiceNode(root=root)), root), + key=lambda node: (spec.get_weight(store, node), node.root), ) From 84fda22c7a1c5549dcd374408c0470d4710355f8 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Fri, 7 Aug 2026 08:13:11 +0000 Subject: [PATCH 07/20] Drive filter node tree test justification via attestations --- .../test_filter_node_tree_variants.py | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index fb23809f7db..aef3c31337b 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -3,7 +3,7 @@ with_gloas_and_later, with_presets, ) -from eth_consensus_specs.test.helpers.attestations import get_valid_attestation +from eth_consensus_specs.test.helpers.attestations import get_valid_attestation_at_slot from eth_consensus_specs.test.helpers.block import build_empty_block_for_next_slot from eth_consensus_specs.test.helpers.constants import MINIMAL from eth_consensus_specs.test.helpers.execution_payload import ( @@ -32,11 +32,14 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): that fails the FFG test must not be returned by get_head. Block B is built on the justified checkpoint from a fork of its state, so - its voting source is stale and B fails the FFG test. K builds on EMPTY(B) - and pulls up justification to the store's justified checkpoint (set below, - as in the issue's example), so EMPTY(B) is viable. FULL(B) exists because - B's envelope is delivered, but it is childless and never passes the FFG - test, so get_head must not return it. + its voting source is one epoch older than the store's justified checkpoint + and B fails the FFG test once the store advances. K builds on EMPTY(B) + through a chain of blocks that carry attestations targeting the + (B, justified_epoch + 1) checkpoint, so K's branch naturally justifies it + and K's voting source is pulled up to the store's justified checkpoint, + making EMPTY(B) viable. + FULL(B) exists because B's envelope is delivered, but it is childless and + never passes the FFG test, so get_head must not return it. """ store, _, test_steps = yield from setup_finalized_store(spec, state) @@ -47,7 +50,9 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): # B is the first block of epoch `justified_epoch + 1` on a fork of the # justified state. The fork has no on-chain votes, so B's voting source is - # stale and B fails the FFG test. + # the fork's greatest justified checkpoint, one epoch older than the + # store's justified checkpoint. B passes the FFG test while the store is at + # epoch `justified_epoch + 1` and fails only once the store advances. b_slot = spec.compute_start_slot_at_epoch(justified_epoch + 1) fork_state = justified_state.copy() next_slots(spec, fork_state, b_slot - fork_state.slot - 1) @@ -70,26 +75,54 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): spec, store, store.genesis_time + k_slot * spec.config.SLOT_DURATION_MS // 1000, test_steps ) - # Attest B's FULL variant with all committees of B's epoch, so the B branch + # Attest B's FULL variant for every slot of B's epoch after the first + # (post-Electra, each slot has a single aggregate), so the B branch # outweighs the main chain in the LMD-GHOST walk - committees_per_slot = spec.get_committee_count_per_slot(b_state, justified_epoch + 1) + att_state = b_state.copy() for slot in range(b_slot + 1, k_slot): - att_state = b_state.copy() next_slots(spec, att_state, slot - att_state.slot) - for index in range(committees_per_slot): - attestation = get_valid_attestation( - spec, - att_state, - slot=slot, - index=index, - payload_index=1, - beacon_block_root=b_root, - signed=True, + attestation = get_valid_attestation_at_slot( + att_state, spec, slot, beacon_block_root=b_root, payload_index=1 + ) + yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) + + # K's branch justifies the (B, justified_epoch + 1) checkpoint by including + # attestations targeting B in its blocks, so that K's voting source is + # naturally pulled up to the store's justified checkpoint once K is + # processed. Attesting all but the first and last slots of B's epoch + # exceeds 2/3 of the total active balance, which justifies the checkpoint + # at the next epoch boundary. (The first slot is skipped since same-slot + # attestations must carry index 0, and the last slot is skipped since its + # attestations could only be included after the epoch boundary.) + att_state = b_state.copy() + slot_attestations = [] + for slot in range(b_slot + 1, k_slot - 1): + next_slots(spec, att_state, slot - att_state.slot) + slot_attestations.append( + get_valid_attestation_at_slot( + att_state, spec, slot, beacon_block_root=b_root, payload_index=1 ) - yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) - - # K builds on EMPTY(B) at the start of the next epoch - k_state = b_state.copy() + ) + + # Intermediate blocks on the B fork carry the attestations, up to the + # block-level maximum per block + branch_state = b_state.copy() + branch_root = b_root + for att_start in range(0, len(slot_attestations), spec.MAX_ATTESTATIONS_ELECTRA): + attestations = slot_attestations[att_start : att_start + spec.MAX_ATTESTATIONS_ELECTRA] + # The block must be built after the latest attested slot so that the + # attestation inclusion delay is satisfied + branch_slot = attestations[-1].data.slot + 1 + next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) + branch_block = build_empty_block_for_next_slot(spec, branch_state) + assert branch_block.slot == branch_slot + branch_block.body.attestations = attestations + signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) + yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) + branch_root = signed_branch_block.message.hash_tree_root() + + # K builds on the last branch block at the start of the next epoch + k_state = store.block_states[branch_root].copy() next_slots(spec, k_state, k_slot - k_state.slot - 1) k_block = build_empty_block_for_next_slot(spec, k_state) assert k_block.slot == k_slot @@ -98,10 +131,11 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): yield from tick_and_add_block(spec, store, signed_k, test_steps) # K must claim an EMPTY parent for the B branch to stay viable assert spec.get_parent_payload_status(store, signed_k.message) == spec.PAYLOAD_STATUS_EMPTY - - # Simulate K's chain having pulled up justification to the store's justified - # checkpoint, while B's justification remains stale - store.unrealized_justifications[k_root] = store.justified_checkpoint + # K's branch justifies the (B, justified_epoch + 1) checkpoint, so K's + # voting source is pulled up to the store's justified checkpoint, which + # advances to (B, justified_epoch + 1) + assert store.justified_checkpoint.epoch == justified_epoch + 1 + assert store.unrealized_justifications[k_root] == store.justified_checkpoint # Advance to the next epoch so that B is more than two epochs behind the # voting source required by the FFG test From 6e5d2e35a0bd0d2d76e34eaa36e1f3bab9ea4a79 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Tue, 11 Aug 2026 09:35:20 +0000 Subject: [PATCH 08/20] Simplify redundant epoch advance in filter node tree test --- .../test_filter_node_tree_variants.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index aef3c31337b..dd90a6eb01a 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -33,7 +33,8 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): Block B is built on the justified checkpoint from a fork of its state, so its voting source is one epoch older than the store's justified checkpoint - and B fails the FFG test once the store advances. K builds on EMPTY(B) + and B fails the FFG test once the store advances past epoch + `justified_epoch + 1`. K builds on EMPTY(B) through a chain of blocks that carry attestations targeting the (B, justified_epoch + 1) checkpoint, so K's branch naturally justifies it and K's voting source is pulled up to the store's justified checkpoint, @@ -51,8 +52,9 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): # B is the first block of epoch `justified_epoch + 1` on a fork of the # justified state. The fork has no on-chain votes, so B's voting source is # the fork's greatest justified checkpoint, one epoch older than the - # store's justified checkpoint. B passes the FFG test while the store is at - # epoch `justified_epoch + 1` and fails only once the store advances. + # store's justified checkpoint: `justified_epoch - 1`. B passes the FFG + # test while the store is at epoch `justified_epoch + 1` and fails from + # epoch `justified_epoch + 2` onwards. b_slot = spec.compute_start_slot_at_epoch(justified_epoch + 1) fork_state = justified_state.copy() next_slots(spec, fork_state, b_slot - fork_state.slot - 1) @@ -137,15 +139,11 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): assert store.justified_checkpoint.epoch == justified_epoch + 1 assert store.unrealized_justifications[k_root] == store.justified_checkpoint - # Advance to the next epoch so that B is more than two epochs behind the - # voting source required by the FFG test - next_epoch_slot = spec.compute_start_slot_at_epoch(justified_epoch + 3) - on_tick_and_append_step( - spec, - store, - store.genesis_time + next_epoch_slot * spec.config.SLOT_DURATION_MS // 1000, - test_steps, - ) + # The store is already at the start of epoch `justified_epoch + 2`, where + # K was built. With B's voting source at `justified_epoch - 1`, B is more + # than two epochs behind the store's current epoch, so it fails the FFG + # test right here: no further advancement is needed for B to be filtered + # out. full_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_FULL) empty_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_EMPTY) From 2e28fcbde2d6408a24ed569c5c31d8272fd0d02a Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Wed, 12 Aug 2026 12:49:03 +0000 Subject: [PATCH 09/20] Apply review feedback on `filter_node_tree` notes --- specs/gloas/fork-choice.md | 1 - specs/phase0/fork-choice.md | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 5f5e0a66a29..3f1fde71288 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -563,7 +563,6 @@ def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: root=store.justified_checkpoint.root, payload_status=PAYLOAD_STATUS_PENDING, ) - # [Modified in Gloas:EIP7732] viable_nodes: Set[ForkChoiceNode] = set() filter_node_tree(store, base, viable_nodes) return viable_nodes diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 35ec98aecee..d1fe8852419 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -418,7 +418,9 @@ def get_node_children( *Note*: External calls to `filter_node_tree` (i.e., any calls that are not made by the recursive logic in this function) MUST set `node` to a `ForkChoiceNode` -with root `store.justified_checkpoint.root`. +with root `store.justified_checkpoint.root`. In forks that extend +`ForkChoiceNode` with a `payload_status` field (e.g. Gloas), that field MUST be +set to `PAYLOAD_STATUS_PENDING`. ```python def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: From 135ade3442f41454f4fd2ecb2fe4311b3d40ee1b Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Mon, 24 Aug 2026 19:26:10 +0000 Subject: [PATCH 10/20] Remove `ForkChoiceNode` __hash__ override Builtin hash is no longer shadowed in the compiled spec namespace after #5555, so the dataclass-generated hash can be used. --- specs/gloas/fork-choice.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 63be08501e4..8b9f9a4dcc1 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -152,12 +152,6 @@ class ForkChoiceNode: root: Root # [New in Gloas:EIP7732] payload_status: PayloadStatus # One of PAYLOAD_STATUS_* values - - # The dataclass-generated ``__hash__`` would call the SHA256 ``hash`` - # function defined in this module instead of the builtin ``hash``, so - # it is defined explicitly. - def __hash__(self) -> int: - return int.from_bytes(self.root, "little") * 31 + int(self.payload_status) ``` ### Modified `PayloadAttributes` From 80e1a08555734128a26fa4e8a1e3bc7ba15a5d6a Mon Sep 17 00:00:00 2001 From: Justin Traglia <95511699+jtraglia@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:03:30 -0500 Subject: [PATCH 11/20] Remove overridden `__hash__` function --- specs/phase0/fork-choice.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index 3ce0d422f88..eda972a2261 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -152,12 +152,6 @@ This abstraction is introduced to support upgradability. @dataclass(eq=True, frozen=True) class ForkChoiceNode: root: Root - - # The dataclass-generated ``__hash__`` would call the SHA256 ``hash`` - # function defined in this module instead of the builtin ``hash``, so - # it is defined explicitly. - def __hash__(self) -> int: - return int.from_bytes(self.root, "little") ``` #### `LatestMessage` From 1c939ac851202af3acbcff6d5bf980e846109add Mon Sep 17 00:00:00 2001 From: Justin Traglia Date: Wed, 26 Aug 2026 13:59:09 -0500 Subject: [PATCH 12/20] Refactor filter_node_tree --- specs/gloas/fork-choice.md | 6 ++---- specs/phase0/fork-choice.md | 34 ++++++++++++---------------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 8b9f9a4dcc1..9b5457bcf90 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -589,7 +589,7 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei: ### Modified `get_filtered_node_tree` ```python -def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: +def get_filtered_node_tree(store: Store) -> Sequence[ForkChoiceNode]: """ Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. @@ -599,9 +599,7 @@ def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: root=store.justified_checkpoint.root, payload_status=PAYLOAD_STATUS_PENDING, ) - viable_nodes: Set[ForkChoiceNode] = set() - filter_node_tree(store, base, viable_nodes) - return viable_nodes + return filter_node_tree(store, base) ``` ### Modified `get_node_children` diff --git a/specs/phase0/fork-choice.md b/specs/phase0/fork-choice.md index eda972a2261..18e6b0f2575 100644 --- a/specs/phase0/fork-choice.md +++ b/specs/phase0/fork-choice.md @@ -410,26 +410,19 @@ def get_node_children( #### `filter_node_tree` -*Note*: External calls to `filter_node_tree` (i.e., any calls that are not made -by the recursive logic in this function) MUST set `node` to a `ForkChoiceNode` -with root `store.justified_checkpoint.root`. In forks that extend -`ForkChoiceNode` with a `payload_status` field (e.g. Gloas), that field MUST be -set to `PAYLOAD_STATUS_PENDING`. - ```python -def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkChoiceNode]) -> bool: +def filter_node_tree(store: Store, node: ForkChoiceNode) -> Sequence[ForkChoiceNode]: children = get_node_children(store, node) # If any children branches contain expected finalized/justified checkpoints, - # add to filtered node tree and signal viability to parent. + # include this node and those descendants in the filtered node tree. if any(children): - filter_node_tree_result = [ - filter_node_tree(store, child, viable_nodes) for child in children - ] - if any(filter_node_tree_result): - viable_nodes.add(node) - return True - return False + viable_nodes: list[ForkChoiceNode] = [] + for child in children: + viable_nodes.extend(filter_node_tree(store, child)) + if any(viable_nodes): + return viable_nodes + [node] + return [] current_epoch = get_current_store_epoch(store) voting_source = get_voting_source(store, node.root) @@ -455,25 +448,22 @@ def filter_node_tree(store: Store, node: ForkChoiceNode, viable_nodes: Set[ForkC # If expected finalized/justified, add to viable node tree and signal viability to parent. if correct_justified and correct_finalized: - viable_nodes.add(node) - return True + return [node] # Otherwise, branch not viable - return False + return [] ``` #### `get_filtered_node_tree` ```python -def get_filtered_node_tree(store: Store) -> Set[ForkChoiceNode]: +def get_filtered_node_tree(store: Store) -> Sequence[ForkChoiceNode]: """ Retrieve a filtered node tree from ``store``, only returning branches whose leaf state's justified/finalized info agrees with that in ``store``. """ base = ForkChoiceNode(root=store.justified_checkpoint.root) - viable_nodes: Set[ForkChoiceNode] = set() - filter_node_tree(store, base, viable_nodes) - return viable_nodes + return filter_node_tree(store, base) ``` #### `get_head` From 9ea3a11d2425a415e6eda74bcedefcc5238206d1 Mon Sep 17 00:00:00 2001 From: Justin Traglia <95511699+jtraglia@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:02:30 -0500 Subject: [PATCH 13/20] Add an assert that B is the justified root --- .../test/gloas/fork_choice/test_filter_node_tree_variants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index dd90a6eb01a..afdb5c31197 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -137,6 +137,7 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): # voting source is pulled up to the store's justified checkpoint, which # advances to (B, justified_epoch + 1) assert store.justified_checkpoint.epoch == justified_epoch + 1 + assert store.justified_checkpoint.root == b_root assert store.unrealized_justifications[k_root] == store.justified_checkpoint # The store is already at the start of epoch `justified_epoch + 2`, where From 90d435a94a5009152e2827e0bf1c2483daec66de Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Sun, 30 Aug 2026 19:01:12 +0000 Subject: [PATCH 14/20] Add mirror and childless payload status variant tests for get_filtered_node_tree --- .../test_filter_node_tree_variants.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index afdb5c31197..051c5b2e6b5 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -166,3 +166,183 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): output_head_check(spec, store, test_steps) yield "steps", test_steps + + +@with_gloas_and_later +@with_presets([MINIMAL], reason="too slow") +@spec_state_test +def test_get_head_prunes_childless_unviable_empty_variant(spec, state): + """ + Mirror of test_get_head_prunes_childless_unviable_full_variant: + a childless EMPTY payload-status variant of a block that fails the FFG test + must not be returned by get_head or included in get_filtered_node_tree. + + Block B is built on the justified checkpoint from a fork of its state, so + its voting source is one epoch older than the store's justified checkpoint + and B fails the FFG test once the store advances past epoch + `justified_epoch + 1`. K builds on FULL(B) through a chain of blocks that + carry attestations targeting the (B, justified_epoch + 1) checkpoint, so K's + branch naturally justifies it and K's voting source is pulled up to the + store's justified checkpoint, making FULL(B) viable. + EMPTY(B) exists because B was processed, but it is childless (since K built + on FULL(B)) and never passes the FFG test, so get_head must not return it + and get_filtered_node_tree must prune it. + """ + store, _, test_steps = yield from setup_finalized_store(spec, state) + + justified_epoch = store.justified_checkpoint.epoch + assert store.finalized_checkpoint.epoch == justified_epoch - 1 + + justified_state = store.block_states[store.justified_checkpoint.root] + + # B is the first block of epoch `justified_epoch + 1` on a fork of the + # justified state. The fork has no on-chain votes, so B's voting source is + # the fork's greatest justified checkpoint, one epoch older than the + # store's justified checkpoint: `justified_epoch - 1`. B passes the FFG + # test while the store is at epoch `justified_epoch + 1` and fails from + # epoch `justified_epoch + 2` onwards. + b_slot = spec.compute_start_slot_at_epoch(justified_epoch + 1) + fork_state = justified_state.copy() + next_slots(spec, fork_state, b_slot - fork_state.slot - 1) + b_block = build_empty_block_for_next_slot(spec, fork_state) + assert b_block.slot == b_slot + signed_b = state_transition_and_sign_block(spec, fork_state, b_block) + b_root = signed_b.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_b, test_steps) + + # Deliver B's envelope so the FULL(B) variant exists + b_state = store.block_states[b_root] + envelope = build_signed_execution_payload_envelope(spec, b_state, b_root, signed_b) + yield from add_execution_payload(spec, store, envelope, test_steps) + assert spec.is_payload_verified(store, b_root) + + # Tick to the start of the next epoch so attestations for B's epoch can be + # processed + k_slot = spec.compute_start_slot_at_epoch(justified_epoch + 2) + on_tick_and_append_step( + spec, store, store.genesis_time + k_slot * spec.config.SLOT_DURATION_MS // 1000, test_steps + ) + + # Attest B's FULL variant for every slot of B's epoch after the first + # (post-Electra, each slot has a single aggregate), so the B branch + # outweighs the main chain in the LMD-GHOST walk + att_state = b_state.copy() + for slot in range(b_slot + 1, k_slot): + next_slots(spec, att_state, slot - att_state.slot) + attestation = get_valid_attestation_at_slot( + att_state, spec, slot, beacon_block_root=b_root, payload_index=1 + ) + yield from tick_and_run_on_attestation(spec, store, attestation, test_steps) + + # K's branch justifies the (B, justified_epoch + 1) checkpoint by including + # attestations targeting B in its blocks, so that K's voting source is + # naturally pulled up to the store's justified checkpoint once K is + # processed. + att_state = b_state.copy() + slot_attestations = [] + for slot in range(b_slot + 1, k_slot - 1): + next_slots(spec, att_state, slot - att_state.slot) + slot_attestations.append( + get_valid_attestation_at_slot( + att_state, spec, slot, beacon_block_root=b_root, payload_index=1 + ) + ) + + # Intermediate blocks on the B fork carry the attestations. + # The first block building directly on B explicitly claims FULL parent payload status + # by matching parent_block_hash to signed_b's bid block_hash. + branch_state = b_state.copy() + branch_root = b_root + first_branch_block = None + for att_start in range(0, len(slot_attestations), spec.MAX_ATTESTATIONS_ELECTRA): + attestations = slot_attestations[att_start : att_start + spec.MAX_ATTESTATIONS_ELECTRA] + branch_slot = attestations[-1].data.slot + 1 + next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) + branch_block = build_empty_block_for_next_slot(spec, branch_state) + assert branch_block.slot == branch_slot + branch_block.body.attestations = attestations + if branch_root == b_root: + branch_block.body.signed_execution_payload_bid.message.parent_block_hash = ( + signed_b.message.body.signed_execution_payload_bid.message.block_hash + ) + branch_block.body.signed_execution_payload_bid.message.block_hash = spec.Hash32( + b"\x02" + b"\x00" * 31 + ) + signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) + if first_branch_block is None: + first_branch_block = signed_branch_block.message + yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) + branch_root = signed_branch_block.message.hash_tree_root() + + # Confirm the branch directly builds on FULL(B) + assert spec.get_parent_payload_status(store, first_branch_block) == spec.PAYLOAD_STATUS_FULL + + # K builds on the last branch block at the start of the next epoch + k_state = store.block_states[branch_root].copy() + next_slots(spec, k_state, k_slot - k_state.slot - 1) + k_block = build_empty_block_for_next_slot(spec, k_state) + assert k_block.slot == k_slot + signed_k = state_transition_and_sign_block(spec, k_state, k_block) + k_root = signed_k.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_k, test_steps) + + # K's branch justifies the (B, justified_epoch + 1) checkpoint, so K's + # voting source is pulled up to the store's justified checkpoint, which + # advances to (B, justified_epoch + 1) + assert store.justified_checkpoint.epoch == justified_epoch + 1 + assert store.justified_checkpoint.root == b_root + assert store.unrealized_justifications[k_root] == store.justified_checkpoint + + full_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_FULL) + empty_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_EMPTY) + + # B fails the FFG test while K passes it + assert spec.get_voting_source(store, b_root).epoch + 2 < spec.get_current_store_epoch(store) + assert spec.get_voting_source(store, b_root).epoch != store.justified_checkpoint.epoch + assert spec.get_voting_source(store, k_root).epoch == store.justified_checkpoint.epoch + + # The childless EMPTY(B) variant is not viable and must not be the head + head = spec.get_head(store) + assert head != empty_b_node + assert head.root == k_root + + filtered_tree = spec.get_filtered_node_tree(store) + assert empty_b_node not in filtered_tree + assert full_b_node in filtered_tree + + output_head_check(spec, store, test_steps) + yield "steps", test_steps + + +@with_gloas_and_later +@with_presets([MINIMAL], reason="too slow") +@spec_state_test +def test_get_filtered_node_tree_contains_both_viable_childless_variants(spec, state): + """ + Verifies that when both FULL(B) and EMPTY(B) variants of a block pass the FFG test + and are both childless, get_filtered_node_tree contains both variants. + """ + store, state, test_steps = yield from setup_finalized_store(spec, state) + + # Build block B directly on the justified state (passes FFG test) + b_block = build_empty_block_for_next_slot(spec, state) + signed_b = state_transition_and_sign_block(spec, state, b_block) + b_root = signed_b.message.hash_tree_root() + yield from tick_and_add_block(spec, store, signed_b, test_steps) + + # Deliver B's envelope so both FULL(B) and EMPTY(B) variants exist in store + b_state = store.block_states[b_root] + envelope = build_signed_execution_payload_envelope(spec, b_state, b_root, signed_b) + yield from add_execution_payload(spec, store, envelope, test_steps) + assert spec.is_payload_verified(store, b_root) + + full_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_FULL) + empty_b_node = spec.ForkChoiceNode(root=b_root, payload_status=spec.PAYLOAD_STATUS_EMPTY) + + # Both childless variants pass the FFG test and must be present in get_filtered_node_tree + filtered_tree = spec.get_filtered_node_tree(store) + assert full_b_node in filtered_tree + assert empty_b_node in filtered_tree + + output_head_check(spec, store, test_steps) + yield "steps", test_steps From 3d8c93e53f38769ad3d7c131cff69082aad88889 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Sun, 30 Aug 2026 19:22:08 +0000 Subject: [PATCH 15/20] Fix BeaconBlockBody attestations SSZ type in Gloas filter_node_tree tests --- .../test/gloas/fork_choice/test_filter_node_tree_variants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index 051c5b2e6b5..437ea341c91 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -118,7 +118,7 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = attestations + branch_block.body.attestations = spec.Attestations(attestations) signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) branch_root = signed_branch_block.message.hash_tree_root() @@ -260,7 +260,7 @@ def test_get_head_prunes_childless_unviable_empty_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = attestations + branch_block.body.attestations = spec.Attestations(attestations) if branch_root == b_root: branch_block.body.signed_execution_payload_bid.message.parent_block_hash = ( signed_b.message.body.signed_execution_payload_bid.message.block_hash From 67a24be0d3d4f220f6d247656e309eeb22571ea1 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Sun, 30 Aug 2026 20:20:52 +0000 Subject: [PATCH 16/20] Fix spec.Attestations initialization with star unpacking --- .../test/gloas/fork_choice/test_filter_node_tree_variants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index 437ea341c91..b923cc20c05 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -118,7 +118,7 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = spec.Attestations(attestations) + branch_block.body.attestations = spec.Attestations(*attestations) signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) branch_root = signed_branch_block.message.hash_tree_root() @@ -260,7 +260,7 @@ def test_get_head_prunes_childless_unviable_empty_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = spec.Attestations(attestations) + branch_block.body.attestations = spec.Attestations(*attestations) if branch_root == b_root: branch_block.body.signed_execution_payload_bid.message.parent_block_hash = ( signed_b.message.body.signed_execution_payload_bid.message.block_hash From 4054cb5b0cbeaece268455491756a0395ef3734a Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Sun, 30 Aug 2026 21:34:05 +0000 Subject: [PATCH 17/20] Fix block body attestations assignment in Gloas tests --- .../test/gloas/fork_choice/test_filter_node_tree_variants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index b923cc20c05..051c5b2e6b5 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -118,7 +118,7 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = spec.Attestations(*attestations) + branch_block.body.attestations = attestations signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) branch_root = signed_branch_block.message.hash_tree_root() @@ -260,7 +260,7 @@ def test_get_head_prunes_childless_unviable_empty_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = spec.Attestations(*attestations) + branch_block.body.attestations = attestations if branch_root == b_root: branch_block.body.signed_execution_payload_bid.message.parent_block_hash = ( signed_b.message.body.signed_execution_payload_bid.message.block_hash From d12218157e9156b39b586d5b6789ae2ff543ab63 Mon Sep 17 00:00:00 2001 From: 0xsamalt Date: Mon, 31 Aug 2026 06:30:01 +0000 Subject: [PATCH 18/20] Append block body attestations iteratively in Gloas tests --- .../gloas/fork_choice/test_filter_node_tree_variants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py index 051c5b2e6b5..9bb5ae8ea6c 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/fork_choice/test_filter_node_tree_variants.py @@ -118,7 +118,8 @@ def test_get_head_prunes_childless_unviable_full_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = attestations + for attestation in attestations: + branch_block.body.attestations.append(attestation) signed_branch_block = state_transition_and_sign_block(spec, branch_state, branch_block) yield from tick_and_add_block(spec, store, signed_branch_block, test_steps) branch_root = signed_branch_block.message.hash_tree_root() @@ -260,7 +261,8 @@ def test_get_head_prunes_childless_unviable_empty_variant(spec, state): next_slots(spec, branch_state, branch_slot - branch_state.slot - 1) branch_block = build_empty_block_for_next_slot(spec, branch_state) assert branch_block.slot == branch_slot - branch_block.body.attestations = attestations + for attestation in attestations: + branch_block.body.attestations.append(attestation) if branch_root == b_root: branch_block.body.signed_execution_payload_bid.message.parent_block_hash = ( signed_b.message.body.signed_execution_payload_bid.message.block_hash From 5a677602df2279199b970ffed3f0bb0cdfaf5700 Mon Sep 17 00:00:00 2001 From: Mikhail Kalinin Date: Thu, 3 Sep 2026 18:39:19 +0600 Subject: [PATCH 19/20] Handle no viable nodes in Gloas --- specs/gloas/fork-choice.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index 9b5457bcf90..e9cadfe2e6d 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -634,6 +634,16 @@ between *full* and *empty* nodes. def get_head(store: Store) -> ForkChoiceNode: # Get filtered node tree that only includes viable branches filtered_node_tree = get_filtered_node_tree(store) + + # [New in Gloas:EIP7732] + if not any(filtered_node_tree): + # Return empty node if there are no viable nodes + # to ensure that head is never a pending node + return ForkChoiceNode( + root=store.justified_checkpoint.root, + payload_status=PAYLOAD_STATUS_EMPTY + ) + # Execute the LMD-GHOST fork-choice head = ForkChoiceNode( root=store.justified_checkpoint.root, From 5336fcf1411b2280aec8750223ff75bc9c371ffe Mon Sep 17 00:00:00 2001 From: Mikhail Kalinin Date: Thu, 3 Sep 2026 18:40:20 +0600 Subject: [PATCH 20/20] Fix lint --- specs/gloas/fork-choice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/gloas/fork-choice.md b/specs/gloas/fork-choice.md index e9cadfe2e6d..7802e1d4bdd 100644 --- a/specs/gloas/fork-choice.md +++ b/specs/gloas/fork-choice.md @@ -641,7 +641,7 @@ def get_head(store: Store) -> ForkChoiceNode: # to ensure that head is never a pending node return ForkChoiceNode( root=store.justified_checkpoint.root, - payload_status=PAYLOAD_STATUS_EMPTY + payload_status=PAYLOAD_STATUS_EMPTY, ) # Execute the LMD-GHOST fork-choice