Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc5b903
FFG-test payload-status variants individually in `filter_block_tree`
0xsamalt Aug 2, 2026
3161090
Rename `filter_block_tree` to `filter_node_tree`
0xsamalt Aug 3, 2026
f066d02
Decouple `get_node_children` from fork-choice filtering
0xsamalt Aug 3, 2026
019e6da
Apply review feedback on fork-choice node-tree redesign
0xsamalt Aug 4, 2026
5df3d78
Revert accidental rename of `_generate_filter_block_tree`
0xsamalt Aug 5, 2026
9355082
Apply review feedback on filter_node_tree notes and node handling
0xsamalt Aug 6, 2026
d3caa46
Merge branch 'master' into fix-gloas-filter-block-tree-payload-variants
mkalinin Aug 7, 2026
84fda22
Drive filter node tree test justification via attestations
0xsamalt Aug 7, 2026
6e5d2e3
Simplify redundant epoch advance in filter node tree test
0xsamalt Aug 11, 2026
db2f2f2
Merge branch 'master' into fix-gloas-filter-block-tree-payload-variants
mkalinin Aug 11, 2026
2e28fcb
Apply review feedback on `filter_node_tree` notes
0xsamalt Aug 12, 2026
04c0e99
Merge branch 'master' into fix-gloas-filter-block-tree-payload-variants
0xsamalt Aug 13, 2026
5c6fcb9
Merge remote-tracking branch 'upstream/master' into fix-gloas-filter-…
0xsamalt Aug 24, 2026
135ade3
Remove `ForkChoiceNode` __hash__ override
0xsamalt Aug 24, 2026
80e1a08
Remove overridden `__hash__` function
jtraglia Aug 24, 2026
1c939ac
Refactor filter_node_tree
jtraglia Aug 26, 2026
9ea3a11
Add an assert that B is the justified root
jtraglia Aug 26, 2026
c041c24
Merge branch 'master' into fix-gloas-filter-block-tree-payload-variants
jtraglia Aug 26, 2026
90d435a
Add mirror and childless payload status variant tests for get_filtere…
0xsamalt Aug 30, 2026
3d8c93e
Fix BeaconBlockBody attestations SSZ type in Gloas filter_node_tree t…
0xsamalt Aug 30, 2026
67a24be
Fix spec.Attestations initialization with star unpacking
0xsamalt Aug 30, 2026
4054cb5
Fix block body attestations assignment in Gloas tests
0xsamalt Aug 30, 2026
d122181
Append block body attestations iteratively in Gloas tests
0xsamalt Aug 31, 2026
5a67760
Handle no viable nodes in Gloas
mkalinin Sep 3, 2026
5336fcf
Fix lint
mkalinin Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion specs/bellatrix/optimistic-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,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.
Expand Down
45 changes: 36 additions & 9 deletions specs/gloas/fork-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- [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 `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)
Expand Down Expand Up @@ -585,15 +586,29 @@ def get_weight(store: Store, node: ForkChoiceNode) -> Gwei:
return attestation_score + proposer_score
```

### Modified `get_filtered_node_tree`

```python
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``.
"""
# [Modified in Gloas:EIP7732]
base = ForkChoiceNode(
root=store.justified_checkpoint.root,
payload_status=PAYLOAD_STATUS_PENDING,
)
return filter_node_tree(store, base)
```

### Modified `get_node_children`

*Note*: This function is modified to introduce new type of children nodes
representing *full* and *empty* blocks.

```python
def get_node_children(
store: Store, blocks: Dict[Root, BeaconBlock], node: ForkChoiceNode
) -> Sequence[ForkChoiceNode]:
def get_node_children(store: Store, node: ForkChoiceNode) -> Sequence[ForkChoiceNode]:
if node.payload_status == PAYLOAD_STATUS_PENDING:
children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)]
if is_payload_verified(store, node.root):
Expand All @@ -602,10 +617,10 @@ def get_node_children(
else:
return [
ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING)
for root in blocks
for root in store.blocks
if (
blocks[root].parent_root == node.root
and node.payload_status == get_parent_payload_status(store, blocks[root])
store.blocks[root].parent_root == node.root
and node.payload_status == get_parent_payload_status(store, store.blocks[root])
)
]
```
Expand All @@ -617,8 +632,18 @@ 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
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,
Expand All @@ -627,7 +652,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
Expand Down
82 changes: 40 additions & 42 deletions specs/phase0/fork-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
- [`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)
- [`get_node_children`](#get_node_children)
- [`filter_node_tree`](#filter_node_tree)
- [`get_filtered_node_tree`](#get_filtered_node_tree)
- [`get_head`](#get_head)
- [`update_checkpoints`](#update_checkpoints)
- [`update_unrealized_checkpoints`](#update_unrealized_checkpoints)
Expand Down Expand Up @@ -394,28 +394,38 @@ def get_voting_source(store: Store, block_root: Root) -> Checkpoint:
return head_state.current_justified_checkpoint
```

#### `filter_block_tree`
#### `get_node_children`

```python
def get_node_children(

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.

This is a deeper semantics change that probably should be commented, previous to this change get_node_children gets a prefiltered list of blocks that are descendant of the justified checkpoint, while now it takes nodes from the store and gives all children.

store: Store,
node: ForkChoiceNode,
) -> Sequence[ForkChoiceNode]:
return [
ForkChoiceNode(root=root)
for root in store.blocks
if store.blocks[root].parent_root == node.root
]
```

*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`.
#### `filter_node_tree`

```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) -> Sequence[ForkChoiceNode]:
children = get_node_children(store, node)

# If any children branches contain expected finalized/justified checkpoints,
# add to filtered block-tree and signal viability to parent.
# include this node and those descendants in the filtered node tree.
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
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, 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
Expand All @@ -427,7 +437,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,
)

Expand All @@ -436,50 +446,38 @@ 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
return True
return [node]

# Otherwise, branch not viable
return False
return []
```

#### `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) -> Sequence[ForkChoiceNode]:
"""
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
blocks: Dict[Root, BeaconBlock] = {}
filter_block_tree(store, base, blocks)
return blocks
```

#### `get_node_children`

```python
def get_node_children(
store: Store, # noqa: ARG001
blocks: Dict[Root, BeaconBlock],
node: ForkChoiceNode,
) -> Sequence[ForkChoiceNode]:
return [ForkChoiceNode(root=root) for root in blocks if blocks[root].parent_root == node.root]
base = ForkChoiceNode(root=store.justified_checkpoint.root)

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.

It's weird to think in nodes in phase 0 as now we have this bad situation in which we pass a ForkChoiceNode without any PayloadStatus.

return filter_node_tree(store, base)
```

#### `get_head`

```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
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
Comment thread
mkalinin marked this conversation as resolved.

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.

This is the semantic change consumer which requires a check in the caller instead of just a single filtering as we did before.

]
if len(children) == 0:
return head
# Sort by latest attesting balance with ties broken lexicographically
Expand Down
Loading