Skip to content

Only return viable payload-status variants from get_head - #5509

Open
0xsamalt wants to merge 18 commits into
ethereum:masterfrom
0xsamalt:fix-gloas-filter-block-tree-payload-variants
Open

Only return viable payload-status variants from get_head#5509
0xsamalt wants to merge 18 commits into
ethereum:masterfrom
0xsamalt:fix-gloas-filter-block-tree-payload-variants

Conversation

@0xsamalt

@0xsamalt 0xsamalt commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

In Gloas, get_node_children expands a PENDING node into its EMPTY and FULL payload-status variants without consulting the filtered block tree or any FFG test (introduced in #5249). As a result, a childless variant of a block that fails the FFG test is never pruned: it can become a leaf of the LMD-GHOST walk and be returned by get_head, even though it is not viable (#5496).

This PR makes filter_block_tree operate on payload-status variants, keyed by (root, payload_status), so that every variant is FFG-tested independently. The FFG test itself is unchanged and still computed per block root. get_node_children now only returns children present in the filtered block tree, so the head walk can never stop at a non-viable variant, and get_filtered_block_tree seeds the recursion from a pending node at store.justified_checkpoint.root.

Fixes #5496

@github-actions github-actions Bot added testing CI, actions, tests, testing infra gloas labels Aug 2, 2026
@0xsamalt
0xsamalt marked this pull request as ready for review August 2, 2026 20:11
@mkalinin

mkalinin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What do you think about renaming filter_block_tree to filter_node_tree with the corresponding semantics change as an alternative approach to deal with the original issue? And then apply the same to get_filtered_block_tree

@0xsamalt

0xsamalt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the suggestion. I like the direction, since the function is now FFG-testing payload-status variants rather than blocks, so filter_node_tree/get_filtered_node_tree would be more accurate names. If we go this route, I'd propose the "semantics change" also means dropping the ad-hoc Tuple[Root, PayloadStatus] key in favor of keying the dict directly by ForkChoiceNode (Dict[ForkChoiceNode, BeaconBlock]), since ForkChoiceNode already models that pair and is hashable (@dataclass(eq=True, frozen=True)).

One thing to flag: pysetup merges same-named functions across forks but has no rename detection, so renaming in gloas/fork-choice.md alone would leave phase0's filter_block_tree/get_filtered_block_tree in the compiled Gloas spec alongside the new ones. I'd add both old names to GloasSpecBuilder.deprecate_functions() in pysetup/spec_builders/gloas.py (same pattern as process_execution_payload), plus a prose-only ##### Removed note for clarity. The deprecation also means any stale callers (the shared get_viable_for_head_checks helper, the block_cover.py compliance runner, and the new test) get updated in the same PR and leftover references will fail loudly rather than silently using phase0 semantics.

Happy to make this change if you think it's worth it, wanted to confirm the approach and the deprecate_functions() step before reworking the PR. Let me know what you think.

@mkalinin

mkalinin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I think we can alter the spec by introducing filter_node_tree since Phase0 and then introduce required modifications in Gloas if any

@0xsamalt

0xsamalt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Good call, that also sidesteps the deprecation issue since Gloas redefines these functions rather than inheriting phase0's, so a phase0-origin rename should merge cleanly. I'll rework the PR: rename filter_block_tree to filter_node_tree and get_filtered_block_tree to get_filtered_node_tree starting in phase0, update Gloas on top with the payload-status testing and ForkChoiceNode-keyed dict, and fix the callers. Pushing shortly.

@github-actions github-actions Bot added the phase0 label Aug 3, 2026
@0xsamalt

0xsamalt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the rename. filter_block_tree/get_filtered_block_tree are now filter_node_tree/get_filtered_node_tree, starting at the phase0 definition, with Gloas and all callers updated to match. I left out the dict-keying change (Tuple[Root, PayloadStatus] to ForkChoiceNode) since that wasn't part of what we settled on here, happy to open a follow-up for it if you think it's worth doing.

@mkalinin

mkalinin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I was thinking about a little bit different design:

  • get_node_children returns all possible nodes that are children of a given one regardless of the filtering, it encapsulates child-parent relation logic
  • filter_node_tree relies on get_node_children and returns a Set[ForkChoiceNode] of viable nodes
  • get_head queries get_node_children and then operates over children that are present in filtered_node_tree returned by get_filtered_node_tree
  • Corresponding adjustments to the test helpers and tests that are affected by this change

What are your thoughts on this?

@0xsamalt

0xsamalt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Implemented as described: get_node_children returns all children regardless of viability, filter_node_tree/get_filtered_node_tree return a Set[ForkChoiceNode] of viable nodes, and get_head filters against that set. Also dropped block_cover.py's is_post_gloas branch since both forks now share the same return shape. Tests and lint are green.

Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread tests/generators/compliance_runners/fork_choice/instantiators/block_cover.py Outdated
Comment thread tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py Outdated
Comment thread tests/core/pyspec/eth_consensus_specs/test/helpers/optimistic_sync.py Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
@0xsamalt
0xsamalt requested a review from mkalinin August 10, 2026 09:04
@mkalinin
mkalinin requested a review from jtraglia August 11, 2026 06:03
Comment thread specs/phase0/fork-choice.md Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this leads to possibility that get_head returns PENDING variant, which has an effect downstream to the consumers of this function. We should make sure this always return EMPTY or FULL

@0xsamalt 0xsamalt Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, traced it: get_head can only return PENDING if the justified root's subtree has zero viable EMPTY/FULL nodes (any PENDING node in the set has a viable EMPTY/FULL child, so only base can be returned). That can't happen for a well-formed store, but it isn't asserted anywhere. want me to add an assert to Gloas get_head?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every viable PENDING node has at least a viable EMPTY child because the viability in both cases is determined by a node.root. Also, store.justified_checkpoint.root block must have at least a single viable descendant with post_state.justified_checkpoint == store.justified_checkpoint in the store.

I don’t think it worth adding any additional asserts.

@ensi321 ensi321 Aug 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To clarify, if get_filtered_node_tree is empty, under the current status quo, get_head will return EMPTY(justified) because it calls children = get_node_children(store, blocks, head) unconditionally.

The new change here

children = [
            child for child in get_node_children(store, head) if child in filtered_node_tree 
        ]

means children will be empty because filtered_node_tree is empty.

Then get_head will return ForkChoiceNode(root=store.justified_checkpoint.root) which has default payload status to PENDING.

This will have downstream effect, eg. should_build_on_full will always fail because it asserts head to not be PENDING.

I think the fix is not having assertion in get_head, but rather a soft override. Something like

    if head.payload_status == PAYLOAD_STATUS_PENDING:
        # Entire viable tree is empty fall back to the EMPTY variant of the justified root
        head = ForkChoiceNode(root=head.root, payload_status=PAYLOAD_STATUS_EMPTY)
    return head

to line 496

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wdyt about the following mod to get_head prior to the tree traversal:

# [New in Gloas:EIP7732]
if not any(filtered_node_tree):
     # Must never return a pending node
     return ForkChoiceNode(
          root=store.justified_checkpoint.root,
          payload_status=PAYLOAD_STATUS_EMPTY
     )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes I think this is good too

Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Builtin hash is no longer shadowed in the compiled spec namespace
after ethereum#5555, so the dataclass-generated hash can be used.
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated

@jtraglia jtraglia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I pushed some changes to this PR, please double check that those are good. If @mkalinin and @ensi321 approve this PR, we can merge it. I'm not really an expert with fork-choice, so I wouldn't feel comfortable making the final decision here. The code itself looks fine though.

@jtraglia jtraglia changed the title FFG-test payload-status variants individually in filter_block_tree Fix get_head returning unviable Gloas payload-status variants Aug 26, 2026
@jtraglia jtraglia changed the title Fix get_head returning unviable Gloas payload-status variants Only return viable payload-status variants from get_head Aug 26, 2026
@jtraglia

Copy link
Copy Markdown
Member

Also, please take a moment to mark resolved comment as "resolved".

@with_gloas_and_later
@with_presets([MINIMAL], reason="too slow")
@spec_state_test
def test_get_head_prunes_childless_unviable_full_variant(spec, state):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you add a test that's the mirror of this case?

ie. childless EMPTY variant of a block that fails FFG test and K builds on FULL(B)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also add one more:
both FULL(B) and EMPTY(B) pass FFG test, both childless, see if get_filtered_node_tree contains both

@mkalinin

Copy link
Copy Markdown
Contributor

It would be great if @potuz could take a look at this PR as he was opposing to making this change into the spec

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bellatrix gloas phase0 testing CI, actions, tests, testing infra

Projects

None yet

Development

Successfully merging this pull request may close these issues.

childless payload-status variants are never FFG-tested, so get_head can return a non-viable node

4 participants