From 82dc8b4edc859469647e07d475d4b68466beb498 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 23 Apr 2026 20:32:26 +0900 Subject: [PATCH] Ensure payload envelope streamer always serves canonical envelopes after the split slot (#9085) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi --- .../beacon_chain/src/canonical_head.rs | 23 +- .../beacon_chain_adapter.rs | 4 +- .../src/payload_envelope_streamer/mod.rs | 9 +- .../src/payload_envelope_streamer/tests.rs | 19 +- consensus/fork_choice/src/fork_choice.rs | 24 ++ .../src/fork_choice_test_definition.rs | 113 +++++++- .../gloas_payload.rs | 273 +++++++++++++++++- consensus/proto_array/src/proto_array.rs | 86 +++++- .../src/proto_array_fork_choice.rs | 18 ++ 9 files changed, 533 insertions(+), 36 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1e5e1300ab..74670b02d7 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -383,11 +383,24 @@ impl CanonicalHead { Ok((head, execution_status)) } - // TODO(gloas) just a stub for now, implement this once we have fork choice. - /// Returns true if the payload for this block is canonical according to fork choice - /// Returns an error if the block root doesn't exist in fork choice. - pub fn block_has_canonical_payload(&self, _root: &Hash256) -> Result { - Ok(true) + /// Returns `true` if the payload for this block is canonical (Full) according to fork choice. + pub fn block_has_canonical_payload( + &self, + root: &Hash256, + spec: &ChainSpec, + ) -> Result { + let cached_head = self.cached_head(); + let head_root = cached_head.head_block_root(); + let head_payload_status = cached_head.head_payload_status(); + + if *root == head_root { + return Ok(head_payload_status == PayloadStatus::Full); + } + + self.fork_choice_read_lock() + .get_canonical_payload_status(root, spec) + .map(|status| status == PayloadStatus::Full) + .map_err(Error::ForkChoiceError) } /// Returns a clone of `self.cached_head`. diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs index 47c58f07b9..4e36cf7895 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs @@ -37,6 +37,8 @@ impl EnvelopeStreamerBeaconAdapter { &self, root: &Hash256, ) -> Result { - self.chain.canonical_head.block_has_canonical_payload(root) + self.chain + .canonical_head + .block_has_canonical_payload(root, &self.chain.spec) } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs index d10e3762a4..5b1bda5dd5 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs @@ -132,13 +132,8 @@ impl PayloadEnvelopeStreamer { results.push((*root, Ok(None))); } } - Err(_) => { - results.push(( - *root, - Err(BeaconChainError::EnvelopeStreamerError( - Error::BlockMissingFromForkChoice, - )), - )); + Err(e) => { + results.push((*root, Err(e))); } } } else { diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs index 0db6d57ed6..be3dbf33ce 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::beacon_chain::ForkChoiceError; use crate::payload_envelope_streamer::beacon_chain_adapter::MockEnvelopeStreamerBeaconAdapter; use crate::test_utils::EphemeralHarnessType; use bls::{FixedBytesExtended, Signature}; @@ -279,15 +280,18 @@ async fn stream_envelopes_by_root() { } /// When `block_has_canonical_payload` returns an error, the streamer should -/// yield `Err(EnvelopeStreamerError(BlockMissingFromForkChoice))` for those roots. +/// propagate that error for those roots. #[tokio::test] async fn stream_envelopes_error() { let chain = build_chain(4, &[], &[], &[]); let (mut mock, _runtime) = mock_adapter(); mock.expect_get_split_slot().return_const(Slot::new(0)); mock_envelopes(&mut mock, &chain); - mock.expect_block_has_canonical_payload() - .returning(|_| Err(BeaconChainError::CanonicalHeadLockTimeout)); + mock.expect_block_has_canonical_payload().returning(|_| { + Err(BeaconChainError::ForkChoiceError( + ForkChoiceError::DoesNotDescendFromFinalizedCheckpoint, + )) + }); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -299,13 +303,8 @@ async fn stream_envelopes_error() { .unwrap_or_else(|| panic!("stream ended early at index {i}")); assert_eq!(root, entry.block_root, "root mismatch at index {i}"); assert!( - matches!( - result.as_ref(), - Err(BeaconChainError::EnvelopeStreamerError( - Error::BlockMissingFromForkChoice - )) - ), - "expected BlockMissingFromForkChoice error at index {i}, got {:?}", + result.as_ref().is_err(), + "expected error at index {i}, got {:?}", result ); } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 21415e478a..f9d779fd24 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -78,6 +78,7 @@ pub enum Error { UnrealizedVoteProcessing(state_processing::EpochProcessingError), ValidatorStatuses(BeaconStateError), ChainSpecError(String), + DoesNotDescendFromFinalizedCheckpoint, } impl From for Error { @@ -1523,6 +1524,29 @@ where } } + /// Returns the canonical payload status of a block. See + /// `ProtoArrayForkChoice::get_canonical_payload_status`. + pub fn get_canonical_payload_status( + &self, + block_root: &Hash256, + spec: &ChainSpec, + ) -> Result> { + if self.is_finalized_checkpoint_or_descendant(*block_root) { + let current_slot = self.fc_store.get_current_slot(); + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .get_canonical_payload_status::( + block_root, + current_slot, + proposer_boost_root, + spec, + ) + .map_err(Error::ProtoArrayError) + } else { + Err(Error::DoesNotDescendFromFinalizedCheckpoint) + } + } + /// Returns the weight for the given block root. pub fn get_block_weight(&self, block_root: &Hash256) -> Option { self.proto_array.get_weight(block_root) diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index c9764d3e44..d537f16bb2 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -4,6 +4,7 @@ mod gloas_payload; mod no_votes; mod votes; +use crate::error::Error; use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; @@ -30,6 +31,8 @@ pub enum Operation { justified_state_balances: Vec, expected_head: Hash256, current_slot: Slot, + // TODO(gloas): Make this non-optional. `find_head` always returns a `PayloadStatus` + // (Empty for pre-GLOAS), so every test should assert on it explicitly. #[serde(default)] expected_payload_status: Option, }, @@ -61,6 +64,12 @@ pub enum Operation { block_root: Hash256, attestation_slot: Slot, }, + ProcessGloasAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, + payload_present: bool, + }, ProcessPayloadAttestation { validator_index: usize, block_root: Hash256, @@ -105,6 +114,16 @@ pub enum Operation { block_root: Hash256, expected: bool, }, + AssertPayloadStatusByWeight { + block_root: Hash256, + expected_status: PayloadStatus, + /// Override `current_slot`. Defaults to the `current_slot` of the last `FindHead`. + #[serde(default)] + current_slot: Option, + /// Override the proposer boost root. Defaults to `Hash256::zero()`. + #[serde(default)] + proposer_boost_root: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -149,6 +168,7 @@ impl ForkChoiceTestDefinition { ) .expect("should create fork choice struct"); let equivocating_indices = BTreeSet::new(); + let mut last_current_slot = Slot::new(0); for (op_index, op) in self.operations.into_iter().enumerate() { match op.clone() { @@ -189,6 +209,16 @@ impl ForkChoiceTestDefinition { op_index, op ); } + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + current_slot, + Hash256::zero(), + &spec, + payload_status, + op_index, + ); + last_current_slot = current_slot; check_bytes_round_trip(&fork_choice); } Operation::ProposerBoostFindHead { @@ -201,7 +231,7 @@ impl ForkChoiceTestDefinition { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let (head, _payload_status) = fork_choice + let (head, payload_status) = fork_choice .find_head::( justified_checkpoint, finalized_checkpoint, @@ -220,6 +250,15 @@ impl ForkChoiceTestDefinition { "Operation at index {} failed head check. Operation: {:?}", op_index, op ); + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + Slot::new(0), + proposer_boost_root, + &spec, + payload_status, + op_index, + ); check_bytes_round_trip(&fork_choice); } Operation::InvalidFindHead { @@ -308,6 +347,27 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } + Operation::ProcessGloasAttestation { + validator_index, + block_root, + attestation_slot, + payload_present, + } => { + fork_choice + .process_attestation( + validator_index, + block_root, + attestation_slot, + payload_present, + ) + .unwrap_or_else(|_| { + panic!( + "process_attestation op at index {} returned error", + op_index + ) + }); + check_bytes_round_trip(&fork_choice); + } Operation::ProcessPayloadAttestation { validator_index, block_root, @@ -522,6 +582,26 @@ impl ForkChoiceTestDefinition { op_index ); } + Operation::AssertPayloadStatusByWeight { + block_root, + expected_status, + current_slot, + proposer_boost_root, + } => { + let actual = fork_choice + .get_canonical_payload_status::( + &block_root, + current_slot.unwrap_or(last_current_slot), + proposer_boost_root.unwrap_or_else(Hash256::zero), + &spec, + ) + .unwrap(); + assert_eq!( + actual, expected_status, + "canonical payload status mismatch at op index {}", + op_index + ); + } } } } @@ -546,6 +626,37 @@ fn get_checkpoint(i: u64) -> Checkpoint { } } +/// Checks that `get_canonical_payload_status` agrees with the `payload_status` +/// returned by `find_head` for the head block. +fn assert_canonical_payload_status_matches_find_head( + fork_choice: &ProtoArrayForkChoice, + head: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + expected: PayloadStatus, + op_index: usize, +) { + match fork_choice.get_canonical_payload_status::( + head, + current_slot, + proposer_boost_root, + spec, + ) { + Ok(actual) => assert_eq!( + actual, expected, + "get_canonical_payload_status disagreed with find_head for head {:?} at op index {}", + head, op_index + ), + // Skip the check for pre-gloas nodes + Err(Error::InvalidNodeVariant { .. }) => {} + Err(e) => panic!( + "get_canonical_payload_status failed at op index {}: {:?}", + op_index, e + ), + } +} + fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { let bytes = original.as_bytes(); let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone()) 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 197e1102a3..ac4f8992c4 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 @@ -81,20 +81,88 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { expected_payload_status: None, }); - ops.push(Operation::SetPayloadTiebreak { - block_root: get_root(0), - is_timely: false, - is_data_available: false, + // Cross-slot attestation with payload_present=true to Full branch (root 3, slot 2). + // vote_slot=3 differs from block_slot=2 and payload_present=true, so it counts as Full weight. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(3), + payload_present: true, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Full weight propagated up: root 0 and root 1 should show Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + // Root 2 has no payload received, so it's always Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + + // Cross-slot attestations with payload_present=false to Empty branch (root 4, slot 2). + // Two validators so Empty branch outweighs Full branch. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::ProcessGloasAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], expected_head: get_root(4), current_slot: Slot::new(0), expected_payload_status: None, }); + // Empty weight now dominates, so root 0 flips to Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + // Root 1 (Full branch) still has 1 Full vote and 0 Empty, so it stays Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + ForkChoiceTestDefinition { finalized_block_slot: Slot::new(0), justified_checkpoint: get_checkpoint(0), @@ -143,7 +211,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { justified_state_balances: vec![1, 1], expected_head: get_root(1), current_slot: Slot::new(0), - // With MainnetEthSpec PTC_SIZE=512, 1 bit set out of 256 threshold → not timely → Empty. + // With MainnetEthSpec PTC_SIZE=512 and a 256-bit threshold, 1 bit set is not timely, so Empty. expected_payload_status: Some(PayloadStatus::Empty), }); // PTC votes write to bitfields only, not to full/empty weight. @@ -286,7 +354,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe expected_payload_status: None, }); - // CL attestation to Empty branch (root 4) from validator 0 → head flips to 4. + // CL attestation to Empty branch (root 4) from validator 0 flips the head to 4. ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(4), @@ -301,7 +369,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe expected_payload_status: None, }); - // CL attestation back to Full branch (root 3) → head returns to 3. + // CL attestation back to Full branch (root 3) returns the head to 3. ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), @@ -546,7 +614,7 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef block_root: get_root(1), }); - // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. + // Step 4: Set tiebreaker to Empty on genesis so the Empty branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: false, @@ -560,8 +628,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef current_slot: Slot::new(1), expected_payload_status: None, }); + // Weights are tied (1 vote each branch), tiebreaker is Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); - // Step 5: Flip tiebreaker to Full → Full branch wins. + // Step 5: Flip tiebreaker to Full so the Full branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: true, @@ -575,8 +650,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef current_slot: Slot::new(100), expected_payload_status: None, }); + // Weights still tied, tiebreaker flipped to Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); - // Step 6: Add extra CL weight to Empty branch → overrides Full tiebreaker. + // Step 6: Add extra CL weight to the Empty branch; this overrides the Full tiebreaker. ops.push(Operation::ProcessAttestation { validator_index: 2, block_root: get_root(4), @@ -732,6 +814,163 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe } } +/// When `current_slot == node.slot + 1`, spec `get_weight` zeroes out Full and Empty +/// weights so the tiebreaker decides. Tests that the zero-out is applied and +/// doesn't just compare raw payload weights. +pub fn get_gloas_previous_slot_tiebreaker_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with its payload received. + // Genesis has zero block hash so all its children are Empty (genesis never has + // payload_received). Block 1's parent_hash doesn't match zero → Empty child. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + 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(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Block 2 at slot 2 with a mismatched EL parent hash, giving it an Empty parent payload status. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(2), + parent_root: get_root(1), + 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(2)), + }); + + // More Full weight than Empty on block 1. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + }); + + // Materialize the attestation into `full_payload_weight`. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(1), + current_slot: Slot::new(1), + expected_payload_status: Some(PayloadStatus::Full), + }); + + // Before zero-out (current_slot == block 1's slot), raw weights decide payload status (Full) + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(1)), + proposer_boost_root: None, + }); + + // At current_slot == block 1's slot + 1, both weights zero out and the + // tiebreaker picks Empty (block 2 extends block 1 with an Empty parent + // payload status). + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(2)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +/// Proposer boost on a descendant can flip an ancestor's canonical payload status. +/// Boost supports the ancestor's Full variant (via the descendant's Full parent +/// payload status) but not Empty, so a large enough boost overrides raw Empty weight. +pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with payload received. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + 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(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Block 2 at slot 3 with a Full parent payload status (skip slot 2 so + // block 1's previous-slot zero-out doesn't fire at current_slot 3). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // One Empty vote on block 1. Balance totals are chosen so the proposer + // boost score exceeds the single Empty voter's balance. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: false, + }); + + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![100, 10000], + expected_head: get_root(1), + current_slot: Slot::new(3), + expected_payload_status: Some(PayloadStatus::Empty), + }); + + // Without boost the raw weights decide and Empty wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(3)), + proposer_boost_root: None, + }); + + // With boost on block 2 the boost supports block 1's Full variant, so Full wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(3)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -758,7 +997,7 @@ mod tests { let mut ops = vec![]; // Block at slot 31 — last pre-Gloas slot. Created as a V17 node because - // gloas_fork_epoch = 1 → Gloas starts at slot 32. + // gloas_fork_epoch = 1 means Gloas starts at slot 32. // // The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)), // so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)). @@ -909,6 +1148,18 @@ mod tests { test.run(); } + #[test] + fn previous_slot_tiebreaker() { + let test = get_gloas_previous_slot_tiebreaker_test_definition(); + test.run(); + } + + #[test] + fn proposer_boost_flips_ancestor() { + let test = get_gloas_proposer_boost_flips_ancestor_test_definition(); + test.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 4ca7dab69c..8548974054 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1262,6 +1262,90 @@ impl ProtoArray { } } + /// Returns the canonical payload status of a block, matching the decision + /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. + pub(crate) fn get_canonical_payload_status( + &self, + root: Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + let proto_node_index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let proto_node = self + .nodes + .get(proto_node_index) + .ok_or(Error::InvalidNodeIndex(proto_node_index))?; + + if !proto_node + .payload_received() + .map_err(|_| Error::InvalidNodeVariant { block_root: root })? + { + return Ok(PayloadStatus::Empty); + } + + let full_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Full, + }; + let empty_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Empty, + }; + + // Matches the hoisting optimization in `find_head`: `get_weight`'s spec-level + // `should_apply_proposer_boost` check is precomputed once. + let apply_proposer_boost = + self.should_apply_proposer_boost::(proposer_boost_root, justified_balances, spec)?; + + let full_weight = self.get_weight::( + &full_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + let empty_weight = self.get_weight::( + &empty_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + match full_weight.cmp(&empty_weight) { + std::cmp::Ordering::Greater => Ok(PayloadStatus::Full), + std::cmp::Ordering::Less => Ok(PayloadStatus::Empty), + std::cmp::Ordering::Equal => { + let full_tb = self.get_payload_status_tiebreaker::( + &full_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + let empty_tb = self.get_payload_status_tiebreaker::( + &empty_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + if full_tb >= empty_tb { + Ok(PayloadStatus::Full) + } else { + Ok(PayloadStatus::Empty) + } + } + } + } + /// Spec: `get_weight`. #[allow(clippy::too_many_arguments)] fn get_weight( @@ -1417,7 +1501,7 @@ impl ProtoArray { } } - fn get_payload_status_tiebreaker( + pub(crate) fn get_payload_status_tiebreaker( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 577e89baa1..1c6d3f3201 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1053,6 +1053,24 @@ impl ProtoArrayForkChoice { .unwrap_or(false) } + /// Returns the canonical payload status of a block, matching the decision + /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. + pub fn get_canonical_payload_status( + &self, + block_root: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + ) -> Result { + self.proto_array.get_canonical_payload_status::( + *block_root, + current_slot, + proposer_boost_root, + &self.balances, + spec, + ) + } + /// Returns the weight of a given block. pub fn get_weight(&self, block_root: &Hash256) -> Option { let block_index = self.proto_array.indices.get(block_root)?;