From c3b36391b24fc040b089edbf05f957ef7971e697 Mon Sep 17 00:00:00 2001 From: Nikhil Sharma Date: Wed, 2 Sep 2026 11:58:43 +0530 Subject: [PATCH] Fix PENDING GLOAS head when justified subtree is non-viable Signed-off-by: Nikhil Sharma --- .../gloas_payload.rs | 60 +++++++++++++++++++ consensus/proto_array/src/proto_array.rs | 14 +++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs index c4f9660a512..51f3d717295 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -1003,6 +1003,61 @@ pub fn get_gloas_should_build_on_full_test_definition() -> ForkChoiceTestDefinit } } +/// When the justified checkpoint has no viable descendants, `get_head` must still +/// resolve the PENDING seed to EMPTY or FULL. +pub fn get_pending_head_resolves_when_justified_subtree_non_viable_test_definition() +-> ForkChoiceTestDefinition { + let ops = vec![ + // Justified block at slot 32. + Operation::ProcessBlock { + slot: Slot::new(32), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(2)), + }, + // Child with stale voting source (below viability horizon). + Operation::ProcessBlock { + slot: Slot::new(64), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(3)), + }, + Operation::FindHead { + justified_checkpoint: Checkpoint { + epoch: Epoch::new(1), + root: get_root(2), + }, + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(2), + current_slot: Slot::new(128), + expected_payload_status: Some(PayloadStatus::Empty), + }, + Operation::AssertShouldBuildOnFull { + block_root: get_root(2), + parent_payload_status: PayloadStatus::Empty, + proposal_slot: Slot::new(129), + expected: false, + }, + ]; + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1198,6 +1253,11 @@ mod tests { test.run(); } + #[test] + fn pending_head_resolves_when_justified_subtree_non_viable() { + get_pending_head_resolves_when_justified_subtree_non_viable_test_definition().run(); + } + /// Test that execution payload invalidation propagates across the V17→V29 fork /// boundary: after invalidating a V17 parent, head must not select any descendant. /// diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index d7a4b193abe..c7d2ed82bb2 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1262,11 +1262,15 @@ impl ProtoArray { self.should_apply_proposer_boost::(proposer_boost_root, justified_balances, spec)?; loop { - let children: Vec<_> = self - .get_node_children(&head)? - .into_iter() - .filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index)) - .collect(); + let children: Vec<_> = if head.payload_status == PayloadStatus::Pending { + // Spec: `get_node_children` does not consult `get_filtered_block_tree` for PENDING. + self.get_node_children(&head)? + } else { + self.get_node_children(&head)? + .into_iter() + .filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index)) + .collect() + }; if children.is_empty() { return Ok(head);