From 491b69f364bcc0d75f0183a6bd288cb8684d86f0 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Thu, 19 Feb 2026 23:07:31 -0500 Subject: [PATCH 01/20] proto node versioning --- consensus/fork_choice/src/fork_choice.rs | 30 +++ consensus/proto_array/src/error.rs | 7 + consensus/proto_array/src/lib.rs | 2 +- consensus/proto_array/src/proto_array.rs | 183 ++++++++++++------ .../src/proto_array_fork_choice.rs | 13 ++ 5 files changed, 176 insertions(+), 59 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 9744b9fa084..5edd9b139df 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -138,6 +138,10 @@ pub enum InvalidBlock { finalized_root: Hash256, block_ancestor: Option, }, + MissingExecutionPayloadBid{ + block_slot: Slot, + block_root: Hash256, + } } #[derive(Debug)] @@ -241,6 +245,7 @@ pub struct QueuedAttestation { attesting_indices: Vec, block_root: Hash256, target_epoch: Epoch, + payload_present: bool, } impl<'a, E: EthSpec> From> for QueuedAttestation { @@ -250,6 +255,7 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { attesting_indices: a.attesting_indices_to_vec(), block_root: a.data().beacon_block_root, target_epoch: a.data().target.epoch, + payload_present: a.data().index == 1, } } } @@ -882,6 +888,25 @@ where ExecutionStatus::irrelevant() }; + let (execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(signed_bid) = block.body().signed_execution_payload_bid() { + ( + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else { + if spec.fork_name_at_slot::(block.slot()).gloas_enabled() { + return Err(Error::InvalidBlock( + InvalidBlock::MissingExecutionPayloadBid{ + block_slot: block.slot(), + block_root, + } + + )) + } + (None, None) + }; + // This does not apply a vote to the block, it just makes fork choice aware of the block so // it can still be identified as the head even if it doesn't have any votes. self.proto_array.process_block::( @@ -908,10 +933,14 @@ where execution_status, unrealized_justified_checkpoint: Some(unrealized_justified_checkpoint), unrealized_finalized_checkpoint: Some(unrealized_finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + }, current_slot, self.justified_checkpoint(), self.finalized_checkpoint(), + spec, )?; Ok(()) @@ -1103,6 +1132,7 @@ where if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { + let payload_present = attestation.data().index == 1; self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index 35cb4007b78..c3e60277a3a 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -54,6 +54,13 @@ pub enum Error { }, InvalidEpochOffset(u64), Arith(ArithError), + GloasNotImplemented, + InvalidNodeVariant{ + block_root: Hash256, + }, + BrokenBlock{ + block_root: Hash256, + }, } impl From for Error { diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 964e836d91d..222f9274781 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -9,7 +9,7 @@ pub use crate::justified_balances::JustifiedBalances; pub use crate::proto_array::{InvalidationOperation, calculate_committee_fraction}; pub use crate::proto_array_fork_choice::{ Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, ProposerHeadError, - ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, + ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, PayloadStatus, }; pub use error::Error; diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 5bfcdae463d..d7b1ec63135 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1,5 +1,5 @@ use crate::error::InvalidBestNodeInfo; -use crate::{Block, ExecutionStatus, JustifiedBalances, error::Error}; +use crate::{Block, ExecutionStatus, JustifiedBalances, error::Error, PayloadStatus}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz::Encode; @@ -68,47 +68,68 @@ impl InvalidationOperation { } } -pub type ProtoNode = ProtoNodeV17; #[superstruct( - variants(V17), + variants(V17, V29), variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)), - no_enum )] +#[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Clone)] +#[ssz(enum_behaviour = "transparent")] pub struct ProtoNode { /// The `slot` is not necessary for `ProtoArray`, it just exists so external components can /// easily query the block slot. This is useful for upstream fork choice logic. + #[superstruct(getter(copy))] pub slot: Slot, /// The `state_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely attestation verification). + #[superstruct(getter(copy))] pub state_root: Hash256, /// The root that would be used for the `attestation.data.target.root` if a LMD vote was cast /// for this block. /// /// The `target_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely fork choice attestation verification). + #[superstruct(getter(copy))] pub target_root: Hash256, pub current_epoch_shuffling_id: AttestationShufflingId, pub next_epoch_shuffling_id: AttestationShufflingId, + #[superstruct(getter(copy))] pub root: Hash256, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_usize")] pub parent: Option, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub justified_checkpoint: Checkpoint, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub finalized_checkpoint: Checkpoint, + #[superstruct(getter(copy))] pub weight: u64, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_child: Option, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_descendant: Option, /// Indicates if an execution node has marked this block as valid. Also contains the execution /// block hash. + #[superstruct(only(V17), partial_getter(copy))] pub execution_status: ExecutionStatus, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_justified_checkpoint: Option, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_finalized_checkpoint: Option, + + /// We track the parent payload status from which the current node was extended. + #[superstruct(only(V29), partial_getter(copy))] + pub parent_payload_status: PayloadStatus, + #[superstruct(only(V29), partial_getter(copy))] + pub empty_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub full_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub execution_payload_block_hash: ExecutionBlockHash, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -181,16 +202,14 @@ impl ProtoArray { // There is no need to adjust the balances or manage parent of the zero hash since it // is an alias to the genesis block. The weight applied to the genesis block is // irrelevant as we _always_ choose it and it's impossible for it to have a parent. - if node.root == Hash256::zero() { + if node.root() == Hash256::zero() { continue; } - let execution_status_is_invalid = node.execution_status.is_invalid(); - - let mut node_delta = if execution_status_is_invalid { + let mut node_delta = if let Ok(proto_node) = node.as_v17() && proto_node.execution_status.is_invalid() { // If the node has an invalid execution payload, reduce its weight to zero. 0_i64 - .checked_sub(node.weight as i64) + .checked_sub(node.weight() as i64) .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? } else { deltas @@ -202,7 +221,7 @@ impl ProtoArray { // If we find the node for which the proposer boost was previously applied, decrease // the delta by the previous score amount. if self.previous_proposer_boost.root != Hash256::zero() - && self.previous_proposer_boost.root == node.root + && self.previous_proposer_boost.root == node.root() // Invalid nodes will always have a weight of zero so there's no need to subtract // the proposer boost delta. && !execution_status_is_invalid @@ -217,7 +236,7 @@ impl ProtoArray { // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance if let Some(proposer_score_boost) = spec.proposer_score_boost && proposer_boost_root != Hash256::zero() - && proposer_boost_root == node.root + && proposer_boost_root == node.root() // Invalid nodes (or their ancestors) should not receive a proposer boost. && !execution_status_is_invalid { @@ -232,7 +251,7 @@ impl ProtoArray { // Apply the delta to the node. if execution_status_is_invalid { // Invalid nodes always have a weight of 0. - node.weight = 0 + node.weight() = 0 } else if node_delta < 0 { // Note: I am conflicted about whether to use `saturating_sub` or `checked_sub` // here. @@ -243,19 +262,19 @@ impl ProtoArray { // // However, I am not fully convinced that some valid case for `saturating_sub` does // not exist. - node.weight = node - .weight + node.weight() = node + .weight() .checked_sub(node_delta.unsigned_abs()) .ok_or(Error::DeltaOverflow(node_index))?; } else { node.weight = node - .weight + .weight() .checked_add(node_delta as u64) .ok_or(Error::DeltaOverflow(node_index))?; } // Update the parent delta (if any). - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { let parent_delta = deltas .get_mut(parent_index) .ok_or(Error::InvalidParentDelta(parent_index))?; @@ -283,7 +302,7 @@ impl ProtoArray { .ok_or(Error::InvalidNodeIndex(node_index))?; // If the node has a parent, try to update its best-child and best-descendant. - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { self.maybe_update_best_child_and_descendant::( parent_index, node_index, @@ -306,6 +325,7 @@ impl ProtoArray { current_slot: Slot, best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, + spec: &ChainSpec, ) -> Result<(), Error> { // If the block is already known, simply ignore it. if self.indices.contains_key(&block.root) { @@ -314,45 +334,92 @@ impl ProtoArray { let node_index = self.nodes.len(); - let node = ProtoNode { - slot: block.slot, - root: block.root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id, - next_epoch_shuffling_id: block.next_epoch_shuffling_id, - state_root: block.state_root, - parent: block - .parent_root - .and_then(|parent| self.indices.get(&parent).copied()), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - weight: 0, - best_child: None, - best_descendant: None, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + let parent_index = block + .parent_root + .and_then(|parent| self.indices.get(&parent).copied()); + + let node = if !spec.fork_name_at_slot::(current_slot).gloas_enabled() { + ProtoNode::V17(ProtoNodeV17 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + best_child: None, + best_descendant: None, + execution_status: block.execution_status, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + }) + } else { + let execution_payload_block_hash = block + .execution_payload_block_hash + .ok_or(Error::BrokenBlock{block_root: block.root})?; + + let parent_payload_status: PayloadStatus = + if let Some(parent_node) = + parent_index.and_then(|idx| self.nodes.get(idx)) + { + let v29 = parent_node + .as_v29() + .map_err(|_| Error::InvalidNodeVariant{block_root: block.root})?; + if execution_payload_block_hash == v29.execution_payload_block_hash + { + PayloadStatus::Empty + } else { + PayloadStatus::Full + } + } else { + PayloadStatus::Full + }; + + ProtoNode::V29(ProtoNodeV29 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + best_child: None, + best_descendant: None, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + parent_payload_status, + empty_payload_weight: 0, + full_payload_weight: 0, + execution_payload_block_hash, + }) }; - // If the parent has an invalid execution status, return an error before adding the block to - // `self`. - if let Some(parent_index) = node.parent { + // If the parent has an invalid execution status, return an error before adding the + // block to `self`. This applies when the parent is a V17 node with execution tracking. + if let Some(parent_index) = node.parent() { let parent = self .nodes .get(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - if parent.execution_status.is_invalid() { + + if let Ok(status) = parent.execution_status() && status.is_invalid() { return Err(Error::ParentExecutionStatusIsInvalid { block_root: block.root, - parent_root: parent.root, + parent_root: parent.root(), }); } } - self.indices.insert(node.root, node_index); + self.indices.insert(node.root(), node_index); self.nodes.push(node.clone()); - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { self.maybe_update_best_child_and_descendant::( parent_index, node_index, @@ -805,12 +872,12 @@ impl ProtoArray { let change_to_none = (None, None); let change_to_child = ( Some(child_index), - child.best_descendant.or(Some(child_index)), + child.best_descendant().or(Some(child_index)), ); - let no_change = (parent.best_child, parent.best_descendant); + let no_change = (parent.best_child(), parent.best_descendant()); let (new_best_child, new_best_descendant) = - if let Some(best_child_index) = parent.best_child { + if let Some(best_child_index) = parent.best_child() { if best_child_index == child_index && !child_leads_to_viable_head { // If the child is already the best-child of the parent but it's not viable for // the head, remove it. @@ -838,16 +905,16 @@ impl ProtoArray { } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { // The best child leads to a viable head, but the child doesn't. no_change - } else if child.weight == best_child.weight { + } else if child.weight() == best_child.weight() { // Tie-breaker of equal weights by root. - if child.root >= best_child.root { + if *child.root() >= *best_child.root() { change_to_child } else { no_change } } else { // Choose the winner by weight. - if child.weight > best_child.weight { + if child.weight() > best_child.weight() { change_to_child } else { no_change @@ -867,8 +934,8 @@ impl ProtoArray { .get_mut(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - parent.best_child = new_best_child; - parent.best_descendant = new_best_descendant; + *parent.best_child_mut() = new_best_child; + *parent.best_descendant_mut() = new_best_descendant; Ok(()) } @@ -883,7 +950,7 @@ impl ProtoArray { best_finalized_checkpoint: Checkpoint, ) -> Result { let best_descendant_is_viable_for_head = - if let Some(best_descendant_index) = node.best_descendant { + if let Some(best_descendant_index) = node.best_descendant() { let best_descendant = self .nodes .get(best_descendant_index) @@ -921,21 +988,21 @@ impl ProtoArray { best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, ) -> bool { - if node.execution_status.is_invalid() { + if let Ok(proto_node) = node.as_v17() && proto_node.execution_status.is_invalid() { return false; } let genesis_epoch = Epoch::new(0); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let node_epoch = node.slot.epoch(E::slots_per_epoch()); - let node_justified_checkpoint = node.justified_checkpoint; + let node_epoch = node.slot().epoch(E::slots_per_epoch()); + let node_justified_checkpoint = node.justified_checkpoint(); let voting_source = if current_epoch > node_epoch { // The block is from a prior epoch, the voting source will be pulled-up. - node.unrealized_justified_checkpoint + node.unrealized_justified_checkpoint() // Sometimes we don't track the unrealized justification. In // that case, just use the fully-realized justified checkpoint. - .unwrap_or(node_justified_checkpoint) + .unwrap_or(*node_justified_checkpoint) } else { // The block is not from a prior epoch, therefore the voting source // is not pulled up. diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 3edf1e0644d..a0cd50db8be 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -159,6 +159,10 @@ pub struct Block { pub execution_status: ExecutionStatus, pub unrealized_justified_checkpoint: Option, pub unrealized_finalized_checkpoint: Option, + + /// post-Gloas fields + pub execution_payload_parent_hash: Option, + pub execution_payload_block_hash: Option, } impl Block { @@ -422,6 +426,9 @@ impl ProtoArrayForkChoice { current_epoch_shuffling_id: AttestationShufflingId, next_epoch_shuffling_id: AttestationShufflingId, execution_status: ExecutionStatus, + execution_payload_parent_hash: Option, + execution_payload_block_hash: Option, + ) -> Result { let mut proto_array = ProtoArray { prune_threshold: DEFAULT_PRUNE_THRESHOLD, @@ -445,6 +452,9 @@ impl ProtoArrayForkChoice { execution_status, unrealized_justified_checkpoint: Some(justified_checkpoint), unrealized_finalized_checkpoint: Some(finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + }; proto_array @@ -453,6 +463,7 @@ impl ProtoArrayForkChoice { current_slot, justified_checkpoint, finalized_checkpoint, + spec, ) .map_err(|e| format!("Failed to add finalized block to proto_array: {:?}", e))?; @@ -506,6 +517,7 @@ impl ProtoArrayForkChoice { current_slot: Slot, justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, + spec: &ChainSpec, ) -> Result<(), String> { if block.parent_root.is_none() { return Err("Missing parent root".to_string()); @@ -517,6 +529,7 @@ impl ProtoArrayForkChoice { current_slot, justified_checkpoint, finalized_checkpoint, + spec, ) .map_err(|e| format!("process_block_error: {:?}", e)) } From 3e3ccba1a689d74228f459951e73d43fbc42b06b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 11 Dec 2025 12:33:39 +1100 Subject: [PATCH 02/20] adding michael commits --- consensus/fork_choice/src/fork_choice.rs | 15 ++++--- consensus/proto_array/src/bin.rs | 3 ++ .../src/fork_choice_test_definition.rs | 5 ++- consensus/proto_array/src/lib.rs | 4 +- consensus/proto_array/src/proto_array.rs | 2 +- .../src/proto_array_fork_choice.rs | 41 +++++++++++++++---- 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 5edd9b139df..3e1c2dc3611 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -3,7 +3,7 @@ use crate::{ForkChoiceStore, InvalidationOperation}; use fixed_bytes::FixedBytesExtended; use logging::crit; use proto_array::{ - Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, + Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, LatestMessage, ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; use ssz::{Decode, Encode}; @@ -1136,7 +1136,8 @@ where self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, - attestation.data().target.epoch, + attestation.data().slot, + payload_present, )?; } } else { @@ -1256,10 +1257,12 @@ where &mut self.queued_attestations, ) { for validator_index in attestation.attesting_indices.iter() { + // FIXME(sproul): backwards compat/fork abstraction self.proto_array.process_attestation( *validator_index as usize, attestation.block_root, - attestation.target_epoch, + attestation.slot, + attestation.payload_present, )?; } } @@ -1389,13 +1392,15 @@ where /// Returns the latest message for a given validator, if any. /// - /// Returns `(block_root, block_slot)`. + /// Returns `block_root, block_slot, payload_present`. /// /// ## Notes /// /// It may be prudent to call `Self::update_time` before calling this function, /// since some attestations might be queued and awaiting processing. - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { + /// + /// This function is only used in tests. + pub fn latest_message(&self, validator_index: usize) -> Option { self.proto_array.latest_message(validator_index) } diff --git a/consensus/proto_array/src/bin.rs b/consensus/proto_array/src/bin.rs index e1d307affb4..94a10fb127c 100644 --- a/consensus/proto_array/src/bin.rs +++ b/consensus/proto_array/src/bin.rs @@ -1,3 +1,4 @@ +/* FIXME(sproul) use proto_array::fork_choice_test_definition::*; use std::fs::File; @@ -24,3 +25,5 @@ fn write_test_def_to_yaml(filename: &str, def: ForkChoiceTestDefinition) { let file = File::create(filename).expect("Should be able to open file"); serde_yaml::to_writer(file, &def).expect("Should be able to write YAML to file"); } +*/ +fn main() {} diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index e9deb6759fc..ac765b51d82 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -1,3 +1,4 @@ +/* FIXME(sproul) fix these tests later mod execution_status; mod ffg_updates; mod no_votes; @@ -227,13 +228,14 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } + // FIXME(sproul): update with payload_present Operation::ProcessAttestation { validator_index, block_root, target_epoch, } => { fork_choice - .process_attestation(validator_index, block_root, target_epoch) + .process_attestation(validator_index, block_root, target_epoch, false) .unwrap_or_else(|_| { panic!( "process_attestation op at index {} returned error", @@ -323,3 +325,4 @@ fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { "fork choice should encode and decode without change" ); } +*/ diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 222f9274781..1f126246b34 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -8,8 +8,8 @@ mod ssz_container; pub use crate::justified_balances::JustifiedBalances; pub use crate::proto_array::{InvalidationOperation, calculate_committee_fraction}; pub use crate::proto_array_fork_choice::{ - Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, ProposerHeadError, - ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, PayloadStatus, + Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, LatestMessage, PayloadStatus, + ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; pub use error::Error; diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index d7b1ec63135..1eb7cc9d882 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -111,7 +111,7 @@ pub struct ProtoNode { #[ssz(with = "four_byte_option_usize")] pub best_descendant: Option, /// Indicates if an execution node has marked this block as valid. Also contains the execution - /// block hash. + /// block hash. This is only used pre-Gloas. #[superstruct(only(V17), partial_getter(copy))] pub execution_status: ExecutionStatus, #[superstruct(getter(copy))] diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index a0cd50db8be..928e8ce8603 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -23,13 +23,23 @@ use types::{ pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; #[derive(Default, PartialEq, Clone, Encode, Decode)] +// FIXME(sproul): the "next" naming here is a bit odd +// FIXME(sproul): version this type? pub struct VoteTracker { current_root: Hash256, next_root: Hash256, - next_epoch: Epoch, + next_slot: Slot, + next_payload_present: bool, } -/// Represents the verification status of an execution payload. +// FIXME(sproul): version this type +pub struct LatestMessage { + slot: Slot, + root: Hash256, + payload_present: bool, +} + +/// Represents the verification status of an execution payload pre-Gloas. #[derive(Clone, Copy, Debug, PartialEq, Encode, Decode, Serialize, Deserialize)] #[ssz(enum_behaviour = "union")] pub enum ExecutionStatus { @@ -49,6 +59,16 @@ pub enum ExecutionStatus { Irrelevant(bool), } +/// Represents the status of an execution payload post-Gloas. +#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode, Serialize, Deserialize)] +#[ssz(enum_behaviour = "tag")] +#[repr(u8)] +pub enum PayloadStatus { + Pending = 0, + Empty = 1, + Full = 2, +} + impl ExecutionStatus { pub fn is_execution_enabled(&self) -> bool { !matches!(self, ExecutionStatus::Irrelevant(_)) @@ -499,13 +519,15 @@ impl ProtoArrayForkChoice { &mut self, validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + payload_present: bool, ) -> Result<(), String> { let vote = self.votes.get_mut(validator_index); - if target_epoch > vote.next_epoch || *vote == VoteTracker::default() { + if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; - vote.next_epoch = target_epoch; + vote.next_slot = attestation_slot; + vote.next_payload_present = payload_present; } Ok(()) @@ -920,14 +942,18 @@ impl ProtoArrayForkChoice { .is_finalized_checkpoint_or_descendant::(descendant_root, best_finalized_checkpoint) } - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { + pub fn latest_message(&self, validator_index: usize) -> Option { if validator_index < self.votes.0.len() { let vote = &self.votes.0[validator_index]; if *vote == VoteTracker::default() { None } else { - Some((vote.next_root, vote.next_epoch)) + Some(LatestMessage { + root: vote.next_root, + slot: vote.next_slot, + payload_present: vote.next_payload_present, + }) } } else { None @@ -1013,6 +1039,7 @@ impl ProtoArrayForkChoice { /// - If a value in `indices` is greater to or equal to `indices.len()`. /// - If some `Hash256` in `votes` is not a key in `indices` (except for `Hash256::zero()`, this is /// always valid). +// FIXME(sproul): implement get-weight changes here fn compute_deltas( indices: &HashMap, votes: &mut ElasticList, From d5c5077a31aa5c28ddff3944305966157b1e8d37 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Tue, 24 Feb 2026 17:40:11 -0500 Subject: [PATCH 03/20] implement scoring mechanisms and plumbing --- beacon_node/beacon_chain/src/beacon_chain.rs | 26 +- .../beacon_chain/src/block_production/mod.rs | 4 +- .../beacon_chain/src/block_verification.rs | 20 + .../beacon_chain/src/persisted_fork_choice.rs | 21 +- .../src/schema_change/migration_schema_v23.rs | 21 +- .../src/schema_change/migration_schema_v28.rs | 35 +- .../tests/payload_invalidation.rs | 4 +- beacon_node/beacon_chain/tests/tests.rs | 14 +- beacon_node/http_api/src/lib.rs | 58 +- beacon_node/http_api/tests/tests.rs | 59 ++- consensus/fork_choice/src/fork_choice.rs | 151 +++++- consensus/fork_choice/src/lib.rs | 3 +- consensus/fork_choice/tests/tests.rs | 30 ++ consensus/proto_array/src/bin.rs | 3 - consensus/proto_array/src/error.rs | 4 +- .../src/fork_choice_test_definition.rs | 204 +++++++- .../execution_status.rs | 57 +- .../ffg_updates.rs | 38 +- .../gloas_payload.rs | 222 ++++++++ .../fork_choice_test_definition/no_votes.rs | 15 + .../src/fork_choice_test_definition/votes.rs | 73 ++- consensus/proto_array/src/lib.rs | 4 +- consensus/proto_array/src/proto_array.rs | 494 ++++++++++++------ .../src/proto_array_fork_choice.rs | 348 +++++++++--- consensus/proto_array/src/ssz_container.rs | 76 ++- testing/ef_tests/src/cases/fork_choice.rs | 2 +- 26 files changed, 1573 insertions(+), 413 deletions(-) create mode 100644 consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 9f62bf11f5f..f95a2fb5035 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1441,7 +1441,7 @@ impl BeaconChain { .proto_array() .heads_descended_from_finalization::(fork_choice.finalized_checkpoint()) .iter() - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) .collect() } @@ -4776,7 +4776,7 @@ impl BeaconChain { // The slot of our potential re-org block is always 1 greater than the head block because we // only attempt single-slot re-orgs. - let head_slot = info.head_node.slot; + let head_slot = info.head_node.slot(); let re_org_block_slot = head_slot + 1; let fork_choice_slot = info.current_slot; @@ -4811,9 +4811,9 @@ impl BeaconChain { .fork_name_at_slot::(re_org_block_slot) .fulu_enabled() { - info.head_node.current_epoch_shuffling_id + info.head_node.current_epoch_shuffling_id() } else { - info.head_node.next_epoch_shuffling_id + info.head_node.next_epoch_shuffling_id() } .shuffling_decision_block; let proposer_index = self @@ -4844,8 +4844,8 @@ impl BeaconChain { // and the actual weight of the parent against the parent re-org threshold. let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( - info.head_node.weight < info.re_org_head_weight_threshold, - info.parent_node.weight > info.re_org_parent_weight_threshold, + info.head_node.weight() < info.re_org_head_weight_threshold, + info.parent_node.weight() > info.re_org_parent_weight_threshold, ) } else { (true, true) @@ -4853,7 +4853,7 @@ impl BeaconChain { if !head_weak { return Err(Box::new( DoNotReOrg::HeadNotWeak { - head_weight: info.head_node.weight, + head_weight: info.head_node.weight(), re_org_head_weight_threshold: info.re_org_head_weight_threshold, } .into(), @@ -4862,7 +4862,7 @@ impl BeaconChain { if !parent_strong { return Err(Box::new( DoNotReOrg::ParentNotStrong { - parent_weight: info.parent_node.weight, + parent_weight: info.parent_node.weight(), re_org_parent_weight_threshold: info.re_org_parent_weight_threshold, } .into(), @@ -4880,9 +4880,13 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::HeadNotLate.into())); } - let parent_head_hash = info.parent_node.execution_status.block_hash(); + let parent_head_hash = info + .parent_node + .execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()); let forkchoice_update_params = ForkchoiceUpdateParameters { - head_root: info.parent_node.root, + head_root: info.parent_node.root(), head_hash: parent_head_hash, justified_hash: canonical_forkchoice_params.justified_hash, finalized_hash: canonical_forkchoice_params.finalized_hash, @@ -4890,7 +4894,7 @@ impl BeaconChain { debug!( canonical_head = ?head_block_root, - ?info.parent_node.root, + parent_root = ?info.parent_node.root(), slot = %fork_choice_slot, "Fork choice update overridden" ); diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 76c8b77e934..f924461012c 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -200,7 +200,7 @@ impl BeaconChain { }) .ok()?; drop(proposer_head_timer); - let re_org_parent_block = proposer_head.parent_node.root; + let re_org_parent_block = proposer_head.parent_node.root(); let (state_root, state) = self .store @@ -213,7 +213,7 @@ impl BeaconChain { info!( weak_head = ?canonical_head, parent = ?re_org_parent_block, - head_weight = proposer_head.head_node.weight, + head_weight = proposer_head.head_node.weight(), threshold_weight = proposer_head.re_org_head_weight_threshold, "Attempting re-org due to weak head" ); diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index e0943d5d931..be1974b8124 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1683,6 +1683,26 @@ impl ExecutionPendingBlock { Err(e) => Err(BlockError::BeaconChainError(Box::new(e.into()))), }?; } + + // Register each payload attestation in the block with fork choice. + if let Ok(payload_attestations) = block.message().body().payload_attestations() { + for (i, payload_attestation) in payload_attestations.iter().enumerate() { + let indexed_payload_attestation = consensus_context + .get_indexed_payload_attestation(&state, payload_attestation, &chain.spec) + .map_err(|e| BlockError::PerBlockProcessingError(e.into_with_index(i)))?; + + match fork_choice.on_payload_attestation( + current_slot, + indexed_payload_attestation, + AttestationFromBlock::True, + ) { + Ok(()) => Ok(()), + // Ignore invalid payload attestations whilst importing from a block. + Err(ForkChoiceError::InvalidAttestation(_)) => Ok(()), + Err(e) => Err(BlockError::BeaconChainError(Box::new(e.into()))), + }?; + } + } drop(fork_choice); Ok(Self { diff --git a/beacon_node/beacon_chain/src/persisted_fork_choice.rs b/beacon_node/beacon_chain/src/persisted_fork_choice.rs index d8fcc0901bf..5551e1d7c94 100644 --- a/beacon_node/beacon_chain/src/persisted_fork_choice.rs +++ b/beacon_node/beacon_chain/src/persisted_fork_choice.rs @@ -9,10 +9,10 @@ use superstruct::superstruct; use types::Hash256; // If adding a new version you should update this type alias and fix the breakages. -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; #[superstruct( - variants(V17, V28), + variants(V17, V28, V29), variant_attributes(derive(Encode, Decode)), no_enum )] @@ -20,10 +20,12 @@ pub struct PersistedForkChoice { #[superstruct(only(V17))] pub fork_choice_v17: fork_choice::PersistedForkChoiceV17, #[superstruct(only(V28))] - pub fork_choice: fork_choice::PersistedForkChoiceV28, + pub fork_choice_v28: fork_choice::PersistedForkChoiceV28, + #[superstruct(only(V29))] + pub fork_choice: fork_choice::PersistedForkChoiceV29, #[superstruct(only(V17))] pub fork_choice_store_v17: PersistedForkChoiceStoreV17, - #[superstruct(only(V28))] + #[superstruct(only(V28, V29))] pub fork_choice_store: PersistedForkChoiceStoreV28, } @@ -47,7 +49,7 @@ macro_rules! impl_store_item { impl_store_item!(PersistedForkChoiceV17); -impl PersistedForkChoiceV28 { +impl PersistedForkChoiceV29 { pub fn from_bytes(bytes: &[u8], store_config: &StoreConfig) -> Result { let decompressed_bytes = store_config .decompress_bytes(bytes) @@ -78,3 +80,12 @@ impl PersistedForkChoiceV28 { )) } } + +impl From for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + fork_choice: v28.fork_choice_v28.into(), + fork_choice_store: v28.fork_choice_store, + } + } +} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs index e238e1efb6c..a6671c55be5 100644 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs @@ -110,22 +110,21 @@ pub fn downgrade_from_v23( // Doesn't matter what policy we use for invalid payloads, as our head calculation just // considers descent from finalization. let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice.fork_choice_v17.try_into()?, - reset_payload_statuses, - fc_store, - &db.spec, - ) - .map_err(|e| { - Error::MigrationError(format!("Error loading fork choice from persisted: {e:?}")) - })?; + let persisted_fc_v28: fork_choice::PersistedForkChoiceV28 = + persisted_fork_choice.fork_choice_v17.try_into()?; + let persisted_fc_v29: fork_choice::PersistedForkChoiceV29 = persisted_fc_v28.into(); + let fork_choice = + ForkChoice::from_persisted(persisted_fc_v29, reset_payload_statuses, fc_store, &db.spec) + .map_err(|e| { + Error::MigrationError(format!("Error loading fork choice from persisted: {e:?}")) + })?; let heads = fork_choice .proto_array() .heads_descended_from_finalization::(fork_choice.finalized_checkpoint()); - let head_roots = heads.iter().map(|node| node.root).collect(); - let head_slots = heads.iter().map(|node| node.slot).collect(); + let head_roots = heads.iter().map(|node| node.root()).collect(); + let head_slots = heads.iter().map(|node| node.slot()).collect(); let persisted_beacon_chain_v22 = PersistedBeaconChainV22 { _canonical_head_block_root: DUMMY_CANONICAL_HEAD_BLOCK_ROOT, diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs index 5885eaabc00..86b96080d43 100644 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs @@ -1,7 +1,7 @@ use crate::{ BeaconChain, BeaconChainTypes, BeaconForkChoiceStore, PersistedForkChoiceStoreV17, beacon_chain::FORK_CHOICE_DB_KEY, - persisted_fork_choice::{PersistedForkChoiceV17, PersistedForkChoiceV28}, + persisted_fork_choice::PersistedForkChoiceV17, summaries_dag::{DAGStateSummary, StateSummariesDAG}, }; use fork_choice::{ForkChoice, ForkChoiceStore, ResetPayloadStatuses}; @@ -88,8 +88,11 @@ pub fn upgrade_to_v28( // Construct top-level ForkChoice struct using the patched fork choice store, and the converted // proto array. let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; + let persisted_fc_v28: fork_choice::PersistedForkChoiceV28 = + persisted_fork_choice_v17.fork_choice_v17.try_into()?; + let persisted_fc_v29: fork_choice::PersistedForkChoiceV29 = persisted_fc_v28.into(); let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice_v17.fork_choice_v17.try_into()?, + persisted_fc_v29, reset_payload_statuses, fc_store, db.get_chain_spec(), @@ -118,26 +121,22 @@ pub fn downgrade_from_v28( return Ok(vec![]); }; - // Recreate V28 persisted fork choice, then convert each field back to its V17 version. - let persisted_fork_choice = PersistedForkChoiceV28 { - fork_choice: fork_choice.to_persisted(), - fork_choice_store: fork_choice.fc_store().to_persisted(), - }; - + let persisted_v29 = fork_choice.to_persisted(); + let fc_store_v28 = fork_choice.fc_store().to_persisted(); let justified_balances = fork_choice.fc_store().justified_balances(); + // Convert V29 proto_array back to legacy V28 for downgrade. + let persisted_fork_choice_v28 = fork_choice::PersistedForkChoiceV28 { + proto_array_v28: persisted_v29.proto_array.into(), + queued_attestations: persisted_v29.queued_attestations, + }; + // 1. Create `proto_array::PersistedForkChoiceV17`. - let fork_choice_v17: fork_choice::PersistedForkChoiceV17 = ( - persisted_fork_choice.fork_choice, - justified_balances.clone(), - ) - .into(); + let fork_choice_v17: fork_choice::PersistedForkChoiceV17 = + (persisted_fork_choice_v28, justified_balances.clone()).into(); - let fork_choice_store_v17: PersistedForkChoiceStoreV17 = ( - persisted_fork_choice.fork_choice_store, - justified_balances.clone(), - ) - .into(); + let fork_choice_store_v17: PersistedForkChoiceStoreV17 = + (fc_store_v28, justified_balances.clone()).into(); let persisted_fork_choice_v17 = PersistedForkChoiceV17 { fork_choice_v17, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index eb8e57a5d5f..b1e2fd2ccc3 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1498,7 +1498,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::>(); rig.invalidate_manually(roots[1]).await; @@ -1518,7 +1518,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::>(); assert_eq!(original_weights, new_weights); diff --git a/beacon_node/beacon_chain/tests/tests.rs b/beacon_node/beacon_chain/tests/tests.rs index b052ba66f1a..10c0b429a95 100644 --- a/beacon_node/beacon_chain/tests/tests.rs +++ b/beacon_node/beacon_chain/tests/tests.rs @@ -590,7 +590,10 @@ async fn unaggregated_attestations_added_to_fork_choice_some_none() { if slot <= num_blocks_produced && slot != 0 { assert_eq!( - latest_message.unwrap().1, + latest_message + .expect("latest message should be present") + .slot + .epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message epoch for {} should be equal to epoch {}.", validator, @@ -700,10 +703,12 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { let validator_slots: Vec<(&usize, Slot)> = validators.iter().zip(slots).collect(); for (validator, slot) in validator_slots { - let latest_message = fork_choice.latest_message(*validator); + let latest_message = fork_choice + .latest_message(*validator) + .expect("latest message should be present"); assert_eq!( - latest_message.unwrap().1, + latest_message.slot.epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message slot should be equal to attester duty." ); @@ -714,8 +719,7 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { .expect("Should get block root at slot"); assert_eq!( - latest_message.unwrap().0, - *block_root, + latest_message.root, *block_root, "Latest message block root should be equal to block at slot." ); } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 92a1ad934db..3077439b6f3 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2078,52 +2078,64 @@ pub fn serve( .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; + let execution_status_string = node + .execution_status() + .ok() + .map(|status| status.to_string()) + .unwrap_or_else(|| "n/a".to_string()); + ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: execution_status_string, best_child: node - .best_child + .best_child() .and_then(|index| proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() .and_then(|index| proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 7e3eb8b9807..a43c8216da0 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -33,7 +33,7 @@ use lighthouse_network::{Enr, PeerId, types::SyncState}; use network::NetworkReceivers; use network_utils::enr_ext::EnrExt; use operation_pool::attestation_storage::CheckpointKey; -use proto_array::ExecutionStatus; +use proto_array::{ExecutionStatus, core::ProtoNode}; use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; @@ -3072,51 +3072,61 @@ impl ApiTester { .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: node + .execution_status() + .ok() + .map(|status| status.to_string()) + .unwrap_or_else(|| "n/a".to_string()), best_child: node - .best_child + .best_child() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) @@ -7048,6 +7058,7 @@ impl ApiTester { .core_proto_array_mut() .nodes .last_mut() + && let ProtoNode::V17(head_node) = head_node { head_node.execution_status = ExecutionStatus::Optimistic(ExecutionBlockHash::zero()) } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 3e1c2dc3611..77442a62f57 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -20,7 +20,8 @@ use tracing::{debug, instrument, warn}; use types::{ AbstractExecPayload, AttestationShufflingId, AttesterSlashingRef, BeaconBlockRef, BeaconState, BeaconStateError, ChainSpec, Checkpoint, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, - Hash256, IndexedAttestationRef, RelativeEpoch, SignedBeaconBlock, Slot, + Hash256, IndexedAttestationRef, IndexedPayloadAttestation, RelativeEpoch, SignedBeaconBlock, + Slot, }; #[derive(Debug)] @@ -138,10 +139,10 @@ pub enum InvalidBlock { finalized_root: Hash256, block_ancestor: Option, }, - MissingExecutionPayloadBid{ + MissingExecutionPayloadBid { block_slot: Slot, block_root: Hash256, - } + }, } #[derive(Debug)] @@ -174,6 +175,9 @@ pub enum InvalidAttestation { /// The attestation is attesting to a state that is later than itself. (Viz., attesting to the /// future). AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// A same-slot attestation has a non-zero index, indicating a payload attestation during the + /// same slot as the block. Payload attestations must only arrive in subsequent slots. + PayloadAttestationDuringSameSlot { slot: Slot }, } impl From for Error { @@ -401,6 +405,9 @@ where current_epoch_shuffling_id, next_epoch_shuffling_id, execution_status, + None, + None, + spec, )?; let mut fork_choice = Self { @@ -889,23 +896,22 @@ where }; let (execution_payload_parent_hash, execution_payload_block_hash) = - if let Ok(signed_bid) = block.body().signed_execution_payload_bid() { - ( - Some(signed_bid.message.parent_block_hash), - Some(signed_bid.message.block_hash), - ) - } else { - if spec.fork_name_at_slot::(block.slot()).gloas_enabled() { - return Err(Error::InvalidBlock( - InvalidBlock::MissingExecutionPayloadBid{ - block_slot: block.slot(), - block_root, - } - - )) - } - (None, None) - }; + if let Ok(signed_bid) = block.body().signed_execution_payload_bid() { + ( + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else { + if spec.fork_name_at_slot::(block.slot()).gloas_enabled() { + return Err(Error::InvalidBlock( + InvalidBlock::MissingExecutionPayloadBid { + block_slot: block.slot(), + block_root, + }, + )); + } + (None, None) + }; // This does not apply a vote to the block, it just makes fork choice aware of the block so // it can still be identified as the head even if it doesn't have any votes. @@ -935,7 +941,6 @@ where unrealized_finalized_checkpoint: Some(unrealized_finalized_checkpoint), execution_payload_parent_hash, execution_payload_block_hash, - }, current_slot, self.justified_checkpoint(), @@ -1081,6 +1086,46 @@ where }); } + // Same-slot attestations must have index == 0 (i.e., indicate pending payload status). + // Payload-present attestations (index == 1) for the same slot as the block are invalid + // because PTC votes should only arrive in subsequent slots. + if indexed_attestation.data().slot == block.slot && indexed_attestation.data().index != 0 { + return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); + } + + Ok(()) + } + + /// Validates a payload attestation for application to fork choice. + fn validate_on_payload_attestation( + &self, + indexed_payload_attestation: &IndexedPayloadAttestation, + _is_from_block: AttestationFromBlock, + ) -> Result<(), InvalidAttestation> { + if indexed_payload_attestation.attesting_indices.is_empty() { + return Err(InvalidAttestation::EmptyAggregationBitfield); + } + + let block = self + .proto_array + .get_block(&indexed_payload_attestation.data.beacon_block_root) + .ok_or(InvalidAttestation::UnknownHeadBlock { + beacon_block_root: indexed_payload_attestation.data.beacon_block_root, + })?; + + if block.slot > indexed_payload_attestation.data.slot { + return Err(InvalidAttestation::AttestsToFutureBlock { + block: block.slot, + attestation: indexed_payload_attestation.data.slot, + }); + } + + if indexed_payload_attestation.data.slot == block.slot + && indexed_payload_attestation.data.payload_present + { + return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); + } + Ok(()) } @@ -1154,6 +1199,43 @@ where Ok(()) } + /// Register a payload attestation with the fork choice DAG. + pub fn on_payload_attestation( + &mut self, + system_time_current_slot: Slot, + attestation: &IndexedPayloadAttestation, + is_from_block: AttestationFromBlock, + ) -> Result<(), Error> { + self.update_time(system_time_current_slot)?; + + if attestation.data.beacon_block_root == Hash256::zero() { + return Ok(()); + } + + self.validate_on_payload_attestation(attestation, is_from_block)?; + + if attestation.data.slot < self.fc_store.get_current_slot() { + for validator_index in attestation.attesting_indices_iter() { + self.proto_array.process_attestation( + *validator_index as usize, + attestation.data.beacon_block_root, + attestation.data.slot, + attestation.data.payload_present, + )?; + } + } else { + self.queued_attestations.push(QueuedAttestation { + slot: attestation.data.slot, + attesting_indices: attestation.attesting_indices.iter().copied().collect(), + block_root: attestation.data.beacon_block_root, + target_epoch: attestation.data.slot.epoch(E::slots_per_epoch()), + payload_present: attestation.data.payload_present, + }); + } + + Ok(()) + } + /// Apply an attester slashing to fork choice. /// /// We assume that the attester slashing provided to this function has already been verified. @@ -1564,7 +1646,7 @@ where /// /// This is used when persisting the state of the fork choice to disk. #[superstruct( - variants(V17, V28), + variants(V17, V28, V29), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] @@ -1572,30 +1654,42 @@ pub struct PersistedForkChoice { #[superstruct(only(V17))] pub proto_array_bytes: Vec, #[superstruct(only(V28))] - pub proto_array: proto_array::core::SszContainerV28, + pub proto_array_v28: proto_array::core::SszContainerLegacyV28, + #[superstruct(only(V29))] + pub proto_array: proto_array::core::SszContainerV29, pub queued_attestations: Vec, } -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; impl TryFrom for PersistedForkChoiceV28 { type Error = ssz::DecodeError; fn try_from(v17: PersistedForkChoiceV17) -> Result { let container_v17 = - proto_array::core::SszContainerV17::from_ssz_bytes(&v17.proto_array_bytes)?; - let container_v28 = container_v17.into(); + proto_array::core::SszContainerLegacyV17::from_ssz_bytes(&v17.proto_array_bytes)?; + let container_v28: proto_array::core::SszContainerLegacyV28 = container_v17.into(); Ok(Self { - proto_array: container_v28, + proto_array_v28: container_v28, queued_attestations: v17.queued_attestations, }) } } +impl From for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + proto_array: v28.proto_array_v28.into(), + queued_attestations: v28.queued_attestations, + } + } +} + impl From<(PersistedForkChoiceV28, JustifiedBalances)> for PersistedForkChoiceV17 { fn from((v28, balances): (PersistedForkChoiceV28, JustifiedBalances)) -> Self { - let container_v17 = proto_array::core::SszContainerV17::from((v28.proto_array, balances)); + let container_v17 = + proto_array::core::SszContainerLegacyV17::from((v28.proto_array_v28, balances)); let proto_array_bytes = container_v17.as_ssz_bytes(); Self { @@ -1640,6 +1734,7 @@ mod tests { attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), + payload_present: false, }) .collect() } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index afe06dee1bc..87438f2f855 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -5,7 +5,8 @@ mod metrics; pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, - PersistedForkChoiceV17, PersistedForkChoiceV28, QueuedAttestation, ResetPayloadStatuses, + PersistedForkChoiceV17, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, + ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index d3a84ee85be..86ef0e2f907 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -923,6 +923,36 @@ async fn invalid_attestation_future_block() { .await; } +/// Payload attestations (index == 1) are invalid when they refer to a block in the same slot. +#[tokio::test] +async fn invalid_attestation_payload_during_same_slot() { + ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await + .apply_attestation_to_chain( + MutationDelay::NoDelay, + |attestation, chain| { + let block_slot = chain + .get_blinded_block(&attestation.data().beacon_block_root) + .expect("should read attested block") + .expect("attested block should exist") + .slot(); + + attestation.data_mut().slot = block_slot; + attestation.data_mut().target.epoch = block_slot.epoch(E::slots_per_epoch()); + attestation.data_mut().index = 1; + }, + |result| { + assert_invalid_attestation!( + result, + InvalidAttestation::PayloadAttestationDuringSameSlot { slot } + if slot == Slot::new(1) + ) + }, + ) + .await; +} + /// Specification v0.12.1: /// /// assert target.root == get_ancestor(store, attestation.data.beacon_block_root, target_slot) diff --git a/consensus/proto_array/src/bin.rs b/consensus/proto_array/src/bin.rs index 94a10fb127c..e1d307affb4 100644 --- a/consensus/proto_array/src/bin.rs +++ b/consensus/proto_array/src/bin.rs @@ -1,4 +1,3 @@ -/* FIXME(sproul) use proto_array::fork_choice_test_definition::*; use std::fs::File; @@ -25,5 +24,3 @@ fn write_test_def_to_yaml(filename: &str, def: ForkChoiceTestDefinition) { let file = File::create(filename).expect("Should be able to open file"); serde_yaml::to_writer(file, &def).expect("Should be able to write YAML to file"); } -*/ -fn main() {} diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index c3e60277a3a..d6bd7f2cbfa 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -55,10 +55,10 @@ pub enum Error { InvalidEpochOffset(u64), Arith(ArithError), GloasNotImplemented, - InvalidNodeVariant{ + InvalidNodeVariant { block_root: Hash256, }, - BrokenBlock{ + BrokenBlock { block_root: Hash256, }, } diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index ac765b51d82..ec4227584a6 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -1,21 +1,23 @@ -/* FIXME(sproul) fix these tests later mod execution_status; mod ffg_updates; +mod gloas_payload; mod no_votes; mod votes; -use crate::proto_array_fork_choice::{Block, ExecutionStatus, ProtoArrayForkChoice}; +use crate::proto_array::PayloadTiebreak; +use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use types::{ - AttestationShufflingId, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, + AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, Slot, }; pub use execution_status::*; pub use ffg_updates::*; +pub use gloas_payload::*; pub use no_votes::*; pub use votes::*; @@ -45,11 +47,17 @@ pub enum Operation { parent_root: Hash256, justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, + #[serde(default)] + execution_payload_parent_hash: Option, + #[serde(default)] + execution_payload_block_hash: Option, }, ProcessAttestation { validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + #[serde(default)] + payload_present: bool, }, Prune { finalized_root: Hash256, @@ -64,6 +72,24 @@ pub enum Operation { block_root: Hash256, weight: u64, }, + AssertPayloadWeights { + block_root: Hash256, + expected_full_weight: u64, + expected_empty_weight: u64, + }, + AssertParentPayloadStatus { + block_root: Hash256, + expected_status: PayloadStatus, + }, + AssertHeadPayloadStatus { + head_root: Hash256, + expected_status: PayloadStatus, + }, + SetPayloadTiebreak { + block_root: Hash256, + is_timely: bool, + is_data_available: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -72,12 +98,23 @@ pub struct ForkChoiceTestDefinition { pub justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, pub operations: Vec, + #[serde(default)] + pub execution_payload_parent_hash: Option, + #[serde(default)] + pub execution_payload_block_hash: Option, + #[serde(skip)] + pub spec: Option, } impl ForkChoiceTestDefinition { pub fn run(self) { - let mut spec = MainnetEthSpec::default_spec(); - spec.proposer_score_boost = Some(50); + let spec = self.spec.unwrap_or_else(|| { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + // Legacy test definitions target pre-Gloas behaviour unless explicitly overridden. + spec.gloas_fork_epoch = None; + spec + }); let junk_shuffling_id = AttestationShufflingId::from_components(Epoch::new(0), Hash256::zero()); @@ -90,6 +127,9 @@ impl ForkChoiceTestDefinition { junk_shuffling_id.clone(), junk_shuffling_id, ExecutionStatus::Optimistic(ExecutionBlockHash::zero()), + self.execution_payload_parent_hash, + self.execution_payload_block_hash, + &spec, ) .expect("should create fork choice struct"); let equivocating_indices = BTreeSet::new(); @@ -189,6 +229,8 @@ impl ForkChoiceTestDefinition { parent_root, justified_checkpoint, finalized_checkpoint, + execution_payload_parent_hash, + execution_payload_block_hash, } => { let block = Block { slot, @@ -212,6 +254,8 @@ impl ForkChoiceTestDefinition { ), unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash, + execution_payload_block_hash, }; fork_choice .process_block::( @@ -219,6 +263,7 @@ impl ForkChoiceTestDefinition { slot, self.justified_checkpoint, self.finalized_checkpoint, + &spec, ) .unwrap_or_else(|e| { panic!( @@ -228,14 +273,19 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } - // FIXME(sproul): update with payload_present Operation::ProcessAttestation { validator_index, block_root, - target_epoch, + attestation_slot, + payload_present, } => { fork_choice - .process_attestation(validator_index, block_root, target_epoch, false) + .process_attestation( + validator_index, + block_root, + attestation_slot, + payload_present, + ) .unwrap_or_else(|_| { panic!( "process_attestation op at index {} returned error", @@ -289,8 +339,141 @@ impl ForkChoiceTestDefinition { Operation::AssertWeight { block_root, weight } => assert_eq!( fork_choice.get_weight(&block_root).unwrap(), weight, - "block weight" + "block weight at op index {}", + op_index ), + Operation::AssertPayloadWeights { + block_root, + expected_full_weight, + expected_empty_weight, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertPayloadWeights: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.full_payload_weight, expected_full_weight, + "full_payload_weight mismatch at op index {}", + op_index + ); + assert_eq!( + v29.empty_payload_weight, expected_empty_weight, + "empty_payload_weight mismatch at op index {}", + op_index + ); + } + Operation::AssertParentPayloadStatus { + block_root, + expected_status, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertParentPayloadStatus: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.parent_payload_status, expected_status, + "parent_payload_status mismatch at op index {}", + op_index + ); + } + Operation::AssertHeadPayloadStatus { + head_root, + expected_status, + } => { + let actual = fork_choice + .head_payload_status(&head_root) + .unwrap_or_else(|| { + panic!( + "AssertHeadPayloadStatus: head root not found at op index {}", + op_index + ) + }); + assert_eq!( + actual, expected_status, + "head_payload_status mismatch at op index {}", + op_index + ); + } + Operation::SetPayloadTiebreak { + block_root, + is_timely, + is_data_available, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get_mut(*block_index) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: node not found at op index {}", + op_index + ) + }); + let node_v29 = node.as_v29_mut().unwrap_or_else(|_| { + panic!( + "SetPayloadTiebreak: node is not V29 at op index {}", + op_index + ) + }); + node_v29.payload_tiebreak = PayloadTiebreak { + is_timely, + is_data_available, + }; + } } } } @@ -325,4 +508,3 @@ fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { "fork choice should encode and decode without change" ); } -*/ diff --git a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs index aa26a843069..93c97d09db4 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs @@ -35,6 +35,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -73,6 +75,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -101,7 +105,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -143,7 +148,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -196,6 +202,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -245,7 +253,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Ensure that the head is still 2 @@ -347,7 +356,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Ensure that the head has switched back to 1 @@ -399,6 +409,9 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -437,6 +450,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -475,6 +490,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -503,7 +520,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -545,7 +563,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -598,6 +617,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -647,7 +668,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Move validator #1 vote from 2 to 3 @@ -660,7 +682,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Ensure that the head is now 3. @@ -763,6 +786,9 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -801,6 +827,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -839,6 +867,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -867,7 +897,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -909,7 +940,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is 1. @@ -962,6 +994,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 3, applying a proposer boost to 3 as well. @@ -1065,6 +1099,9 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs index 3b31616145d..ee55ea649fe 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs @@ -27,6 +27,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -34,6 +36,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(1), justified_checkpoint: get_checkpoint(1), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -41,6 +45,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(2), finalized_checkpoint: get_checkpoint(1), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that with justified epoch 0 we find 3 @@ -101,6 +107,9 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -137,6 +146,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -147,6 +158,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -157,6 +170,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -167,6 +182,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -177,6 +194,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(3), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Right branch @@ -186,6 +205,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -193,6 +214,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -200,6 +223,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(4), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -210,6 +235,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(2), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -220,6 +247,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(4), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -282,7 +311,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), + payload_present: false, }); // Ensure that if we start at 0 we find 9 (just: 0, fin: 0). @@ -345,7 +375,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), + payload_present: false, }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -489,6 +520,9 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } 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 new file mode 100644 index 00000000000..b6568106e39 --- /dev/null +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -0,0 +1,222 @@ +use super::*; + +fn gloas_spec() -> ChainSpec { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + spec +} + +pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches off genesis where one child extends parent's payload chain (Full) + // and the other does not (Empty). + 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::ProcessBlock { + slot: Slot::new(1), + 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(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Extend both branches to verify that head selection follows the selected chain. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + }); + + // With equal full/empty parent weights, tiebreak decides which chain to follow. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: 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), + }); + + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: false, + is_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(4), + }); + + 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()), + } +} + +pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + 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)), + }); + + // One Full and one Empty vote for the same head block: tie should probe as Full. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + 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![1, 1], + expected_head: get_root(1), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 1, + expected_empty_weight: 1, + }); + ops.push(Operation::AssertHeadPayloadStatus { + head_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + + // Flip validator 0 to Empty; probe should now report Empty. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + 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], + expected_head: get_root(1), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 0, + expected_empty_weight: 2, + }); + ops.push(Operation::AssertHeadPayloadStatus { + head_root: get_root(1), + expected_status: PayloadStatus::Empty, + }); + + // Same-slot attestation to a new head candidate should be Pending (no payload bucket change). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(5), + 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(5)), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 2, + block_root: get_root(5), + 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, 1, 1], + expected_head: get_root(5), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(5), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + ops.push(Operation::AssertHeadPayloadStatus { + head_root: get_root(5), + expected_status: PayloadStatus::Full, + }); + + 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::*; + + #[test] + fn chain_following() { + let test = get_gloas_chain_following_test_definition(); + test.run(); + } + + #[test] + fn payload_probe() { + let test = get_gloas_payload_probe_test_definition(); + test.run(); + } +} diff --git a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs index d20eaacb99a..61e4c1270ce 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs @@ -36,6 +36,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 2 // @@ -71,6 +73,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is still 2 // @@ -108,6 +112,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 2 is still the head // @@ -147,6 +153,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 4. // @@ -185,6 +193,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is now 5 whilst the justified epoch is 0. // @@ -271,6 +281,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 6 is the head // @@ -305,6 +317,9 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { root: Hash256::zero(), }, operations, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index 01994fff9b2..d170e0974ff 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -35,6 +35,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -73,6 +75,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -101,7 +105,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -130,7 +135,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), + payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -170,6 +176,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -202,7 +210,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Ensure that the head is still 2 @@ -236,7 +245,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), + payload_present: false, }); // Ensure that the head is now 3 @@ -280,6 +290,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 4 @@ -327,9 +339,11 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(1), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); - // Ensure that 5 is filtered out and the head stays at 4. + // Ensure that 5 becomes the head. // // 0 // / \ @@ -337,9 +351,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { // | // 3 // | - // 4 <- head + // 4 // / - // 5 + // head-> 5 ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -350,7 +364,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(4), + expected_head: get_root(5), }); // Add block 6, which has a justified epoch of 0. @@ -376,6 +390,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Move both votes to 5. @@ -392,12 +408,14 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), + payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), + payload_present: false, }); // Add blocks 7, 8 and 9. Adding these blocks helps test the `best_descendant` @@ -430,6 +448,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -443,6 +463,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -456,10 +478,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); - // Ensure that 6 is the head, even though 5 has all the votes. This is testing to ensure - // that 5 is filtered out due to a differing justified epoch. + // Ensure that 9 is the head. The branch rooted at 5 remains viable and its best descendant + // is selected. // // 0 // / \ @@ -469,13 +493,13 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { // | // 4 // / \ - // 5 6 <- head + // 5 6 // | // 7 // | // 8 // / - // 9 + // head-> 9 ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -486,7 +510,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(6), + expected_head: get_root(9), }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -545,12 +569,14 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), + payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), + payload_present: false, }); // Add block 10 @@ -582,6 +608,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Double-check the head is still 9 (no diagram this time) @@ -621,12 +649,14 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 2, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), + payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 3, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), + payload_present: false, }); // Check the head is now 10. @@ -817,6 +847,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure the head is now 11 @@ -854,6 +886,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 1f126246b34..b131fb403e7 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -16,5 +16,7 @@ pub use error::Error; pub mod core { pub use super::proto_array::{ProposerBoost, ProtoArray, ProtoNode}; pub use super::proto_array_fork_choice::VoteTracker; - pub use super::ssz_container::{SszContainer, SszContainerV17, SszContainerV28}; + pub use super::ssz_container::{ + SszContainer, SszContainerLegacyV17, SszContainerLegacyV28, SszContainerV29, + }; } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 1eb7cc9d882..926767093f7 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1,5 +1,5 @@ use crate::error::InvalidBestNodeInfo; -use crate::{Block, ExecutionStatus, JustifiedBalances, error::Error, PayloadStatus}; +use crate::{Block, ExecutionStatus, JustifiedBalances, PayloadStatus, error::Error}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz::Encode; @@ -68,13 +68,12 @@ impl InvalidationOperation { } } - #[superstruct( variants(V17, V29), - variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)), + variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)) )] #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Clone)] -#[ssz(enum_behaviour = "transparent")] +#[ssz(enum_behaviour = "union")] pub struct ProtoNode { /// The `slot` is not necessary for `ProtoArray`, it just exists so external components can /// easily query the block slot. This is useful for upstream fork choice logic. @@ -130,6 +129,10 @@ pub struct ProtoNode { pub full_payload_weight: u64, #[superstruct(only(V29), partial_getter(copy))] pub execution_payload_block_hash: ExecutionBlockHash, + /// Tiebreaker for payload preference when full_payload_weight == empty_payload_weight. + /// Per spec: prefer Full if block was timely and data is available; otherwise prefer Empty. + #[superstruct(only(V29), partial_getter(copy))] + pub payload_tiebreak: PayloadTiebreak, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -147,6 +150,83 @@ impl Default for ProposerBoost { } } +#[derive(Clone, PartialEq, Debug, Copy)] +pub struct NodeDelta { + pub delta: i64, + pub empty_delta: i64, + pub full_delta: i64, + pub payload_tiebreaker: Option, +} + +impl NodeDelta { + /// Determine the payload bucket for a vote based on whether the vote's slot matches the + /// block's slot (Pending), or the vote's `payload_present` flag (Full/Empty). + pub fn payload_status( + vote_slot: Slot, + payload_present: bool, + block_slot: Slot, + ) -> PayloadStatus { + if vote_slot == block_slot { + PayloadStatus::Pending + } else if payload_present { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } + + /// Add a balance to the appropriate payload status. + pub fn add_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_add(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } + + /// Subtract a balance from the appropriate payload status. + pub fn sub_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_sub(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } +} + +impl PartialEq for NodeDelta { + fn eq(&self, other: &i64) -> bool { + self.delta == *other + && self.empty_delta == 0 + && self.full_delta == 0 + && self.payload_tiebreaker.is_none() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Encode, Decode, Serialize, Deserialize)] +pub struct PayloadTiebreak { + pub is_timely: bool, + pub is_data_available: bool, +} + #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct ProtoArray { /// Do not attempt to prune the tree unless it has at least this many nodes. Small prunes @@ -174,7 +254,7 @@ impl ProtoArray { #[allow(clippy::too_many_arguments)] pub fn apply_score_changes( &mut self, - mut deltas: Vec, + mut deltas: Vec, best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, new_justified_balances: &JustifiedBalances, @@ -206,16 +286,32 @@ impl ProtoArray { continue; } - let mut node_delta = if let Ok(proto_node) = node.as_v17() && proto_node.execution_status.is_invalid() { + let execution_status_is_invalid = if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() + { + true + } else { + false + }; + + let node_deltas = deltas + .get(node_index) + .copied() + .ok_or(Error::InvalidNodeDelta(node_index))?; + + let mut node_delta = if execution_status_is_invalid { // If the node has an invalid execution payload, reduce its weight to zero. 0_i64 .checked_sub(node.weight() as i64) .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? } else { - deltas - .get(node_index) - .copied() - .ok_or(Error::InvalidNodeDelta(node_index))? + node_deltas.delta + }; + + let (node_empty_delta, node_full_delta) = if node.as_v29().is_ok() { + (node_deltas.empty_delta, node_deltas.full_delta) + } else { + (0, 0) }; // If we find the node for which the proposer boost was previously applied, decrease @@ -250,27 +346,17 @@ impl ProtoArray { // Apply the delta to the node. if execution_status_is_invalid { - // Invalid nodes always have a weight of 0. - node.weight() = 0 - } else if node_delta < 0 { - // Note: I am conflicted about whether to use `saturating_sub` or `checked_sub` - // here. - // - // I can't think of any valid reason why `node_delta.abs()` should be greater than - // `node.weight`, so I have chosen `checked_sub` to try and fail-fast if there is - // some error. - // - // However, I am not fully convinced that some valid case for `saturating_sub` does - // not exist. - node.weight() = node - .weight() - .checked_sub(node_delta.unsigned_abs()) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = 0; } else { - node.weight = node - .weight() - .checked_add(node_delta as u64) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = apply_delta(node.weight(), node_delta, node_index)?; + } + + // Apply post-Gloas score deltas. + if let Ok(node) = node.as_v29_mut() { + node.empty_payload_weight = + apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; + node.full_payload_weight = + apply_delta(node.full_payload_weight, node_full_delta, node_index)?; } // Update the parent delta (if any). @@ -279,8 +365,32 @@ impl ProtoArray { .get_mut(parent_index) .ok_or(Error::InvalidParentDelta(parent_index))?; - // Back-propagate the nodes delta to its parent. - *parent_delta += node_delta; + // Back-propagate the node's delta to its parent. + parent_delta.delta = parent_delta + .delta + .checked_add(node_delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + + // Per spec's `is_supporting_vote`: a vote for descendant B supports + // ancestor A's payload status based on B's `parent_payload_status`. + // Route the child's *total* weight delta to the parent's appropriate + // payload bucket. + match node.parent_payload_status() { + Ok(PayloadStatus::Full) => { + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(node_delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + Ok(PayloadStatus::Empty) => { + parent_delta.empty_delta = parent_delta + .empty_delta + .checked_add(node_delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + // Pending or V17 nodes: no payload propagation. + _ => {} + } } } @@ -357,26 +467,40 @@ impl ProtoArray { unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, }) } else { - let execution_payload_block_hash = block - .execution_payload_block_hash - .ok_or(Error::BrokenBlock{block_root: block.root})?; - - let parent_payload_status: PayloadStatus = - if let Some(parent_node) = - parent_index.and_then(|idx| self.nodes.get(idx)) - { - let v29 = parent_node - .as_v29() - .map_err(|_| Error::InvalidNodeVariant{block_root: block.root})?; - if execution_payload_block_hash == v29.execution_payload_block_hash - { - PayloadStatus::Empty - } else { - PayloadStatus::Full - } - } else { - PayloadStatus::Full + let execution_payload_block_hash = + block + .execution_payload_block_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let execution_payload_parent_hash = + block + .execution_payload_parent_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let parent_payload_status: PayloadStatus = if let Some(parent_node) = + parent_index.and_then(|idx| self.nodes.get(idx)) + { + // Get the parent's execution block hash, handling both V17 and V29 nodes. + // V17 parents occur during the Gloas fork transition. + let parent_el_block_hash = match parent_node { + ProtoNode::V29(v29) => Some(v29.execution_payload_block_hash), + ProtoNode::V17(v17) => v17.execution_status.block_hash(), }; + // Per spec's `is_parent_node_full`: if the child's EL parent hash + // matches the parent's EL block hash, the child extends the parent's + // payload chain, meaning the parent was Full. + if parent_el_block_hash.is_some_and(|hash| execution_payload_parent_hash == hash) { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } else { + PayloadStatus::Full + }; ProtoNode::V29(ProtoNodeV29 { slot: block.slot, @@ -397,6 +521,7 @@ impl ProtoArray { empty_payload_weight: 0, full_payload_weight: 0, execution_payload_block_hash, + payload_tiebreak: PayloadTiebreak::default(), }) }; @@ -408,7 +533,9 @@ impl ProtoArray { .get(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - if let Ok(status) = parent.execution_status() && status.is_invalid() { + if let Ok(status) = parent.execution_status() + && status.is_invalid() + { return Err(Error::ParentExecutionStatusIsInvalid { block_root: block.root, parent_root: parent.root(), @@ -469,33 +596,43 @@ impl ProtoArray { .nodes .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - let parent_index = match node.execution_status { - // We have reached a node that we already know is valid. No need to iterate further - // since we assume an ancestors have already been set to valid. - ExecutionStatus::Valid(_) => return Ok(()), - // We have reached an irrelevant node, this node is prior to a terminal execution - // block. There's no need to iterate further, it's impossible for this block to have - // any relevant ancestors. - ExecutionStatus::Irrelevant(_) => return Ok(()), - // The block has an unknown status, set it to valid since any ancestor of a valid - // payload can be considered valid. - ExecutionStatus::Optimistic(payload_block_hash) => { - node.execution_status = ExecutionStatus::Valid(payload_block_hash); + let parent_index = match node { + ProtoNode::V17(node) => match node.execution_status { + // We have reached a node that we already know is valid. No need to iterate further + // since we assume an ancestors have already been set to valid. + ExecutionStatus::Valid(_) => return Ok(()), + // We have reached an irrelevant node, this node is prior to a terminal execution + // block. There's no need to iterate further, it's impossible for this block to have + // any relevant ancestors. + ExecutionStatus::Irrelevant(_) => return Ok(()), + // The block has an unknown status, set it to valid since any ancestor of a valid + // payload can be considered valid. + ExecutionStatus::Optimistic(payload_block_hash) => { + node.execution_status = ExecutionStatus::Valid(payload_block_hash); + if let Some(parent_index) = node.parent { + parent_index + } else { + // We have reached the root block, iteration complete. + return Ok(()); + } + } + // An ancestor of the valid payload was invalid. This is a serious error which + // indicates a consensus failure in the execution node. This is unrecoverable. + ExecutionStatus::Invalid(ancestor_payload_block_hash) => { + return Err(Error::InvalidAncestorOfValidPayload { + ancestor_block_root: node.root, + ancestor_payload_block_hash, + }); + } + }, + // Gloas nodes don't carry `ExecutionStatus`. + ProtoNode::V29(node) => { if let Some(parent_index) = node.parent { parent_index } else { - // We have reached the root block, iteration complete. return Ok(()); } } - // An ancestor of the valid payload was invalid. This is a serious error which - // indicates a consensus failure in the execution node. This is unrecoverable. - ExecutionStatus::Invalid(ancestor_payload_block_hash) => { - return Err(Error::InvalidAncestorOfValidPayload { - ancestor_block_root: node.root, - ancestor_payload_block_hash, - }); - } }; index = parent_index; @@ -551,10 +688,11 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - match node.execution_status { - ExecutionStatus::Valid(hash) - | ExecutionStatus::Invalid(hash) - | ExecutionStatus::Optimistic(hash) => { + let node_execution_status = node.execution_status(); + match node_execution_status { + Ok(ExecutionStatus::Valid(hash)) + | Ok(ExecutionStatus::Invalid(hash)) + | Ok(ExecutionStatus::Optimistic(hash)) => { // If we're no longer processing the `head_block_root` and the last valid // ancestor is unknown, exit this loop and proceed to invalidate and // descendants of `head_block_root`/`latest_valid_ancestor_root`. @@ -563,7 +701,7 @@ impl ProtoArray { // supplied, don't validate any ancestors. The alternative is to invalidate // *all* ancestors, which would likely involve shutting down the client due to // an invalid justified checkpoint. - if !latest_valid_ancestor_is_descendant && node.root != head_block_root { + if !latest_valid_ancestor_is_descendant && node.root() != head_block_root { break; } else if op.latest_valid_ancestor() == Some(hash) { // If the `best_child` or `best_descendant` of the latest valid hash was @@ -574,63 +712,67 @@ impl ProtoArray { // defend against errors which might result in an invalid block being set as // head. if node - .best_child + .best_child() .is_some_and(|i| invalidated_indices.contains(&i)) { - node.best_child = None + *node.best_child_mut() = None } if node - .best_descendant + .best_descendant() .is_some_and(|i| invalidated_indices.contains(&i)) { - node.best_descendant = None + *node.best_descendant_mut() = None } break; } } - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => break, } // Only invalidate the head block if either: // // - The head block was specifically indicated to be invalidated. // - The latest valid hash is a known ancestor. - if node.root != head_block_root + if node.root() != head_block_root || op.invalidate_block_root() || latest_valid_ancestor_is_descendant { - match &node.execution_status { + match node.execution_status() { // It's illegal for an execution client to declare that some previously-valid block // is now invalid. This is a consensus failure on their behalf. - ExecutionStatus::Valid(hash) => { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) => { + Ok(ExecutionStatus::Optimistic(hash)) => { invalidated_indices.insert(index); - node.execution_status = ExecutionStatus::Invalid(*hash); + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash); + } // It's impossible for an invalid block to lead to a "best" block, so set these // fields to `None`. // // Failing to set these values will result in `Self::node_leads_to_viable_head` // returning `false` for *valid* ancestors of invalid blocks. - node.best_child = None; - node.best_descendant = None; + *node.best_child_mut() = None; + *node.best_descendant_mut() = None; } // The block is already invalid, but keep going backwards to ensure all ancestors // are updated. - ExecutionStatus::Invalid(_) => (), + Ok(ExecutionStatus::Invalid(_)) => (), // This block is pre-merge, therefore it has no execution status. Nor do its // ancestors. - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => (), } } - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { index = parent_index } else { // The root of the block tree has been reached (aka the finalized block), without @@ -664,24 +806,27 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - if let Some(parent_index) = node.parent + if let Some(parent_index) = node.parent() && invalidated_indices.contains(&parent_index) { - match &node.execution_status { - ExecutionStatus::Valid(hash) => { + match node.execution_status() { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) | ExecutionStatus::Invalid(hash) => { - node.execution_status = ExecutionStatus::Invalid(*hash) + Ok(ExecutionStatus::Optimistic(hash)) | Ok(ExecutionStatus::Invalid(hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash) + } } - ExecutionStatus::Irrelevant(_) => { + Ok(ExecutionStatus::Irrelevant(_)) => { return Err(Error::IrrelevantDescendant { - block_root: node.root, + block_root: node.root(), }); } + Err(_) => (), } invalidated_indices.insert(index); @@ -724,13 +869,15 @@ impl ProtoArray { // practically possible to set a new justified root if we are unable to find a new head. // // This scenario is *unsupported*. It represents a serious consensus failure. - if justified_node.execution_status.is_invalid() { + if let Ok(execution_status) = justified_node.execution_status() + && execution_status.is_invalid() + { return Err(Error::InvalidJustifiedCheckpointExecutionStatus { justified_root: *justified_root, }); } - let best_descendant_index = justified_node.best_descendant.unwrap_or(justified_index); + let best_descendant_index = justified_node.best_descendant().unwrap_or(justified_index); let best_node = self .nodes @@ -749,13 +896,13 @@ impl ProtoArray { start_root: *justified_root, justified_checkpoint: best_justified_checkpoint, finalized_checkpoint: best_finalized_checkpoint, - head_root: best_node.root, - head_justified_checkpoint: best_node.justified_checkpoint, - head_finalized_checkpoint: best_node.finalized_checkpoint, + head_root: best_node.root(), + head_justified_checkpoint: *best_node.justified_checkpoint(), + head_finalized_checkpoint: *best_node.finalized_checkpoint(), }))); } - Ok(best_node.root) + Ok(best_node.root()) } /// Update the tree with new finalization information. The tree is only actually pruned if both @@ -788,7 +935,7 @@ impl ProtoArray { .nodes .get(node_index) .ok_or(Error::InvalidNodeIndex(node_index))? - .root; + .root(); self.indices.remove(root); } @@ -805,19 +952,19 @@ impl ProtoArray { // Iterate through all the existing nodes and adjust their indices to match the new layout // of `self.nodes`. for node in self.nodes.iter_mut() { - if let Some(parent) = node.parent { + if let Some(parent) = node.parent() { // If `node.parent` is less than `finalized_index`, set it to `None`. - node.parent = parent.checked_sub(finalized_index); + *node.parent_mut() = parent.checked_sub(finalized_index); } - if let Some(best_child) = node.best_child { - node.best_child = Some( + if let Some(best_child) = node.best_child() { + *node.best_child_mut() = Some( best_child .checked_sub(finalized_index) .ok_or(Error::IndexOverflow("best_child"))?, ); } - if let Some(best_descendant) = node.best_descendant { - node.best_descendant = Some( + if let Some(best_descendant) = node.best_descendant() { + *node.best_descendant_mut() = Some( best_descendant .checked_sub(finalized_index) .ok_or(Error::IndexOverflow("best_descendant"))?, @@ -905,19 +1052,32 @@ impl ProtoArray { } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { // The best child leads to a viable head, but the child doesn't. no_change - } else if child.weight() == best_child.weight() { - // Tie-breaker of equal weights by root. - if *child.root() >= *best_child.root() { - change_to_child - } else { - no_change - } } else { - // Choose the winner by weight. - if child.weight() > best_child.weight() { + // Both viable or both non-viable. For V29 parents, prefer the child + // whose parent_payload_status matches the parent's payload preference + // (Full if full_payload_weight >= empty_payload_weight, else Empty). + let child_matches = child_matches_parent_payload_preference(parent, child); + let best_child_matches = + child_matches_parent_payload_preference(parent, best_child); + + if child_matches && !best_child_matches { change_to_child - } else { + } else if !child_matches && best_child_matches { no_change + } else if child.weight() == best_child.weight() { + // Tie-breaker of equal weights by root. + if *child.root() >= *best_child.root() { + change_to_child + } else { + no_change + } + } else { + // Choose the winner by weight. + if child.weight() > best_child.weight() { + change_to_child + } else { + no_change + } } } } @@ -988,11 +1148,13 @@ impl ProtoArray { best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, ) -> bool { - if let Ok(proto_node) = node.as_v17() && proto_node.execution_status.is_invalid() { + if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() + { return false; } - let genesis_epoch = Epoch::new(0); + let genesis_epoch = Epoch::new(1); let current_epoch = current_slot.epoch(E::slots_per_epoch()); let node_epoch = node.slot().epoch(E::slots_per_epoch()); let node_justified_checkpoint = node.justified_checkpoint(); @@ -1006,7 +1168,7 @@ impl ProtoArray { } else { // The block is not from a prior epoch, therefore the voting source // is not pulled up. - node_justified_checkpoint + *node_justified_checkpoint }; let correct_justified = best_justified_checkpoint.epoch == genesis_epoch @@ -1015,7 +1177,7 @@ impl ProtoArray { let correct_finalized = best_finalized_checkpoint.epoch == genesis_epoch || self - .is_finalized_checkpoint_or_descendant::(node.root, best_finalized_checkpoint); + .is_finalized_checkpoint_or_descendant::(node.root(), best_finalized_checkpoint); correct_justified && correct_finalized } @@ -1037,7 +1199,7 @@ impl ProtoArray { block_root: &Hash256, ) -> impl Iterator + 'a { self.iter_nodes(block_root) - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) } /// Returns `true` if the `descendant_root` has an ancestor with `ancestor_root`. Always @@ -1058,8 +1220,8 @@ impl ProtoArray { .and_then(|ancestor_index| self.nodes.get(*ancestor_index)) .and_then(|ancestor| { self.iter_block_roots(&descendant_root) - .take_while(|(_root, slot)| *slot >= ancestor.slot) - .find(|(_root, slot)| *slot == ancestor.slot) + .take_while(|(_root, slot)| *slot >= ancestor.slot()) + .find(|(_root, slot)| *slot == ancestor.slot()) .map(|(root, _slot)| root == ancestor_root) }) .unwrap_or(false) @@ -1098,15 +1260,15 @@ impl ProtoArray { // Run this check once, outside of the loop rather than inside the loop. // If the conditions don't match for this node then they're unlikely to // start matching for its ancestors. - for checkpoint in &[node.finalized_checkpoint, node.justified_checkpoint] { - if checkpoint == &best_finalized_checkpoint { + for checkpoint in &[node.finalized_checkpoint(), node.justified_checkpoint()] { + if **checkpoint == best_finalized_checkpoint { return true; } } for checkpoint in &[ - node.unrealized_finalized_checkpoint, - node.unrealized_justified_checkpoint, + node.unrealized_finalized_checkpoint(), + node.unrealized_justified_checkpoint(), ] { if checkpoint.is_some_and(|cp| cp == best_finalized_checkpoint) { return true; @@ -1116,13 +1278,13 @@ impl ProtoArray { loop { // If `node` is less than or equal to the finalized slot then `node` // must be the finalized block. - if node.slot <= finalized_slot { - return node.root == finalized_root; + if node.slot() <= finalized_slot { + return node.root() == finalized_root; } // Since `node` is from a higher slot that the finalized checkpoint, // replace `node` with the parent of `node`. - if let Some(parent) = node.parent.and_then(|index| self.nodes.get(index)) { + if let Some(parent) = node.parent().and_then(|index| self.nodes.get(index)) { node = parent } else { // If `node` is not the finalized block and its parent does not @@ -1144,11 +1306,12 @@ impl ProtoArray { .iter() .rev() .find(|node| { - node.execution_status - .block_hash() + node.execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()) .is_some_and(|node_block_hash| node_block_hash == *block_hash) }) - .map(|node| node.root) + .map(|node| node.root()) } /// Returns all nodes that have zero children and are descended from the finalized checkpoint. @@ -1163,9 +1326,9 @@ impl ProtoArray { self.nodes .iter() .filter(|node| { - node.best_child.is_none() + node.best_child().is_none() && self.is_finalized_checkpoint_or_descendant::( - node.root, + node.root(), best_finalized_checkpoint, ) }) @@ -1173,6 +1336,30 @@ impl ProtoArray { } } +/// For V29 parents, returns `true` if the child's `parent_payload_status` matches the parent's +/// preferred payload status. When full and empty weights are unequal, the higher weight wins. +/// When equal, the tiebreaker uses the parent's `payload_tiebreak`: prefer Full if the block +/// was timely and data is available; otherwise prefer Empty. +/// For V17 parents (or mixed), always returns `true` (no payload preference). +fn child_matches_parent_payload_preference(parent: &ProtoNode, child: &ProtoNode) -> bool { + let (Ok(parent_v29), Ok(child_v29)) = (parent.as_v29(), child.as_v29()) else { + return true; + }; + let prefers_full = if parent_v29.full_payload_weight > parent_v29.empty_payload_weight { + true + } else if parent_v29.empty_payload_weight > parent_v29.full_payload_weight { + false + } else { + // Equal weights: tiebreaker per spec + parent_v29.payload_tiebreak.is_timely && parent_v29.payload_tiebreak.is_data_available + }; + if prefers_full { + child_v29.parent_payload_status == PayloadStatus::Full + } else { + child_v29.parent_payload_status == PayloadStatus::Empty + } +} + /// A helper method to calculate the proposer boost based on the given `justified_balances`. /// /// https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance @@ -1188,6 +1375,19 @@ pub fn calculate_committee_fraction( .checked_div(100) } +/// Apply a signed delta to an unsigned weight, returning an error on overflow. +fn apply_delta(weight: u64, delta: i64, index: usize) -> Result { + if delta < 0 { + weight + .checked_sub(delta.unsigned_abs()) + .ok_or(Error::DeltaOverflow(index)) + } else { + weight + .checked_add(delta as u64) + .ok_or(Error::DeltaOverflow(index)) + } +} + /// Reverse iterator over one path through a `ProtoArray`. pub struct Iter<'a> { next_node_index: Option, @@ -1200,7 +1400,7 @@ impl<'a> Iterator for Iter<'a> { fn next(&mut self) -> Option { let next_node_index = self.next_node_index?; let node = self.proto_array.nodes.get(next_node_index)?; - self.next_node_index = node.parent; + self.next_node_index = node.parent(); Some(node) } } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 928e8ce8603..e1c893db9af 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -2,7 +2,7 @@ use crate::{ JustifiedBalances, error::Error, proto_array::{ - InvalidationOperation, Iter, ProposerBoost, ProtoArray, ProtoNode, + InvalidationOperation, Iter, NodeDelta, ProposerBoost, ProtoArray, ProtoNode, calculate_committee_fraction, }, ssz_container::SszContainer, @@ -28,15 +28,17 @@ pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; pub struct VoteTracker { current_root: Hash256, next_root: Hash256, + current_slot: Slot, next_slot: Slot, + current_payload_present: bool, next_payload_present: bool, } // FIXME(sproul): version this type pub struct LatestMessage { - slot: Slot, - root: Hash256, - payload_present: bool, + pub slot: Slot, + pub root: Hash256, + pub payload_present: bool, } /// Represents the verification status of an execution payload pre-Gloas. @@ -448,7 +450,7 @@ impl ProtoArrayForkChoice { execution_status: ExecutionStatus, execution_payload_parent_hash: Option, execution_payload_block_hash: Option, - + spec: &ChainSpec, ) -> Result { let mut proto_array = ProtoArray { prune_threshold: DEFAULT_PRUNE_THRESHOLD, @@ -474,7 +476,6 @@ impl ProtoArrayForkChoice { unrealized_finalized_checkpoint: Some(finalized_checkpoint), execution_payload_parent_hash, execution_payload_block_hash, - }; proto_array @@ -569,9 +570,16 @@ impl ProtoArrayForkChoice { ) -> Result { let old_balances = &mut self.balances; let new_balances = justified_state_balances; + let node_slots = self + .proto_array + .nodes + .iter() + .map(|node| node.slot()) + .collect::>(); let deltas = compute_deltas( &self.proto_array.indices, + &node_slots, &mut self.votes, &old_balances.effective_balances, &new_balances.effective_balances, @@ -628,13 +636,13 @@ impl ProtoArrayForkChoice { )?; // Only re-org a single slot. This prevents cascading failures during asynchrony. - let head_slot_ok = info.head_node.slot + 1 == current_slot; + let head_slot_ok = info.head_node.slot() + 1 == current_slot; if !head_slot_ok { return Err(DoNotReOrg::HeadDistance.into()); } // Only re-org if the head's weight is less than the heads configured committee fraction. - let head_weight = info.head_node.weight; + let head_weight = info.head_node.weight(); let re_org_head_weight_threshold = info.re_org_head_weight_threshold; let weak_head = head_weight < re_org_head_weight_threshold; if !weak_head { @@ -646,7 +654,7 @@ impl ProtoArrayForkChoice { } // Only re-org if the parent's weight is greater than the parents configured committee fraction. - let parent_weight = info.parent_node.weight; + let parent_weight = info.parent_node.weight(); let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; let parent_strong = parent_weight > re_org_parent_weight_threshold; if !parent_strong { @@ -685,14 +693,14 @@ impl ProtoArrayForkChoice { let parent_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; let head_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; - let parent_slot = parent_node.slot; - let head_slot = head_node.slot; + let parent_slot = parent_node.slot(); + let head_slot = head_node.slot(); let re_org_block_slot = head_slot + 1; // Check finalization distance. let proposal_epoch = re_org_block_slot.epoch(E::slots_per_epoch()); let finalized_epoch = head_node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .ok_or(DoNotReOrg::MissingHeadFinalizedCheckpoint)? .epoch; let epochs_since_finalization = proposal_epoch.saturating_sub(finalized_epoch).as_u64(); @@ -724,10 +732,10 @@ impl ProtoArrayForkChoice { } // Check FFG. - let ffg_competitive = parent_node.unrealized_justified_checkpoint - == head_node.unrealized_justified_checkpoint - && parent_node.unrealized_finalized_checkpoint - == head_node.unrealized_finalized_checkpoint; + let ffg_competitive = parent_node.unrealized_justified_checkpoint() + == head_node.unrealized_justified_checkpoint() + && parent_node.unrealized_finalized_checkpoint() + == head_node.unrealized_finalized_checkpoint(); if !ffg_competitive { return Err(DoNotReOrg::JustificationAndFinalizationNotCompetitive.into()); } @@ -755,10 +763,10 @@ impl ProtoArrayForkChoice { /// This will operate on *all* blocks, even those that do not descend from the finalized /// ancestor. pub fn contains_invalid_payloads(&mut self) -> bool { - self.proto_array - .nodes - .iter() - .any(|node| node.execution_status.is_invalid()) + self.proto_array.nodes.iter().any(|node| { + node.execution_status() + .is_ok_and(|status| status.is_invalid()) + }) } /// For all nodes, regardless of their relationship to the finalized block, set their execution @@ -783,9 +791,11 @@ impl ProtoArrayForkChoice { .get_mut(node_index) .ok_or("unreachable index out of bounds in proto_array nodes")?; - match node.execution_status { - ExecutionStatus::Invalid(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash); + match node.execution_status() { + Ok(ExecutionStatus::Invalid(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash); + } // Restore the weight of the node, it would have been set to `0` in // `apply_score_changes` when it was invalidated. @@ -795,7 +805,7 @@ impl ProtoArrayForkChoice { .iter() .enumerate() .filter_map(|(validator_index, vote)| { - if vote.current_root == node.root { + if vote.current_root == node.root() { // Any voting validator that does not have a balance should be // ignored. This is consistent with `compute_deltas`. self.balances.effective_balances.get(validator_index) @@ -808,7 +818,7 @@ impl ProtoArrayForkChoice { // If the invalid root was boosted, apply the weight to it and // ancestors. if let Some(proposer_score_boost) = spec.proposer_score_boost - && self.proto_array.previous_proposer_boost.root == node.root + && self.proto_array.previous_proposer_boost.root == node.root() { // Compute the score based upon the current balances. We can't rely on // the `previous_proposr_boost.score` since it is set to zero with an @@ -829,12 +839,12 @@ impl ProtoArrayForkChoice { if restored_weight > 0 { let mut node_or_ancestor = node; loop { - node_or_ancestor.weight = node_or_ancestor - .weight + *node_or_ancestor.weight_mut() = node_or_ancestor + .weight() .checked_add(restored_weight) .ok_or("Overflow when adding weight to ancestor")?; - if let Some(parent_index) = node_or_ancestor.parent { + if let Some(parent_index) = node_or_ancestor.parent() { node_or_ancestor = self .proto_array .nodes @@ -850,11 +860,14 @@ impl ProtoArrayForkChoice { } // There are no balance changes required if the node was either valid or // optimistic. - ExecutionStatus::Valid(block_hash) | ExecutionStatus::Optimistic(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash) + Ok(ExecutionStatus::Valid(block_hash)) + | Ok(ExecutionStatus::Optimistic(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash) + } } // An irrelevant node cannot become optimistic, this is a no-op. - ExecutionStatus::Irrelevant(_) => (), + Ok(ExecutionStatus::Irrelevant(_)) | Err(_) => (), } } @@ -891,30 +904,34 @@ impl ProtoArrayForkChoice { pub fn get_block(&self, block_root: &Hash256) -> Option { let block = self.get_proto_node(block_root)?; let parent_root = block - .parent + .parent() .and_then(|i| self.proto_array.nodes.get(i)) - .map(|parent| parent.root); + .map(|parent| parent.root()); Some(Block { - slot: block.slot, - root: block.root, + slot: block.slot(), + root: block.root(), parent_root, - state_root: block.state_root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id.clone(), - next_epoch_shuffling_id: block.next_epoch_shuffling_id.clone(), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + state_root: block.state_root(), + target_root: block.target_root(), + current_epoch_shuffling_id: block.current_epoch_shuffling_id().clone(), + next_epoch_shuffling_id: block.next_epoch_shuffling_id().clone(), + justified_checkpoint: *block.justified_checkpoint(), + finalized_checkpoint: *block.finalized_checkpoint(), + execution_status: block + .execution_status() + .unwrap_or_else(|_| ExecutionStatus::irrelevant()), + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint(), + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint(), + execution_payload_parent_hash: None, + execution_payload_block_hash: block.execution_payload_block_hash().ok(), }) } /// Returns the `block.execution_status` field, if the block is present. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option { let block = self.get_proto_node(block_root)?; - Some(block.execution_status) + block.execution_status().ok() } /// Returns the weight of a given block. @@ -923,7 +940,22 @@ impl ProtoArrayForkChoice { self.proto_array .nodes .get(*block_index) - .map(|node| node.weight) + .map(|node| node.weight()) + } + + /// Returns the payload status of the head node based on accumulated weights. + /// + /// Returns `Full` if `full_payload_weight >= empty_payload_weight` (Full wins ties per spec's + /// `get_payload_status_tiebreaker` natural ordering FULL=2 > EMPTY=1). + /// Returns `Empty` otherwise. Returns `None` for V17 nodes. + pub fn head_payload_status(&self, head_root: &Hash256) -> Option { + let node = self.get_proto_node(head_root)?; + let v29 = node.as_v29().ok()?; + if v29.full_payload_weight >= v29.empty_payload_weight { + Some(PayloadStatus::Full) + } else { + Some(PayloadStatus::Empty) + } } /// See `ProtoArray` documentation. @@ -1039,15 +1071,30 @@ impl ProtoArrayForkChoice { /// - If a value in `indices` is greater to or equal to `indices.len()`. /// - If some `Hash256` in `votes` is not a key in `indices` (except for `Hash256::zero()`, this is /// always valid). -// FIXME(sproul): implement get-weight changes here fn compute_deltas( indices: &HashMap, + node_slots: &[Slot], votes: &mut ElasticList, old_balances: &[u64], new_balances: &[u64], equivocating_indices: &BTreeSet, -) -> Result, Error> { - let mut deltas = vec![0_i64; indices.len()]; +) -> Result, Error> { + let block_slot = |index: usize| -> Result { + node_slots + .get(index) + .copied() + .ok_or(Error::InvalidNodeDelta(index)) + }; + + let mut deltas = vec![ + NodeDelta { + delta: 0, + empty_delta: 0, + full_delta: 0, + payload_tiebreaker: None, + }; + indices.len() + ]; for (val_index, vote) in votes.iter_mut().enumerate() { // There is no need to create a score change if the validator has never voted or both their @@ -1072,17 +1119,25 @@ fn compute_deltas( let old_balance = old_balances.get(val_index).copied().unwrap_or(0); if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; } vote.current_root = Hash256::zero(); + vote.current_slot = Slot::new(0); + vote.current_payload_present = false; } // We've handled this slashed validator, continue without applying an ordinary delta. continue; @@ -1099,34 +1154,52 @@ fn compute_deltas( // on-boarded less validators than the prior fork. let new_balance = new_balances.get(val_index).copied().unwrap_or(0); - if vote.current_root != vote.next_root || old_balance != new_balance { + if vote.current_root != vote.next_root + || old_balance != new_balance + || vote.current_payload_present != vote.next_payload_present + || vote.current_slot != vote.next_slot + { // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; } // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(next_delta_index) = indices.get(&vote.next_root).copied() { - let delta = deltas - .get(next_delta_index) - .ok_or(Error::InvalidNodeDelta(next_delta_index))? + let node_delta = deltas + .get_mut(next_delta_index) + .ok_or(Error::InvalidNodeDelta(next_delta_index))?; + node_delta.delta = node_delta + .delta .checked_add(new_balance as i64) .ok_or(Error::DeltaOverflow(next_delta_index))?; - // Array access safe due to check on previous line. - deltas[next_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.next_slot, + vote.next_payload_present, + block_slot(next_delta_index)?, + ); + node_delta.add_payload_delta(status, new_balance, next_delta_index)?; } vote.current_root = vote.next_root; + vote.current_slot = vote.next_slot; + vote.current_payload_present = vote.next_payload_present; } } @@ -1144,8 +1217,13 @@ mod test_compute_deltas { Hash256::from_low_u64_be(i as u64 + 1) } + fn test_node_slots(count: usize) -> Vec { + vec![Slot::new(0); count] + } + #[test] fn finalized_descendant() { + let spec = MainnetEthSpec::default_spec(); let genesis_slot = Slot::new(0); let genesis_epoch = Epoch::new(0); @@ -1176,6 +1254,9 @@ mod test_compute_deltas { junk_shuffling_id.clone(), junk_shuffling_id.clone(), execution_status, + None, + None, + &spec, ) .unwrap(); @@ -1195,10 +1276,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, genesis_slot + 1, genesis_checkpoint, genesis_checkpoint, + &spec, ) .unwrap(); @@ -1220,10 +1304,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, genesis_slot + 1, genesis_checkpoint, genesis_checkpoint, + &spec, ) .unwrap(); @@ -1299,6 +1386,7 @@ mod test_compute_deltas { /// *checkpoint*, not just the finalized *block*. #[test] fn finalized_descendant_edge_case() { + let spec = MainnetEthSpec::default_spec(); let get_block_root = Hash256::from_low_u64_be; let genesis_slot = Slot::new(0); let junk_state_root = Hash256::zero(); @@ -1320,6 +1408,9 @@ mod test_compute_deltas { junk_shuffling_id.clone(), junk_shuffling_id.clone(), execution_status, + None, + None, + &spec, ) .unwrap(); @@ -1348,10 +1439,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, Slot::from(block.slot), genesis_checkpoint, genesis_checkpoint, + &spec, ) .unwrap(); }; @@ -1454,7 +1548,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(0); new_balances.push(0); @@ -1462,6 +1559,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1505,7 +1603,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(0), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1513,6 +1614,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1563,7 +1665,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(i), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1571,6 +1676,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1616,7 +1722,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1624,6 +1733,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1680,18 +1790,25 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); // One validator moves their vote from the block to something outside the tree. votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::from_low_u64_be(1337), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1733,7 +1850,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(OLD_BALANCE); new_balances.push(NEW_BALANCE); @@ -1741,6 +1861,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1802,12 +1923,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1858,12 +1983,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1912,7 +2041,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } @@ -1921,6 +2053,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1950,6 +2083,7 @@ mod test_compute_deltas { // Re-computing the deltas should be a no-op (no repeat deduction for the slashed validator). let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &new_balances, &new_balances, @@ -1958,4 +2092,68 @@ mod test_compute_deltas { .expect("should compute deltas"); assert_eq!(deltas, vec![0, 0]); } + + #[test] + fn payload_bucket_changes_on_non_pending_vote() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(1), + next_slot: Slot::new(1), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, -(BALANCE as i64)); + assert_eq!(deltas[0].full_delta, BALANCE as i64); + } + + #[test] + fn pending_vote_only_updates_regular_weight() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, 0); + assert_eq!(deltas[0].full_delta, 0); + } } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 1e01b74c8cd..07baaa47867 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -1,7 +1,7 @@ use crate::proto_array::ProposerBoost; use crate::{ Error, JustifiedBalances, - proto_array::{ProtoArray, ProtoNodeV17}, + proto_array::{ProtoArray, ProtoNode, ProtoNodeV17}, proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker}, }; use ssz::{Encode, four_byte_option_impl}; @@ -14,14 +14,15 @@ use types::{Checkpoint, Hash256}; // selector. four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); -pub type SszContainer = SszContainerV28; +pub type SszContainer = SszContainerV29; +// Legacy containers (V17/V28) for backward compatibility with older schema versions. #[superstruct( variants(V17, V28), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] -pub struct SszContainer { +pub struct SszContainerLegacy { pub votes: Vec, #[superstruct(only(V17))] pub balances: Vec, @@ -35,7 +36,21 @@ pub struct SszContainer { pub previous_proposer_boost: ProposerBoost, } -impl SszContainer { +/// Current container version. Uses union-encoded `ProtoNode` to support mixed V17/V29 nodes. +#[derive(Encode, Decode, Clone)] +pub struct SszContainerV29 { + pub votes: Vec, + pub prune_threshold: usize, + // Deprecated, remove in a future schema migration + justified_checkpoint: Checkpoint, + // Deprecated, remove in a future schema migration + finalized_checkpoint: Checkpoint, + pub nodes: Vec, + pub indices: Vec<(Hash256, usize)>, + pub previous_proposer_boost: ProposerBoost, +} + +impl SszContainerV29 { pub fn from_proto_array( from: &ProtoArrayForkChoice, justified_checkpoint: Checkpoint, @@ -55,10 +70,10 @@ impl SszContainer { } } -impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { +impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice { type Error = Error; - fn try_from((from, balances): (SszContainer, JustifiedBalances)) -> Result { + fn try_from((from, balances): (SszContainerV29, JustifiedBalances)) -> Result { let proto_array = ProtoArray { prune_threshold: from.prune_threshold, nodes: from.nodes, @@ -74,9 +89,9 @@ impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { } } -// Convert V17 to V28 by dropping balances. -impl From for SszContainerV28 { - fn from(v17: SszContainerV17) -> Self { +// Convert legacy V17 to V28 by dropping balances. +impl From for SszContainerLegacyV28 { + fn from(v17: SszContainerLegacyV17) -> Self { Self { votes: v17.votes, prune_threshold: v17.prune_threshold, @@ -89,9 +104,9 @@ impl From for SszContainerV28 { } } -// Convert V28 to V17 by re-adding balances. -impl From<(SszContainerV28, JustifiedBalances)> for SszContainerV17 { - fn from((v28, balances): (SszContainerV28, JustifiedBalances)) -> Self { +// Convert legacy V28 to V17 by re-adding balances. +impl From<(SszContainerLegacyV28, JustifiedBalances)> for SszContainerLegacyV17 { + fn from((v28, balances): (SszContainerLegacyV28, JustifiedBalances)) -> Self { Self { votes: v28.votes, balances: balances.effective_balances.clone(), @@ -104,3 +119,40 @@ impl From<(SszContainerV28, JustifiedBalances)> for SszContainerV17 { } } } + +// Convert legacy V28 to current V29. +impl From for SszContainerV29 { + fn from(v28: SszContainerLegacyV28) -> Self { + Self { + votes: v28.votes, + prune_threshold: v28.prune_threshold, + justified_checkpoint: v28.justified_checkpoint, + finalized_checkpoint: v28.finalized_checkpoint, + nodes: v28.nodes.into_iter().map(ProtoNode::V17).collect(), + indices: v28.indices, + previous_proposer_boost: v28.previous_proposer_boost, + } + } +} + +// Downgrade current V29 to legacy V28 (lossy: V29 nodes lose payload-specific fields). +impl From for SszContainerLegacyV28 { + fn from(v29: SszContainerV29) -> Self { + Self { + votes: v29.votes, + prune_threshold: v29.prune_threshold, + justified_checkpoint: v29.justified_checkpoint, + finalized_checkpoint: v29.finalized_checkpoint, + nodes: v29 + .nodes + .into_iter() + .filter_map(|node| match node { + ProtoNode::V17(v17) => Some(v17), + ProtoNode::V29(_) => None, + }) + .collect(), + indices: v29.indices, + previous_proposer_boost: v29.previous_proposer_boost, + } + } +} diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index ca77dc8d796..ca28f3a2cab 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -939,7 +939,7 @@ impl Tester { DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, ); let proposer_head = match proposer_head_result { - Ok(head) => head.parent_node.root, + Ok(head) => head.parent_node.root(), Err(ProposerHeadError::DoNotReOrg(_)) => canonical_head, _ => panic!("Unexpected error in get proposer head"), }; From e04a8c31eadae5c771fc824f60bc6f7e8919d35c Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Thu, 26 Feb 2026 03:14:57 -0500 Subject: [PATCH 04/20] adding tests and payload changes --- Cargo.lock | 1 + consensus/fork_choice/Cargo.toml | 1 + consensus/fork_choice/src/fork_choice.rs | 102 +++++-- consensus/fork_choice/src/lib.rs | 2 +- consensus/fork_choice/tests/tests.rs | 143 ++++++++- consensus/proto_array/src/bin.rs | 8 + .../src/fork_choice_test_definition.rs | 29 +- .../execution_status.rs | 10 - .../ffg_updates.rs | 2 - .../gloas_payload.rs | 280 +++++++++++++++++- .../src/fork_choice_test_definition/votes.rs | 10 - consensus/proto_array/src/proto_array.rs | 30 +- .../src/proto_array_fork_choice.rs | 80 ++++- 13 files changed, 626 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a8e76a8a8d..555eb019d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3583,6 +3583,7 @@ name = "fork_choice" version = "0.1.0" dependencies = [ "beacon_chain", + "bls", "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", diff --git a/consensus/fork_choice/Cargo.toml b/consensus/fork_choice/Cargo.toml index a07aa38aa5b..df47a5c9d1f 100644 --- a/consensus/fork_choice/Cargo.toml +++ b/consensus/fork_choice/Cargo.toml @@ -19,5 +19,6 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } +bls = { workspace = true } store = { workspace = true } tokio = { workspace = true } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 77442a62f57..ae08b3675f6 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -249,7 +249,6 @@ pub struct QueuedAttestation { attesting_indices: Vec, block_root: Hash256, target_epoch: Epoch, - payload_present: bool, } impl<'a, E: EthSpec> From> for QueuedAttestation { @@ -259,11 +258,22 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { attesting_indices: a.attesting_indices_to_vec(), block_root: a.data().beacon_block_root, target_epoch: a.data().target.epoch, - payload_present: a.data().index == 1, } } } +/// Used for queuing payload attestations (PTC votes) from the current slot. +/// Payload attestations have different dequeue timing than regular attestations: +/// non-block payload attestations need an extra slot of delay (slot + 1 < current_slot). +#[derive(Clone, PartialEq, Encode, Decode)] +pub struct QueuedPayloadAttestation { + slot: Slot, + attesting_indices: Vec, + block_root: Hash256, + payload_present: bool, + blob_data_available: bool, +} + /// Returns all values in `self.queued_attestations` that have a slot that is earlier than the /// current slot. Also removes those values from `self.queued_attestations`. fn dequeue_attestations( @@ -285,6 +295,22 @@ fn dequeue_attestations( std::mem::replace(queued_attestations, remaining) } +/// Returns all values in `queued` that have `slot + 1 < current_slot`. +/// Payload attestations need an extra slot of delay compared to regular attestations. +fn dequeue_payload_attestations( + current_slot: Slot, + queued: &mut Vec, +) -> Vec { + let remaining = queued.split_off( + queued + .iter() + .position(|a| a.slot.saturating_add(1_u64) >= current_slot) + .unwrap_or(queued.len()), + ); + + std::mem::replace(queued, remaining) +} + /// Denotes whether an attestation we are processing was received from a block or from gossip. /// Equivalent to the `is_from_block` `bool` in: /// @@ -329,6 +355,9 @@ pub struct ForkChoice { proto_array: ProtoArrayForkChoice, /// Attestations that arrived at the current slot and must be queued for later processing. queued_attestations: Vec, + /// Payload attestations (PTC votes) that must be queued for later processing. + /// These have different dequeue timing than regular attestations. + queued_payload_attestations: Vec, /// Stores a cache of the values required to be sent to the execution layer. forkchoice_update_parameters: ForkchoiceUpdateParameters, _phantom: PhantomData, @@ -343,6 +372,7 @@ where self.fc_store == other.fc_store && self.proto_array == other.proto_array && self.queued_attestations == other.queued_attestations + && self.queued_payload_attestations == other.queued_payload_attestations } } @@ -414,6 +444,7 @@ where fc_store, proto_array, queued_attestations: vec![], + queued_payload_attestations: vec![], // This will be updated during the next call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1120,7 +1151,7 @@ where }); } - if indexed_payload_attestation.data.slot == block.slot + if self.fc_store.get_current_slot() == block.slot && indexed_payload_attestation.data.payload_present { return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); @@ -1177,12 +1208,10 @@ where if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { - let payload_present = attestation.data().index == 1; self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, attestation.data().slot, - payload_present, )?; } } else { @@ -1214,23 +1243,33 @@ where self.validate_on_payload_attestation(attestation, is_from_block)?; - if attestation.data.slot < self.fc_store.get_current_slot() { + let processing_slot = self.fc_store.get_current_slot(); + // Payload attestations from blocks can be applied in the next slot (S+1 for data.slot=S), + // while non-block payload attestations are delayed one extra slot. + let should_process_now = match is_from_block { + AttestationFromBlock::True => attestation.data.slot < processing_slot, + AttestationFromBlock::False => attestation.data.slot + 1_u64 < processing_slot, + }; + + if should_process_now { for validator_index in attestation.attesting_indices_iter() { - self.proto_array.process_attestation( + self.proto_array.process_payload_attestation( *validator_index as usize, attestation.data.beacon_block_root, - attestation.data.slot, + processing_slot, attestation.data.payload_present, + attestation.data.blob_data_available, )?; } } else { - self.queued_attestations.push(QueuedAttestation { - slot: attestation.data.slot, - attesting_indices: attestation.attesting_indices.iter().copied().collect(), - block_root: attestation.data.beacon_block_root, - target_epoch: attestation.data.slot.epoch(E::slots_per_epoch()), - payload_present: attestation.data.payload_present, - }); + self.queued_payload_attestations + .push(QueuedPayloadAttestation { + slot: attestation.data.slot, + attesting_indices: attestation.attesting_indices.iter().copied().collect(), + block_root: attestation.data.beacon_block_root, + payload_present: attestation.data.payload_present, + blob_data_available: attestation.data.blob_data_available, + }); } Ok(()) @@ -1265,6 +1304,7 @@ where // Process any attestations that might now be eligible. self.process_attestation_queue()?; + self.process_payload_attestation_queue()?; Ok(self.fc_store.get_current_slot()) } @@ -1339,12 +1379,31 @@ where &mut self.queued_attestations, ) { for validator_index in attestation.attesting_indices.iter() { - // FIXME(sproul): backwards compat/fork abstraction self.proto_array.process_attestation( *validator_index as usize, attestation.block_root, attestation.slot, + )?; + } + } + + Ok(()) + } + + /// Processes and removes from the queue any queued payload attestations which may now be + /// eligible for processing. Payload attestations use `slot + 1 < current_slot` timing. + fn process_payload_attestation_queue(&mut self) -> Result<(), Error> { + let current_slot = self.fc_store.get_current_slot(); + for attestation in + dequeue_payload_attestations(current_slot, &mut self.queued_payload_attestations) + { + for validator_index in attestation.attesting_indices.iter() { + self.proto_array.process_payload_attestation( + *validator_index as usize, + attestation.block_root, + current_slot, attestation.payload_present, + attestation.blob_data_available, )?; } } @@ -1507,6 +1566,11 @@ where &self.queued_attestations } + /// Returns a reference to the currently queued payload attestations. + pub fn queued_payload_attestations(&self) -> &[QueuedPayloadAttestation] { + &self.queued_payload_attestations + } + /// Returns the store's `proposer_boost_root`. pub fn proposer_boost_root(&self) -> Hash256 { self.fc_store.proposer_boost_root() @@ -1591,6 +1655,7 @@ where fc_store, proto_array, queued_attestations: persisted.queued_attestations, + queued_payload_attestations: persisted.queued_payload_attestations, // Will be updated in the following call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1633,6 +1698,7 @@ where .proto_array() .as_ssz_container(self.justified_checkpoint(), self.finalized_checkpoint()), queued_attestations: self.queued_attestations().to_vec(), + queued_payload_attestations: self.queued_payload_attestations.clone(), } } @@ -1658,6 +1724,8 @@ pub struct PersistedForkChoice { #[superstruct(only(V29))] pub proto_array: proto_array::core::SszContainerV29, pub queued_attestations: Vec, + #[superstruct(only(V29))] + pub queued_payload_attestations: Vec, } pub type PersistedForkChoice = PersistedForkChoiceV29; @@ -1682,6 +1750,7 @@ impl From for PersistedForkChoiceV29 { Self { proto_array: v28.proto_array_v28.into(), queued_attestations: v28.queued_attestations, + queued_payload_attestations: vec![], } } } @@ -1734,7 +1803,6 @@ mod tests { attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), - payload_present: false, }) .collect() } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index 87438f2f855..6091de6fdd9 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -6,7 +6,7 @@ pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, PersistedForkChoiceV17, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, - ResetPayloadStatuses, + QueuedPayloadAttestation, ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 86ef0e2f907..9887e2eb924 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -7,9 +7,11 @@ use beacon_chain::{ BeaconChain, BeaconChainError, BeaconForkChoiceStore, ChainConfig, ForkChoiceError, StateSkipConfig, WhenSlotSkipped, }; +use bls::AggregateSignature; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - ForkChoiceStore, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, QueuedAttestation, + AttestationFromBlock, ForkChoiceStore, InvalidAttestation, InvalidBlock, + PayloadVerificationStatus, QueuedAttestation, QueuedPayloadAttestation, }; use state_processing::state_advance::complete_state_advance; use std::fmt; @@ -19,8 +21,8 @@ use store::MemoryStore; use types::SingleAttestation; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, Epoch, EthSpec, ForkName, Hash256, - IndexedAttestation, MainnetEthSpec, RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, - test_utils::generate_deterministic_keypair, + IndexedAttestation, IndexedPayloadAttestation, MainnetEthSpec, PayloadAttestationData, + RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, test_utils::generate_deterministic_keypair, }; pub type E = MainnetEthSpec; @@ -154,6 +156,28 @@ impl ForkChoiceTest { self } + /// Inspect the queued payload attestations in fork choice. + #[allow(dead_code)] + pub fn inspect_queued_payload_attestations(self, mut func: F) -> Self + where + F: FnMut(&[QueuedPayloadAttestation]), + { + self.harness + .chain + .canonical_head + .fork_choice_write_lock() + .update_time(self.harness.chain.slot().unwrap()) + .unwrap(); + func( + self.harness + .chain + .canonical_head + .fork_choice_read_lock() + .queued_payload_attestations(), + ); + self + } + /// Skip a slot, without producing a block. pub fn skip_slot(self) -> Self { self.harness.advance_slot(); @@ -953,6 +977,119 @@ async fn invalid_attestation_payload_during_same_slot() { .await; } +/// A payload attestation for block A at slot S should be accepted when processed at slot S+1. +#[tokio::test] +async fn payload_attestation_for_previous_slot_is_accepted_at_next_slot() { + let test = ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await; + + let chain = &test.harness.chain; + let block_a = chain + .block_at_slot(Slot::new(1), WhenSlotSkipped::Prev) + .expect("lookup should succeed") + .expect("block A should exist"); + let block_a_root = block_a.canonical_root(); + let current_slot = block_a.slot().saturating_add(1_u64); + + let payload_attestation = IndexedPayloadAttestation:: { + attesting_indices: vec![0_u64].try_into().expect("valid attesting indices"), + data: PayloadAttestationData { + beacon_block_root: block_a_root, + slot: Slot::new(1), + payload_present: true, + blob_data_available: true, + }, + signature: AggregateSignature::empty(), + }; + + let result = chain + .canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + current_slot, + &payload_attestation, + AttestationFromBlock::True, + ); + + assert!( + result.is_ok(), + "payload attestation at slot S should be accepted at S+1, got: {:?}", + result + ); + + let latest_message = chain + .canonical_head + .fork_choice_read_lock() + .latest_message(0) + .expect("latest message should exist"); + assert_eq!(latest_message.slot, current_slot); + assert!(latest_message.payload_present); +} + +/// Non-block payload attestations at slot S+1 for data.slot S are delayed; they are not applied +/// until a later slot. +#[tokio::test] +async fn non_block_payload_attestation_at_next_slot_is_delayed() { + let test = ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await; + + let chain = &test.harness.chain; + let block_a = chain + .block_at_slot(Slot::new(1), WhenSlotSkipped::Prev) + .expect("lookup should succeed") + .expect("block A should exist"); + let block_a_root = block_a.canonical_root(); + let s_plus_1 = block_a.slot().saturating_add(1_u64); + let s_plus_2 = block_a.slot().saturating_add(2_u64); + + let payload_attestation = IndexedPayloadAttestation:: { + attesting_indices: vec![0_u64].try_into().expect("valid attesting indices"), + data: PayloadAttestationData { + beacon_block_root: block_a_root, + slot: Slot::new(1), + payload_present: true, + blob_data_available: true, + }, + signature: AggregateSignature::empty(), + }; + + let result = chain + .canonical_head + .fork_choice_write_lock() + .on_payload_attestation(s_plus_1, &payload_attestation, AttestationFromBlock::False); + assert!( + result.is_ok(), + "payload attestation should be accepted for queueing" + ); + + // Vote should not be applied yet; message remains unset. + let latest_before = chain + .canonical_head + .fork_choice_read_lock() + .latest_message(0); + assert!( + latest_before.is_none(), + "non-block payload attestation at S+1 should not apply immediately" + ); + + // Advance fork choice time to S+2, queue should now be processed. + chain + .canonical_head + .fork_choice_write_lock() + .update_time(s_plus_2) + .expect("update_time should succeed"); + + let latest_after = chain + .canonical_head + .fork_choice_read_lock() + .latest_message(0) + .expect("latest message should exist after delay"); + assert_eq!(latest_after.slot, s_plus_2); + assert!(latest_after.payload_present); +} + /// Specification v0.12.1: /// /// assert target.root == get_ancestor(store, attestation.data.beacon_block_root, target_slot) diff --git a/consensus/proto_array/src/bin.rs b/consensus/proto_array/src/bin.rs index e1d307affb4..c5df3f17e4a 100644 --- a/consensus/proto_array/src/bin.rs +++ b/consensus/proto_array/src/bin.rs @@ -18,6 +18,14 @@ fn main() { "execution_status_03.yaml", get_execution_status_test_definition_03(), ); + write_test_def_to_yaml( + "gloas_chain_following.yaml", + get_gloas_chain_following_test_definition(), + ); + write_test_def_to_yaml( + "gloas_payload_probe.yaml", + get_gloas_payload_probe_test_definition(), + ); } fn write_test_def_to_yaml(filename: &str, def: ForkChoiceTestDefinition) { diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index ec4227584a6..f88cf06349e 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -56,8 +56,14 @@ pub enum Operation { validator_index: usize, block_root: Hash256, attestation_slot: Slot, - #[serde(default)] + }, + ProcessPayloadAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, payload_present: bool, + #[serde(default)] + blob_data_available: bool, }, Prune { finalized_root: Hash256, @@ -277,18 +283,35 @@ impl ForkChoiceTestDefinition { validator_index, block_root, attestation_slot, + } => { + fork_choice + .process_attestation(validator_index, block_root, attestation_slot) + .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, + attestation_slot, payload_present, + blob_data_available, } => { fork_choice - .process_attestation( + .process_payload_attestation( validator_index, block_root, attestation_slot, payload_present, + blob_data_available, ) .unwrap_or_else(|_| { panic!( - "process_attestation op at index {} returned error", + "process_payload_attestation op at index {} returned error", op_index ) }); diff --git a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs index 93c97d09db4..318407f5983 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs @@ -106,7 +106,6 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -149,7 +148,6 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(2), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -254,7 +252,6 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(3), attestation_slot: Slot::new(3), - payload_present: false, }); // Ensure that the head is still 2 @@ -357,7 +354,6 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(1), attestation_slot: Slot::new(3), - payload_present: false, }); // Ensure that the head has switched back to 1 @@ -521,7 +517,6 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -564,7 +559,6 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(2), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -669,7 +663,6 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(3), attestation_slot: Slot::new(3), - payload_present: false, }); // Move validator #1 vote from 2 to 3 @@ -683,7 +676,6 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(3), attestation_slot: Slot::new(3), - payload_present: false, }); // Ensure that the head is now 3. @@ -898,7 +890,6 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -941,7 +932,6 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(1), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is 1. diff --git a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs index ee55ea649fe..88665a22add 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs @@ -312,7 +312,6 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(0), - payload_present: false, }); // Ensure that if we start at 0 we find 9 (just: 0, fin: 0). @@ -376,7 +375,6 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(2), attestation_slot: Slot::new(0), - payload_present: false, }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). 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 b6568106e39..7579b016369 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 @@ -109,18 +109,21 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(1)), }); - // One Full and one Empty vote for the same head block: tie should probe as Full. - ops.push(Operation::ProcessAttestation { + // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, + // which defaults to Empty unless timely+data-available evidence is set. + ops.push(Operation::ProcessPayloadAttestation { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(2), payload_present: true, + blob_data_available: false, }); - ops.push(Operation::ProcessAttestation { + ops.push(Operation::ProcessPayloadAttestation { validator_index: 1, block_root: get_root(1), attestation_slot: Slot::new(2), payload_present: false, + blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -135,15 +138,16 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { }); ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(1), - expected_status: PayloadStatus::Full, + expected_status: PayloadStatus::Empty, }); // Flip validator 0 to Empty; probe should now report Empty. - ops.push(Operation::ProcessAttestation { + ops.push(Operation::ProcessPayloadAttestation { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(3), payload_present: false, + blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -171,11 +175,12 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { execution_payload_parent_hash: Some(get_hash(1)), execution_payload_block_hash: Some(get_hash(5)), }); - ops.push(Operation::ProcessAttestation { + ops.push(Operation::ProcessPayloadAttestation { validator_index: 2, block_root: get_root(5), attestation_slot: Slot::new(3), payload_present: true, + blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -190,7 +195,250 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { }); ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(5), - expected_status: PayloadStatus::Full, + expected_status: PayloadStatus::Empty, + }); + + 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()), + } +} + +pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Competing branches with distinct payload ancestry (Full vs Empty from genesis). + 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::ProcessBlock { + slot: Slot::new(1), + 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(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Equal branch weights: tiebreak FULL picks branch rooted at 3. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: 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), + }); + + // Validator 0 votes Empty branch -> head flips to 4. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(4), + }); + + // Latest-message update back to Full branch -> head returns to 3. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(4), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(3), + expected_full_weight: 1, + expected_empty_weight: 0, + }); + + 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()), + } +} + +pub fn get_gloas_weight_priority_over_payload_preference_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches where one child extends payload (Full) and the other doesn't (Empty). + 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::ProcessBlock { + slot: Slot::new(1), + 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(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Parent prefers Full on equal branch weights. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: 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), + }); + + // Add two Empty votes to make the Empty branch strictly heavier. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(4), + }); + + 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()), + } +} + +pub fn get_gloas_parent_empty_when_child_points_to_grandparent_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build a three-block chain A -> B -> C (CL parent links). + // A: EL parent = genesis hash(0), EL hash = hash(1). + 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)), + }); + + // B: EL parent = hash(1), EL hash = hash(2). + 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(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // C: CL parent is B, but EL parent points to A (hash 1), not B (hash 2). + // This models B's payload not arriving in time, so C records parent status as Empty. + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + 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(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: PayloadStatus::Empty, }); ForkChoiceTestDefinition { @@ -219,4 +467,22 @@ mod tests { let test = get_gloas_payload_probe_test_definition(); test.run(); } + + #[test] + fn find_head_vote_transition() { + let test = get_gloas_find_head_vote_transition_test_definition(); + test.run(); + } + + #[test] + fn weight_priority_over_payload_preference() { + let test = get_gloas_weight_priority_over_payload_preference_test_definition(); + test.run(); + } + + #[test] + fn parent_empty_when_child_points_to_grandparent() { + let test = get_gloas_parent_empty_when_child_points_to_grandparent_test_definition(); + test.run(); + } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index d170e0974ff..49afae2d4a1 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -106,7 +106,6 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(1), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is now 1, because 1 has a vote. @@ -136,7 +135,6 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(2), attestation_slot: Slot::new(2), - payload_present: false, }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -211,7 +209,6 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(3), attestation_slot: Slot::new(3), - payload_present: false, }); // Ensure that the head is still 2 @@ -246,7 +243,6 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 1, block_root: get_root(1), attestation_slot: Slot::new(3), - payload_present: false, }); // Ensure that the head is now 3 @@ -409,13 +405,11 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(5), attestation_slot: Slot::new(4), - payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(5), attestation_slot: Slot::new(4), - payload_present: false, }); // Add blocks 7, 8 and 9. Adding these blocks helps test the `best_descendant` @@ -570,13 +564,11 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 0, block_root: get_root(9), attestation_slot: Slot::new(5), - payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(9), attestation_slot: Slot::new(5), - payload_present: false, }); // Add block 10 @@ -650,13 +642,11 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { validator_index: 2, block_root: get_root(10), attestation_slot: Slot::new(5), - payload_present: false, }); ops.push(Operation::ProcessAttestation { validator_index: 3, block_root: get_root(10), attestation_slot: Slot::new(5), - payload_present: false, }); // Check the head is now 10. diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 926767093f7..7403937d393 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -357,6 +357,9 @@ impl ProtoArray { apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; node.full_payload_weight = apply_delta(node.full_payload_weight, node_full_delta, node_index)?; + if let Some(payload_tiebreaker) = node_deltas.payload_tiebreaker { + node.payload_tiebreak = payload_tiebreaker; + } } // Update the parent delta (if any). @@ -1052,10 +1055,14 @@ impl ProtoArray { } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { // The best child leads to a viable head, but the child doesn't. no_change + } else if child.weight() > best_child.weight() { + // Weight is the primary ordering criterion. + change_to_child + } else if child.weight() < best_child.weight() { + no_change } else { - // Both viable or both non-viable. For V29 parents, prefer the child - // whose parent_payload_status matches the parent's payload preference - // (Full if full_payload_weight >= empty_payload_weight, else Empty). + // Equal weights: for V29 parents, prefer the child whose + // parent_payload_status matches the parent's payload preference. let child_matches = child_matches_parent_payload_preference(parent, child); let best_child_matches = child_matches_parent_payload_preference(parent, best_child); @@ -1064,20 +1071,11 @@ impl ProtoArray { change_to_child } else if !child_matches && best_child_matches { no_change - } else if child.weight() == best_child.weight() { - // Tie-breaker of equal weights by root. - if *child.root() >= *best_child.root() { - change_to_child - } else { - no_change - } + } else if *child.root() >= *best_child.root() { + // Final tie-breaker of equal weights by root. + change_to_child } else { - // Choose the winner by weight. - if child.weight() > best_child.weight() { - change_to_child - } else { - no_change - } + no_change } } } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index e1c893db9af..9400aafed7a 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -32,6 +32,8 @@ pub struct VoteTracker { next_slot: Slot, current_payload_present: bool, next_payload_present: bool, + current_blob_data_available: bool, + next_blob_data_available: bool, } // FIXME(sproul): version this type @@ -39,6 +41,7 @@ pub struct LatestMessage { pub slot: Slot, pub root: Hash256, pub payload_present: bool, + pub blob_data_available: bool, } /// Represents the verification status of an execution payload pre-Gloas. @@ -521,7 +524,24 @@ impl ProtoArrayForkChoice { validator_index: usize, block_root: Hash256, attestation_slot: Slot, + ) -> Result<(), String> { + let vote = self.votes.get_mut(validator_index); + + if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { + vote.next_root = block_root; + vote.next_slot = attestation_slot; + } + + Ok(()) + } + + pub fn process_payload_attestation( + &mut self, + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, payload_present: bool, + blob_data_available: bool, ) -> Result<(), String> { let vote = self.votes.get_mut(validator_index); @@ -529,6 +549,7 @@ impl ProtoArrayForkChoice { vote.next_root = block_root; vote.next_slot = attestation_slot; vote.next_payload_present = payload_present; + vote.next_blob_data_available = blob_data_available; } Ok(()) @@ -945,13 +966,19 @@ impl ProtoArrayForkChoice { /// Returns the payload status of the head node based on accumulated weights. /// - /// Returns `Full` if `full_payload_weight >= empty_payload_weight` (Full wins ties per spec's - /// `get_payload_status_tiebreaker` natural ordering FULL=2 > EMPTY=1). + /// Returns `Full` if `full_payload_weight > empty_payload_weight`. + /// Returns `Empty` if `empty_payload_weight > full_payload_weight`. + /// On ties, consult the node's runtime `payload_tiebreak`: prefer `Full` only when timely and + /// data is available, otherwise `Empty`. /// Returns `Empty` otherwise. Returns `None` for V17 nodes. pub fn head_payload_status(&self, head_root: &Hash256) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; - if v29.full_payload_weight >= v29.empty_payload_weight { + if v29.full_payload_weight > v29.empty_payload_weight { + Some(PayloadStatus::Full) + } else if v29.empty_payload_weight > v29.full_payload_weight { + Some(PayloadStatus::Empty) + } else if v29.payload_tiebreak.is_timely && v29.payload_tiebreak.is_data_available { Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) @@ -985,6 +1012,7 @@ impl ProtoArrayForkChoice { root: vote.next_root, slot: vote.next_slot, payload_present: vote.next_payload_present, + blob_data_available: vote.next_blob_data_available, }) } } else { @@ -1079,6 +1107,17 @@ fn compute_deltas( new_balances: &[u64], equivocating_indices: &BTreeSet, ) -> Result, Error> { + let merge_payload_tiebreaker = + |delta: &mut NodeDelta, incoming: crate::proto_array::PayloadTiebreak| { + delta.payload_tiebreaker = Some(match delta.payload_tiebreaker { + Some(existing) => crate::proto_array::PayloadTiebreak { + is_timely: existing.is_timely || incoming.is_timely, + is_data_available: existing.is_data_available || incoming.is_data_available, + }, + None => incoming, + }); + }; + let block_slot = |index: usize| -> Result { node_slots .get(index) @@ -1138,6 +1177,7 @@ fn compute_deltas( vote.current_root = Hash256::zero(); vote.current_slot = Slot::new(0); vote.current_payload_present = false; + vote.current_blob_data_available = false; } // We've handled this slashed validator, continue without applying an ordinary delta. continue; @@ -1195,11 +1235,21 @@ fn compute_deltas( block_slot(next_delta_index)?, ); node_delta.add_payload_delta(status, new_balance, next_delta_index)?; + if status != PayloadStatus::Pending { + merge_payload_tiebreaker( + node_delta, + crate::proto_array::PayloadTiebreak { + is_timely: vote.next_payload_present, + is_data_available: vote.next_blob_data_available, + }, + ); + } } vote.current_root = vote.next_root; vote.current_slot = vote.next_slot; vote.current_payload_present = vote.next_payload_present; + vote.current_blob_data_available = vote.next_blob_data_available; } } @@ -1552,6 +1602,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); old_balances.push(0); new_balances.push(0); @@ -1607,6 +1659,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1669,6 +1723,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1726,6 +1782,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1794,6 +1852,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); // One validator moves their vote from the block to something outside the tree. @@ -1804,6 +1864,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); let deltas = compute_deltas( @@ -1854,6 +1916,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); old_balances.push(OLD_BALANCE); new_balances.push(NEW_BALANCE); @@ -1927,6 +1991,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); } @@ -1987,6 +2053,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); } @@ -2045,6 +2113,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, + current_blob_data_available: false, + next_blob_data_available: false, }); } @@ -2108,6 +2178,8 @@ mod test_compute_deltas { next_slot: Slot::new(1), current_payload_present: false, next_payload_present: true, + current_blob_data_available: false, + next_blob_data_available: false, }]); let deltas = compute_deltas( @@ -2140,6 +2212,8 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: true, + current_blob_data_available: false, + next_blob_data_available: false, }]); let deltas = compute_deltas( From eb1b81063d47a120857179aac3d59a3f1ebed884 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Thu, 26 Feb 2026 04:38:45 -0500 Subject: [PATCH 05/20] fixing test --- beacon_node/beacon_chain/src/beacon_chain.rs | 1 + .../beacon_chain/src/block_verification.rs | 1 + consensus/fork_choice/src/fork_choice.rs | 68 ++++++++++++------- consensus/fork_choice/tests/tests.rs | 26 ++++++- consensus/proto_array/src/lib.rs | 2 +- consensus/proto_array/src/ssz_container.rs | 40 ++++++----- 6 files changed, 96 insertions(+), 42 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 22df1e1a2d7..fd85fce5fd3 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2263,6 +2263,7 @@ impl BeaconChain { self.slot()?, verified.indexed_attestation().to_ref(), AttestationFromBlock::False, + &self.spec, ) .map_err(Into::into) } diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 1d7e20ec1fc..ee0bb9e6ff6 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1665,6 +1665,7 @@ impl ExecutionPendingBlock { current_slot, indexed_attestation, AttestationFromBlock::True, + &chain.spec, ) { Ok(()) => Ok(()), // Ignore invalid attestations whilst importing attestations from a block. The diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index ae08b3675f6..990aedf2c35 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -407,21 +407,33 @@ where AttestationShufflingId::new(anchor_block_root, anchor_state, RelativeEpoch::Next) .map_err(Error::BeaconStateError)?; - let execution_status = anchor_block.message().execution_payload().map_or_else( - // If the block doesn't have an execution payload then it can't have - // execution enabled. - |_| ExecutionStatus::irrelevant(), - |execution_payload| { + let (execution_status, execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(execution_payload) = anchor_block.message().execution_payload() { + // Pre-Gloas forks: hashes come from the execution payload. if execution_payload.is_default_with_empty_roots() { - // A default payload does not have execution enabled. - ExecutionStatus::irrelevant() + (ExecutionStatus::irrelevant(), None, None) } else { - // Assume that this payload is valid, since the anchor should be a trusted block and - // state. - ExecutionStatus::Valid(execution_payload.block_hash()) + // Assume that this payload is valid, since the anchor should be a + // trusted block and state. + ( + ExecutionStatus::Valid(execution_payload.block_hash()), + Some(execution_payload.parent_hash()), + Some(execution_payload.block_hash()), + ) } - }, - ); + } else if let Ok(signed_bid) = + anchor_block.message().body().signed_execution_payload_bid() + { + // Gloas: hashes come from the execution payload bid. + ( + ExecutionStatus::irrelevant(), + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else { + // Pre-merge: no execution payload at all. + (ExecutionStatus::irrelevant(), None, None) + }; // If the current slot is not provided, use the value that was last provided to the store. let current_slot = current_slot.unwrap_or_else(|| fc_store.get_current_slot()); @@ -435,8 +447,8 @@ where current_epoch_shuffling_id, next_epoch_shuffling_id, execution_status, - None, - None, + execution_payload_parent_hash, + execution_payload_block_hash, spec, )?; @@ -1045,6 +1057,7 @@ where &self, indexed_attestation: IndexedAttestationRef, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), InvalidAttestation> { // There is no point in processing an attestation with an empty bitfield. Reject // it immediately. @@ -1117,11 +1130,17 @@ where }); } - // Same-slot attestations must have index == 0 (i.e., indicate pending payload status). - // Payload-present attestations (index == 1) for the same slot as the block are invalid - // because PTC votes should only arrive in subsequent slots. - if indexed_attestation.data().slot == block.slot && indexed_attestation.data().index != 0 { - return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); + // Post-GLOAS: same-slot attestations with index != 0 indicate a payload-present vote. + // These must go through `on_payload_attestation`, not `on_attestation`. + if spec + .fork_name_at_slot::(indexed_attestation.data().slot) + .gloas_enabled() + && indexed_attestation.data().slot == block.slot + && indexed_attestation.data().index != 0 + { + return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { + slot: block.slot, + }); } Ok(()) @@ -1182,6 +1201,7 @@ where system_time_current_slot: Slot, attestation: IndexedAttestationRef, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_ATTESTATION_TIMES); @@ -1204,7 +1224,7 @@ where return Ok(()); } - self.validate_on_attestation(attestation, is_from_block)?; + self.validate_on_attestation(attestation, is_from_block, spec)?; if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { @@ -1720,7 +1740,7 @@ pub struct PersistedForkChoice { #[superstruct(only(V17))] pub proto_array_bytes: Vec, #[superstruct(only(V28))] - pub proto_array_v28: proto_array::core::SszContainerLegacyV28, + pub proto_array_v28: proto_array::core::SszContainerV28, #[superstruct(only(V29))] pub proto_array: proto_array::core::SszContainerV29, pub queued_attestations: Vec, @@ -1735,8 +1755,8 @@ impl TryFrom for PersistedForkChoiceV28 { fn try_from(v17: PersistedForkChoiceV17) -> Result { let container_v17 = - proto_array::core::SszContainerLegacyV17::from_ssz_bytes(&v17.proto_array_bytes)?; - let container_v28: proto_array::core::SszContainerLegacyV28 = container_v17.into(); + proto_array::core::SszContainerV17::from_ssz_bytes(&v17.proto_array_bytes)?; + let container_v28: proto_array::core::SszContainerV28 = container_v17.into(); Ok(Self { proto_array_v28: container_v28, @@ -1758,7 +1778,7 @@ impl From for PersistedForkChoiceV29 { impl From<(PersistedForkChoiceV28, JustifiedBalances)> for PersistedForkChoiceV17 { fn from((v28, balances): (PersistedForkChoiceV28, JustifiedBalances)) -> Self { let container_v17 = - proto_array::core::SszContainerLegacyV17::from((v28.proto_array_v28, balances)); + proto_array::core::SszContainerV17::from((v28.proto_array_v28, balances)); let proto_array_bytes = container_v17.as_ssz_bytes(); Self { diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 9887e2eb924..029e8612899 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -73,6 +73,22 @@ impl ForkChoiceTest { Self { harness } } + /// Creates a new tester with the GLOAS fork active at epoch 1. + /// Genesis is a standard Fulu block (epoch 0), so block production works normally. + /// Tests that need GLOAS semantics should advance the chain into epoch 1 first. + pub fn new_with_gloas() -> Self { + let mut spec = ForkName::latest_stable().make_genesis_spec(ChainSpec::default()); + spec.gloas_fork_epoch = Some(Epoch::new(1)); + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.into()) + .deterministic_keypairs(VALIDATOR_COUNT) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + Self { harness } + } + /// Get a value from the `ForkChoice` instantiation. fn get(&self, func: T) -> U where @@ -948,9 +964,17 @@ async fn invalid_attestation_future_block() { } /// Payload attestations (index == 1) are invalid when they refer to a block in the same slot. +/// This check only applies when GLOAS is active. +/// +/// TODO(gloas): un-ignore once the test harness supports Gloas block production. +/// The validation logic is gated on `spec.fork_name_at_slot().gloas_enabled()` in +/// `validate_on_attestation`, which requires a block to exist at a GLOAS-enabled slot. +/// Currently the mock execution layer cannot produce Gloas blocks (no +/// `signed_execution_payload_bid` support). +#[ignore] #[tokio::test] async fn invalid_attestation_payload_during_same_slot() { - ForkChoiceTest::new() + ForkChoiceTest::new_with_gloas() .apply_blocks_without_new_attestations(1) .await .apply_attestation_to_chain( diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index b131fb403e7..42c65e6ffe6 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -17,6 +17,6 @@ pub mod core { pub use super::proto_array::{ProposerBoost, ProtoArray, ProtoNode}; pub use super::proto_array_fork_choice::VoteTracker; pub use super::ssz_container::{ - SszContainer, SszContainerLegacyV17, SszContainerLegacyV28, SszContainerV29, + SszContainer, SszContainerV17, SszContainerV28, SszContainerV29, }; } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 07baaa47867..b7d4fa91b05 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -7,7 +7,6 @@ use crate::{ use ssz::{Encode, four_byte_option_impl}; use ssz_derive::{Decode, Encode}; use std::collections::HashMap; -use superstruct::superstruct; use types::{Checkpoint, Hash256}; // Define a "legacy" implementation of `Option` which uses four bytes for encoding the union @@ -16,15 +15,10 @@ four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); pub type SszContainer = SszContainerV29; -// Legacy containers (V17/V28) for backward compatibility with older schema versions. -#[superstruct( - variants(V17, V28), - variant_attributes(derive(Encode, Decode, Clone)), - no_enum -)] -pub struct SszContainerLegacy { +/// Proto-array container introduced in schema V17. +#[derive(Encode, Decode, Clone)] +pub struct SszContainerV17 { pub votes: Vec, - #[superstruct(only(V17))] pub balances: Vec, pub prune_threshold: usize, // Deprecated, remove in a future schema migration @@ -36,6 +30,20 @@ pub struct SszContainerLegacy { pub previous_proposer_boost: ProposerBoost, } +/// Proto-array container introduced in schema V28. +#[derive(Encode, Decode, Clone)] +pub struct SszContainerV28 { + pub votes: Vec, + pub prune_threshold: usize, + // Deprecated, remove in a future schema migration + justified_checkpoint: Checkpoint, + // Deprecated, remove in a future schema migration + finalized_checkpoint: Checkpoint, + pub nodes: Vec, + pub indices: Vec<(Hash256, usize)>, + pub previous_proposer_boost: ProposerBoost, +} + /// Current container version. Uses union-encoded `ProtoNode` to support mixed V17/V29 nodes. #[derive(Encode, Decode, Clone)] pub struct SszContainerV29 { @@ -90,8 +98,8 @@ impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice { } // Convert legacy V17 to V28 by dropping balances. -impl From for SszContainerLegacyV28 { - fn from(v17: SszContainerLegacyV17) -> Self { +impl From for SszContainerV28 { + fn from(v17: SszContainerV17) -> Self { Self { votes: v17.votes, prune_threshold: v17.prune_threshold, @@ -105,8 +113,8 @@ impl From for SszContainerLegacyV28 { } // Convert legacy V28 to V17 by re-adding balances. -impl From<(SszContainerLegacyV28, JustifiedBalances)> for SszContainerLegacyV17 { - fn from((v28, balances): (SszContainerLegacyV28, JustifiedBalances)) -> Self { +impl From<(SszContainerV28, JustifiedBalances)> for SszContainerV17 { + fn from((v28, balances): (SszContainerV28, JustifiedBalances)) -> Self { Self { votes: v28.votes, balances: balances.effective_balances.clone(), @@ -121,8 +129,8 @@ impl From<(SszContainerLegacyV28, JustifiedBalances)> for SszContainerLegacyV17 } // Convert legacy V28 to current V29. -impl From for SszContainerV29 { - fn from(v28: SszContainerLegacyV28) -> Self { +impl From for SszContainerV29 { + fn from(v28: SszContainerV28) -> Self { Self { votes: v28.votes, prune_threshold: v28.prune_threshold, @@ -136,7 +144,7 @@ impl From for SszContainerV29 { } // Downgrade current V29 to legacy V28 (lossy: V29 nodes lose payload-specific fields). -impl From for SszContainerLegacyV28 { +impl From for SszContainerV28 { fn from(v29: SszContainerV29) -> Self { Self { votes: v29.votes, From 59033a509206f705716c716fd44513cfb04a2f54 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Thu, 26 Feb 2026 04:46:26 -0500 Subject: [PATCH 06/20] lint --- consensus/fork_choice/src/fork_choice.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 990aedf2c35..5305621d286 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1138,9 +1138,7 @@ where && indexed_attestation.data().slot == block.slot && indexed_attestation.data().index != 0 { - return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { - slot: block.slot, - }); + return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); } Ok(()) From e68cc0311495ecb99a6de920001bfc0ef48b2f4a Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 2 Mar 2026 13:25:03 -0500 Subject: [PATCH 07/20] vote sanity and genesis epoch fix --- beacon_node/beacon_chain/src/beacon_chain.rs | 4 +-- .../beacon_chain/src/block_verification.rs | 6 ++--- consensus/fork_choice/src/fork_choice.rs | 25 +++++++------------ consensus/fork_choice/src/lib.rs | 2 +- consensus/fork_choice/tests/tests.rs | 6 ++--- .../src/fork_choice_test_definition/votes.rs | 18 ++++++------- consensus/proto_array/src/proto_array.rs | 3 +-- .../src/proto_array_fork_choice.rs | 2 ++ 8 files changed, 30 insertions(+), 36 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index fd85fce5fd3..a5beb4d2b8e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -85,7 +85,7 @@ use execution_layer::{ }; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - AttestationFromBlock, ExecutionStatus, ForkChoice, ForkchoiceUpdateParameters, + ExecutionStatus, ForkChoice, ForkchoiceUpdateParameters, InvalidationOperation, PayloadVerificationStatus, ResetPayloadStatuses, }; use futures::channel::mpsc::Sender; @@ -2262,7 +2262,7 @@ impl BeaconChain { .on_attestation( self.slot()?, verified.indexed_attestation().to_ref(), - AttestationFromBlock::False, + false, &self.spec, ) .map_err(Into::into) diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index ee0bb9e6ff6..41daa2c4603 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -71,7 +71,7 @@ use bls::{PublicKey, PublicKeyBytes}; use educe::Educe; use eth2::types::{BlockGossip, EventKind}; use execution_layer::PayloadStatus; -pub use fork_choice::{AttestationFromBlock, PayloadVerificationStatus}; +pub use fork_choice::PayloadVerificationStatus; use metrics::TryExt; use parking_lot::RwLockReadGuard; use proto_array::Block as ProtoBlock; @@ -1664,7 +1664,7 @@ impl ExecutionPendingBlock { match fork_choice.on_attestation( current_slot, indexed_attestation, - AttestationFromBlock::True, + true, &chain.spec, ) { Ok(()) => Ok(()), @@ -1685,7 +1685,7 @@ impl ExecutionPendingBlock { match fork_choice.on_payload_attestation( current_slot, indexed_payload_attestation, - AttestationFromBlock::True, + true, ) { Ok(()) => Ok(()), // Ignore invalid payload attestations whilst importing from a block. diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 5305621d286..26361c7941e 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -312,14 +312,6 @@ fn dequeue_payload_attestations( } /// Denotes whether an attestation we are processing was received from a block or from gossip. -/// Equivalent to the `is_from_block` `bool` in: -/// -/// https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#validate_on_attestation -#[derive(Clone, Copy)] -pub enum AttestationFromBlock { - True, - False, -} /// Parameters which are cached between calls to `ForkChoice::get_head`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1056,7 +1048,7 @@ where fn validate_on_attestation( &self, indexed_attestation: IndexedAttestationRef, - is_from_block: AttestationFromBlock, + is_from_block: bool, spec: &ChainSpec, ) -> Result<(), InvalidAttestation> { // There is no point in processing an attestation with an empty bitfield. Reject @@ -1070,7 +1062,7 @@ where let target = indexed_attestation.data().target; - if matches!(is_from_block, AttestationFromBlock::False) { + if !is_from_block { self.validate_target_epoch_against_current_time(target.epoch)?; } @@ -1148,7 +1140,7 @@ where fn validate_on_payload_attestation( &self, indexed_payload_attestation: &IndexedPayloadAttestation, - _is_from_block: AttestationFromBlock, + _is_from_block: bool, ) -> Result<(), InvalidAttestation> { if indexed_payload_attestation.attesting_indices.is_empty() { return Err(InvalidAttestation::EmptyAggregationBitfield); @@ -1198,7 +1190,7 @@ where &mut self, system_time_current_slot: Slot, attestation: IndexedAttestationRef, - is_from_block: AttestationFromBlock, + is_from_block: bool, spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_ATTESTATION_TIMES); @@ -1251,7 +1243,7 @@ where &mut self, system_time_current_slot: Slot, attestation: &IndexedPayloadAttestation, - is_from_block: AttestationFromBlock, + is_from_block: bool, ) -> Result<(), Error> { self.update_time(system_time_current_slot)?; @@ -1264,9 +1256,10 @@ where let processing_slot = self.fc_store.get_current_slot(); // Payload attestations from blocks can be applied in the next slot (S+1 for data.slot=S), // while non-block payload attestations are delayed one extra slot. - let should_process_now = match is_from_block { - AttestationFromBlock::True => attestation.data.slot < processing_slot, - AttestationFromBlock::False => attestation.data.slot + 1_u64 < processing_slot, + let should_process_now = if is_from_block { + attestation.data.slot < processing_slot + } else { + attestation.data.slot.saturating_add(1_u64) < processing_slot }; if should_process_now { diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index 6091de6fdd9..d3a9d246228 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -3,7 +3,7 @@ mod fork_choice_store; mod metrics; pub use crate::fork_choice::{ - AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, + Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, PersistedForkChoiceV17, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, QueuedPayloadAttestation, ResetPayloadStatuses, diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 029e8612899..eea94b2e775 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -10,7 +10,7 @@ use beacon_chain::{ use bls::AggregateSignature; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - AttestationFromBlock, ForkChoiceStore, InvalidAttestation, InvalidBlock, + ForkChoiceStore, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, QueuedAttestation, QueuedPayloadAttestation, }; use state_processing::state_advance::complete_state_advance; @@ -1033,7 +1033,7 @@ async fn payload_attestation_for_previous_slot_is_accepted_at_next_slot() { .on_payload_attestation( current_slot, &payload_attestation, - AttestationFromBlock::True, + true, ); assert!( @@ -1082,7 +1082,7 @@ async fn non_block_payload_attestation_at_next_slot_is_delayed() { let result = chain .canonical_head .fork_choice_write_lock() - .on_payload_attestation(s_plus_1, &payload_attestation, AttestationFromBlock::False); + .on_payload_attestation(s_plus_1, &payload_attestation, false); assert!( result.is_ok(), "payload attestation should be accepted for queueing" diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index 49afae2d4a1..ad45d073c2b 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -339,7 +339,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: None, }); - // Ensure that 5 becomes the head. + // Ensure that 5 is filtered out and the head stays at 4. // // 0 // / \ @@ -347,9 +347,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { // | // 3 // | - // 4 + // 4 <- head // / - // head-> 5 + // 5 ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -360,7 +360,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(5), + expected_head: get_root(4), }); // Add block 6, which has a justified epoch of 0. @@ -476,8 +476,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: None, }); - // Ensure that 9 is the head. The branch rooted at 5 remains viable and its best descendant - // is selected. + // Ensure that 6 is the head, even though 5 has all the votes. This is testing to ensure + // that 5 is filtered out due to a differing justified epoch. // // 0 // / \ @@ -487,13 +487,13 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { // | // 4 // / \ - // 5 6 + // 5 6 <- head // | // 7 // | // 8 // / - // head-> 9 + // 9 ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -504,7 +504,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(9), + expected_head: get_root(6), }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 7403937d393..f062fef4188 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -130,7 +130,6 @@ pub struct ProtoNode { #[superstruct(only(V29), partial_getter(copy))] pub execution_payload_block_hash: ExecutionBlockHash, /// Tiebreaker for payload preference when full_payload_weight == empty_payload_weight. - /// Per spec: prefer Full if block was timely and data is available; otherwise prefer Empty. #[superstruct(only(V29), partial_getter(copy))] pub payload_tiebreak: PayloadTiebreak, } @@ -1152,7 +1151,7 @@ impl ProtoArray { return false; } - let genesis_epoch = Epoch::new(1); + let genesis_epoch = Epoch::new(0); let current_epoch = current_slot.epoch(E::slots_per_epoch()); let node_epoch = node.slot().epoch(E::slots_per_epoch()); let node_justified_checkpoint = node.justified_checkpoint(); diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 9400aafed7a..37054d95524 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -530,6 +530,8 @@ impl ProtoArrayForkChoice { if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; vote.next_slot = attestation_slot; + vote.next_payload_present = false; + vote.next_blob_data_available = false; } Ok(()) From 6f6da5b393091b4b98f63f5b410804968d856586 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 2 Mar 2026 13:27:45 -0500 Subject: [PATCH 08/20] lint --- beacon_node/beacon_chain/src/beacon_chain.rs | 4 ++-- beacon_node/beacon_chain/src/block_verification.rs | 7 +------ consensus/fork_choice/src/fork_choice.rs | 1 - consensus/fork_choice/src/lib.rs | 8 ++++---- consensus/fork_choice/tests/tests.rs | 10 +++------- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index a5beb4d2b8e..9dc1a5206e3 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -85,8 +85,8 @@ use execution_layer::{ }; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - ExecutionStatus, ForkChoice, ForkchoiceUpdateParameters, - InvalidationOperation, PayloadVerificationStatus, ResetPayloadStatuses, + ExecutionStatus, ForkChoice, ForkchoiceUpdateParameters, InvalidationOperation, + PayloadVerificationStatus, ResetPayloadStatuses, }; use futures::channel::mpsc::Sender; use itertools::Itertools; diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 41daa2c4603..ab60b8b9555 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1661,12 +1661,7 @@ impl ExecutionPendingBlock { .get_indexed_attestation(&state, attestation) .map_err(|e| BlockError::PerBlockProcessingError(e.into_with_index(i)))?; - match fork_choice.on_attestation( - current_slot, - indexed_attestation, - true, - &chain.spec, - ) { + match fork_choice.on_attestation(current_slot, indexed_attestation, true, &chain.spec) { Ok(()) => Ok(()), // Ignore invalid attestations whilst importing attestations from a block. The // block might be very old and therefore the attestations useless to fork choice. diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 26361c7941e..112c86eee69 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -312,7 +312,6 @@ fn dequeue_payload_attestations( } /// Denotes whether an attestation we are processing was received from a block or from gossip. - /// Parameters which are cached between calls to `ForkChoice::get_head`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ForkchoiceUpdateParameters { diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index d3a9d246228..824fc2dff05 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -3,10 +3,10 @@ mod fork_choice_store; mod metrics; pub use crate::fork_choice::{ - Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, - InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, - PersistedForkChoiceV17, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, - QueuedPayloadAttestation, ResetPayloadStatuses, + Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, + InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, PersistedForkChoiceV17, + PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, QueuedPayloadAttestation, + ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index eea94b2e775..da5405f06d5 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -10,8 +10,8 @@ use beacon_chain::{ use bls::AggregateSignature; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - ForkChoiceStore, InvalidAttestation, InvalidBlock, - PayloadVerificationStatus, QueuedAttestation, QueuedPayloadAttestation, + ForkChoiceStore, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, + QueuedAttestation, QueuedPayloadAttestation, }; use state_processing::state_advance::complete_state_advance; use std::fmt; @@ -1030,11 +1030,7 @@ async fn payload_attestation_for_previous_slot_is_accepted_at_next_slot() { let result = chain .canonical_head .fork_choice_write_lock() - .on_payload_attestation( - current_slot, - &payload_attestation, - true, - ); + .on_payload_attestation(current_slot, &payload_attestation, true); assert!( result.is_ok(), From 275ac11200416d436714c2ea1859f1d62e397aa2 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 2 Mar 2026 15:33:53 -0500 Subject: [PATCH 09/20] test fixes --- consensus/fork_choice/src/fork_choice.rs | 18 +++++++- consensus/fork_choice/tests/tests.rs | 42 +++++-------------- .../src/proto_array_fork_choice.rs | 2 +- 3 files changed, 29 insertions(+), 33 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 112c86eee69..d4c4fa25873 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -178,6 +178,11 @@ pub enum InvalidAttestation { /// A same-slot attestation has a non-zero index, indicating a payload attestation during the /// same slot as the block. Payload attestations must only arrive in subsequent slots. PayloadAttestationDuringSameSlot { slot: Slot }, + /// A gossip payload attestation must be for the current slot. + PayloadAttestationNotCurrentSlot { + attestation_slot: Slot, + current_slot: Slot, + }, } impl From for Error { @@ -1139,7 +1144,7 @@ where fn validate_on_payload_attestation( &self, indexed_payload_attestation: &IndexedPayloadAttestation, - _is_from_block: bool, + is_from_block: bool, ) -> Result<(), InvalidAttestation> { if indexed_payload_attestation.attesting_indices.is_empty() { return Err(InvalidAttestation::EmptyAggregationBitfield); @@ -1159,6 +1164,17 @@ where }); } + // Gossip payload attestations must be for the current slot. + // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md + if !is_from_block + && indexed_payload_attestation.data.slot != self.fc_store.get_current_slot() + { + return Err(InvalidAttestation::PayloadAttestationNotCurrentSlot { + attestation_slot: indexed_payload_attestation.data.slot, + current_slot: self.fc_store.get_current_slot(), + }); + } + if self.fc_store.get_current_slot() == block.slot && indexed_payload_attestation.data.payload_present { diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index da5405f06d5..68ec79d113e 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -1047,10 +1047,10 @@ async fn payload_attestation_for_previous_slot_is_accepted_at_next_slot() { assert!(latest_message.payload_present); } -/// Non-block payload attestations at slot S+1 for data.slot S are delayed; they are not applied -/// until a later slot. +/// Gossip payload attestations must be for the current slot. A payload attestation for slot S +/// received at slot S+1 should be rejected per the spec. #[tokio::test] -async fn non_block_payload_attestation_at_next_slot_is_delayed() { +async fn non_block_payload_attestation_for_previous_slot_is_rejected() { let test = ForkChoiceTest::new() .apply_blocks_without_new_attestations(1) .await; @@ -1062,7 +1062,6 @@ async fn non_block_payload_attestation_at_next_slot_is_delayed() { .expect("block A should exist"); let block_a_root = block_a.canonical_root(); let s_plus_1 = block_a.slot().saturating_add(1_u64); - let s_plus_2 = block_a.slot().saturating_add(2_u64); let payload_attestation = IndexedPayloadAttestation:: { attesting_indices: vec![0_u64].try_into().expect("valid attesting indices"), @@ -1080,34 +1079,15 @@ async fn non_block_payload_attestation_at_next_slot_is_delayed() { .fork_choice_write_lock() .on_payload_attestation(s_plus_1, &payload_attestation, false); assert!( - result.is_ok(), - "payload attestation should be accepted for queueing" - ); - - // Vote should not be applied yet; message remains unset. - let latest_before = chain - .canonical_head - .fork_choice_read_lock() - .latest_message(0); - assert!( - latest_before.is_none(), - "non-block payload attestation at S+1 should not apply immediately" + matches!( + result, + Err(ForkChoiceError::InvalidAttestation( + InvalidAttestation::PayloadAttestationNotCurrentSlot { .. } + )) + ), + "gossip payload attestation for previous slot should be rejected, got: {:?}", + result ); - - // Advance fork choice time to S+2, queue should now be processed. - chain - .canonical_head - .fork_choice_write_lock() - .update_time(s_plus_2) - .expect("update_time should succeed"); - - let latest_after = chain - .canonical_head - .fork_choice_read_lock() - .latest_message(0) - .expect("latest message should exist after delay"); - assert_eq!(latest_after.slot, s_plus_2); - assert!(latest_after.payload_present); } /// Specification v0.12.1: diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 37054d95524..01a8f10064e 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -659,7 +659,7 @@ impl ProtoArrayForkChoice { )?; // Only re-org a single slot. This prevents cascading failures during asynchrony. - let head_slot_ok = info.head_node.slot() + 1 == current_slot; + let head_slot_ok = info.head_node.slot().saturating_add(1_u64) == current_slot; if !head_slot_ok { return Err(DoNotReOrg::HeadDistance.into()); } From 9c6f25cf3642c2ddfe40138098618066cd20f542 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 9 Mar 2026 19:06:50 -0400 Subject: [PATCH 10/20] fix migration `SszContainer` scripts --- consensus/fork_choice/src/fork_choice.rs | 2 +- .../src/fork_choice_test_definition.rs | 3 +-- .../proto_array/src/proto_array_fork_choice.rs | 8 ++------ consensus/proto_array/src/ssz_container.rs | 16 ++++------------ 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index d4c4fa25873..12258a03dcc 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1722,7 +1722,7 @@ where PersistedForkChoice { proto_array: self .proto_array() - .as_ssz_container(self.justified_checkpoint(), self.finalized_checkpoint()), + .as_ssz_container(), queued_attestations: self.queued_attestations().to_vec(), queued_payload_attestations: self.queued_payload_attestations.clone(), } diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index f88cf06349e..4ff91638f8c 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -522,8 +522,7 @@ fn get_checkpoint(i: u64) -> Checkpoint { } fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { - // The checkpoint are ignored `ProtoArrayForkChoice::from_bytes` so any value is ok - let bytes = original.as_bytes(Checkpoint::default(), Checkpoint::default()); + let bytes = original.as_bytes(); let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone()) .expect("fork choice should decode from bytes"); assert!( diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 01a8f10064e..15062367bf0 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1037,18 +1037,14 @@ impl ProtoArrayForkChoice { pub fn as_ssz_container( &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, ) -> SszContainer { - SszContainer::from_proto_array(self, justified_checkpoint, finalized_checkpoint) + SszContainer::from_proto_array(self) } pub fn as_bytes( &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, ) -> Vec { - self.as_ssz_container(justified_checkpoint, finalized_checkpoint) + self.as_ssz_container() .as_ssz_bytes() } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index b7d4fa91b05..02c3e333451 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -49,10 +49,6 @@ pub struct SszContainerV28 { pub struct SszContainerV29 { pub votes: Vec, pub prune_threshold: usize, - // Deprecated, remove in a future schema migration - justified_checkpoint: Checkpoint, - // Deprecated, remove in a future schema migration - finalized_checkpoint: Checkpoint, pub nodes: Vec, pub indices: Vec<(Hash256, usize)>, pub previous_proposer_boost: ProposerBoost, @@ -61,16 +57,12 @@ pub struct SszContainerV29 { impl SszContainerV29 { pub fn from_proto_array( from: &ProtoArrayForkChoice, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, ) -> Self { let proto_array = &from.proto_array; Self { votes: from.votes.0.clone(), prune_threshold: proto_array.prune_threshold, - justified_checkpoint, - finalized_checkpoint, nodes: proto_array.nodes.clone(), indices: proto_array.indices.iter().map(|(k, v)| (*k, *v)).collect(), previous_proposer_boost: proto_array.previous_proposer_boost, @@ -134,8 +126,6 @@ impl From for SszContainerV29 { Self { votes: v28.votes, prune_threshold: v28.prune_threshold, - justified_checkpoint: v28.justified_checkpoint, - finalized_checkpoint: v28.finalized_checkpoint, nodes: v28.nodes.into_iter().map(ProtoNode::V17).collect(), indices: v28.indices, previous_proposer_boost: v28.previous_proposer_boost, @@ -149,8 +139,10 @@ impl From for SszContainerV28 { Self { votes: v29.votes, prune_threshold: v29.prune_threshold, - justified_checkpoint: v29.justified_checkpoint, - finalized_checkpoint: v29.finalized_checkpoint, + // These checkpoints are not consumed in v28 paths since the upgrade from v17, + // we can safely default the values. + justified_checkpoint: Checkpoint::default(), + finalized_checkpoint: Checkpoint::default(), nodes: v29 .nodes .into_iter() From 5679994285612728857f1ab7c7d064b7dc64e5d6 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Fri, 13 Mar 2026 10:55:16 -0400 Subject: [PATCH 11/20] addressing comments --- consensus/fork_choice/src/fork_choice.rs | 39 ++-- consensus/fork_choice/tests/tests.rs | 2 +- .../src/fork_choice_test_definition.rs | 4 +- .../execution_status.rs | 22 ++ .../ffg_updates.rs | 20 ++ .../gloas_payload.rs | 153 +++++++++++++ .../fork_choice_test_definition/no_votes.rs | 9 + .../src/fork_choice_test_definition/votes.rs | 20 ++ consensus/proto_array/src/proto_array.rs | 206 ++++++++++++------ .../src/proto_array_fork_choice.rs | 20 +- consensus/proto_array/src/ssz_container.rs | 4 +- 11 files changed, 398 insertions(+), 101 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 12258a03dcc..ea17e20f027 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -175,9 +175,11 @@ pub enum InvalidAttestation { /// The attestation is attesting to a state that is later than itself. (Viz., attesting to the /// future). AttestsToFutureBlock { block: Slot, attestation: Slot }, - /// A same-slot attestation has a non-zero index, indicating a payload attestation during the - /// same slot as the block. Payload attestations must only arrive in subsequent slots. - PayloadAttestationDuringSameSlot { slot: Slot }, + /// A same-slot attestation has a non-zero index, which is invalid post-GLOAS. + InvalidSameSlotAttestationIndex { slot: Slot }, + /// A payload attestation votes payload_present for a block in the current slot, which is + /// invalid because the payload cannot be known yet. + PayloadPresentDuringSameSlot { slot: Slot }, /// A gossip payload attestation must be for the current slot. PayloadAttestationNotCurrentSlot { attestation_slot: Slot, @@ -269,7 +271,7 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { /// Used for queuing payload attestations (PTC votes) from the current slot. /// Payload attestations have different dequeue timing than regular attestations: -/// non-block payload attestations need an extra slot of delay (slot + 1 < current_slot). +/// gossiped payload attestations need an extra slot of delay (slot + 1 < current_slot). #[derive(Clone, PartialEq, Encode, Decode)] pub struct QueuedPayloadAttestation { slot: Slot, @@ -420,7 +422,8 @@ where } else if let Ok(signed_bid) = anchor_block.message().body().signed_execution_payload_bid() { - // Gloas: hashes come from the execution payload bid. + // Gloas: execution status is irrelevant post-Gloas; payload validation + // is decoupled from beacon blocks. ( ExecutionStatus::irrelevant(), Some(signed_bid.message.parent_block_hash), @@ -990,6 +993,12 @@ where Ok(()) } + pub fn on_execution_payload(&mut self, block_root: Hash256) -> Result<(), Error> { + self.proto_array + .on_execution_payload(block_root) + .map_err(Error::FailedToProcessValidExecutionPayload) + } + /// Update checkpoints in store if necessary fn update_checkpoints( &mut self, @@ -1126,15 +1135,15 @@ where }); } - // Post-GLOAS: same-slot attestations with index != 0 indicate a payload-present vote. - // These must go through `on_payload_attestation`, not `on_attestation`. + // Post-GLOAS: same-slot attestations must have index == 0. Attestations with + // index != 0 during the same slot as the block are invalid. if spec .fork_name_at_slot::(indexed_attestation.data().slot) .gloas_enabled() && indexed_attestation.data().slot == block.slot && indexed_attestation.data().index != 0 { - return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); + return Err(InvalidAttestation::InvalidSameSlotAttestationIndex { slot: block.slot }); } Ok(()) @@ -1175,10 +1184,14 @@ where }); } - if self.fc_store.get_current_slot() == block.slot + // A payload attestation voting payload_present for a block in the current slot is + // invalid: the payload cannot be known yet. This only applies to gossip attestations; + // payload attestations from blocks have already been validated by the block producer. + if !is_from_block + && self.fc_store.get_current_slot() == block.slot && indexed_payload_attestation.data.payload_present { - return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); + return Err(InvalidAttestation::PayloadPresentDuringSameSlot { slot: block.slot }); } Ok(()) @@ -1270,7 +1283,7 @@ where let processing_slot = self.fc_store.get_current_slot(); // Payload attestations from blocks can be applied in the next slot (S+1 for data.slot=S), - // while non-block payload attestations are delayed one extra slot. + // while gossiped payload attestations are delayed one extra slot. let should_process_now = if is_from_block { attestation.data.slot < processing_slot } else { @@ -1720,9 +1733,7 @@ where /// be instantiated again later. pub fn to_persisted(&self) -> PersistedForkChoice { PersistedForkChoice { - proto_array: self - .proto_array() - .as_ssz_container(), + proto_array: self.proto_array().as_ssz_container(), queued_attestations: self.queued_attestations().to_vec(), queued_payload_attestations: self.queued_payload_attestations.clone(), } diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 68ec79d113e..6ec1c8aeba6 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -993,7 +993,7 @@ async fn invalid_attestation_payload_during_same_slot() { |result| { assert_invalid_attestation!( result, - InvalidAttestation::PayloadAttestationDuringSameSlot { slot } + InvalidAttestation::InvalidSameSlotAttestationIndex { slot } if slot == Slot::new(1) ) }, diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 4ff91638f8c..8451e2dc80f 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -28,6 +28,7 @@ pub enum Operation { finalized_checkpoint: Checkpoint, justified_state_balances: Vec, expected_head: Hash256, + current_slot: Slot, }, ProposerBoostFindHead { justified_checkpoint: Checkpoint, @@ -147,6 +148,7 @@ impl ForkChoiceTestDefinition { finalized_checkpoint, justified_state_balances, expected_head, + current_slot, } => { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) @@ -158,7 +160,7 @@ impl ForkChoiceTestDefinition { &justified_balances, Hash256::zero(), &equivocating_indices, - Slot::new(0), + current_slot, &spec, ) .unwrap_or_else(|e| { diff --git a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs index 318407f5983..59e80dbe66b 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs @@ -16,6 +16,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Add a block with a hash of 2. @@ -55,6 +56,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -95,6 +97,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a vote to block 1 @@ -124,6 +127,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -166,6 +170,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -222,6 +227,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -272,6 +278,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -321,6 +328,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Invalidation of 3 should have removed upstream weight. @@ -374,6 +382,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -427,6 +436,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Add a block with a hash of 2. @@ -466,6 +476,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -506,6 +517,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a vote to block 1 @@ -535,6 +547,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -577,6 +590,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -633,6 +647,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -696,6 +711,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -745,6 +761,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(2), + current_slot: Slot::new(0), }); // Invalidation of 3 should have removed upstream weight. @@ -800,6 +817,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Add a block with a hash of 2. @@ -839,6 +857,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -879,6 +898,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a vote to block 1 @@ -908,6 +928,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { @@ -950,6 +971,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertWeight { diff --git a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs index 88665a22add..34a4372e274 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs @@ -10,6 +10,7 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Build the following tree (stick? lol). @@ -63,6 +64,7 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), }); // Ensure that with justified epoch 1 we find 3 @@ -83,6 +85,7 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), }); // Ensure that with justified epoch 2 we find 3 @@ -99,6 +102,7 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(1), justified_state_balances: balances, expected_head: get_root(3), + current_slot: Slot::new(0), }); // END OF TESTS @@ -123,6 +127,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Build the following tree. @@ -269,6 +274,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above, but with justified epoch 2. ops.push(Operation::FindHead { @@ -279,6 +285,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above, but with justified epoch 3. // @@ -293,6 +300,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Add a vote to 1. @@ -332,6 +340,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Save as above but justified epoch 2. ops.push(Operation::FindHead { @@ -342,6 +351,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Save as above but justified epoch 3. // @@ -356,6 +366,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Add a vote to 2. @@ -395,6 +406,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -405,6 +417,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above but justified epoch 3. // @@ -419,6 +432,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Ensure that if we start at 1 we find 9 (just: 0, fin: 0). @@ -442,6 +456,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -452,6 +467,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Same as above but justified epoch 3. // @@ -466,6 +482,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Ensure that if we start at 2 we find 10 (just: 0, fin: 0). @@ -486,6 +503,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -496,6 +514,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Same as above but justified epoch 3. // @@ -510,6 +529,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances, expected_head: get_root(10), + current_slot: Slot::new(0), }); // END OF TESTS 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 7579b016369..01f804c9aa4 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 @@ -71,6 +71,7 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(3), + current_slot: Slot::new(0), }); ops.push(Operation::SetPayloadTiebreak { @@ -83,6 +84,7 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(4), + current_slot: Slot::new(0), }); ForkChoiceTestDefinition { @@ -130,6 +132,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertPayloadWeights { block_root: get_root(1), @@ -154,6 +157,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], expected_head: get_root(1), + current_slot: Slot::new(0), }); ops.push(Operation::AssertPayloadWeights { block_root: get_root(1), @@ -187,6 +191,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1, 1], expected_head: get_root(5), + current_slot: Slot::new(0), }); ops.push(Operation::AssertPayloadWeights { block_root: get_root(5), @@ -261,6 +266,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(3), + current_slot: Slot::new(0), }); // Validator 0 votes Empty branch -> head flips to 4. @@ -276,6 +282,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(4), + current_slot: Slot::new(0), }); // Latest-message update back to Full branch -> head returns to 3. @@ -291,6 +298,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(3), + current_slot: Slot::new(0), }); ops.push(Operation::AssertPayloadWeights { block_root: get_root(3), @@ -362,6 +370,7 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], expected_head: get_root(3), + current_slot: Slot::new(0), }); // Add two Empty votes to make the Empty branch strictly heavier. @@ -384,6 +393,7 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], expected_head: get_root(4), + current_slot: Slot::new(0), }); ForkChoiceTestDefinition { @@ -452,6 +462,143 @@ pub fn get_gloas_parent_empty_when_child_points_to_grandparent_test_definition() } } +/// Test interleaving of blocks, regular attestations, and late-arriving PTC votes. +/// +/// Exercises the spec's `get_weight` rule: FULL/EMPTY virtual nodes at `current_slot - 1` +/// have weight 0, so payload preference is determined solely by the tiebreaker. +/// +/// genesis → block 1 (Full) → block 3 +/// → block 2 (Empty) → block 4 +/// +/// Timeline: +/// 1. Blocks 1 (Full) and 2 (Empty) arrive at slot 1 +/// 2. Regular attestations arrive (equal weight per branch) +/// 3. Child blocks 3 and 4 arrive at slot 2 +/// 4. PTC votes arrive for genesis (2 Full), making genesis prefer Full by weight +/// 5. At current_slot=1 (genesis is current-1), PTC weights are ignored → tiebreaker decides +/// 6. At current_slot=100 (genesis is old), PTC weights apply → Full branch wins +pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Step 1: Two competing blocks at slot 1. + 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::ProcessBlock { + slot: Slot::new(1), + 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(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Step 2: Regular attestations arrive, one per branch (equal CL weight). + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(1), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(2), + attestation_slot: Slot::new(1), + }); + + // Step 3: Child blocks at slot 2. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Step 4: PTC votes arrive for genesis, 2 Full votes from fresh validators. + // Vals 0 and 1 can't be reused because they already have votes at slot 1. + // Vals 2 and 3 target genesis; CL weight on genesis doesn't affect branch comparison. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 2, + block_root: get_root(0), + attestation_slot: Slot::new(1), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 3, + block_root: get_root(0), + attestation_slot: Slot::new(1), + payload_present: true, + blob_data_available: false, + }); + + // Set tiebreaker to Empty on genesis. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: false, + is_data_available: false, + }); + + // Step 5: At current_slot=1, genesis (slot 0) is at current_slot-1. + // Per spec, FULL/EMPTY weights are zeroed → tiebreaker decides. + // Tiebreaker is Empty → Empty branch (block 4) wins. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1, 1], + expected_head: get_root(4), + current_slot: Slot::new(1), + }); + + // Step 6: At current_slot=100, genesis (slot 0) is no longer at current_slot-1. + // FULL/EMPTY weights now apply. Genesis has Full > Empty → prefers Full. + // Full branch (block 3) wins despite Empty tiebreaker. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1, 1], + expected_head: get_root(3), + current_slot: Slot::new(100), + }); + + // Verify the PTC weights are recorded on genesis. + // full = 2 (PTC votes) + 1 (back-propagated from Full child block 1) = 3 + // empty = 0 (PTC votes) + 1 (back-propagated from Empty child block 2) = 1 + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(0), + expected_full_weight: 3, + expected_empty_weight: 1, + }); + + 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::*; @@ -485,4 +632,10 @@ mod tests { let test = get_gloas_parent_empty_when_child_points_to_grandparent_test_definition(); test.run(); } + + #[test] + fn interleaved_attestations() { + let test = get_gloas_interleaved_attestations_test_definition(); + test.run(); + } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs index 61e4c1270ce..71d4c035aef 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs @@ -18,6 +18,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: Hash256::zero(), + current_slot: Slot::new(0), }, // Add block 2 // @@ -55,6 +56,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }, // Add block 1 // @@ -92,6 +94,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }, // Add block 3 // @@ -133,6 +136,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }, // Add block 4 // @@ -174,6 +178,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), }, // Add block 5 with a justified epoch of 2 // @@ -216,6 +221,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), }, // Ensure there is no error when starting from a block that has the // wrong justified epoch. @@ -242,6 +248,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), }, // Set the justified epoch to 2 and the start block to 5 and ensure 5 is the head. // @@ -260,6 +267,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), }, // Add block 6 // @@ -303,6 +311,7 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(6), + current_slot: Slot::new(0), }, ]; diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index ad45d073c2b..3ba21db48a4 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -16,6 +16,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), }); // Add a block with a hash of 2. @@ -55,6 +56,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -95,6 +97,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add a vote to block 1 @@ -124,6 +127,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), }); // Add a vote to block 2 @@ -153,6 +157,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Add block 3. @@ -196,6 +201,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Move validator #0 vote from 1 to 3 @@ -229,6 +235,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), }); // Move validator #1 vote from 2 to 1 (this is an equivocation, but fork choice doesn't @@ -263,6 +270,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), }); // Add block 4. @@ -310,6 +318,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), }); // Add block 5, which has a justified epoch of 2. @@ -361,6 +370,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), }); // Add block 6, which has a justified epoch of 0. @@ -505,6 +515,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(6), + current_slot: Slot::new(0), }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -538,6 +549,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -616,6 +628,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Introduce 2 more validators into the system @@ -677,6 +690,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Set the balances of the last two validators to zero @@ -702,6 +716,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Set the balances of the last two validators back to 1 @@ -727,6 +742,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), }); // Remove the last two validators @@ -753,6 +769,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Ensure that pruning below the prune threshold does not prune. @@ -774,6 +791,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Ensure that pruning above the prune threshold does prune. @@ -812,6 +830,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), }); // Add block 11 @@ -863,6 +882,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(11), + current_slot: Slot::new(0), }); ForkChoiceTestDefinition { diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index f062fef4188..d0806b9e312 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -293,35 +293,38 @@ impl ProtoArray { false }; - let node_deltas = deltas + let node_delta = deltas .get(node_index) .copied() .ok_or(Error::InvalidNodeDelta(node_index))?; - let mut node_delta = if execution_status_is_invalid { + let mut delta = if execution_status_is_invalid { // If the node has an invalid execution payload, reduce its weight to zero. 0_i64 .checked_sub(node.weight() as i64) .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? } else { - node_deltas.delta + node_delta.delta }; let (node_empty_delta, node_full_delta) = if node.as_v29().is_ok() { - (node_deltas.empty_delta, node_deltas.full_delta) + (node_delta.empty_delta, node_delta.full_delta) } else { (0, 0) }; // If we find the node for which the proposer boost was previously applied, decrease // the delta by the previous score amount. + // TODO(gloas): implement `should_apply_proposer_boost` from the Gloas spec. + // The spec conditionally applies proposer boost based on parent weakness and + // early equivocations. Currently boost is applied unconditionally. if self.previous_proposer_boost.root != Hash256::zero() && self.previous_proposer_boost.root == node.root() // Invalid nodes will always have a weight of zero so there's no need to subtract // the proposer boost delta. && !execution_status_is_invalid { - node_delta = node_delta + delta = delta .checked_sub(self.previous_proposer_boost.score as i64) .ok_or(Error::DeltaOverflow(node_index))?; } @@ -329,6 +332,10 @@ impl ProtoArray { // the delta by the new score amount (unless the block has an invalid execution status). // // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance + // + // TODO(gloas): proposer boost should also be subtracted from `empty_delta` per spec, + // since the spec creates a virtual vote with `payload_present=False` for the proposer + // boost, biasing toward Empty for non-current-slot payload decisions. if let Some(proposer_score_boost) = spec.proposer_score_boost && proposer_boost_root != Hash256::zero() && proposer_boost_root == node.root() @@ -338,7 +345,7 @@ impl ProtoArray { proposer_score = calculate_committee_fraction::(new_justified_balances, proposer_score_boost) .ok_or(Error::ProposerBoostOverflow(node_index))?; - node_delta = node_delta + delta = delta .checked_add(proposer_score as i64) .ok_or(Error::DeltaOverflow(node_index))?; } @@ -347,7 +354,7 @@ impl ProtoArray { if execution_status_is_invalid { *node.weight_mut() = 0; } else { - *node.weight_mut() = apply_delta(node.weight(), node_delta, node_index)?; + *node.weight_mut() = apply_delta(node.weight(), delta, node_index)?; } // Apply post-Gloas score deltas. @@ -356,7 +363,7 @@ impl ProtoArray { apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; node.full_payload_weight = apply_delta(node.full_payload_weight, node_full_delta, node_index)?; - if let Some(payload_tiebreaker) = node_deltas.payload_tiebreaker { + if let Some(payload_tiebreaker) = node_delta.payload_tiebreaker { node.payload_tiebreak = payload_tiebreaker; } } @@ -370,7 +377,7 @@ impl ProtoArray { // Back-propagate the node's delta to its parent. parent_delta.delta = parent_delta .delta - .checked_add(node_delta) + .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; // Per spec's `is_supporting_vote`: a vote for descendant B supports @@ -381,13 +388,13 @@ impl ProtoArray { Ok(PayloadStatus::Full) => { parent_delta.full_delta = parent_delta .full_delta - .checked_add(node_delta) + .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; } Ok(PayloadStatus::Empty) => { parent_delta.empty_delta = parent_delta .empty_delta - .checked_add(node_delta) + .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; } // Pending or V17 nodes: no payload propagation. @@ -488,6 +495,8 @@ impl ProtoArray { { // Get the parent's execution block hash, handling both V17 and V29 nodes. // V17 parents occur during the Gloas fork transition. + // TODO(gloas): the spec's `get_parent_payload_status` assumes all blocks are + // post-Gloas with bids. Revisit once the spec clarifies fork-transition behavior. let parent_el_block_hash = match parent_node { ProtoNode::V29(v29) => Some(v29.execution_payload_block_hash), ProtoNode::V17(v17) => v17.execution_status.block_hash(), @@ -501,6 +510,9 @@ impl ProtoArray { PayloadStatus::Empty } } else { + // Parent is missing (genesis or pruned due to finalization). Default to Full + // since this path should only be hit at Gloas genesis, and extending the payload + // chain is the safe default. PayloadStatus::Full }; @@ -528,15 +540,16 @@ impl ProtoArray { }; // If the parent has an invalid execution status, return an error before adding the - // block to `self`. This applies when the parent is a V17 node with execution tracking. + // block to `self`. This applies only when the parent is a V17 node with execution tracking. if let Some(parent_index) = node.parent() { let parent = self .nodes .get(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - if let Ok(status) = parent.execution_status() - && status.is_invalid() + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = parent.as_v17() + && v17.execution_status.is_invalid() { return Err(Error::ParentExecutionStatusIsInvalid { block_root: block.root, @@ -565,6 +578,29 @@ impl ProtoArray { Ok(()) } + /// Process an excution payload for a Gloas block. + /// + /// this function assumes the + pub fn on_valid_execution_payload(&mut self, block_root: Hash256) -> Result<(), Error> { + let index = *self + .indices + .get(&block_root) + .ok_or(Error::NodeUnknown(block_root))?; + let node = self + .nodes + .get_mut(index) + .ok_or(Error::InvalidNodeIndex(index))?; + let v29 = node + .as_v29_mut() + .map_err(|_| Error::InvalidNodeVariant { block_root })?; + v29.payload_tiebreak = PayloadTiebreak { + is_timely: true, + is_data_available: true, + }; + + Ok(()) + } + /// Updates the `block_root` and all ancestors to have validated execution payloads. /// /// Returns an error if: @@ -871,8 +907,9 @@ impl ProtoArray { // practically possible to set a new justified root if we are unable to find a new head. // // This scenario is *unsupported*. It represents a serious consensus failure. - if let Ok(execution_status) = justified_node.execution_status() - && execution_status.is_invalid() + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = justified_node.as_v17() + && v17.execution_status.is_invalid() { return Err(Error::InvalidJustifiedCheckpointExecutionStatus { justified_root: *justified_root, @@ -1025,66 +1062,72 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); - let (new_best_child, new_best_descendant) = - if let Some(best_child_index) = parent.best_child() { - if best_child_index == child_index && !child_leads_to_viable_head { - // If the child is already the best-child of the parent but it's not viable for - // the head, remove it. - change_to_none - } else if best_child_index == child_index { - // If the child is the best-child already, set it again to ensure that the - // best-descendant of the parent is updated. + let (new_best_child, new_best_descendant) = if let Some(best_child_index) = + parent.best_child() + { + if best_child_index == child_index && !child_leads_to_viable_head { + // If the child is already the best-child of the parent but it's not viable for + // the head, remove it. + change_to_none + } else if best_child_index == child_index { + // If the child is the best-child already, set it again to ensure that the + // best-descendant of the parent is updated. + change_to_child + } else { + let best_child = self + .nodes + .get(best_child_index) + .ok_or(Error::InvalidBestDescendant(best_child_index))?; + + let best_child_leads_to_viable_head = self.node_leads_to_viable_head::( + best_child, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )?; + + if child_leads_to_viable_head && !best_child_leads_to_viable_head { + // The child leads to a viable head, but the current best-child doesn't. change_to_child + } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { + // The best child leads to a viable head, but the child doesn't. + no_change + } else if child.weight() > best_child.weight() { + // Weight is the primary ordering criterion. + change_to_child + } else if child.weight() < best_child.weight() { + no_change } else { - let best_child = self - .nodes - .get(best_child_index) - .ok_or(Error::InvalidBestDescendant(best_child_index))?; - - let best_child_leads_to_viable_head = self.node_leads_to_viable_head::( - best_child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if child_leads_to_viable_head && !best_child_leads_to_viable_head { - // The child leads to a viable head, but the current best-child doesn't. + // Equal weights: for V29 parents, prefer the child whose + // parent_payload_status matches the parent's payload preference + // (full vs empty). This corresponds to the spec's + // `get_payload_status_tiebreaker` ordering in `get_head`. + let child_matches = + child_matches_parent_payload_preference(parent, child, current_slot); + let best_child_matches = + child_matches_parent_payload_preference(parent, best_child, current_slot); + + if child_matches && !best_child_matches { + // Child extends the preferred payload chain, best_child doesn't. change_to_child - } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { - // The best child leads to a viable head, but the child doesn't. + } else if !child_matches && best_child_matches { + // Best child extends the preferred payload chain, child doesn't. no_change - } else if child.weight() > best_child.weight() { - // Weight is the primary ordering criterion. + } else if *child.root() >= *best_child.root() { + // Final tie-breaker: both match or both don't, break by root. change_to_child - } else if child.weight() < best_child.weight() { - no_change } else { - // Equal weights: for V29 parents, prefer the child whose - // parent_payload_status matches the parent's payload preference. - let child_matches = child_matches_parent_payload_preference(parent, child); - let best_child_matches = - child_matches_parent_payload_preference(parent, best_child); - - if child_matches && !best_child_matches { - change_to_child - } else if !child_matches && best_child_matches { - no_change - } else if *child.root() >= *best_child.root() { - // Final tie-breaker of equal weights by root. - change_to_child - } else { - no_change - } + no_change } } - } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. - change_to_child - } else { - // There is no current best-child but the child is not viable. - no_change - }; + } + } else if child_leads_to_viable_head { + // There is no current best-child and the child is viable. + change_to_child + } else { + // There is no current best-child but the child is not viable. + no_change + }; let parent = self .nodes @@ -1338,16 +1381,35 @@ impl ProtoArray { /// When equal, the tiebreaker uses the parent's `payload_tiebreak`: prefer Full if the block /// was timely and data is available; otherwise prefer Empty. /// For V17 parents (or mixed), always returns `true` (no payload preference). -fn child_matches_parent_payload_preference(parent: &ProtoNode, child: &ProtoNode) -> bool { +/// +/// TODO(gloas): the spec's `should_extend_payload` has additional conditions beyond the +/// tiebreaker: it also checks proposer_boost_root (empty, different parent, or extends full). +/// See: https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md#new-should_extend_payload +/// +/// TODO(gloas): the spec's `should_extend_payload` has additional conditions beyond the +/// tiebreaker: it also checks proposer_boost_root (empty, different parent, or extends full). +/// See: https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md#new-should_extend_payload +fn child_matches_parent_payload_preference( + parent: &ProtoNode, + child: &ProtoNode, + current_slot: Slot, +) -> bool { let (Ok(parent_v29), Ok(child_v29)) = (parent.as_v29(), child.as_v29()) else { return true; }; - let prefers_full = if parent_v29.full_payload_weight > parent_v29.empty_payload_weight { + // Per spec `get_weight`: FULL/EMPTY virtual nodes at `current_slot - 1` have weight 0. + // The PTC is still voting, so payload preference is determined solely by the tiebreaker. + let use_tiebreaker_only = parent.slot() + 1 == current_slot; + let prefers_full = if !use_tiebreaker_only + && parent_v29.full_payload_weight > parent_v29.empty_payload_weight + { true - } else if parent_v29.empty_payload_weight > parent_v29.full_payload_weight { + } else if !use_tiebreaker_only + && parent_v29.empty_payload_weight > parent_v29.full_payload_weight + { false } else { - // Equal weights: tiebreaker per spec + // Equal weights (or current-slot parent): tiebreaker per spec. parent_v29.payload_tiebreak.is_timely && parent_v29.payload_tiebreak.is_data_available }; if prefers_full { diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 15062367bf0..66f36274830 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -498,6 +498,11 @@ impl ProtoArrayForkChoice { }) } + pub fn on_execution_payload(&mut self, block_root: Hash256) -> Result<(), String> { + self.proto_array + .on_valid_execution_payload(block_root) + .map_err(|e| format!("Failed to process execution payload: {:?}", e)) + } /// See `ProtoArray::propagate_execution_payload_validation` for documentation. pub fn process_execution_payload_validation( &mut self, @@ -718,7 +723,7 @@ impl ProtoArrayForkChoice { let parent_slot = parent_node.slot(); let head_slot = head_node.slot(); - let re_org_block_slot = head_slot + 1; + let re_org_block_slot = head_slot.saturating_add(1_u64); // Check finalization distance. let proposal_epoch = re_org_block_slot.epoch(E::slots_per_epoch()); @@ -1035,17 +1040,12 @@ impl ProtoArrayForkChoice { self.proto_array.iter_block_roots(block_root) } - pub fn as_ssz_container( - &self, - ) -> SszContainer { + pub fn as_ssz_container(&self) -> SszContainer { SszContainer::from_proto_array(self) } - pub fn as_bytes( - &self, - ) -> Vec { - self.as_ssz_container() - .as_ssz_bytes() + pub fn as_bytes(&self) -> Vec { + self.as_ssz_container().as_ssz_bytes() } pub fn from_bytes(bytes: &[u8], balances: JustifiedBalances) -> Result { @@ -1321,8 +1321,8 @@ mod test_compute_deltas { next_epoch_shuffling_id: junk_shuffling_id.clone(), justified_checkpoint: genesis_checkpoint, finalized_checkpoint: genesis_checkpoint, - execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), + execution_status, unrealized_finalized_checkpoint: Some(genesis_checkpoint), execution_payload_parent_hash: None, execution_payload_block_hash: None, diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 02c3e333451..664dfe3ceba 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -55,9 +55,7 @@ pub struct SszContainerV29 { } impl SszContainerV29 { - pub fn from_proto_array( - from: &ProtoArrayForkChoice, - ) -> Self { + pub fn from_proto_array(from: &ProtoArrayForkChoice) -> Self { let proto_array = &from.proto_array; Self { From f74769611339b040791689cf4b8e55103c7b7ed3 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 16 Mar 2026 02:30:35 -0400 Subject: [PATCH 12/20] bitfield for `PTC` votes --- .../beacon_chain/src/block_verification.rs | 5 + consensus/fork_choice/src/fork_choice.rs | 26 ++- consensus/fork_choice/tests/tests.rs | 9 +- .../src/fork_choice_test_definition.rs | 17 +- .../gloas_payload.rs | 114 ++++------ consensus/proto_array/src/proto_array.rs | 195 +++++++++++------- .../src/proto_array_fork_choice.rs | 109 ++++------ 7 files changed, 237 insertions(+), 238 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 9b2515f9757..c140c431bc6 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1665,10 +1665,15 @@ impl ExecutionPendingBlock { .get_indexed_payload_attestation(&state, payload_attestation, &chain.spec) .map_err(|e| BlockError::PerBlockProcessingError(e.into_with_index(i)))?; + let ptc = state + .get_ptc(indexed_payload_attestation.data.slot, &chain.spec) + .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; + match fork_choice.on_payload_attestation( current_slot, indexed_payload_attestation, true, + &ptc.0, ) { Ok(()) => Ok(()), // Ignore invalid payload attestations whilst importing from a block. diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index ea17e20f027..63220f0bc6b 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -275,7 +275,8 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { #[derive(Clone, PartialEq, Encode, Decode)] pub struct QueuedPayloadAttestation { slot: Slot, - attesting_indices: Vec, + /// Resolved PTC committee positions (not validator indices). + ptc_indices: Vec, block_root: Hash256, payload_present: bool, blob_data_available: bool, @@ -1267,11 +1268,16 @@ where } /// Register a payload attestation with the fork choice DAG. + /// + /// `ptc` is the PTC committee for the attestation's slot: a list of validator indices + /// ordered by committee position. Each attesting validator index is resolved to its + /// position within `ptc` (its `ptc_index`) before being applied to the proto-array. pub fn on_payload_attestation( &mut self, system_time_current_slot: Slot, attestation: &IndexedPayloadAttestation, is_from_block: bool, + ptc: &[usize], ) -> Result<(), Error> { self.update_time(system_time_current_slot)?; @@ -1281,6 +1287,12 @@ where self.validate_on_payload_attestation(attestation, is_from_block)?; + // Resolve validator indices to PTC committee positions. + let ptc_indices: Vec = attestation + .attesting_indices_iter() + .filter_map(|vi| ptc.iter().position(|&p| p == *vi as usize)) + .collect(); + let processing_slot = self.fc_store.get_current_slot(); // Payload attestations from blocks can be applied in the next slot (S+1 for data.slot=S), // while gossiped payload attestations are delayed one extra slot. @@ -1291,11 +1303,10 @@ where }; if should_process_now { - for validator_index in attestation.attesting_indices_iter() { + for &ptc_index in &ptc_indices { self.proto_array.process_payload_attestation( - *validator_index as usize, attestation.data.beacon_block_root, - processing_slot, + ptc_index, attestation.data.payload_present, attestation.data.blob_data_available, )?; @@ -1304,7 +1315,7 @@ where self.queued_payload_attestations .push(QueuedPayloadAttestation { slot: attestation.data.slot, - attesting_indices: attestation.attesting_indices.iter().copied().collect(), + ptc_indices, block_root: attestation.data.beacon_block_root, payload_present: attestation.data.payload_present, blob_data_available: attestation.data.blob_data_available, @@ -1436,11 +1447,10 @@ where for attestation in dequeue_payload_attestations(current_slot, &mut self.queued_payload_attestations) { - for validator_index in attestation.attesting_indices.iter() { + for &ptc_index in &attestation.ptc_indices { self.proto_array.process_payload_attestation( - *validator_index as usize, attestation.block_root, - current_slot, + ptc_index, attestation.payload_present, attestation.blob_data_available, )?; diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 6ec1c8aeba6..44da1af148e 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -1027,10 +1027,13 @@ async fn payload_attestation_for_previous_slot_is_accepted_at_next_slot() { signature: AggregateSignature::empty(), }; + // PTC mapping: validator 0 is at ptc position 0. + let ptc = &[0_usize]; + let result = chain .canonical_head .fork_choice_write_lock() - .on_payload_attestation(current_slot, &payload_attestation, true); + .on_payload_attestation(current_slot, &payload_attestation, true, ptc); assert!( result.is_ok(), @@ -1074,10 +1077,12 @@ async fn non_block_payload_attestation_for_previous_slot_is_rejected() { signature: AggregateSignature::empty(), }; + let ptc = &[0_usize]; + let result = chain .canonical_head .fork_choice_write_lock() - .on_payload_attestation(s_plus_1, &payload_attestation, false); + .on_payload_attestation(s_plus_1, &payload_attestation, false, ptc); assert!( matches!( result, diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 8451e2dc80f..45aed23b293 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -4,7 +4,6 @@ mod gloas_payload; mod no_votes; mod votes; -use crate::proto_array::PayloadTiebreak; use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; @@ -299,15 +298,14 @@ impl ForkChoiceTestDefinition { Operation::ProcessPayloadAttestation { validator_index, block_root, - attestation_slot, + attestation_slot: _, payload_present, blob_data_available, } => { fork_choice .process_payload_attestation( - validator_index, block_root, - attestation_slot, + validator_index, payload_present, blob_data_available, ) @@ -450,7 +448,7 @@ impl ForkChoiceTestDefinition { expected_status, } => { let actual = fork_choice - .head_payload_status(&head_root) + .head_payload_status::(&head_root) .unwrap_or_else(|| { panic!( "AssertHeadPayloadStatus: head root not found at op index {}", @@ -494,10 +492,11 @@ impl ForkChoiceTestDefinition { op_index ) }); - node_v29.payload_tiebreak = PayloadTiebreak { - is_timely, - is_data_available, - }; + // Set all bits (exceeds any threshold) or clear all bits. + let fill = if is_timely { 0xFF } else { 0x00 }; + node_v29.payload_timeliness_votes.fill(fill); + let fill = if is_data_available { 0xFF } else { 0x00 }; + node_v29.payload_data_availability_votes.fill(fill); } } } 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 01f804c9aa4..9a0043a467b 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 @@ -134,17 +134,20 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { expected_head: get_root(1), current_slot: Slot::new(0), }); + // PTC votes write to bitfields only, not to full/empty weight. + // Weight is 0 because no CL attestations target this block. ops.push(Operation::AssertPayloadWeights { block_root: get_root(1), - expected_full_weight: 1, - expected_empty_weight: 1, + expected_full_weight: 0, + expected_empty_weight: 0, }); + // With MainnetEthSpec PTC_SIZE=512, 1 bit set out of 256 threshold → not timely → Empty. ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(1), expected_status: PayloadStatus::Empty, }); - // Flip validator 0 to Empty; probe should now report Empty. + // Flip validator 0 to Empty; both bits now clear. ops.push(Operation::ProcessPayloadAttestation { validator_index: 0, block_root: get_root(1), @@ -162,7 +165,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::AssertPayloadWeights { block_root: get_root(1), expected_full_weight: 0, - expected_empty_weight: 2, + expected_empty_weight: 0, }); ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(1), @@ -214,6 +217,8 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { } } +/// Test that CL attestation weight can flip the head between Full/Empty branches, +/// overriding the tiebreaker. pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDefinition { let mut ops = vec![]; @@ -269,13 +274,11 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe current_slot: Slot::new(0), }); - // Validator 0 votes Empty branch -> head flips to 4. - ops.push(Operation::ProcessPayloadAttestation { + // CL attestation to Empty branch (root 4) from validator 0 → head flips to 4. + ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(4), attestation_slot: Slot::new(3), - payload_present: false, - blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -285,13 +288,11 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe current_slot: Slot::new(0), }); - // Latest-message update back to Full branch -> head returns to 3. - ops.push(Operation::ProcessPayloadAttestation { + // CL attestation back to Full branch (root 3) → head returns to 3. + ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), attestation_slot: Slot::new(4), - payload_present: true, - blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -300,11 +301,6 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe expected_head: get_root(3), current_slot: Slot::new(0), }); - ops.push(Operation::AssertPayloadWeights { - block_root: get_root(3), - expected_full_weight: 1, - expected_empty_weight: 0, - }); ForkChoiceTestDefinition { finalized_block_slot: Slot::new(0), @@ -317,6 +313,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe } } +/// CL attestation weight overrides payload preference tiebreaker. pub fn get_gloas_weight_priority_over_payload_preference_test_definition() -> ForkChoiceTestDefinition { let mut ops = vec![]; @@ -359,7 +356,7 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() execution_payload_block_hash: Some(get_hash(4)), }); - // Parent prefers Full on equal branch weights. + // Parent prefers Full on equal branch weights (tiebreaker). ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: true, @@ -373,20 +370,17 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() current_slot: Slot::new(0), }); - // Add two Empty votes to make the Empty branch strictly heavier. - ops.push(Operation::ProcessPayloadAttestation { + // Two CL attestations to the Empty branch make it strictly heavier, + // overriding the Full tiebreaker. + ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(4), attestation_slot: Slot::new(3), - payload_present: false, - blob_data_available: false, }); - ops.push(Operation::ProcessPayloadAttestation { + ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(4), attestation_slot: Slot::new(3), - payload_present: false, - blob_data_available: false, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), @@ -462,21 +456,13 @@ pub fn get_gloas_parent_empty_when_child_points_to_grandparent_test_definition() } } -/// Test interleaving of blocks, regular attestations, and late-arriving PTC votes. -/// -/// Exercises the spec's `get_weight` rule: FULL/EMPTY virtual nodes at `current_slot - 1` -/// have weight 0, so payload preference is determined solely by the tiebreaker. +/// Test interleaving of blocks, regular attestations, and tiebreaker. /// /// genesis → block 1 (Full) → block 3 /// → block 2 (Empty) → block 4 /// -/// Timeline: -/// 1. Blocks 1 (Full) and 2 (Empty) arrive at slot 1 -/// 2. Regular attestations arrive (equal weight per branch) -/// 3. Child blocks 3 and 4 arrive at slot 2 -/// 4. PTC votes arrive for genesis (2 Full), making genesis prefer Full by weight -/// 5. At current_slot=1 (genesis is current-1), PTC weights are ignored → tiebreaker decides -/// 6. At current_slot=100 (genesis is old), PTC weights apply → Full branch wins +/// With equal CL weight, tiebreaker determines which branch wins. +/// An extra CL attestation can override the tiebreaker. pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDefinition { let mut ops = vec![]; @@ -532,60 +518,46 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef execution_payload_block_hash: Some(get_hash(4)), }); - // Step 4: PTC votes arrive for genesis, 2 Full votes from fresh validators. - // Vals 0 and 1 can't be reused because they already have votes at slot 1. - // Vals 2 and 3 target genesis; CL weight on genesis doesn't affect branch comparison. - ops.push(Operation::ProcessPayloadAttestation { - validator_index: 2, - block_root: get_root(0), - attestation_slot: Slot::new(1), - payload_present: true, - blob_data_available: false, - }); - ops.push(Operation::ProcessPayloadAttestation { - validator_index: 3, - block_root: get_root(0), - attestation_slot: Slot::new(1), - payload_present: true, - blob_data_available: false, - }); - - // Set tiebreaker to Empty on genesis. + // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: false, is_data_available: false, }); - - // Step 5: At current_slot=1, genesis (slot 0) is at current_slot-1. - // Per spec, FULL/EMPTY weights are zeroed → tiebreaker decides. - // Tiebreaker is Empty → Empty branch (block 4) wins. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), - justified_state_balances: vec![1, 1, 1, 1], + justified_state_balances: vec![1, 1], expected_head: get_root(4), current_slot: Slot::new(1), }); - // Step 6: At current_slot=100, genesis (slot 0) is no longer at current_slot-1. - // FULL/EMPTY weights now apply. Genesis has Full > Empty → prefers Full. - // Full branch (block 3) wins despite Empty tiebreaker. + // Step 5: Flip tiebreaker to Full → Full branch wins. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), - justified_state_balances: vec![1, 1, 1, 1], + justified_state_balances: vec![1, 1], expected_head: get_root(3), current_slot: Slot::new(100), }); - // Verify the PTC weights are recorded on genesis. - // full = 2 (PTC votes) + 1 (back-propagated from Full child block 1) = 3 - // empty = 0 (PTC votes) + 1 (back-propagated from Empty child block 2) = 1 - ops.push(Operation::AssertPayloadWeights { - block_root: get_root(0), - expected_full_weight: 3, - expected_empty_weight: 1, + // Step 6: Add extra CL weight to Empty branch → overrides Full tiebreaker. + ops.push(Operation::ProcessAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + 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(100), }); ForkChoiceTestDefinition { diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index d0806b9e312..4f89e6084f6 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -129,9 +129,16 @@ pub struct ProtoNode { pub full_payload_weight: u64, #[superstruct(only(V29), partial_getter(copy))] pub execution_payload_block_hash: ExecutionBlockHash, - /// Tiebreaker for payload preference when full_payload_weight == empty_payload_weight. - #[superstruct(only(V29), partial_getter(copy))] - pub payload_tiebreak: PayloadTiebreak, + /// PTC timeliness vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `payload_present = true`. + /// Tiebreak derived as: `count_ones() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_timeliness_votes: Vec, + /// PTC data availability vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `blob_data_available = true`. + /// Tiebreak derived as: `count_ones() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_data_availability_votes: Vec, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -154,7 +161,6 @@ pub struct NodeDelta { pub delta: i64, pub empty_delta: i64, pub full_delta: i64, - pub payload_tiebreaker: Option, } impl NodeDelta { @@ -192,6 +198,15 @@ impl NodeDelta { Ok(()) } + /// Create a delta that only affects the aggregate `delta` field. + pub fn from_delta(delta: i64) -> Self { + Self { + delta, + empty_delta: 0, + full_delta: 0, + } + } + /// Subtract a balance from the appropriate payload status. pub fn sub_payload_delta( &mut self, @@ -211,21 +226,14 @@ impl NodeDelta { } } +/// Compare NodeDelta with i64 by comparing the aggregate `delta` field. +/// This is used by tests that only care about the total weight delta. impl PartialEq for NodeDelta { fn eq(&self, other: &i64) -> bool { self.delta == *other - && self.empty_delta == 0 - && self.full_delta == 0 - && self.payload_tiebreaker.is_none() } } -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Encode, Decode, Serialize, Deserialize)] -pub struct PayloadTiebreak { - pub is_timely: bool, - pub is_data_available: bool, -} - #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct ProtoArray { /// Do not attempt to prune the tree unless it has at least this many nodes. Small prunes @@ -363,9 +371,6 @@ impl ProtoArray { apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; node.full_payload_weight = apply_delta(node.full_payload_weight, node_full_delta, node_index)?; - if let Some(payload_tiebreaker) = node_delta.payload_tiebreaker { - node.payload_tiebreak = payload_tiebreaker; - } } // Update the parent delta (if any). @@ -535,7 +540,8 @@ impl ProtoArray { empty_payload_weight: 0, full_payload_weight: 0, execution_payload_block_hash, - payload_tiebreak: PayloadTiebreak::default(), + payload_timeliness_votes: empty_ptc_bitfield(E::ptc_size()), + payload_data_availability_votes: empty_ptc_bitfield(E::ptc_size()), }) }; @@ -593,10 +599,10 @@ impl ProtoArray { let v29 = node .as_v29_mut() .map_err(|_| Error::InvalidNodeVariant { block_root })?; - v29.payload_tiebreak = PayloadTiebreak { - is_timely: true, - is_data_available: true, - }; + // A valid execution payload means the payload is timely and data is available. + // Set all bits to ensure the threshold is met regardless of PTC size. + v29.payload_timeliness_votes.fill(0xFF); + v29.payload_data_availability_votes.fill(0xFF); Ok(()) } @@ -1062,72 +1068,79 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); - let (new_best_child, new_best_descendant) = if let Some(best_child_index) = - parent.best_child() - { - if best_child_index == child_index && !child_leads_to_viable_head { - // If the child is already the best-child of the parent but it's not viable for - // the head, remove it. - change_to_none - } else if best_child_index == child_index { - // If the child is the best-child already, set it again to ensure that the - // best-descendant of the parent is updated. - change_to_child - } else { - let best_child = self - .nodes - .get(best_child_index) - .ok_or(Error::InvalidBestDescendant(best_child_index))?; - - let best_child_leads_to_viable_head = self.node_leads_to_viable_head::( - best_child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if child_leads_to_viable_head && !best_child_leads_to_viable_head { - // The child leads to a viable head, but the current best-child doesn't. - change_to_child - } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { - // The best child leads to a viable head, but the child doesn't. - no_change - } else if child.weight() > best_child.weight() { - // Weight is the primary ordering criterion. + let (new_best_child, new_best_descendant) = + if let Some(best_child_index) = parent.best_child() { + if best_child_index == child_index && !child_leads_to_viable_head { + // If the child is already the best-child of the parent but it's not viable for + // the head, remove it. + change_to_none + } else if best_child_index == child_index { + // If the child is the best-child already, set it again to ensure that the + // best-descendant of the parent is updated. change_to_child - } else if child.weight() < best_child.weight() { - no_change } else { - // Equal weights: for V29 parents, prefer the child whose - // parent_payload_status matches the parent's payload preference - // (full vs empty). This corresponds to the spec's - // `get_payload_status_tiebreaker` ordering in `get_head`. - let child_matches = - child_matches_parent_payload_preference(parent, child, current_slot); - let best_child_matches = - child_matches_parent_payload_preference(parent, best_child, current_slot); - - if child_matches && !best_child_matches { - // Child extends the preferred payload chain, best_child doesn't. + let best_child = self + .nodes + .get(best_child_index) + .ok_or(Error::InvalidBestDescendant(best_child_index))?; + + let best_child_leads_to_viable_head = self.node_leads_to_viable_head::( + best_child, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )?; + + if child_leads_to_viable_head && !best_child_leads_to_viable_head { + // The child leads to a viable head, but the current best-child doesn't. change_to_child - } else if !child_matches && best_child_matches { - // Best child extends the preferred payload chain, child doesn't. + } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { + // The best child leads to a viable head, but the child doesn't. no_change - } else if *child.root() >= *best_child.root() { - // Final tie-breaker: both match or both don't, break by root. + } else if child.weight() > best_child.weight() { + // Weight is the primary ordering criterion. change_to_child - } else { + } else if child.weight() < best_child.weight() { no_change + } else { + // Equal weights: for V29 parents, prefer the child whose + // parent_payload_status matches the parent's payload preference + // (full vs empty). This corresponds to the spec's + // `get_payload_status_tiebreaker` ordering in `get_head`. + let child_matches = child_matches_parent_payload_preference( + parent, + child, + current_slot, + E::ptc_size(), + ); + let best_child_matches = child_matches_parent_payload_preference( + parent, + best_child, + current_slot, + E::ptc_size(), + ); + + if child_matches && !best_child_matches { + // Child extends the preferred payload chain, best_child doesn't. + change_to_child + } else if !child_matches && best_child_matches { + // Best child extends the preferred payload chain, child doesn't. + no_change + } else if *child.root() >= *best_child.root() { + // Final tie-breaker: both match or both don't, break by root. + change_to_child + } else { + no_change + } } } - } - } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. - change_to_child - } else { - // There is no current best-child but the child is not viable. - no_change - }; + } else if child_leads_to_viable_head { + // There is no current best-child and the child is viable. + change_to_child + } else { + // There is no current best-child but the child is not viable. + no_change + }; let parent = self .nodes @@ -1393,6 +1406,7 @@ fn child_matches_parent_payload_preference( parent: &ProtoNode, child: &ProtoNode, current_slot: Slot, + ptc_size: usize, ) -> bool { let (Ok(parent_v29), Ok(child_v29)) = (parent.as_v29(), child.as_v29()) else { return true; @@ -1410,7 +1424,8 @@ fn child_matches_parent_payload_preference( false } else { // Equal weights (or current-slot parent): tiebreaker per spec. - parent_v29.payload_tiebreak.is_timely && parent_v29.payload_tiebreak.is_data_available + is_payload_timely(&parent_v29.payload_timeliness_votes, ptc_size) + && is_payload_data_available(&parent_v29.payload_data_availability_votes, ptc_size) }; if prefers_full { child_v29.parent_payload_status == PayloadStatus::Full @@ -1419,6 +1434,26 @@ fn child_matches_parent_payload_preference( } } +/// Count the number of set bits in a byte-slice bitfield. +pub fn count_set_bits(bitfield: &[u8]) -> usize { + bitfield.iter().map(|b| b.count_ones() as usize).sum() +} + +/// Create a zero-initialized bitfield for the given PTC size. +pub fn empty_ptc_bitfield(ptc_size: usize) -> Vec { + vec![0u8; ptc_size.div_ceil(8)] +} + +/// Derive `is_payload_timely` from the timeliness vote bitfield. +pub fn is_payload_timely(timeliness_votes: &[u8], ptc_size: usize) -> bool { + count_set_bits(timeliness_votes) > ptc_size / 2 +} + +/// Derive `is_payload_data_available` from the data availability vote bitfield. +pub fn is_payload_data_available(availability_votes: &[u8], ptc_size: usize) -> bool { + count_set_bits(availability_votes) > ptc_size / 2 +} + /// A helper method to calculate the proposer boost based on the given `justified_balances`. /// /// https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 66f36274830..021d62e63f9 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -3,7 +3,7 @@ use crate::{ error::Error, proto_array::{ InvalidationOperation, Iter, NodeDelta, ProposerBoost, ProtoArray, ProtoNode, - calculate_committee_fraction, + calculate_committee_fraction, is_payload_data_available, is_payload_timely, }, ssz_container::SszContainer, }; @@ -23,8 +23,6 @@ use types::{ pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; #[derive(Default, PartialEq, Clone, Encode, Decode)] -// FIXME(sproul): the "next" naming here is a bit odd -// FIXME(sproul): version this type? pub struct VoteTracker { current_root: Hash256, next_root: Hash256, @@ -32,16 +30,12 @@ pub struct VoteTracker { next_slot: Slot, current_payload_present: bool, next_payload_present: bool, - current_blob_data_available: bool, - next_blob_data_available: bool, } -// FIXME(sproul): version this type pub struct LatestMessage { pub slot: Slot, pub root: Hash256, pub payload_present: bool, - pub blob_data_available: bool, } /// Represents the verification status of an execution payload pre-Gloas. @@ -535,28 +529,53 @@ impl ProtoArrayForkChoice { if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; vote.next_slot = attestation_slot; - vote.next_payload_present = false; - vote.next_blob_data_available = false; } Ok(()) } + /// Process a PTC vote by setting the appropriate bits on the target block's V29 node. + /// + /// `ptc_index` is the voter's position in the PTC committee (resolved by the caller). + /// This writes directly to the node's bitfields, bypassing the delta pipeline. pub fn process_payload_attestation( &mut self, - validator_index: usize, block_root: Hash256, - attestation_slot: Slot, + ptc_index: usize, payload_present: bool, blob_data_available: bool, ) -> Result<(), String> { - let vote = self.votes.get_mut(validator_index); - - if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { - vote.next_root = block_root; - vote.next_slot = attestation_slot; - vote.next_payload_present = payload_present; - vote.next_blob_data_available = blob_data_available; + let node_index = self + .proto_array + .indices + .get(&block_root) + .copied() + .ok_or_else(|| { + format!("process_payload_attestation: unknown block root {block_root:?}") + })?; + let node = self.proto_array.nodes.get_mut(node_index).ok_or_else(|| { + format!("process_payload_attestation: invalid node index {node_index}") + })?; + let v29 = node + .as_v29_mut() + .map_err(|_| format!("process_payload_attestation: node {block_root:?} is not V29"))?; + + let byte_index = ptc_index / 8; + let bit_mask = 1u8 << (ptc_index % 8); + + if let Some(byte) = v29.payload_timeliness_votes.get_mut(byte_index) { + if payload_present { + *byte |= bit_mask; + } else { + *byte &= !bit_mask; + } + } + if let Some(byte) = v29.payload_data_availability_votes.get_mut(byte_index) { + if blob_data_available { + *byte |= bit_mask; + } else { + *byte &= !bit_mask; + } } Ok(()) @@ -978,14 +997,16 @@ impl ProtoArrayForkChoice { /// On ties, consult the node's runtime `payload_tiebreak`: prefer `Full` only when timely and /// data is available, otherwise `Empty`. /// Returns `Empty` otherwise. Returns `None` for V17 nodes. - pub fn head_payload_status(&self, head_root: &Hash256) -> Option { + pub fn head_payload_status(&self, head_root: &Hash256) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; if v29.full_payload_weight > v29.empty_payload_weight { Some(PayloadStatus::Full) } else if v29.empty_payload_weight > v29.full_payload_weight { Some(PayloadStatus::Empty) - } else if v29.payload_tiebreak.is_timely && v29.payload_tiebreak.is_data_available { + } else if is_payload_timely(&v29.payload_timeliness_votes, E::ptc_size()) + && is_payload_data_available(&v29.payload_data_availability_votes, E::ptc_size()) + { Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) @@ -1019,7 +1040,6 @@ impl ProtoArrayForkChoice { root: vote.next_root, slot: vote.next_slot, payload_present: vote.next_payload_present, - blob_data_available: vote.next_blob_data_available, }) } } else { @@ -1105,17 +1125,6 @@ fn compute_deltas( new_balances: &[u64], equivocating_indices: &BTreeSet, ) -> Result, Error> { - let merge_payload_tiebreaker = - |delta: &mut NodeDelta, incoming: crate::proto_array::PayloadTiebreak| { - delta.payload_tiebreaker = Some(match delta.payload_tiebreaker { - Some(existing) => crate::proto_array::PayloadTiebreak { - is_timely: existing.is_timely || incoming.is_timely, - is_data_available: existing.is_data_available || incoming.is_data_available, - }, - None => incoming, - }); - }; - let block_slot = |index: usize| -> Result { node_slots .get(index) @@ -1128,7 +1137,6 @@ fn compute_deltas( delta: 0, empty_delta: 0, full_delta: 0, - payload_tiebreaker: None, }; indices.len() ]; @@ -1175,7 +1183,6 @@ fn compute_deltas( vote.current_root = Hash256::zero(); vote.current_slot = Slot::new(0); vote.current_payload_present = false; - vote.current_blob_data_available = false; } // We've handled this slashed validator, continue without applying an ordinary delta. continue; @@ -1233,21 +1240,11 @@ fn compute_deltas( block_slot(next_delta_index)?, ); node_delta.add_payload_delta(status, new_balance, next_delta_index)?; - if status != PayloadStatus::Pending { - merge_payload_tiebreaker( - node_delta, - crate::proto_array::PayloadTiebreak { - is_timely: vote.next_payload_present, - is_data_available: vote.next_blob_data_available, - }, - ); - } } vote.current_root = vote.next_root; vote.current_slot = vote.next_slot; vote.current_payload_present = vote.next_payload_present; - vote.current_blob_data_available = vote.next_blob_data_available; } } @@ -1600,8 +1597,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); old_balances.push(0); new_balances.push(0); @@ -1657,8 +1652,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1721,8 +1714,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1780,8 +1771,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1850,8 +1839,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); // One validator moves their vote from the block to something outside the tree. @@ -1862,8 +1849,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); let deltas = compute_deltas( @@ -1914,8 +1899,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); old_balances.push(OLD_BALANCE); new_balances.push(NEW_BALANCE); @@ -1989,8 +1972,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); } @@ -2051,8 +2032,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); } @@ -2111,8 +2090,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: false, - current_blob_data_available: false, - next_blob_data_available: false, }); } @@ -2176,8 +2153,6 @@ mod test_compute_deltas { next_slot: Slot::new(1), current_payload_present: false, next_payload_present: true, - current_blob_data_available: false, - next_blob_data_available: false, }]); let deltas = compute_deltas( @@ -2210,8 +2185,6 @@ mod test_compute_deltas { next_slot: Slot::new(0), current_payload_present: false, next_payload_present: true, - current_blob_data_available: false, - next_blob_data_available: false, }]); let deltas = compute_deltas( From 0df749f0a206c0de1b86999ea0e8d4d4aaf1c1ce Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 16 Mar 2026 05:53:47 -0400 Subject: [PATCH 13/20] completing `should_extend_payload` implementation --- Cargo.lock | 2 + beacon_node/beacon_chain/src/beacon_chain.rs | 51 +++++- beacon_node/beacon_chain/src/invariants.rs | 4 +- consensus/fork_choice/src/fork_choice.rs | 43 ++++- consensus/proto_array/Cargo.toml | 2 + .../src/fork_choice_test_definition.rs | 45 ++++- .../gloas_payload.rs | 120 +++++++++++++ consensus/proto_array/src/proto_array.rs | 157 +++++++++++------- .../src/proto_array_fork_choice.rs | 53 +++--- 9 files changed, 381 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 653be9351eb..a14aacc0a2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7020,7 +7020,9 @@ dependencies = [ "safe_arith", "serde", "serde_yaml", + "smallvec", "superstruct", + "typenum", "types", ] diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d9a1e46b033..29cd437c43a 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4840,10 +4840,53 @@ impl BeaconChain { // If the current slot is already equal to the proposal slot (or we are in the tail end of // the prior slot), then check the actual weight of the head against the head re-org threshold // and the actual weight of the parent against the parent re-org threshold. + // Per spec `is_head_weak`: uses get_attestation_score(head, PENDING) which is + // the total weight. Per spec `is_parent_strong`: uses + // get_attestation_score(parent, parent_payload_status) where parent_payload_status + // is determined by the head block's relationship to its parent. + let head_weight = info.head_node.weight(); + let parent_weight = if let Ok(head_payload_status) = info.head_node.parent_payload_status() + { + // Post-GLOAS: use the payload-filtered weight matching how the head + // extends from its parent. + match head_payload_status { + proto_array::PayloadStatus::Full => { + info.parent_node.full_payload_weight().map_err(|()| { + Box::new(ProposerHeadError::Error( + Error::ProposerHeadForkChoiceError( + fork_choice::Error::ProtoArrayError( + proto_array::Error::InvalidNodeVariant { + block_root: info.parent_node.root(), + }, + ), + ), + )) + })? + } + proto_array::PayloadStatus::Empty => { + info.parent_node.empty_payload_weight().map_err(|()| { + Box::new(ProposerHeadError::Error( + Error::ProposerHeadForkChoiceError( + fork_choice::Error::ProtoArrayError( + proto_array::Error::InvalidNodeVariant { + block_root: info.parent_node.root(), + }, + ), + ), + )) + })? + } + proto_array::PayloadStatus::Pending => info.parent_node.weight(), + } + } else { + // Pre-GLOAS (V17): use total weight. + info.parent_node.weight() + }; + let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( - info.head_node.weight() < info.re_org_head_weight_threshold, - info.parent_node.weight() > info.re_org_parent_weight_threshold, + head_weight < info.re_org_head_weight_threshold, + parent_weight > info.re_org_parent_weight_threshold, ) } else { (true, true) @@ -4851,7 +4894,7 @@ impl BeaconChain { if !head_weak { return Err(Box::new( DoNotReOrg::HeadNotWeak { - head_weight: info.head_node.weight(), + head_weight, re_org_head_weight_threshold: info.re_org_head_weight_threshold, } .into(), @@ -4860,7 +4903,7 @@ impl BeaconChain { if !parent_strong { return Err(Box::new( DoNotReOrg::ParentNotStrong { - parent_weight: info.parent_node.weight(), + parent_weight, re_org_parent_weight_threshold: info.re_org_parent_weight_threshold, } .into(), diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs index 7bcec7b0b41..b365f37a0aa 100644 --- a/beacon_node/beacon_chain/src/invariants.rs +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -23,9 +23,9 @@ impl BeaconChain { // Only check blocks that are descendants of the finalized checkpoint. // Pruned non-canonical fork blocks may linger in the proto-array but // are legitimately absent from the database. - fc.is_finalized_checkpoint_or_descendant(node.root) + fc.is_finalized_checkpoint_or_descendant(node.root()) }) - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) .collect() }; diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 63220f0bc6b..30c56c97758 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -175,8 +175,13 @@ pub enum InvalidAttestation { /// The attestation is attesting to a state that is later than itself. (Viz., attesting to the /// future). AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// Post-GLOAS: attestation index must be 0 or 1. + InvalidAttestationIndex { index: u64 }, /// A same-slot attestation has a non-zero index, which is invalid post-GLOAS. InvalidSameSlotAttestationIndex { slot: Slot }, + /// Post-GLOAS: attestation with index == 1 (payload_present) requires the block's + /// payload to have been received (`root in store.payload_states`). + PayloadNotReceived { beacon_block_root: Hash256 }, /// A payload attestation votes payload_present for a block in the current slot, which is /// invalid because the payload cannot be known yet. PayloadPresentDuringSameSlot { slot: Slot }, @@ -256,6 +261,8 @@ pub struct QueuedAttestation { attesting_indices: Vec, block_root: Hash256, target_epoch: Epoch, + /// Per GLOAS spec: `payload_present = attestation.data.index == 1`. + payload_present: bool, } impl<'a, E: EthSpec> From> for QueuedAttestation { @@ -265,6 +272,7 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { attesting_indices: a.attesting_indices_to_vec(), block_root: a.data().beacon_block_root, target_epoch: a.data().target.epoch, + payload_present: a.data().index == 1, } } } @@ -1136,15 +1144,34 @@ where }); } - // Post-GLOAS: same-slot attestations must have index == 0. Attestations with - // index != 0 during the same slot as the block are invalid. if spec .fork_name_at_slot::(indexed_attestation.data().slot) .gloas_enabled() - && indexed_attestation.data().slot == block.slot - && indexed_attestation.data().index != 0 { - return Err(InvalidAttestation::InvalidSameSlotAttestationIndex { slot: block.slot }); + let index = indexed_attestation.data().index; + + // Post-GLOAS: attestation index must be 0 or 1. + if index > 1 { + return Err(InvalidAttestation::InvalidAttestationIndex { index }); + } + + // Same-slot attestations must have index == 0. + if indexed_attestation.data().slot == block.slot && index != 0 { + return Err(InvalidAttestation::InvalidSameSlotAttestationIndex { + slot: block.slot, + }); + } + + // index == 1 (payload_present) requires the block's payload to have been received. + if index == 1 + && !self + .proto_array + .is_payload_received(&indexed_attestation.data().beacon_block_root) + { + return Err(InvalidAttestation::PayloadNotReceived { + beacon_block_root: indexed_attestation.data().beacon_block_root, + }); + } } Ok(()) @@ -1245,12 +1272,16 @@ where self.validate_on_attestation(attestation, is_from_block, spec)?; + // Per GLOAS spec: `payload_present = attestation.data.index == 1`. + let payload_present = attestation.data().index == 1; + if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, attestation.data().slot, + payload_present, )?; } } else { @@ -1433,6 +1464,7 @@ where *validator_index as usize, attestation.block_root, attestation.slot, + attestation.payload_present, )?; } } @@ -1850,6 +1882,7 @@ mod tests { attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), + payload_present: false, }) .collect() } diff --git a/consensus/proto_array/Cargo.toml b/consensus/proto_array/Cargo.toml index 782610e0d35..f9c35bb5850 100644 --- a/consensus/proto_array/Cargo.toml +++ b/consensus/proto_array/Cargo.toml @@ -15,5 +15,7 @@ fixed_bytes = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } +smallvec = { workspace = true } superstruct = { workspace = true } +typenum = { workspace = true } types = { workspace = true } diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 45aed23b293..16c7df4ca26 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -8,6 +8,7 @@ use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, Prot use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use std::collections::BTreeSet; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, @@ -96,6 +97,15 @@ pub enum Operation { is_timely: bool, is_data_available: bool, }, + /// Simulate receiving and validating an execution payload for `block_root`. + /// Sets `payload_received = true` on the V29 node via the live validation path. + ProcessExecutionPayload { + block_root: Hash256, + }, + AssertPayloadReceived { + block_root: Hash256, + expected: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -286,7 +296,7 @@ impl ForkChoiceTestDefinition { attestation_slot, } => { fork_choice - .process_attestation(validator_index, block_root, attestation_slot) + .process_attestation(validator_index, block_root, attestation_slot, false) .unwrap_or_else(|_| { panic!( "process_attestation op at index {} returned error", @@ -494,9 +504,38 @@ impl ForkChoiceTestDefinition { }); // Set all bits (exceeds any threshold) or clear all bits. let fill = if is_timely { 0xFF } else { 0x00 }; - node_v29.payload_timeliness_votes.fill(fill); + node_v29.payload_timeliness_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); let fill = if is_data_available { 0xFF } else { 0x00 }; - node_v29.payload_data_availability_votes.fill(fill); + node_v29.payload_data_availability_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); + // Per spec, is_payload_timely/is_payload_data_available require + // the payload to be in payload_states (payload_received). + node_v29.payload_received = is_timely || is_data_available; + } + Operation::ProcessExecutionPayload { block_root } => { + fork_choice + .on_execution_payload(block_root) + .unwrap_or_else(|e| { + panic!( + "on_execution_payload op at index {} returned error: {}", + op_index, e + ) + }); + check_bytes_round_trip(&fork_choice); + } + Operation::AssertPayloadReceived { + block_root, + expected, + } => { + let actual = fork_choice.is_payload_received(&block_root); + assert_eq!( + actual, expected, + "payload_received mismatch at op index {}", + op_index + ); } } } 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 9a0043a467b..84e2878d32f 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 @@ -571,6 +571,120 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef } } +/// Test interleaving of blocks, payload validation, and attestations. +/// +/// Scenario: +/// - Genesis block (slot 0) +/// - Block 1 (slot 1) extends genesis, Full chain +/// - Block 2 (slot 1) extends genesis, Empty chain +/// - Before payload arrives: payload_received is false for block 1 +/// - Process execution payload for block 1 → payload_received becomes true +/// - Payload attestations arrive voting block 1's payload as timely + available +/// - Head should follow block 1 because the PTC votes now count (payload_received = true) +pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1: extends genesis Full chain. + 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)), + }); + + // Block 2 at slot 1: extends genesis Empty chain (parent_hash doesn't match genesis EL hash). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + 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(99)), + execution_payload_block_hash: Some(get_hash(100)), + }); + + // Both children have parent_payload_status set correctly. + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + }); + + // Before payload arrives: payload_received is false on genesis. + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(0), + expected: false, + }); + + // Give one vote to each child so they have equal weight. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(1), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(2), + attestation_slot: Slot::new(1), + }); + + // Equal weight, no payload received on genesis → tiebreaker uses PTC votes which + // require payload_received. Without it, is_payload_timely returns false → prefers Empty. + // Block 2 (Empty) wins because it matches the Empty preference. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(2), + current_slot: Slot::new(100), + }); + + // Now the execution payload for genesis arrives and is validated. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(0), + }); + + // payload_received is now true. + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(0), + expected: true, + }); + + // Set PTC votes on genesis as timely + data available (simulates PTC voting). + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + + // Now with payload_received=true and PTC votes exceeding threshold: + // is_payload_timely=true, is_payload_data_available=true → prefers Full. + // Block 1 (Full) wins because it matches the Full preference. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(100), + }); + + 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::*; @@ -610,4 +724,10 @@ mod tests { let test = get_gloas_interleaved_attestations_test_definition(); test.run(); } + + #[test] + fn payload_received_interleaving() { + let test = get_gloas_payload_received_interleaving_test_definition(); + test.run(); + } } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 4f89e6084f6..908d3914016 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -2,11 +2,13 @@ use crate::error::InvalidBestNodeInfo; use crate::{Block, ExecutionStatus, JustifiedBalances, PayloadStatus, error::Error}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use ssz::Encode; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; use std::collections::{HashMap, HashSet}; use superstruct::superstruct; +use typenum::U512; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, Slot, @@ -131,14 +133,20 @@ pub struct ProtoNode { pub execution_payload_block_hash: ExecutionBlockHash, /// PTC timeliness vote bitfield, indexed by PTC committee position. /// Bit i set means PTC member i voted `payload_present = true`. - /// Tiebreak derived as: `count_ones() > ptc_size / 2`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. #[superstruct(only(V29))] - pub payload_timeliness_votes: Vec, + pub payload_timeliness_votes: BitVector, /// PTC data availability vote bitfield, indexed by PTC committee position. /// Bit i set means PTC member i voted `blob_data_available = true`. - /// Tiebreak derived as: `count_ones() > ptc_size / 2`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. #[superstruct(only(V29))] - pub payload_data_availability_votes: Vec, + pub payload_data_availability_votes: BitVector, + /// Whether the execution payload for this block has been received and validated locally. + /// Maps to `root in store.payload_states` in the spec. + /// When true, `is_payload_timely` and `is_payload_data_available` return true + /// regardless of PTC vote counts. + #[superstruct(only(V29), partial_getter(copy))] + pub payload_received: bool, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -385,26 +393,18 @@ impl ProtoArray { .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; - // Per spec's `is_supporting_vote`: a vote for descendant B supports - // ancestor A's payload status based on B's `parent_payload_status`. - // Route the child's *total* weight delta to the parent's appropriate - // payload bucket. - match node.parent_payload_status() { - Ok(PayloadStatus::Full) => { - parent_delta.full_delta = parent_delta - .full_delta - .checked_add(delta) - .ok_or(Error::DeltaOverflow(parent_index))?; - } - Ok(PayloadStatus::Empty) => { - parent_delta.empty_delta = parent_delta - .empty_delta - .checked_add(delta) - .ok_or(Error::DeltaOverflow(parent_index))?; - } - // Pending or V17 nodes: no payload propagation. - _ => {} - } + // Per spec's `is_supporting_vote`: a vote supports a parent's + // FULL/EMPTY virtual node based on the voter's `payload_present` + // flag, NOT based on which child the vote goes through. + // Propagate each child's full/empty deltas independently. + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(node_full_delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + parent_delta.empty_delta = parent_delta + .empty_delta + .checked_add(node_empty_delta) + .ok_or(Error::DeltaOverflow(parent_index))?; } } @@ -540,8 +540,9 @@ impl ProtoArray { empty_payload_weight: 0, full_payload_weight: 0, execution_payload_block_hash, - payload_timeliness_votes: empty_ptc_bitfield(E::ptc_size()), - payload_data_availability_votes: empty_ptc_bitfield(E::ptc_size()), + payload_timeliness_votes: BitVector::default(), + payload_data_availability_votes: BitVector::default(), + payload_received: false, }) }; @@ -584,9 +585,11 @@ impl ProtoArray { Ok(()) } - /// Process an excution payload for a Gloas block. + /// Process an execution payload for a Gloas block. /// - /// this function assumes the + /// Sets `payload_received` to true, which makes `is_payload_timely` and + /// `is_payload_data_available` return true regardless of PTC votes. + /// This maps to `store.payload_states[root] = state` in the spec. pub fn on_valid_execution_payload(&mut self, block_root: Hash256) -> Result<(), Error> { let index = *self .indices @@ -599,10 +602,7 @@ impl ProtoArray { let v29 = node .as_v29_mut() .map_err(|_| Error::InvalidNodeVariant { block_root })?; - // A valid execution payload means the payload is timely and data is available. - // Set all bits to ensure the threshold is met regardless of PTC size. - v29.payload_timeliness_votes.fill(0xFF); - v29.payload_data_availability_votes.fill(0xFF); + v29.payload_received = true; Ok(()) } @@ -669,8 +669,13 @@ impl ProtoArray { }); } }, - // Gloas nodes don't carry `ExecutionStatus`. + // Gloas nodes don't carry `ExecutionStatus`. Mark the validated + // block as payload-received so that `is_payload_timely` / + // `is_payload_data_available` and `index == 1` attestations work. ProtoNode::V29(node) => { + if index == verified_node_index { + node.payload_received = true; + } if let Some(parent_index) = node.parent { parent_index } else { @@ -1057,6 +1062,22 @@ impl ProtoArray { best_finalized_checkpoint, )?; + // Per spec `should_extend_payload`: if the proposer-boosted block is a child of + // this parent and extends Empty, force Empty preference regardless of + // weights/tiebreaker. + let proposer_boost_root = self.previous_proposer_boost.root; + let proposer_boost = !proposer_boost_root.is_zero() + && self + .indices + .get(&proposer_boost_root) + .and_then(|&idx| self.nodes.get(idx)) + .is_some_and(|boost_node| { + boost_node.parent() == Some(parent_index) + && boost_node + .parent_payload_status() + .map_or(false, |s| s != PayloadStatus::Full) + }); + // These three variables are aliases to the three options that we may set the // `parent.best_child` and `parent.best_descendant` to. // @@ -1112,12 +1133,14 @@ impl ProtoArray { child, current_slot, E::ptc_size(), + proposer_boost, ); let best_child_matches = child_matches_parent_payload_preference( parent, best_child, current_slot, E::ptc_size(), + proposer_boost, ); if child_matches && !best_child_matches { @@ -1390,27 +1413,30 @@ impl ProtoArray { } /// For V29 parents, returns `true` if the child's `parent_payload_status` matches the parent's -/// preferred payload status. When full and empty weights are unequal, the higher weight wins. -/// When equal, the tiebreaker uses the parent's `payload_tiebreak`: prefer Full if the block -/// was timely and data is available; otherwise prefer Empty. -/// For V17 parents (or mixed), always returns `true` (no payload preference). +/// preferred payload status per spec `should_extend_payload`. /// -/// TODO(gloas): the spec's `should_extend_payload` has additional conditions beyond the -/// tiebreaker: it also checks proposer_boost_root (empty, different parent, or extends full). -/// See: https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md#new-should_extend_payload +/// If `proposer_boost` is set, the parent unconditionally prefers Empty (the proposer-boosted +/// block is a child of this parent and extends Empty). Otherwise, when full and empty weights +/// are unequal the higher weight wins; when equal, the tiebreaker uses PTC votes. /// -/// TODO(gloas): the spec's `should_extend_payload` has additional conditions beyond the -/// tiebreaker: it also checks proposer_boost_root (empty, different parent, or extends full). -/// See: https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md#new-should_extend_payload +/// For V17 parents (or mixed), always returns `true` (no payload preference). fn child_matches_parent_payload_preference( parent: &ProtoNode, child: &ProtoNode, current_slot: Slot, ptc_size: usize, + proposer_boost: bool, ) -> bool { let (Ok(parent_v29), Ok(child_v29)) = (parent.as_v29(), child.as_v29()) else { return true; }; + + // Per spec `should_extend_payload`: if the proposer-boosted block extends Empty from + // this parent, unconditionally prefer Empty. + if proposer_boost { + return child_v29.parent_payload_status == PayloadStatus::Empty; + } + // Per spec `get_weight`: FULL/EMPTY virtual nodes at `current_slot - 1` have weight 0. // The PTC is still voting, so payload preference is determined solely by the tiebreaker. let use_tiebreaker_only = parent.slot() + 1 == current_slot; @@ -1424,8 +1450,15 @@ fn child_matches_parent_payload_preference( false } else { // Equal weights (or current-slot parent): tiebreaker per spec. - is_payload_timely(&parent_v29.payload_timeliness_votes, ptc_size) - && is_payload_data_available(&parent_v29.payload_data_availability_votes, ptc_size) + is_payload_timely( + &parent_v29.payload_timeliness_votes, + ptc_size, + parent_v29.payload_received, + ) && is_payload_data_available( + &parent_v29.payload_data_availability_votes, + ptc_size, + parent_v29.payload_received, + ) }; if prefers_full { child_v29.parent_payload_status == PayloadStatus::Full @@ -1434,24 +1467,30 @@ fn child_matches_parent_payload_preference( } } -/// Count the number of set bits in a byte-slice bitfield. -pub fn count_set_bits(bitfield: &[u8]) -> usize { - bitfield.iter().map(|b| b.count_ones() as usize).sum() -} - -/// Create a zero-initialized bitfield for the given PTC size. -pub fn empty_ptc_bitfield(ptc_size: usize) -> Vec { - vec![0u8; ptc_size.div_ceil(8)] -} - /// Derive `is_payload_timely` from the timeliness vote bitfield. -pub fn is_payload_timely(timeliness_votes: &[u8], ptc_size: usize) -> bool { - count_set_bits(timeliness_votes) > ptc_size / 2 +/// +/// Per spec: returns false if the payload has not been received locally +/// (`payload_received == false`, i.e. `root not in store.payload_states`), +/// regardless of PTC votes. Both local receipt and PTC threshold are required. +pub fn is_payload_timely( + timeliness_votes: &BitVector, + ptc_size: usize, + payload_received: bool, +) -> bool { + payload_received && timeliness_votes.num_set_bits() > ptc_size / 2 } /// Derive `is_payload_data_available` from the data availability vote bitfield. -pub fn is_payload_data_available(availability_votes: &[u8], ptc_size: usize) -> bool { - count_set_bits(availability_votes) > ptc_size / 2 +/// +/// Per spec: returns false if the payload has not been received locally +/// (`payload_received == false`, i.e. `root not in store.payload_states`), +/// regardless of PTC votes. Both local receipt and PTC threshold are required. +pub fn is_payload_data_available( + availability_votes: &BitVector, + ptc_size: usize, + payload_received: bool, +) -> bool { + payload_received && availability_votes.num_set_bits() > ptc_size / 2 } /// A helper method to calculate the proposer boost based on the given `justified_balances`. diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 021d62e63f9..e1b8c43ff16 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -63,9 +63,9 @@ pub enum ExecutionStatus { #[ssz(enum_behaviour = "tag")] #[repr(u8)] pub enum PayloadStatus { - Pending = 0, - Empty = 1, - Full = 2, + Empty = 0, + Full = 1, + Pending = 2, } impl ExecutionStatus { @@ -523,12 +523,14 @@ impl ProtoArrayForkChoice { validator_index: usize, block_root: Hash256, attestation_slot: Slot, + payload_present: bool, ) -> Result<(), String> { let vote = self.votes.get_mut(validator_index); if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; vote.next_slot = attestation_slot; + vote.next_payload_present = payload_present; } Ok(()) @@ -560,23 +562,14 @@ impl ProtoArrayForkChoice { .as_v29_mut() .map_err(|_| format!("process_payload_attestation: node {block_root:?} is not V29"))?; - let byte_index = ptc_index / 8; - let bit_mask = 1u8 << (ptc_index % 8); - - if let Some(byte) = v29.payload_timeliness_votes.get_mut(byte_index) { - if payload_present { - *byte |= bit_mask; - } else { - *byte &= !bit_mask; - } - } - if let Some(byte) = v29.payload_data_availability_votes.get_mut(byte_index) { - if blob_data_available { - *byte |= bit_mask; - } else { - *byte &= !bit_mask; - } - } + v29.payload_timeliness_votes + .set(ptc_index, payload_present) + .map_err(|e| format!("process_payload_attestation: timeliness set failed: {e:?}"))?; + v29.payload_data_availability_votes + .set(ptc_index, blob_data_available) + .map_err(|e| { + format!("process_payload_attestation: data availability set failed: {e:?}") + })?; Ok(()) } @@ -981,6 +974,14 @@ impl ProtoArrayForkChoice { block.execution_status().ok() } + /// Returns whether the execution payload for a block has been received. + /// Returns `false` for pre-GLOAS (V17) nodes or unknown blocks. + pub fn is_payload_received(&self, block_root: &Hash256) -> bool { + self.get_proto_node(block_root) + .and_then(|node| node.payload_received().ok()) + .unwrap_or(false) + } + /// 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)?; @@ -1004,9 +1005,15 @@ impl ProtoArrayForkChoice { Some(PayloadStatus::Full) } else if v29.empty_payload_weight > v29.full_payload_weight { Some(PayloadStatus::Empty) - } else if is_payload_timely(&v29.payload_timeliness_votes, E::ptc_size()) - && is_payload_data_available(&v29.payload_data_availability_votes, E::ptc_size()) - { + } else if is_payload_timely( + &v29.payload_timeliness_votes, + E::ptc_size(), + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + E::ptc_size(), + v29.payload_received, + ) { Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) From 916d9fb018613f3e6caac67b85c15541f935bc36 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 16 Mar 2026 07:00:51 -0400 Subject: [PATCH 14/20] changes --- .../beacon_chain/src/block_verification.rs | 13 +++--- .../src/fork_choice_test_definition.rs | 7 +++- .../gloas_payload.rs | 3 ++ consensus/proto_array/src/proto_array.rs | 8 +++- .../src/proto_array_fork_choice.rs | 42 ++++++++++++------- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index fe66b2f8d6d..a452d528a12 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1961,12 +1961,13 @@ fn load_parent>( { if block.as_block().is_parent_block_full(parent_bid_block_hash) { // TODO(gloas): loading the envelope here is not very efficient - let envelope = chain.store.get_payload_envelope(&root)?.ok_or_else(|| { - BeaconChainError::DBInconsistent(format!( - "Missing envelope for parent block {root:?}", - )) - })?; - (StatePayloadStatus::Full, envelope.message.state_root) + if let Some(envelope) = chain.store.get_payload_envelope(&root)? { + (StatePayloadStatus::Full, envelope.message.state_root) + } else { + // The envelope hasn't been stored yet (e.g. genesis block, or payload + // not yet delivered). Fall back to the pending/empty state. + (StatePayloadStatus::Pending, parent_block.state_root()) + } } else { (StatePayloadStatus::Pending, parent_block.state_root()) } diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 16c7df4ca26..b36e9c21170 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -91,6 +91,7 @@ pub enum Operation { AssertHeadPayloadStatus { head_root: Hash256, expected_status: PayloadStatus, + current_slot: Slot, }, SetPayloadTiebreak { block_root: Hash256, @@ -456,9 +457,13 @@ impl ForkChoiceTestDefinition { Operation::AssertHeadPayloadStatus { head_root, expected_status, + current_slot, } => { let actual = fork_choice - .head_payload_status::(&head_root) + .head_payload_status::( + &head_root, + current_slot, + ) .unwrap_or_else(|| { panic!( "AssertHeadPayloadStatus: head root not found at op index {}", 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 84e2878d32f..e19fb196f26 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 @@ -145,6 +145,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(1), expected_status: PayloadStatus::Empty, + current_slot: Slot::new(0), }); // Flip validator 0 to Empty; both bits now clear. @@ -170,6 +171,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(1), expected_status: PayloadStatus::Empty, + current_slot: Slot::new(0), }); // Same-slot attestation to a new head candidate should be Pending (no payload bucket change). @@ -204,6 +206,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::AssertHeadPayloadStatus { head_root: get_root(5), expected_status: PayloadStatus::Empty, + current_slot: Slot::new(0), }); ForkChoiceTestDefinition { diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 908d3914016..5a0f49e64de 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1448,8 +1448,8 @@ fn child_matches_parent_payload_preference( && parent_v29.empty_payload_weight > parent_v29.full_payload_weight { false - } else { - // Equal weights (or current-slot parent): tiebreaker per spec. + } else if use_tiebreaker_only { + // Previous slot: should_extend_payload = is_payload_timely && is_payload_data_available. is_payload_timely( &parent_v29.payload_timeliness_votes, ptc_size, @@ -1459,6 +1459,10 @@ fn child_matches_parent_payload_preference( ptc_size, parent_v29.payload_received, ) + } else { + // Not previous slot: should_extend_payload = true. + // Full wins the tiebreaker (1 > 0) when the payload has been received. + parent_v29.payload_received }; if prefers_full { child_v29.parent_payload_status == PayloadStatus::Full diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index e1b8c43ff16..b50db01561f 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -991,29 +991,43 @@ impl ProtoArrayForkChoice { .map(|node| node.weight()) } - /// Returns the payload status of the head node based on accumulated weights. + /// Returns the payload status of the head node based on accumulated weights and tiebreaker. /// /// Returns `Full` if `full_payload_weight > empty_payload_weight`. /// Returns `Empty` if `empty_payload_weight > full_payload_weight`. - /// On ties, consult the node's runtime `payload_tiebreak`: prefer `Full` only when timely and - /// data is available, otherwise `Empty`. - /// Returns `Empty` otherwise. Returns `None` for V17 nodes. - pub fn head_payload_status(&self, head_root: &Hash256) -> Option { + /// On ties: + /// - Previous slot (`slot + 1 == current_slot`): prefer Full only when timely and + /// data available (per `should_extend_payload`). + /// - Otherwise: prefer Full when payload has been received. + /// Returns `None` for V17 nodes. + pub fn head_payload_status( + &self, + head_root: &Hash256, + current_slot: Slot, + ) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; if v29.full_payload_weight > v29.empty_payload_weight { Some(PayloadStatus::Full) } else if v29.empty_payload_weight > v29.full_payload_weight { Some(PayloadStatus::Empty) - } else if is_payload_timely( - &v29.payload_timeliness_votes, - E::ptc_size(), - v29.payload_received, - ) && is_payload_data_available( - &v29.payload_data_availability_votes, - E::ptc_size(), - v29.payload_received, - ) { + } else if node.slot() + 1 == current_slot { + // Previous slot: should_extend_payload = is_payload_timely && is_payload_data_available + if is_payload_timely( + &v29.payload_timeliness_votes, + E::ptc_size(), + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + E::ptc_size(), + v29.payload_received, + ) { + Some(PayloadStatus::Full) + } else { + Some(PayloadStatus::Empty) + } + } else if v29.payload_received { + // Not previous slot: Full wins tiebreaker (1 > 0) when payload received. Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) From 9ce88ea3c12c2256a3bfe2805621d35995a72926 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 16 Mar 2026 19:36:48 -0400 Subject: [PATCH 15/20] addressing comments: --- consensus/proto_array/src/error.rs | 1 - .../src/fork_choice_test_definition.rs | 5 +-- consensus/proto_array/src/proto_array.rs | 43 ++++++++++++++++--- .../src/proto_array_fork_choice.rs | 1 + 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index d6bd7f2cbfa..04e747f5f6f 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -54,7 +54,6 @@ pub enum Error { }, InvalidEpochOffset(u64), Arith(ArithError), - GloasNotImplemented, InvalidNodeVariant { block_root: Hash256, }, diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index b36e9c21170..7f607c826fe 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -460,10 +460,7 @@ impl ForkChoiceTestDefinition { current_slot, } => { let actual = fork_choice - .head_payload_status::( - &head_root, - current_slot, - ) + .head_payload_status::(&head_root, current_slot) .unwrap_or_else(|| { panic!( "AssertHeadPayloadStatus: head root not found at op index {}", diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 5a0f49e64de..09538f25eb0 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -164,6 +164,22 @@ impl Default for ProposerBoost { } } +/// Accumulated score changes for a single proto-array node during a `find_head` pass. +/// +/// `delta` tracks the ordinary LMD-GHOST balance change applied to the concrete block node. +/// This is the same notion of weight that pre-GLOAS fork choice used. +/// +/// Under GLOAS we also need to track how votes contribute to the parent's virtual payload +/// branches: +/// +/// - `empty_delta` is the balance change attributable to votes that support the `Empty` payload +/// interpretation of the node +/// - `full_delta` is the balance change attributable to votes that support the `Full` payload +/// interpretation of the node +/// +/// Votes in `Pending` state only affect `delta`; they do not contribute to either payload bucket. +/// During score application these payload deltas are propagated independently up the tree so that +/// ancestors can compare children using payload-aware tie breaking. #[derive(Clone, PartialEq, Debug, Copy)] pub struct NodeDelta { pub delta: i64, @@ -172,8 +188,16 @@ pub struct NodeDelta { } impl NodeDelta { - /// Determine the payload bucket for a vote based on whether the vote's slot matches the - /// block's slot (Pending), or the vote's `payload_present` flag (Full/Empty). + /// Classify a vote into the payload bucket it contributes to for `block_slot`. + /// + /// Per the GLOAS model: + /// + /// - a same-slot vote is `Pending` + /// - a later vote with `payload_present = true` is `Full` + /// - a later vote with `payload_present = false` is `Empty` + /// + /// This classification is used only for payload-aware accounting; all votes still contribute to + /// the aggregate `delta`. pub fn payload_status( vote_slot: Slot, payload_present: bool, @@ -188,7 +212,9 @@ impl NodeDelta { } } - /// Add a balance to the appropriate payload status. + /// Add `balance` to the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. pub fn add_payload_delta( &mut self, status: PayloadStatus, @@ -206,7 +232,10 @@ impl NodeDelta { Ok(()) } - /// Create a delta that only affects the aggregate `delta` field. + /// Create a delta that only affects the aggregate block weight. + /// + /// This is useful for callers or tests that only care about ordinary LMD-GHOST weight changes + /// and do not need payload-aware accounting. pub fn from_delta(delta: i64) -> Self { Self { delta, @@ -215,7 +244,9 @@ impl NodeDelta { } } - /// Subtract a balance from the appropriate payload status. + /// Subtract `balance` from the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. pub fn sub_payload_delta( &mut self, status: PayloadStatus, @@ -1075,7 +1106,7 @@ impl ProtoArray { boost_node.parent() == Some(parent_index) && boost_node .parent_payload_status() - .map_or(false, |s| s != PayloadStatus::Full) + .is_ok_and(|s| s != PayloadStatus::Full) }); // These three variables are aliases to the three options that we may set the diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index b50db01561f..ce634fbdbeb 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -999,6 +999,7 @@ impl ProtoArrayForkChoice { /// - Previous slot (`slot + 1 == current_slot`): prefer Full only when timely and /// data available (per `should_extend_payload`). /// - Otherwise: prefer Full when payload has been received. + /// /// Returns `None` for V17 nodes. pub fn head_payload_status( &self, From a7bcf0f07edf82301b6eacfeae3e1fe181ca64ca Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Tue, 17 Mar 2026 01:49:40 -0400 Subject: [PATCH 16/20] enable ef tests @brech1 commit Co-authored-by: Co-author hopinheimer Co-authored-by: Co-author brech1 <11075677+brech1@users.noreply.github.com> --- .../gloas_payload.rs | 52 ++++++++--- consensus/proto_array/src/proto_array.rs | 75 ++++++++-------- .../src/proto_array_fork_choice.rs | 28 +++--- testing/ef_tests/Makefile | 2 +- testing/ef_tests/src/cases/fork_choice.rs | 86 ++++++++++++++++++- testing/ef_tests/src/handler.rs | 23 +++-- testing/ef_tests/tests/tests.rs | 6 ++ 7 files changed, 199 insertions(+), 73 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 e19fb196f26..8dcf538bd42 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 @@ -51,6 +51,12 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + ops.push(Operation::AssertParentPayloadStatus { block_root: get_root(1), expected_status: PayloadStatus::Full, @@ -111,6 +117,12 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(1)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, // which defaults to Empty unless timely+data-available evidence is set. ops.push(Operation::ProcessPayloadAttestation { @@ -263,6 +275,12 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Equal branch weights: tiebreak FULL picks branch rooted at 3. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -359,6 +377,12 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Parent prefers Full on equal branch weights (tiebreaker). ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -521,6 +545,12 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -619,10 +649,11 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe expected_status: PayloadStatus::Empty, }); - // Before payload arrives: payload_received is false on genesis. + // Per spec `get_forkchoice_store`: genesis starts with payload_received=true + // (anchor block is in `payload_states`). ops.push(Operation::AssertPayloadReceived { block_root: get_root(0), - expected: false, + expected: true, }); // Give one vote to each child so they have equal weight. @@ -637,38 +668,37 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe attestation_slot: Slot::new(1), }); - // Equal weight, no payload received on genesis → tiebreaker uses PTC votes which - // require payload_received. Without it, is_payload_timely returns false → prefers Empty. - // Block 2 (Empty) wins because it matches the Empty preference. + // Equal weight, payload_received=true on genesis → tiebreaker uses + // payload_received (not previous slot, equal payload weights) → prefers Full. + // Block 1 (Full) wins because it matches the Full preference. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], - expected_head: get_root(2), + expected_head: get_root(1), current_slot: Slot::new(100), }); - // Now the execution payload for genesis arrives and is validated. + // ProcessExecutionPayload on genesis is a no-op (already received at init). ops.push(Operation::ProcessExecutionPayload { block_root: get_root(0), }); - // payload_received is now true. ops.push(Operation::AssertPayloadReceived { block_root: get_root(0), expected: true, }); // Set PTC votes on genesis as timely + data available (simulates PTC voting). + // This doesn't change the preference since genesis is not the previous slot + // (slot 0 + 1 != current_slot 100). ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: true, is_data_available: true, }); - // Now with payload_received=true and PTC votes exceeding threshold: - // is_payload_timely=true, is_payload_data_available=true → prefers Full. - // Block 1 (Full) wins because it matches the Full preference. + // Still prefers Full via payload_received tiebreaker → Block 1 (Full) wins. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 09538f25eb0..1b6c0c58bc6 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -552,6 +552,13 @@ impl ProtoArray { PayloadStatus::Full }; + // Per spec `get_forkchoice_store`: the anchor (genesis) block has + // its payload state initialized (`payload_states = {anchor_root: ...}`). + // Without `payload_received = true` on genesis, the FULL virtual + // child doesn't exist in the spec's `get_node_children`, making all + // Full concrete children of genesis unreachable in `get_head`. + let is_genesis = parent_index.is_none(); + ProtoNode::V29(ProtoNodeV29 { slot: block.slot, root: block.root, @@ -573,7 +580,7 @@ impl ProtoArray { execution_payload_block_hash, payload_timeliness_votes: BitVector::default(), payload_data_availability_votes: BitVector::default(), - payload_received: false, + payload_received: is_genesis, }) }; @@ -1120,6 +1127,18 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); + // For V29 (GLOAS) parents, the spec's virtual tree model requires choosing + // FULL or EMPTY direction at each node BEFORE considering concrete children. + // Only children whose parent_payload_status matches the preferred direction + // are eligible for best_child. This is PRIMARY, not a tiebreaker. + let child_matches_dir = child_matches_parent_payload_preference( + parent, + child, + current_slot, + E::ptc_size(), + proposer_boost, + ); + let (new_best_child, new_best_descendant) = if let Some(best_child_index) = parent.best_child() { if best_child_index == child_index && !child_leads_to_viable_head { @@ -1143,6 +1162,14 @@ impl ProtoArray { best_finalized_checkpoint, )?; + let best_child_matches_dir = child_matches_parent_payload_preference( + parent, + best_child, + current_slot, + E::ptc_size(), + proposer_boost, + ); + if child_leads_to_viable_head && !best_child_leads_to_viable_head { // The child leads to a viable head, but the current best-child doesn't. change_to_child @@ -1150,49 +1177,27 @@ impl ProtoArray { // The best child leads to a viable head, but the child doesn't. no_change } else if child.weight() > best_child.weight() { - // Weight is the primary ordering criterion. + // Weight is the primary selector after viability. change_to_child } else if child.weight() < best_child.weight() { no_change + } else if child_matches_dir && !best_child_matches_dir { + // Equal weight: direction matching is the tiebreaker. + change_to_child + } else if !child_matches_dir && best_child_matches_dir { + no_change + } else if *child.root() >= *best_child.root() { + // Final tie-breaker: break by root hash. + change_to_child } else { - // Equal weights: for V29 parents, prefer the child whose - // parent_payload_status matches the parent's payload preference - // (full vs empty). This corresponds to the spec's - // `get_payload_status_tiebreaker` ordering in `get_head`. - let child_matches = child_matches_parent_payload_preference( - parent, - child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - let best_child_matches = child_matches_parent_payload_preference( - parent, - best_child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - - if child_matches && !best_child_matches { - // Child extends the preferred payload chain, best_child doesn't. - change_to_child - } else if !child_matches && best_child_matches { - // Best child extends the preferred payload chain, child doesn't. - no_change - } else if *child.root() >= *best_child.root() { - // Final tie-breaker: both match or both don't, break by root. - change_to_child - } else { - no_change - } + no_change } } } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. + // No current best-child: set if child is viable. change_to_child } else { - // There is no current best-child but the child is not viable. + // Child is not viable. no_change }; diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index ce634fbdbeb..4f5fe45c220 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1004,7 +1004,7 @@ impl ProtoArrayForkChoice { pub fn head_payload_status( &self, head_root: &Hash256, - current_slot: Slot, + _current_slot: Slot, ) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; @@ -1012,23 +1012,15 @@ impl ProtoArrayForkChoice { Some(PayloadStatus::Full) } else if v29.empty_payload_weight > v29.full_payload_weight { Some(PayloadStatus::Empty) - } else if node.slot() + 1 == current_slot { - // Previous slot: should_extend_payload = is_payload_timely && is_payload_data_available - if is_payload_timely( - &v29.payload_timeliness_votes, - E::ptc_size(), - v29.payload_received, - ) && is_payload_data_available( - &v29.payload_data_availability_votes, - E::ptc_size(), - v29.payload_received, - ) { - Some(PayloadStatus::Full) - } else { - Some(PayloadStatus::Empty) - } - } else if v29.payload_received { - // Not previous slot: Full wins tiebreaker (1 > 0) when payload received. + } else if is_payload_timely( + &v29.payload_timeliness_votes, + E::ptc_size(), + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + E::ptc_size(), + v29.payload_received, + ) { Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index fd8a3f6da0f..48378a4c958 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.2 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.3 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 11b2df01238..054c65d0169 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -30,7 +30,8 @@ use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, - KzgProof, ProposerPreparationData, SignedBeaconBlock, Slot, Uint256, + KzgProof, ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + Uint256, }; // When set to true, cache any states fetched from the db. @@ -72,6 +73,7 @@ pub struct Checks { proposer_boost_root: Option, get_proposer_head: Option, should_override_forkchoice_update: Option, + head_payload_status: Option, } #[derive(Debug, Clone, Deserialize)] @@ -94,7 +96,15 @@ impl From for PayloadStatusV1 { #[derive(Debug, Clone, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum Step { +pub enum Step< + TBlock, + TBlobs, + TColumns, + TAttestation, + TAttesterSlashing, + TPowBlock, + TExecutionPayload = String, +> { Tick { tick: u64, }, @@ -128,6 +138,10 @@ pub enum Step, valid: bool, }, + OnExecutionPayload { + execution_payload: TExecutionPayload, + valid: bool, + }, } #[derive(Debug, Clone, Deserialize)] @@ -151,6 +165,7 @@ pub struct ForkChoiceTest { Attestation, AttesterSlashing, PowBlock, + SignedExecutionPayloadEnvelope, >, >, } @@ -271,6 +286,17 @@ impl LoadCase for ForkChoiceTest { valid, }) } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + let envelope = + ssz_decode_file(&path.join(format!("{execution_payload}.ssz_snappy")))?; + Ok(Step::OnExecutionPayload { + execution_payload: envelope, + valid, + }) + } }) .collect::>()?; let anchor_state = ssz_decode_state(&path.join("anchor_state.ssz_snappy"), spec)?; @@ -359,6 +385,7 @@ impl Case for ForkChoiceTest { proposer_boost_root, get_proposer_head, should_override_forkchoice_update: should_override_fcu, + head_payload_status, } = checks.as_ref(); if let Some(expected_head) = head { @@ -405,6 +432,10 @@ impl Case for ForkChoiceTest { if let Some(expected_proposer_head) = get_proposer_head { tester.check_expected_proposer_head(*expected_proposer_head)?; } + + if let Some(expected_status) = head_payload_status { + tester.check_head_payload_status(*expected_status)?; + } } Step::MaybeValidBlockAndColumns { @@ -414,6 +445,13 @@ impl Case for ForkChoiceTest { } => { tester.process_block_and_columns(block.clone(), columns.clone(), *valid)?; } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + tester + .process_execution_payload(execution_payload.beacon_block_root(), *valid)?; + } } } @@ -931,6 +969,50 @@ impl Tester { check_equal("proposer_head", proposer_head, expected_proposer_head) } + pub fn process_execution_payload(&self, block_root: Hash256, valid: bool) -> Result<(), Error> { + let result = self + .harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_execution_payload(block_root); + + if valid { + result.map_err(|e| { + Error::InternalError(format!( + "on_execution_payload for block root {} failed: {:?}", + block_root, e + )) + })?; + } else if result.is_ok() { + return Err(Error::DidntFail(format!( + "on_execution_payload for block root {} should have failed", + block_root + ))); + } + + Ok(()) + } + + pub fn check_head_payload_status(&self, expected_status: u8) -> Result<(), Error> { + let head = self.find_head()?; + let head_root = head.head_block_root(); + let current_slot = self.harness.chain.slot().map_err(|e| { + Error::InternalError(format!("reading current slot failed with {:?}", e)) + })?; + let fc = self.harness.chain.canonical_head.fork_choice_read_lock(); + let actual_status = fc + .proto_array() + .head_payload_status::(&head_root, current_slot) + .ok_or_else(|| { + Error::InternalError(format!( + "head_payload_status not found for head root {}", + head_root + )) + })?; + check_equal("head_payload_status", actual_status as u8, expected_status) + } + pub fn check_should_override_fcu( &self, expected_should_override_fcu: ShouldOverrideFcu, diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index da3c5533b68..895b8f26567 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -709,15 +709,27 @@ impl Handler for ForkChoiceHandler { return false; } - // No FCU override tests prior to bellatrix. + // No FCU override tests prior to bellatrix, and removed in Gloas. if self.handler_name == "should_override_forkchoice_update" - && !fork_name.bellatrix_enabled() + && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) { return false; } - // Deposit tests exist only after Electra. - if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { + // Deposit tests exist only for Electra and Fulu (not Gloas). + if self.handler_name == "deposit_with_reorg" + && (!fork_name.electra_enabled() || fork_name.gloas_enabled()) + { + return false; + } + + // Proposer head tests removed in Gloas. + if self.handler_name == "get_proposer_head" && fork_name.gloas_enabled() { + return false; + } + + // on_execution_payload tests exist only for Gloas. + if self.handler_name == "on_execution_payload" && !fork_name.gloas_enabled() { return false; } @@ -727,8 +739,7 @@ impl Handler for ForkChoiceHandler { } fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once we have Gloas fork choice tests - vec![ForkName::Gloas] + vec![] } } diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 3893df2ef74..cb4abed90ab 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -1032,6 +1032,12 @@ fn fork_choice_deposit_with_reorg() { // There is no mainnet variant for this test. } +#[test] +fn fork_choice_on_execution_payload() { + ForkChoiceHandler::::new("on_execution_payload").run(); + ForkChoiceHandler::::new("on_execution_payload").run(); +} + #[test] fn optimistic_sync() { OptimisticSyncHandler::::default().run(); From ffec1a1f1e3a01259557f8b1284fae2ee3918cef Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Tue, 17 Mar 2026 01:49:40 -0400 Subject: [PATCH 17/20] enable ef tests @brech1 commit --- .../gloas_payload.rs | 52 ++++++++--- consensus/proto_array/src/proto_array.rs | 75 ++++++++-------- .../src/proto_array_fork_choice.rs | 28 +++--- testing/ef_tests/Makefile | 2 +- testing/ef_tests/src/cases/fork_choice.rs | 86 ++++++++++++++++++- testing/ef_tests/src/handler.rs | 23 +++-- testing/ef_tests/tests/tests.rs | 6 ++ 7 files changed, 199 insertions(+), 73 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 e19fb196f26..8dcf538bd42 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 @@ -51,6 +51,12 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + ops.push(Operation::AssertParentPayloadStatus { block_root: get_root(1), expected_status: PayloadStatus::Full, @@ -111,6 +117,12 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(1)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, // which defaults to Empty unless timely+data-available evidence is set. ops.push(Operation::ProcessPayloadAttestation { @@ -263,6 +275,12 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Equal branch weights: tiebreak FULL picks branch rooted at 3. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -359,6 +377,12 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition() execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Parent prefers Full on equal branch weights (tiebreaker). ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -521,6 +545,12 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef execution_payload_block_hash: Some(get_hash(4)), }); + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the GLOAS fork choice tree. + ops.push(Operation::ProcessExecutionPayload { + block_root: get_root(1), + }); + // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), @@ -619,10 +649,11 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe expected_status: PayloadStatus::Empty, }); - // Before payload arrives: payload_received is false on genesis. + // Per spec `get_forkchoice_store`: genesis starts with payload_received=true + // (anchor block is in `payload_states`). ops.push(Operation::AssertPayloadReceived { block_root: get_root(0), - expected: false, + expected: true, }); // Give one vote to each child so they have equal weight. @@ -637,38 +668,37 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe attestation_slot: Slot::new(1), }); - // Equal weight, no payload received on genesis → tiebreaker uses PTC votes which - // require payload_received. Without it, is_payload_timely returns false → prefers Empty. - // Block 2 (Empty) wins because it matches the Empty preference. + // Equal weight, payload_received=true on genesis → tiebreaker uses + // payload_received (not previous slot, equal payload weights) → prefers Full. + // Block 1 (Full) wins because it matches the Full preference. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], - expected_head: get_root(2), + expected_head: get_root(1), current_slot: Slot::new(100), }); - // Now the execution payload for genesis arrives and is validated. + // ProcessExecutionPayload on genesis is a no-op (already received at init). ops.push(Operation::ProcessExecutionPayload { block_root: get_root(0), }); - // payload_received is now true. ops.push(Operation::AssertPayloadReceived { block_root: get_root(0), expected: true, }); // Set PTC votes on genesis as timely + data available (simulates PTC voting). + // This doesn't change the preference since genesis is not the previous slot + // (slot 0 + 1 != current_slot 100). ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: true, is_data_available: true, }); - // Now with payload_received=true and PTC votes exceeding threshold: - // is_payload_timely=true, is_payload_data_available=true → prefers Full. - // Block 1 (Full) wins because it matches the Full preference. + // Still prefers Full via payload_received tiebreaker → Block 1 (Full) wins. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 09538f25eb0..1b6c0c58bc6 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -552,6 +552,13 @@ impl ProtoArray { PayloadStatus::Full }; + // Per spec `get_forkchoice_store`: the anchor (genesis) block has + // its payload state initialized (`payload_states = {anchor_root: ...}`). + // Without `payload_received = true` on genesis, the FULL virtual + // child doesn't exist in the spec's `get_node_children`, making all + // Full concrete children of genesis unreachable in `get_head`. + let is_genesis = parent_index.is_none(); + ProtoNode::V29(ProtoNodeV29 { slot: block.slot, root: block.root, @@ -573,7 +580,7 @@ impl ProtoArray { execution_payload_block_hash, payload_timeliness_votes: BitVector::default(), payload_data_availability_votes: BitVector::default(), - payload_received: false, + payload_received: is_genesis, }) }; @@ -1120,6 +1127,18 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); + // For V29 (GLOAS) parents, the spec's virtual tree model requires choosing + // FULL or EMPTY direction at each node BEFORE considering concrete children. + // Only children whose parent_payload_status matches the preferred direction + // are eligible for best_child. This is PRIMARY, not a tiebreaker. + let child_matches_dir = child_matches_parent_payload_preference( + parent, + child, + current_slot, + E::ptc_size(), + proposer_boost, + ); + let (new_best_child, new_best_descendant) = if let Some(best_child_index) = parent.best_child() { if best_child_index == child_index && !child_leads_to_viable_head { @@ -1143,6 +1162,14 @@ impl ProtoArray { best_finalized_checkpoint, )?; + let best_child_matches_dir = child_matches_parent_payload_preference( + parent, + best_child, + current_slot, + E::ptc_size(), + proposer_boost, + ); + if child_leads_to_viable_head && !best_child_leads_to_viable_head { // The child leads to a viable head, but the current best-child doesn't. change_to_child @@ -1150,49 +1177,27 @@ impl ProtoArray { // The best child leads to a viable head, but the child doesn't. no_change } else if child.weight() > best_child.weight() { - // Weight is the primary ordering criterion. + // Weight is the primary selector after viability. change_to_child } else if child.weight() < best_child.weight() { no_change + } else if child_matches_dir && !best_child_matches_dir { + // Equal weight: direction matching is the tiebreaker. + change_to_child + } else if !child_matches_dir && best_child_matches_dir { + no_change + } else if *child.root() >= *best_child.root() { + // Final tie-breaker: break by root hash. + change_to_child } else { - // Equal weights: for V29 parents, prefer the child whose - // parent_payload_status matches the parent's payload preference - // (full vs empty). This corresponds to the spec's - // `get_payload_status_tiebreaker` ordering in `get_head`. - let child_matches = child_matches_parent_payload_preference( - parent, - child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - let best_child_matches = child_matches_parent_payload_preference( - parent, - best_child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - - if child_matches && !best_child_matches { - // Child extends the preferred payload chain, best_child doesn't. - change_to_child - } else if !child_matches && best_child_matches { - // Best child extends the preferred payload chain, child doesn't. - no_change - } else if *child.root() >= *best_child.root() { - // Final tie-breaker: both match or both don't, break by root. - change_to_child - } else { - no_change - } + no_change } } } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. + // No current best-child: set if child is viable. change_to_child } else { - // There is no current best-child but the child is not viable. + // Child is not viable. no_change }; diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index ce634fbdbeb..4f5fe45c220 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1004,7 +1004,7 @@ impl ProtoArrayForkChoice { pub fn head_payload_status( &self, head_root: &Hash256, - current_slot: Slot, + _current_slot: Slot, ) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; @@ -1012,23 +1012,15 @@ impl ProtoArrayForkChoice { Some(PayloadStatus::Full) } else if v29.empty_payload_weight > v29.full_payload_weight { Some(PayloadStatus::Empty) - } else if node.slot() + 1 == current_slot { - // Previous slot: should_extend_payload = is_payload_timely && is_payload_data_available - if is_payload_timely( - &v29.payload_timeliness_votes, - E::ptc_size(), - v29.payload_received, - ) && is_payload_data_available( - &v29.payload_data_availability_votes, - E::ptc_size(), - v29.payload_received, - ) { - Some(PayloadStatus::Full) - } else { - Some(PayloadStatus::Empty) - } - } else if v29.payload_received { - // Not previous slot: Full wins tiebreaker (1 > 0) when payload received. + } else if is_payload_timely( + &v29.payload_timeliness_votes, + E::ptc_size(), + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + E::ptc_size(), + v29.payload_received, + ) { Some(PayloadStatus::Full) } else { Some(PayloadStatus::Empty) diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index fd8a3f6da0f..48378a4c958 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.2 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.3 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 11b2df01238..054c65d0169 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -30,7 +30,8 @@ use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, - KzgProof, ProposerPreparationData, SignedBeaconBlock, Slot, Uint256, + KzgProof, ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + Uint256, }; // When set to true, cache any states fetched from the db. @@ -72,6 +73,7 @@ pub struct Checks { proposer_boost_root: Option, get_proposer_head: Option, should_override_forkchoice_update: Option, + head_payload_status: Option, } #[derive(Debug, Clone, Deserialize)] @@ -94,7 +96,15 @@ impl From for PayloadStatusV1 { #[derive(Debug, Clone, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum Step { +pub enum Step< + TBlock, + TBlobs, + TColumns, + TAttestation, + TAttesterSlashing, + TPowBlock, + TExecutionPayload = String, +> { Tick { tick: u64, }, @@ -128,6 +138,10 @@ pub enum Step, valid: bool, }, + OnExecutionPayload { + execution_payload: TExecutionPayload, + valid: bool, + }, } #[derive(Debug, Clone, Deserialize)] @@ -151,6 +165,7 @@ pub struct ForkChoiceTest { Attestation, AttesterSlashing, PowBlock, + SignedExecutionPayloadEnvelope, >, >, } @@ -271,6 +286,17 @@ impl LoadCase for ForkChoiceTest { valid, }) } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + let envelope = + ssz_decode_file(&path.join(format!("{execution_payload}.ssz_snappy")))?; + Ok(Step::OnExecutionPayload { + execution_payload: envelope, + valid, + }) + } }) .collect::>()?; let anchor_state = ssz_decode_state(&path.join("anchor_state.ssz_snappy"), spec)?; @@ -359,6 +385,7 @@ impl Case for ForkChoiceTest { proposer_boost_root, get_proposer_head, should_override_forkchoice_update: should_override_fcu, + head_payload_status, } = checks.as_ref(); if let Some(expected_head) = head { @@ -405,6 +432,10 @@ impl Case for ForkChoiceTest { if let Some(expected_proposer_head) = get_proposer_head { tester.check_expected_proposer_head(*expected_proposer_head)?; } + + if let Some(expected_status) = head_payload_status { + tester.check_head_payload_status(*expected_status)?; + } } Step::MaybeValidBlockAndColumns { @@ -414,6 +445,13 @@ impl Case for ForkChoiceTest { } => { tester.process_block_and_columns(block.clone(), columns.clone(), *valid)?; } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + tester + .process_execution_payload(execution_payload.beacon_block_root(), *valid)?; + } } } @@ -931,6 +969,50 @@ impl Tester { check_equal("proposer_head", proposer_head, expected_proposer_head) } + pub fn process_execution_payload(&self, block_root: Hash256, valid: bool) -> Result<(), Error> { + let result = self + .harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_execution_payload(block_root); + + if valid { + result.map_err(|e| { + Error::InternalError(format!( + "on_execution_payload for block root {} failed: {:?}", + block_root, e + )) + })?; + } else if result.is_ok() { + return Err(Error::DidntFail(format!( + "on_execution_payload for block root {} should have failed", + block_root + ))); + } + + Ok(()) + } + + pub fn check_head_payload_status(&self, expected_status: u8) -> Result<(), Error> { + let head = self.find_head()?; + let head_root = head.head_block_root(); + let current_slot = self.harness.chain.slot().map_err(|e| { + Error::InternalError(format!("reading current slot failed with {:?}", e)) + })?; + let fc = self.harness.chain.canonical_head.fork_choice_read_lock(); + let actual_status = fc + .proto_array() + .head_payload_status::(&head_root, current_slot) + .ok_or_else(|| { + Error::InternalError(format!( + "head_payload_status not found for head root {}", + head_root + )) + })?; + check_equal("head_payload_status", actual_status as u8, expected_status) + } + pub fn check_should_override_fcu( &self, expected_should_override_fcu: ShouldOverrideFcu, diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index da3c5533b68..895b8f26567 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -709,15 +709,27 @@ impl Handler for ForkChoiceHandler { return false; } - // No FCU override tests prior to bellatrix. + // No FCU override tests prior to bellatrix, and removed in Gloas. if self.handler_name == "should_override_forkchoice_update" - && !fork_name.bellatrix_enabled() + && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) { return false; } - // Deposit tests exist only after Electra. - if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { + // Deposit tests exist only for Electra and Fulu (not Gloas). + if self.handler_name == "deposit_with_reorg" + && (!fork_name.electra_enabled() || fork_name.gloas_enabled()) + { + return false; + } + + // Proposer head tests removed in Gloas. + if self.handler_name == "get_proposer_head" && fork_name.gloas_enabled() { + return false; + } + + // on_execution_payload tests exist only for Gloas. + if self.handler_name == "on_execution_payload" && !fork_name.gloas_enabled() { return false; } @@ -727,8 +739,7 @@ impl Handler for ForkChoiceHandler { } fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once we have Gloas fork choice tests - vec![ForkName::Gloas] + vec![] } } diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 3893df2ef74..cb4abed90ab 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -1032,6 +1032,12 @@ fn fork_choice_deposit_with_reorg() { // There is no mainnet variant for this test. } +#[test] +fn fork_choice_on_execution_payload() { + ForkChoiceHandler::::new("on_execution_payload").run(); + ForkChoiceHandler::::new("on_execution_payload").run(); +} + #[test] fn optimistic_sync() { OptimisticSyncHandler::::default().run(); From ab1305d49063cf5baa6df082e9c54083018c894a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 19 Mar 2026 11:38:05 +1100 Subject: [PATCH 18/20] Propagate weight to parent's full/empty variants --- consensus/proto_array/src/proto_array.rs | 26 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index a42a1891cb6..50249430c9d 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -432,14 +432,24 @@ impl ProtoArray { // FULL/EMPTY virtual node based on the voter's `payload_present` // flag, NOT based on which child the vote goes through. // Propagate each child's full/empty deltas independently. - parent_delta.full_delta = parent_delta - .full_delta - .checked_add(node_full_delta) - .ok_or(Error::DeltaOverflow(parent_index))?; - parent_delta.empty_delta = parent_delta - .empty_delta - .checked_add(node_empty_delta) - .ok_or(Error::DeltaOverflow(parent_index))?; + match node.parent_payload_status() { + Ok(PayloadStatus::Full) => { + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + Ok(PayloadStatus::Empty) => { + parent_delta.empty_delta = parent_delta + .empty_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + Ok(PayloadStatus::Pending) | Err(..) => { + // Pending is not reachable. Parent payload status must be Full or Empty. + // TODO(gloas): add ParentPayloadStatus = Full | Empty. + } + } } } From cc8466dfa537f90098cb9fc3421f4ed95a777bdc Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Fri, 20 Mar 2026 16:10:43 -0400 Subject: [PATCH 19/20] fixing recursive calls with caching --- beacon_node/beacon_chain/src/beacon_chain.rs | 31 +- consensus/fork_choice/src/fork_choice.rs | 1 + .../src/fork_choice_test_definition.rs | 1 + consensus/proto_array/src/proto_array.rs | 435 ++++++++++++++++-- .../src/proto_array_fork_choice.rs | 54 ++- testing/ef_tests/src/cases/fork_choice.rs | 24 + 6 files changed, 487 insertions(+), 59 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 91dc219258f..96e4ab6c603 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4845,22 +4845,21 @@ impl BeaconChain { // get_attestation_score(parent, parent_payload_status) where parent_payload_status // is determined by the head block's relationship to its parent. let head_weight = info.head_node.weight(); - let parent_weight = - if let (Ok(head_payload_status), Ok(parent_v29)) = ( - info.head_node.parent_payload_status(), - info.parent_node.as_v29(), - ) { - // Post-GLOAS: use the payload-filtered weight matching how the head - // extends from its parent. - match head_payload_status { - proto_array::PayloadStatus::Full => parent_v29.full_payload_weight, - proto_array::PayloadStatus::Empty => parent_v29.empty_payload_weight, - proto_array::PayloadStatus::Pending => info.parent_node.weight(), - } - } else { - // Pre-GLOAS or fork boundary: use total weight. - info.parent_node.weight() - }; + let parent_weight = if let (Ok(head_payload_status), Ok(parent_v29)) = ( + info.head_node.parent_payload_status(), + info.parent_node.as_v29(), + ) { + // Post-GLOAS: use the payload-filtered weight matching how the head + // extends from its parent. + match head_payload_status { + proto_array::PayloadStatus::Full => parent_v29.full_payload_weight, + proto_array::PayloadStatus::Empty => parent_v29.empty_payload_weight, + proto_array::PayloadStatus::Pending => info.parent_node.weight(), + } + } else { + // Pre-GLOAS or fork boundary: use total weight. + info.parent_node.weight() + }; let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 30c56c97758..5dc081d6ce4 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -992,6 +992,7 @@ where unrealized_finalized_checkpoint: Some(unrealized_finalized_checkpoint), execution_payload_parent_hash, execution_payload_block_hash, + proposer_index: Some(block.proposer_index()), }, current_slot, self.justified_checkpoint(), diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 7f607c826fe..a89073a7b86 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -274,6 +274,7 @@ impl ForkChoiceTestDefinition { unrealized_finalized_checkpoint: None, execution_payload_parent_hash, execution_payload_block_hash, + proposer_index: None, }; fork_choice .process_block::( diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 50249430c9d..422d05097b0 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -147,6 +147,19 @@ pub struct ProtoNode { /// regardless of PTC vote counts. #[superstruct(only(V29), partial_getter(copy))] pub payload_received: bool, + /// The proposer index for this block, used by `should_apply_proposer_boost` + /// to detect equivocations at the parent's slot. + #[superstruct(only(V29), partial_getter(copy))] + pub proposer_index: u64, + /// Best child whose `parent_payload_status == Full`. + /// Maintained alongside `best_child` to avoid O(n) scans during the V29 head walk. + #[superstruct(only(V29), partial_getter(copy))] + #[ssz(with = "four_byte_option_usize")] + pub best_full_child: Option, + /// Best child whose `parent_payload_status == Empty`. + #[superstruct(only(V29), partial_getter(copy))] + #[ssz(with = "four_byte_option_usize")] + pub best_empty_child: Option, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -380,17 +393,12 @@ impl ProtoArray { } // If we find the node matching the current proposer boost root, increase // the delta by the new score amount (unless the block has an invalid execution status). - // - // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance - // - // TODO(gloas): proposer boost should also be subtracted from `empty_delta` per spec, - // since the spec creates a virtual vote with `payload_present=False` for the proposer - // boost, biasing toward Empty for non-current-slot payload decisions. + // For Gloas (V29), `should_apply_proposer_boost` is checked after the loop + // with final weights, and the boost is removed if needed. if let Some(proposer_score_boost) = spec.proposer_score_boost && proposer_boost_root != Hash256::zero() - && proposer_boost_root == node.root() - // Invalid nodes (or their ancestors) should not receive a proposer boost. - && !execution_status_is_invalid + && proposer_boost_root == node.root() + && !execution_status_is_invalid { proposer_score = calculate_committee_fraction::(new_justified_balances, proposer_score_boost) @@ -428,29 +436,87 @@ impl ProtoArray { .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; - // Per spec's `is_supporting_vote`: a vote supports a parent's - // FULL/EMPTY virtual node based on the voter's `payload_present` - // flag, NOT based on which child the vote goes through. - // Propagate each child's full/empty deltas independently. - match node.parent_payload_status() { - Ok(PayloadStatus::Full) => { + // Route ALL child weight into the parent's FULL or EMPTY bucket + // based on the child's `parent_payload_status` (the ancestor path + // direction). If this child is on the FULL path from the parent, + // all weight supports the parent's FULL virtual node, and vice versa. + if let Ok(child_v29) = node.as_v29() { + if child_v29.parent_payload_status == PayloadStatus::Full { parent_delta.full_delta = parent_delta .full_delta .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; - } - Ok(PayloadStatus::Empty) => { + } else { parent_delta.empty_delta = parent_delta .empty_delta .checked_add(delta) .ok_or(Error::DeltaOverflow(parent_index))?; } - Ok(PayloadStatus::Pending) | Err(..) => { - // Pending is not reachable. Parent payload status must be Full or Empty. - // TODO(gloas): add ParentPayloadStatus = Full | Empty. + } else { + // V17 child of a V29 parent (fork transition): treat as FULL + // since V17 nodes always have execution payloads inline. + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + } + } + + // Gloas: now that all weights are final, check `should_apply_proposer_boost`. + // If the boost should NOT apply, walk from the boosted node to root and subtract + // `proposer_score` from weight and payload weights in a single pass. + // We detect Gloas by checking the boosted node's variant (V29) directly. + if proposer_score > 0 + && let Some(&boost_index) = self.indices.get(&proposer_boost_root) + && self + .nodes + .get(boost_index) + .is_some_and(|n| n.as_v29().is_ok()) + && !self.should_apply_proposer_boost::( + boost_index, + proposer_score, + new_justified_balances, + spec, + )? + { + // Single walk: subtract proposer_score from weight and payload weights. + let mut walk_index = Some(boost_index); + let mut child_payload_status: Option = None; + while let Some(idx) = walk_index { + let node = self + .nodes + .get_mut(idx) + .ok_or(Error::InvalidNodeIndex(idx))?; + + *node.weight_mut() = node + .weight() + .checked_sub(proposer_score) + .ok_or(Error::DeltaOverflow(idx))?; + + // Subtract from the payload bucket that the child-on-path + // contributed to (based on the child's parent_payload_status). + if let Some(child_ps) = child_payload_status + && let Ok(v29) = node.as_v29_mut() + { + if child_ps == PayloadStatus::Full { + v29.full_payload_weight = v29 + .full_payload_weight + .checked_sub(proposer_score) + .ok_or(Error::DeltaOverflow(idx))?; + } else { + v29.empty_payload_weight = v29 + .empty_payload_weight + .checked_sub(proposer_score) + .ok_or(Error::DeltaOverflow(idx))?; } } + + child_payload_status = node.parent_payload_status().ok(); + walk_index = node.parent(); } + + proposer_score = 0; } // After applying all deltas, update the `previous_proposer_boost`. @@ -592,9 +658,31 @@ impl ProtoArray { empty_payload_weight: 0, full_payload_weight: 0, execution_payload_block_hash, - payload_timeliness_votes: BitVector::default(), - payload_data_availability_votes: BitVector::default(), + // Per spec `get_forkchoice_store`: the anchor block's PTC votes are + // initialized to all-True, ensuring `is_payload_timely` and + // `is_payload_data_available` return true for the anchor. + payload_timeliness_votes: if is_genesis { + let mut bv = BitVector::new(); + for i in 0..bv.len() { + let _ = bv.set(i, true); + } + bv + } else { + BitVector::default() + }, + payload_data_availability_votes: if is_genesis { + let mut bv = BitVector::new(); + for i in 0..bv.len() { + let _ = bv.set(i, true); + } + bv + } else { + BitVector::default() + }, payload_received: is_genesis, + proposer_index: block.proposer_index.unwrap_or(0), + best_full_child: None, + best_empty_child: None, }) }; @@ -637,6 +725,66 @@ impl ProtoArray { Ok(()) } + /// Spec's `should_apply_proposer_boost` for Gloas. + /// + /// Returns `true` if the proposer boost should be kept. Returns `false` if the + /// boost should be subtracted (invalidated) because the parent is weak and there + /// are no equivocating blocks at the parent's slot. + fn should_apply_proposer_boost( + &self, + boost_index: usize, + proposer_score: u64, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + let boost_node = self + .nodes + .get(boost_index) + .ok_or(Error::InvalidNodeIndex(boost_index))?; + + let Some(parent_index) = boost_node.parent() else { + return Ok(true); // Genesis — always apply. + }; + + let parent = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))?; + + // Parent not from the immediately previous slot — always apply. + if parent.slot() + 1 < boost_node.slot() { + return Ok(true); + } + + // Check if the parent is "weak" (low attestation weight). + // Parent weight currently includes the back-propagated boost, so subtract it. + let reorg_threshold = calculate_committee_fraction::( + justified_balances, + spec.reorg_head_weight_threshold.unwrap_or(20), + ) + .unwrap_or(0); + + let parent_weight_without_boost = parent.weight().saturating_sub(proposer_score); + if parent_weight_without_boost >= reorg_threshold { + return Ok(true); // Parent is not weak — apply. + } + + // Parent is weak. Apply boost unless there's an equivocating block at + // the parent's slot from the same proposer. + let parent_slot = parent.slot(); + let parent_root = parent.root(); + let parent_proposer = parent.proposer_index().unwrap_or(u64::MAX); + + let has_equivocation = self.nodes.iter().any(|n| { + n.as_v29().is_ok() + && n.slot() == parent_slot + && n.root() != parent_root + && n.proposer_index().unwrap_or(u64::MAX - 1) == parent_proposer + }); + + Ok(!has_equivocation) + } + /// Process an execution payload for a Gloas block. /// /// Sets `payload_received` to true, which makes `is_payload_timely` and @@ -965,11 +1113,6 @@ impl ProtoArray { // Since there are no valid descendants of a justified block with an invalid execution // payload, there would be no head to choose from. - // - // Fork choice is effectively broken until a new justified root is set. It might not be - // practically possible to set a new justified root if we are unable to find a new head. - // - // This scenario is *unsupported*. It represents a serious consensus failure. // Execution status tracking only exists on V17 (pre-Gloas) nodes. if let Ok(v17) = justified_node.as_v17() && v17.execution_status.is_invalid() @@ -979,6 +1122,42 @@ impl ProtoArray { }); } + // For V29 (Gloas) justified nodes, use the virtual tree walk directly. + if justified_node.as_v29().is_ok() { + return self.find_head_v29_walk::(justified_index, current_slot); + } + + // Pre-Gloas justified node, but descendants may be V29. + // Walk via best_child chain; switch to V29 walk when we hit one. + if justified_node.best_child().is_some() || justified_node.best_descendant().is_some() { + let mut current_index = justified_index; + loop { + let node = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + + // Hit a V29 node — switch to virtual tree walk. + if node.as_v29().is_ok() { + return self.find_head_v29_walk::(current_index, current_slot); + } + + // V17 node: follow best_child. + if let Some(bc_idx) = node.best_child() { + current_index = bc_idx; + } else { + break; + } + } + + let head_node = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + return Ok(head_node.root()); + } + + // Pre-Gloas fallback: use best_descendant directly. let best_descendant_index = justified_node.best_descendant().unwrap_or(justified_index); let best_node = self @@ -1007,6 +1186,81 @@ impl ProtoArray { Ok(best_node.root()) } + /// V29 virtual tree walk for `find_head`. + /// + /// At each V29 node, determine the preferred payload direction (FULL or EMPTY) + /// by comparing weights, then follow the direction-specific best_child pointer. + /// O(depth) — no scanning. + fn find_head_v29_walk( + &self, + start_index: usize, + current_slot: Slot, + ) -> Result { + let ptc_size = E::ptc_size(); + let mut current_index = start_index; + + loop { + let node = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + + let Ok(v29) = node.as_v29() else { break }; + + let prefer_full = Self::v29_prefer_full(v29, node.slot(), current_slot, ptc_size); + + // O(1) lookup via direction-specific best_child pointers. + let next = if prefer_full { + v29.best_full_child + } else { + v29.best_empty_child + }; + + if let Some(child_index) = next { + current_index = child_index; + } else { + break; + } + } + + let head_node = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + Ok(head_node.root()) + } + + /// Determine whether a V29 node prefers the FULL or EMPTY direction. + fn v29_prefer_full( + v29: &ProtoNodeV29, + node_slot: Slot, + current_slot: Slot, + ptc_size: usize, + ) -> bool { + if !v29.payload_received { + return false; + } + if node_slot + 1 != current_slot { + // Weight comparison, tiebreak to payload_received. + if v29.full_payload_weight != v29.empty_payload_weight { + v29.full_payload_weight > v29.empty_payload_weight + } else { + v29.payload_received + } + } else { + // Previous slot: PTC tiebreaker only. + is_payload_timely( + &v29.payload_timeliness_votes, + ptc_size, + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + ptc_size, + v29.payload_received, + ) + } + } + /// Update the tree with new finalization information. The tree is only actually pruned if both /// of the two following criteria are met: /// @@ -1072,6 +1326,20 @@ impl ProtoArray { .ok_or(Error::IndexOverflow("best_descendant"))?, ); } + if let Ok(v29) = node.as_v29_mut() { + if let Some(idx) = v29.best_full_child { + v29.best_full_child = Some( + idx.checked_sub(finalized_index) + .ok_or(Error::IndexOverflow("best_full_child"))?, + ); + } + if let Some(idx) = v29.best_empty_child { + v29.best_empty_child = Some( + idx.checked_sub(finalized_index) + .ok_or(Error::IndexOverflow("best_empty_child"))?, + ); + } + } } Ok(()) @@ -1214,6 +1482,16 @@ impl ProtoArray { no_change }; + // Capture child info before mutable borrows. + let child = self + .nodes + .get(child_index) + .ok_or(Error::InvalidNodeIndex(child_index))?; + let child_payload_dir = child.parent_payload_status().ok(); + let child_weight = child.weight(); + let child_root = child.root(); + + // Update general best_child/best_descendant. let parent = self .nodes .get_mut(parent_index) @@ -1222,6 +1500,109 @@ impl ProtoArray { *parent.best_child_mut() = new_best_child; *parent.best_descendant_mut() = new_best_descendant; + // For V29 parents: also maintain direction-specific best_child pointers + // so the V29 head walk can pick the right child in O(1). + if parent.as_v29().is_ok() + && let Some(dir) = child_payload_dir + { + self.update_directional_best_child::( + parent_index, + child_index, + dir, + child_leads_to_viable_head, + child_weight, + child_root, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )?; + } + + Ok(()) + } + + /// Update `best_full_child` or `best_empty_child` on a V29 parent. + #[allow(clippy::too_many_arguments)] + fn update_directional_best_child( + &mut self, + parent_index: usize, + child_index: usize, + dir: PayloadStatus, + child_viable: bool, + child_weight: u64, + child_root: Hash256, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + ) -> Result<(), Error> { + let parent_v29 = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? + .as_v29() + .map_err(|_| Error::InvalidNodeIndex(parent_index))?; + + let current_best = match dir { + PayloadStatus::Full => parent_v29.best_full_child, + PayloadStatus::Empty => parent_v29.best_empty_child, + PayloadStatus::Pending => return Ok(()), + }; + + if !child_viable { + // Remove if this child was the directional best but is no longer viable. + if current_best == Some(child_index) { + let parent_v29 = self + .nodes + .get_mut(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? + .as_v29_mut() + .map_err(|_| Error::InvalidNodeIndex(parent_index))?; + match dir { + PayloadStatus::Full => parent_v29.best_full_child = None, + PayloadStatus::Empty => parent_v29.best_empty_child = None, + PayloadStatus::Pending => {} + } + } + return Ok(()); + } + + let replace = match current_best { + None => true, + Some(best_idx) => { + let best_node = self + .nodes + .get(best_idx) + .ok_or(Error::InvalidNodeIndex(best_idx))?; + let best_viable = self.node_leads_to_viable_head::( + best_node, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )?; + if !best_viable { + true + } else if child_weight != best_node.weight() { + child_weight > best_node.weight() + } else { + *child_root >= *best_node.root() + } + } + }; + + if replace { + let parent_v29 = self + .nodes + .get_mut(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? + .as_v29_mut() + .map_err(|_| Error::InvalidNodeIndex(parent_index))?; + match dir { + PayloadStatus::Full => parent_v29.best_full_child = Some(child_index), + PayloadStatus::Empty => parent_v29.best_empty_child = Some(child_index), + PayloadStatus::Pending => {} + } + } + Ok(()) } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 4f5fe45c220..64ec5a85498 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -182,6 +182,7 @@ pub struct Block { /// post-Gloas fields pub execution_payload_parent_hash: Option, pub execution_payload_block_hash: Option, + pub proposer_index: Option, } impl Block { @@ -473,6 +474,7 @@ impl ProtoArrayForkChoice { unrealized_finalized_checkpoint: Some(finalized_checkpoint), execution_payload_parent_hash, execution_payload_block_hash, + proposer_index: None, }; proto_array @@ -965,6 +967,7 @@ impl ProtoArrayForkChoice { unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint(), execution_payload_parent_hash: None, execution_payload_block_hash: block.execution_payload_block_hash().ok(), + proposer_index: block.proposer_index().ok(), }) } @@ -1004,26 +1007,42 @@ impl ProtoArrayForkChoice { pub fn head_payload_status( &self, head_root: &Hash256, - _current_slot: Slot, + current_slot: Slot, ) -> Option { let node = self.get_proto_node(head_root)?; let v29 = node.as_v29().ok()?; - if v29.full_payload_weight > v29.empty_payload_weight { - Some(PayloadStatus::Full) - } else if v29.empty_payload_weight > v29.full_payload_weight { - Some(PayloadStatus::Empty) - } else if is_payload_timely( - &v29.payload_timeliness_votes, - E::ptc_size(), - v29.payload_received, - ) && is_payload_data_available( - &v29.payload_data_availability_votes, - E::ptc_size(), - v29.payload_received, - ) { - Some(PayloadStatus::Full) + + // Replicate the spec's virtual tree walk tiebreaker at the head node. + let use_tiebreaker_only = node.slot() + 1 == current_slot; + + if !use_tiebreaker_only { + // Compare weights, then fall back to tiebreaker. + if v29.full_payload_weight > v29.empty_payload_weight { + return Some(PayloadStatus::Full); + } else if v29.empty_payload_weight > v29.full_payload_weight { + return Some(PayloadStatus::Empty); + } + // Equal weights: prefer FULL if payload received. + if v29.payload_received { + Some(PayloadStatus::Full) + } else { + Some(PayloadStatus::Empty) + } } else { - Some(PayloadStatus::Empty) + // Previous slot: should_extend_payload tiebreaker. + if is_payload_timely( + &v29.payload_timeliness_votes, + E::ptc_size(), + v29.payload_received, + ) && is_payload_data_available( + &v29.payload_data_availability_votes, + E::ptc_size(), + v29.payload_received, + ) { + Some(PayloadStatus::Full) + } else { + Some(PayloadStatus::Empty) + } } } @@ -1337,6 +1356,7 @@ mod test_compute_deltas { unrealized_finalized_checkpoint: Some(genesis_checkpoint), execution_payload_parent_hash: None, execution_payload_block_hash: None, + proposer_index: None, }, genesis_slot + 1, genesis_checkpoint, @@ -1365,6 +1385,7 @@ mod test_compute_deltas { unrealized_finalized_checkpoint: None, execution_payload_parent_hash: None, execution_payload_block_hash: None, + proposer_index: None, }, genesis_slot + 1, genesis_checkpoint, @@ -1500,6 +1521,7 @@ mod test_compute_deltas { unrealized_finalized_checkpoint: Some(genesis_checkpoint), execution_payload_parent_hash: None, execution_payload_block_hash: None, + proposer_index: None, }, Slot::from(block.slot), genesis_checkpoint, diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 054c65d0169..a1c93d65bb1 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -622,6 +622,18 @@ impl Tester { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } @@ -712,6 +724,18 @@ impl Tester { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } From ce714710e947b6dd37c4629ff5dbbe806fdc6e45 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 23 Mar 2026 14:40:41 -0400 Subject: [PATCH 20/20] passing ef tests ft. @dapplion --- .../gloas_payload.rs | 10 +- consensus/proto_array/src/proto_array.rs | 353 +++++------------- 2 files changed, 99 insertions(+), 264 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 8dcf538bd42..8354b22e474 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 @@ -117,12 +117,6 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { execution_payload_block_hash: Some(get_hash(1)), }); - // Mark root_1 as having received its execution payload so that - // its FULL virtual node exists in the GLOAS fork choice tree. - ops.push(Operation::ProcessExecutionPayload { - block_root: get_root(1), - }); - // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, // which defaults to Empty unless timely+data-available evidence is set. ops.push(Operation::ProcessPayloadAttestation { @@ -187,13 +181,15 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { }); // Same-slot attestation to a new head candidate should be Pending (no payload bucket change). + // Root 5 is an Empty child of root_1 (parent_hash doesn't match root_1's block_hash), + // so it's reachable through root_1's Empty direction (root_1 has no payload_received). ops.push(Operation::ProcessBlock { slot: Slot::new(3), root: get_root(5), 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_parent_hash: Some(get_hash(101)), execution_payload_block_hash: Some(get_hash(5)), }); ops.push(Operation::ProcessPayloadAttestation { diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 422d05097b0..ac5f5be525d 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -151,15 +151,6 @@ pub struct ProtoNode { /// to detect equivocations at the parent's slot. #[superstruct(only(V29), partial_getter(copy))] pub proposer_index: u64, - /// Best child whose `parent_payload_status == Full`. - /// Maintained alongside `best_child` to avoid O(n) scans during the V29 head walk. - #[superstruct(only(V29), partial_getter(copy))] - #[ssz(with = "four_byte_option_usize")] - pub best_full_child: Option, - /// Best child whose `parent_payload_status == Empty`. - #[superstruct(only(V29), partial_getter(copy))] - #[ssz(with = "four_byte_option_usize")] - pub best_empty_child: Option, } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -180,9 +171,10 @@ impl Default for ProposerBoost { /// Accumulated score changes for a single proto-array node during a `find_head` pass. /// /// `delta` tracks the ordinary LMD-GHOST balance change applied to the concrete block node. -/// This is the same notion of weight that pre-GLOAS fork choice used. +/// This is the same notion of weight that pre-gloas fork choice used. +/// /// -/// Under GLOAS we also need to track how votes contribute to the parent's virtual payload +/// Under gloas we also need to track how votes contribute to the parent's virtual payload /// branches: /// /// - `empty_delta` is the balance change attributable to votes that support the `Empty` payload @@ -206,7 +198,7 @@ pub struct NodeDelta { impl NodeDelta { /// Classify a vote into the payload bucket it contributes to for `block_slot`. /// - /// Per the GLOAS model: + /// Per the gloas model: /// /// - a same-slot vote is `Pending` /// - a later vote with `payload_present = true` is `Full` @@ -681,8 +673,6 @@ impl ProtoArray { }, payload_received: is_genesis, proposer_index: block.proposer_index.unwrap_or(0), - best_full_child: None, - best_empty_child: None, }) }; @@ -1124,7 +1114,12 @@ impl ProtoArray { // For V29 (Gloas) justified nodes, use the virtual tree walk directly. if justified_node.as_v29().is_ok() { - return self.find_head_v29_walk::(justified_index, current_slot); + return self.find_head_v29_walk::( + justified_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ); } // Pre-Gloas justified node, but descendants may be V29. @@ -1139,7 +1134,12 @@ impl ProtoArray { // Hit a V29 node — switch to virtual tree walk. if node.as_v29().is_ok() { - return self.find_head_v29_walk::(current_index, current_slot); + return self.find_head_v29_walk::( + current_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ); } // V17 node: follow best_child. @@ -1189,12 +1189,15 @@ impl ProtoArray { /// V29 virtual tree walk for `find_head`. /// /// At each V29 node, determine the preferred payload direction (FULL or EMPTY) - /// by comparing weights, then follow the direction-specific best_child pointer. - /// O(depth) — no scanning. + /// by comparing weights. If `best_child` matches the preferred direction, follow + /// it directly. Otherwise, scan all nodes to find the best child matching + /// the preferred direction. fn find_head_v29_walk( &self, start_index: usize, current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, ) -> Result { let ptc_size = E::ptc_size(); let mut current_index = start_index; @@ -1208,15 +1211,38 @@ impl ProtoArray { let Ok(v29) = node.as_v29() else { break }; let prefer_full = Self::v29_prefer_full(v29, node.slot(), current_slot, ptc_size); + let preferred_status = if prefer_full { + PayloadStatus::Full + } else { + PayloadStatus::Empty + }; - // O(1) lookup via direction-specific best_child pointers. - let next = if prefer_full { - v29.best_full_child + // Fast path: check if best_child already matches the preferred direction. + let next_index = if let Some(best_child_index) = node.best_child() { + let best_child_node = self + .nodes + .get(best_child_index) + .ok_or(Error::InvalidNodeIndex(best_child_index))?; + if best_child_node + .as_v29() + .is_ok_and(|v| v.parent_payload_status == preferred_status) + { + Some(best_child_index) + } else { + // best_child is on the wrong direction. Scan for the best matching child. + self.find_best_child_with_status::( + current_index, + preferred_status, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )? + } } else { - v29.best_empty_child + None }; - if let Some(child_index) = next { + if let Some(child_index) = next_index { current_index = child_index; } else { break; @@ -1230,6 +1256,53 @@ impl ProtoArray { Ok(head_node.root()) } + /// Find the best viable child of `parent_index` whose `parent_payload_status` matches + /// `target_status`. Returns `None` if no matching viable child exists. + fn find_best_child_with_status( + &self, + parent_index: usize, + target_status: PayloadStatus, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + ) -> Result, Error> { + let mut best: Option<(usize, u64, Hash256)> = None; + for (node_index, node) in self.nodes.iter().enumerate() { + if node.parent() != Some(parent_index) { + continue; + } + if !node + .as_v29() + .is_ok_and(|v| v.parent_payload_status == target_status) + { + continue; + } + if !self.node_leads_to_viable_head::( + node, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + )? { + continue; + } + + let child_weight = node.weight(); + let child_root = node.root(); + let replace = if let Some((_, best_weight, best_root)) = best { + child_weight > best_weight + || (child_weight == best_weight && child_root >= best_root) + } else { + true + }; + + if replace { + best = Some((node_index, child_weight, child_root)); + } + } + + Ok(best.map(|(index, _, _)| index)) + } + /// Determine whether a V29 node prefers the FULL or EMPTY direction. fn v29_prefer_full( v29: &ProtoNodeV29, @@ -1326,20 +1399,6 @@ impl ProtoArray { .ok_or(Error::IndexOverflow("best_descendant"))?, ); } - if let Ok(v29) = node.as_v29_mut() { - if let Some(idx) = v29.best_full_child { - v29.best_full_child = Some( - idx.checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_full_child"))?, - ); - } - if let Some(idx) = v29.best_empty_child { - v29.best_empty_child = Some( - idx.checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_empty_child"))?, - ); - } - } } Ok(()) @@ -1382,26 +1441,8 @@ impl ProtoArray { best_finalized_checkpoint, )?; - // Per spec `should_extend_payload`: if the proposer-boosted block is a child of - // this parent and extends Empty, force Empty preference regardless of - // weights/tiebreaker. - let proposer_boost_root = self.previous_proposer_boost.root; - let proposer_boost = !proposer_boost_root.is_zero() - && self - .indices - .get(&proposer_boost_root) - .and_then(|&idx| self.nodes.get(idx)) - .is_some_and(|boost_node| { - boost_node.parent() == Some(parent_index) - && boost_node - .parent_payload_status() - .is_ok_and(|s| s != PayloadStatus::Full) - }); - // These three variables are aliases to the three options that we may set the // `parent.best_child` and `parent.best_descendant` to. - // - // I use the aliases to assist readability. let change_to_none = (None, None); let change_to_child = ( Some(child_index), @@ -1409,17 +1450,6 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); - // For V29 (GLOAS) parents, the spec's virtual tree model determines a preferred - // FULL or EMPTY direction at each node. Weight is the primary selector among - // viable children; direction matching is the tiebreaker when weights are equal. - let child_matches_dir = child_matches_parent_payload_preference( - parent, - child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - let (new_best_child, new_best_descendant) = if let Some(best_child_index) = parent.best_child() { if best_child_index == child_index && !child_leads_to_viable_head { @@ -1443,55 +1473,26 @@ impl ProtoArray { best_finalized_checkpoint, )?; - let best_child_matches_dir = child_matches_parent_payload_preference( - parent, - best_child, - current_slot, - E::ptc_size(), - proposer_boost, - ); - if child_leads_to_viable_head && !best_child_leads_to_viable_head { - // The child leads to a viable head, but the current best-child doesn't. change_to_child } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { - // The best child leads to a viable head, but the child doesn't. no_change } else if child.weight() > best_child.weight() { - // Weight is the primary selector after viability. change_to_child } else if child.weight() < best_child.weight() { no_change - } else if child_matches_dir && !best_child_matches_dir { - // Equal weight: direction matching is the tiebreaker. - change_to_child - } else if !child_matches_dir && best_child_matches_dir { - no_change } else if *child.root() >= *best_child.root() { - // Final tie-breaker: break by root hash. change_to_child } else { no_change } } } else if child_leads_to_viable_head { - // No current best-child: set if child is viable. change_to_child } else { - // Child is not viable. no_change }; - // Capture child info before mutable borrows. - let child = self - .nodes - .get(child_index) - .ok_or(Error::InvalidNodeIndex(child_index))?; - let child_payload_dir = child.parent_payload_status().ok(); - let child_weight = child.weight(); - let child_root = child.root(); - - // Update general best_child/best_descendant. let parent = self .nodes .get_mut(parent_index) @@ -1500,109 +1501,6 @@ impl ProtoArray { *parent.best_child_mut() = new_best_child; *parent.best_descendant_mut() = new_best_descendant; - // For V29 parents: also maintain direction-specific best_child pointers - // so the V29 head walk can pick the right child in O(1). - if parent.as_v29().is_ok() - && let Some(dir) = child_payload_dir - { - self.update_directional_best_child::( - parent_index, - child_index, - dir, - child_leads_to_viable_head, - child_weight, - child_root, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - } - - Ok(()) - } - - /// Update `best_full_child` or `best_empty_child` on a V29 parent. - #[allow(clippy::too_many_arguments)] - fn update_directional_best_child( - &mut self, - parent_index: usize, - child_index: usize, - dir: PayloadStatus, - child_viable: bool, - child_weight: u64, - child_root: Hash256, - current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - ) -> Result<(), Error> { - let parent_v29 = self - .nodes - .get(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))? - .as_v29() - .map_err(|_| Error::InvalidNodeIndex(parent_index))?; - - let current_best = match dir { - PayloadStatus::Full => parent_v29.best_full_child, - PayloadStatus::Empty => parent_v29.best_empty_child, - PayloadStatus::Pending => return Ok(()), - }; - - if !child_viable { - // Remove if this child was the directional best but is no longer viable. - if current_best == Some(child_index) { - let parent_v29 = self - .nodes - .get_mut(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))? - .as_v29_mut() - .map_err(|_| Error::InvalidNodeIndex(parent_index))?; - match dir { - PayloadStatus::Full => parent_v29.best_full_child = None, - PayloadStatus::Empty => parent_v29.best_empty_child = None, - PayloadStatus::Pending => {} - } - } - return Ok(()); - } - - let replace = match current_best { - None => true, - Some(best_idx) => { - let best_node = self - .nodes - .get(best_idx) - .ok_or(Error::InvalidNodeIndex(best_idx))?; - let best_viable = self.node_leads_to_viable_head::( - best_node, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - if !best_viable { - true - } else if child_weight != best_node.weight() { - child_weight > best_node.weight() - } else { - *child_root >= *best_node.root() - } - } - }; - - if replace { - let parent_v29 = self - .nodes - .get_mut(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))? - .as_v29_mut() - .map_err(|_| Error::InvalidNodeIndex(parent_index))?; - match dir { - PayloadStatus::Full => parent_v29.best_full_child = Some(child_index), - PayloadStatus::Empty => parent_v29.best_empty_child = Some(child_index), - PayloadStatus::Pending => {} - } - } - Ok(()) } @@ -1842,65 +1740,6 @@ impl ProtoArray { } } -/// For V29 parents, returns `true` if the child's `parent_payload_status` matches the parent's -/// preferred payload status per spec `should_extend_payload`. -/// -/// If `proposer_boost` is set, the parent unconditionally prefers Empty (the proposer-boosted -/// block is a child of this parent and extends Empty). Otherwise, when full and empty weights -/// are unequal the higher weight wins; when equal, the tiebreaker uses PTC votes. -/// -/// For V17 parents (or mixed), always returns `true` (no payload preference). -fn child_matches_parent_payload_preference( - parent: &ProtoNode, - child: &ProtoNode, - current_slot: Slot, - ptc_size: usize, - proposer_boost: bool, -) -> bool { - let (Ok(parent_v29), Ok(child_v29)) = (parent.as_v29(), child.as_v29()) else { - return true; - }; - - // Per spec `should_extend_payload`: if the proposer-boosted block extends Empty from - // this parent, unconditionally prefer Empty. - if proposer_boost { - return child_v29.parent_payload_status == PayloadStatus::Empty; - } - - // Per spec `get_weight`: FULL/EMPTY virtual nodes at `current_slot - 1` have weight 0. - // The PTC is still voting, so payload preference is determined solely by the tiebreaker. - let use_tiebreaker_only = parent.slot() + 1 == current_slot; - let prefers_full = if !use_tiebreaker_only - && parent_v29.full_payload_weight > parent_v29.empty_payload_weight - { - true - } else if !use_tiebreaker_only - && parent_v29.empty_payload_weight > parent_v29.full_payload_weight - { - false - } else if use_tiebreaker_only { - // Previous slot: should_extend_payload = is_payload_timely && is_payload_data_available. - is_payload_timely( - &parent_v29.payload_timeliness_votes, - ptc_size, - parent_v29.payload_received, - ) && is_payload_data_available( - &parent_v29.payload_data_availability_votes, - ptc_size, - parent_v29.payload_received, - ) - } else { - // Not previous slot: should_extend_payload = true. - // Full wins the tiebreaker (1 > 0) when the payload has been received. - parent_v29.payload_received - }; - if prefers_full { - child_v29.parent_payload_status == PayloadStatus::Full - } else { - child_v29.parent_payload_status == PayloadStatus::Empty - } -} - /// Derive `is_payload_timely` from the timeliness vote bitfield. /// /// Per spec: returns false if the payload has not been received locally