From 845831ce56a029d9c7bc6a28b5d99b2102ff0937 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:59:50 -0500 Subject: [PATCH] Align GLOAS fork choice with spec - Move proposer boost from apply_score_changes to get_weight, matching the spec's structure where get_weight adds boost via is_supporting_vote - Implement is_supporting_vote and get_ancestor_node spec functions - Fix should_extend_payload: return true when proposer_boost_root is zero - Compute record_block_timeliness from time_into_slot instead of hardcoding false - Fix anchor block_timeliness to [true, true] per get_forkchoice_store spec - Add equivocating_attestation_score for is_head_weak monotonicity - Use payload-aware weight in is_parent_strong - Add with_status helper on IndexedForkChoiceNode - Simplify find_head_walk to return IndexedForkChoiceNode directly --- consensus/fork_choice/src/fork_choice.rs | 6 + .../src/fork_choice_test_definition.rs | 2 + consensus/proto_array/src/proto_array.rs | 405 +++++++++--------- .../src/proto_array_fork_choice.rs | 32 +- 4 files changed, 241 insertions(+), 204 deletions(-) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 3c6dd9e5e0..c6def1562b 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -797,6 +797,11 @@ where let attestation_threshold = spec.get_unaggregated_attestation_due(); // Add proposer score boost if the block is timely. + // TODO(gloas): the spec's `update_proposer_boost_root` additionally checks that + // `block.proposer_index == get_beacon_proposer_index(head_state)` — i.e. that + // the block's proposer matches the expected proposer on the canonical chain. + // This requires calling `get_head` and advancing the head state to the current + // slot, which is expensive. Implement once we have a cached proposer index. let is_before_attesting_interval = block_delay < attestation_threshold; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); @@ -1001,6 +1006,7 @@ where self.justified_checkpoint(), self.finalized_checkpoint(), spec, + block_delay, )?; Ok(()) diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index de31a0905e..4507e013ba 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -10,6 +10,7 @@ use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz::BitVector; use std::collections::BTreeSet; +use std::time::Duration; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, Slot, @@ -288,6 +289,7 @@ impl ForkChoiceTestDefinition { self.justified_checkpoint, self.finalized_checkpoint, &spec, + Duration::ZERO, ) .unwrap_or_else(|e| { panic!( diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 6b338147cf..670ae31cfc 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1,6 +1,8 @@ use crate::error::InvalidBestNodeInfo; use crate::proto_array_fork_choice::IndexedForkChoiceNode; -use crate::{Block, ExecutionStatus, JustifiedBalances, PayloadStatus, error::Error}; +use crate::{ + Block, ExecutionStatus, JustifiedBalances, LatestMessage, PayloadStatus, error::Error, +}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz::BitVector; @@ -8,6 +10,7 @@ use ssz::Encode; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; use std::collections::{HashMap, HashSet}; +use std::time::Duration; use superstruct::superstruct; use typenum::U512; use types::{ @@ -20,6 +23,14 @@ use types::{ four_byte_option_impl!(four_byte_option_usize, usize); four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); +fn all_true_bitvector() -> BitVector { + let mut bv = BitVector::new(); + for i in 0..bv.len() { + let _ = bv.set(i, true); + } + bv +} + /// Defines an operation which may invalidate the `execution_status` of some nodes. #[derive(Clone, Debug)] pub enum InvalidationOperation { @@ -160,22 +171,27 @@ pub struct ProtoNode { /// to detect equivocations at the parent's slot. #[superstruct(only(V29), partial_getter(copy))] pub proposer_index: u64, + /// Weight from equivocating validators that voted for this block. + /// Used by `is_head_weak` to match the spec's monotonicity guarantee: + /// more attestations can only increase head weight, never decrease it. + #[superstruct(only(V29), partial_getter(copy))] + pub equivocating_attestation_score: u64, } impl ProtoNode { /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by /// considering their parents Empty. - fn get_parent_payload_status(&self) -> PayloadStatus { + /// Pre-Gloas nodes have no ePBS, default to Empty. + pub fn get_parent_payload_status(&self) -> PayloadStatus { self.parent_payload_status().unwrap_or(PayloadStatus::Empty) } - fn is_parent_node_full(&self) -> bool { + pub fn is_parent_node_full(&self) -> bool { self.get_parent_payload_status() == PayloadStatus::Full } - fn attestation_score(&self, payload_status: PayloadStatus) -> u64 { + pub fn attestation_score(&self, payload_status: PayloadStatus) -> u64 { match payload_status { - // TODO(gloas): rename weight and remove proposer boost from it? PayloadStatus::Pending => self.weight(), PayloadStatus::Empty => self.empty_payload_weight().unwrap_or(0), PayloadStatus::Full => self.full_payload_weight().unwrap_or(0), @@ -187,8 +203,7 @@ impl ProtoNode { return false; }; - // If the payload is not locally available, the payload - // is not considered available regardless of the PTC vote + // Equivalent to `if root not in store.payload_states` in the spec. if !node.payload_received { return false; } @@ -201,8 +216,7 @@ impl ProtoNode { return false; }; - // If the payload is not locally available, the payload - // is not considered available regardless of the PTC vote + // Equivalent to `if root not in store.payload_states` in the spec. if !node.payload_received { return false; } @@ -252,6 +266,8 @@ pub struct NodeDelta { pub empty_delta: i64, /// Weight change from `PayloadStatus::Full` votes. pub full_delta: i64, + /// Weight from equivocating validators that voted for this node. + pub equivocating_attestation_delta: u64, } impl NodeDelta { @@ -308,6 +324,7 @@ impl NodeDelta { delta, empty_delta: 0, full_delta: 0, + equivocating_attestation_delta: 0, } } @@ -370,10 +387,10 @@ impl ProtoArray { mut deltas: Vec, best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, - new_justified_balances: &JustifiedBalances, - proposer_boost_root: Hash256, + _new_justified_balances: &JustifiedBalances, + _proposer_boost_root: Hash256, current_slot: Slot, - spec: &ChainSpec, + _spec: &ChainSpec, ) -> Result<(), Error> { if deltas.len() != self.indices.len() { return Err(Error::InvalidDeltaLen { @@ -382,9 +399,6 @@ impl ProtoArray { }); } - // Default the proposer boost score to zero. - let mut proposer_score = 0; - // Iterate backwards through all indices in `self.nodes`. for node_index in (0..self.nodes.len()).rev() { let node = self @@ -412,7 +426,7 @@ impl ProtoArray { .copied() .ok_or(Error::InvalidNodeDelta(node_index))?; - let mut delta = if execution_status_is_invalid { + let 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) @@ -427,37 +441,9 @@ impl ProtoArray { (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 - { - delta = delta - .checked_sub(self.previous_proposer_boost.score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } - // 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). - // 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() - && !execution_status_is_invalid - { - proposer_score = - calculate_committee_fraction::(new_justified_balances, proposer_score_boost) - .ok_or(Error::ProposerBoostOverflow(node_index))?; - delta = delta - .checked_add(proposer_score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } + // Proposer boost is NOT applied here. It is computed on-the-fly + // during the virtual tree walk in `get_weight`, matching the spec's + // `get_weight` which adds boost separately from `get_attestation_score`. // Apply the delta to the node. if execution_status_is_invalid { @@ -473,6 +459,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)?; + node.equivocating_attestation_score = node + .equivocating_attestation_score + .saturating_add(node_delta.equivocating_attestation_delta); } // Update the parent delta (if any). @@ -514,67 +503,6 @@ impl ProtoArray { } } - // 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::( - proposer_boost_root, - 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`. - self.previous_proposer_boost = ProposerBoost { - root: proposer_boost_root, - score: proposer_score, - }; - // A second time, iterate backwards through all indices in `self.nodes`. // // We _must_ perform these functions separate from the weight-updating loop above to ensure @@ -611,6 +539,7 @@ impl ProtoArray { best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), Error> { // If the block is already known, simply ignore it. if self.indices.contains_key(&block.root) { @@ -642,6 +571,8 @@ impl ProtoArray { unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, }) } else { + let is_current_slot = current_slot == block.slot; + let execution_payload_block_hash = block .execution_payload_block_hash @@ -712,28 +643,27 @@ impl ProtoArray { // 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 + all_true_bitvector() } 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 + all_true_bitvector() } else { BitVector::default() }, payload_received: is_genesis, proposer_index: block.proposer_index.unwrap_or(0), - // TODO(gloas): initialise these based on block timing - block_timeliness_attestation_threshold: false, - block_timeliness_ptc_threshold: false, + // Spec: `record_block_timeliness` + `get_forkchoice_store`. + // Anchor gets [True, True]. Others computed from time_into_slot. + block_timeliness_attestation_threshold: is_genesis + || (is_current_slot + && time_into_slot < spec.get_unaggregated_attestation_due()), + // TODO(gloas): use GLOAS-specific PTC due threshold once + // `get_payload_attestation_due_ms` is on ChainSpec. + block_timeliness_ptc_threshold: is_genesis + || (is_current_slot && time_into_slot < spec.get_slot_duration() / 2), + equivocating_attestation_score: 0, }) }; @@ -776,6 +706,12 @@ impl ProtoArray { Ok(()) } + /// Spec: `is_head_weak`. + /// + /// The spec adds weight from equivocating validators in the head slot's + /// committees. We approximate this with `equivocating_attestation_score` + /// which tracks equivocating validators that voted for this block (close + /// but not identical to committee membership). fn is_head_weak( &self, head_node: &ProtoNode, @@ -788,11 +724,10 @@ impl ProtoArray { ) .unwrap_or(0); - let head_weight = head_node.attestation_score(PayloadStatus::Pending); + let head_weight = head_node + .attestation_score(PayloadStatus::Pending) + .saturating_add(head_node.equivocating_attestation_score().unwrap_or(0)); - // TODO(gloas): missing equivocating weight from spec - // idea: add equivocating_attestation_score on the proto node that is updated whenever - // an equivocation is processed. head_weight < reorg_threshold } @@ -1207,7 +1142,7 @@ impl ProtoArray { // In the post-Gloas world, always use a virtual tree walk. // // Best child/best descendant is dead. - let (best_fc_node, best_node) = self.find_head_walk::( + let best_fc_node = self.find_head_walk::( justified_index, current_slot, best_justified_checkpoint, @@ -1218,8 +1153,12 @@ impl ProtoArray { )?; // Perform a sanity check that the node is indeed valid to be the head. + let best_node = self + .nodes + .get(best_fc_node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(best_fc_node.proto_node_index))?; if !self.node_is_viable_for_head::( - &best_node, + best_node, current_slot, best_justified_checkpoint, best_finalized_checkpoint, @@ -1238,80 +1177,79 @@ impl ProtoArray { Ok((best_fc_node.root, best_fc_node.payload_status)) } - /// Virtual tree walk for `find_head`. - /// - /// At each node, determine the preferred payload direction (FULL or EMPTY) - /// by comparing weights. Scan all nodes to find the best child matching - /// the preferred direction. + /// Spec: `get_head`. #[allow(clippy::too_many_arguments)] fn find_head_walk( &self, start_index: usize, current_slot: Slot, best_justified_checkpoint: Checkpoint, - _best_finalized_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, proposer_boost_root: Hash256, justified_balances: &JustifiedBalances, spec: &ChainSpec, - ) -> Result<(IndexedForkChoiceNode, ProtoNode), Error> { + ) -> Result { let mut head = IndexedForkChoiceNode { root: best_justified_checkpoint.root, proto_node_index: start_index, payload_status: PayloadStatus::Pending, }; - let mut head_proto_node = self - .nodes - .get(start_index) - .ok_or(Error::NodeUnknown(best_justified_checkpoint.root))? - .clone(); loop { - let children = self.get_node_children(&head)?; + let children: Vec<_> = self + .get_node_children(&head)? + .into_iter() + .filter(|(_, proto_node)| { + // Spec: `get_filtered_block_tree` pre-filters to only include + // blocks on viable branches. We approximate this by checking + // viability of each child during the walk. + self.node_is_viable_for_head::( + proto_node, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ) + }) + .collect(); if children.is_empty() { - break; + return Ok(head); } - let scores = children + head = children .into_iter() - .map(|(child_fc_node, child_proto_node)| { + .map(|(child, _)| -> Result<_, Error> { + let proto_node = self + .nodes + .get(child.proto_node_index) + .ok_or(Error::InvalidNodeIndex(child.proto_node_index))?; let weight = self.get_weight::( - &child_fc_node, - &child_proto_node, + &child, + proto_node, proposer_boost_root, current_slot, justified_balances, spec, )?; let payload_status_tiebreaker = self.get_payload_status_tiebreaker::( - &child_fc_node, - &child_proto_node, + &child, + proto_node, current_slot, proposer_boost_root, )?; - Ok(( - child_fc_node, - child_proto_node, - weight, - payload_status_tiebreaker, - )) + Ok((child, weight, payload_status_tiebreaker)) }) - .collect::, Error>>()?; - // TODO(gloas): proper error - (head, head_proto_node) = scores + .collect::, Error>>()? .into_iter() - .max_by_key( - |(child_fc_node, _proto_node, weight, payload_status_tiebreaker)| { - (*weight, child_fc_node.root, *payload_status_tiebreaker) - }, - ) - .map(|(child_fc_node, child_proto_node, _, _)| (child_fc_node, child_proto_node)) - .unwrap(); + .max_by_key(|(child, weight, payload_status_tiebreaker)| { + (*weight, child.root, *payload_status_tiebreaker) + }) + .map(|(child, _, _)| child) + .expect("children is non-empty"); } - - Ok((head, head_proto_node)) } + /// Spec: `get_weight`. fn get_weight( &self, fc_node: &IndexedForkChoiceNode, @@ -1334,19 +1272,99 @@ impl ProtoArray { return Ok(attestation_score); } - // TODO(gloas): I don't think `is_supporting_vote` is necessary here, confirm by - // checking spec tests or with spec authors. - let proposer_score = if proto_node.root() == proposer_boost_root { + // Spec: proposer boost is treated as a synthetic vote. + let message = LatestMessage { + slot: current_slot, + root: proposer_boost_root, + payload_present: false, + }; + let proposer_score = if self.is_supporting_vote(fc_node, &message)? { get_proposer_score::(justified_balances, spec)? } else { 0 }; + Ok(attestation_score.saturating_add(proposer_score)) } else { Ok(0) } } + /// Spec: `is_supporting_vote`. + fn is_supporting_vote( + &self, + node: &IndexedForkChoiceNode, + message: &LatestMessage, + ) -> Result { + let block = self + .nodes + .get(node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + + if node.root == message.root { + if node.payload_status == PayloadStatus::Pending { + return Ok(true); + } + if message.slot <= block.slot() { + return Ok(false); + } + if message.payload_present { + Ok(node.payload_status == PayloadStatus::Full) + } else { + Ok(node.payload_status == PayloadStatus::Empty) + } + } else { + let ancestor = self.get_ancestor_node(message.root, block.slot())?; + Ok(node.root == ancestor.root + && (node.payload_status == PayloadStatus::Pending + || node.payload_status == ancestor.payload_status)) + } + } + + /// Spec: `get_ancestor` (modified to return ForkChoiceNode with payload_status). + fn get_ancestor_node(&self, root: Hash256, slot: Slot) -> Result { + let index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let block = self + .nodes + .get(index) + .ok_or(Error::InvalidNodeIndex(index))?; + + if block.slot() <= slot { + return Ok(IndexedForkChoiceNode { + root, + proto_node_index: index, + payload_status: PayloadStatus::Pending, + }); + } + + // Walk up until we find the ancestor at `slot`. + let mut child_index = index; + let mut current_index = block.parent().ok_or(Error::NodeUnknown(root))?; + + loop { + let current = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + + if current.slot() <= slot { + let child = self + .nodes + .get(child_index) + .ok_or(Error::InvalidNodeIndex(child_index))?; + return Ok(IndexedForkChoiceNode { + root: current.root(), + proto_node_index: current_index, + payload_status: child.get_parent_payload_status(), + }); + } + + child_index = current_index; + current_index = current.parent().ok_or(Error::NodeUnknown(root))?; + } + } + + /// Spec: `get_node_children`. fn get_node_children( &self, node: &IndexedForkChoiceNode, @@ -1355,30 +1373,15 @@ impl ProtoArray { let proto_node = self .nodes .get(node.proto_node_index) - .ok_or(Error::InvalidNodeIndex(node.proto_node_index))? - .clone(); - let mut children = vec![( - IndexedForkChoiceNode { - root: node.root, - proto_node_index: node.proto_node_index, - payload_status: PayloadStatus::Empty, - }, - proto_node.clone(), - )]; + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + let mut children = vec![(node.with_status(PayloadStatus::Empty), proto_node.clone())]; // The FULL virtual child only exists if the payload has been received. if proto_node.payload_received().is_ok_and(|received| received) { - children.push(( - IndexedForkChoiceNode { - root: node.root, - proto_node_index: node.proto_node_index, - payload_status: PayloadStatus::Full, - }, - proto_node, - )); + children.push((node.with_status(PayloadStatus::Full), proto_node.clone())); } Ok(children) } else { - let children = self + Ok(self .nodes .iter() .enumerate() @@ -1396,8 +1399,7 @@ impl ProtoArray { child_node.clone(), ) }) - .collect(); - Ok(children) + .collect()) } } @@ -1427,8 +1429,10 @@ impl ProtoArray { proto_node: &ProtoNode, proposer_boost_root: Hash256, ) -> Result { + // Per spec: `proposer_root == Root()` is one of the `or` conditions that + // makes `should_extend_payload` return True. if proposer_boost_root.is_zero() { - return Ok(false); + return Ok(true); } let proposer_boost_node_index = *self @@ -1440,20 +1444,18 @@ impl ProtoArray { .get(proposer_boost_node_index) .ok_or(Error::InvalidNodeIndex(proposer_boost_node_index))?; - // Check if the parent of the proposer boost node matches the fc_node's root - let Some(proposer_boost_parent_index) = proposer_boost_node.parent() else { - // TODO(gloas): could be an error - return Ok(false); - }; - let boost_parent_root = self + let parent_index = proposer_boost_node + .parent() + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let proposer_boost_parent_root = self .nodes - .get(proposer_boost_parent_index) - .ok_or(Error::InvalidNodeIndex(proposer_boost_parent_index))? + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? .root(); Ok( (proto_node.is_payload_timely::() && proto_node.is_payload_data_available::()) - || boost_parent_root != fc_node.root + || proposer_boost_parent_root != fc_node.root || proposer_boost_node.is_parent_node_full(), ) } @@ -1879,15 +1881,14 @@ pub fn calculate_committee_fraction( .checked_div(100) } -pub fn get_proposer_score( +/// Spec: `get_proposer_score`. +fn get_proposer_score( justified_balances: &JustifiedBalances, spec: &ChainSpec, ) -> Result { let Some(proposer_score_boost) = spec.proposer_score_boost else { - // TODO(gloas): make proposer boost non-optional in spec return Ok(0); }; - // TODO(gloas): fix error calculate_committee_fraction::(justified_balances, proposer_score_boost) .ok_or(Error::ProposerBoostOverflow(0)) } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 19be43511f..4be77b61ad 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -14,6 +14,7 @@ use ssz_derive::{Decode, Encode}; use std::{ collections::{BTreeSet, HashMap}, fmt, + time::Duration, }; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, @@ -75,6 +76,16 @@ pub struct IndexedForkChoiceNode { pub payload_status: PayloadStatus, } +impl IndexedForkChoiceNode { + pub fn with_status(&self, payload_status: PayloadStatus) -> Self { + Self { + root: self.root, + proto_node_index: self.proto_node_index, + payload_status, + } + } +} + impl ExecutionStatus { pub fn is_execution_enabled(&self) -> bool { !matches!(self, ExecutionStatus::Irrelevant(_)) @@ -491,6 +502,10 @@ impl ProtoArrayForkChoice { justified_checkpoint, finalized_checkpoint, spec, + // Anchor block is always timely (delay=0 ensures both timeliness + // checks pass). Combined with `is_genesis` override in on_block, + // this matches spec's `block_timeliness = {anchor: [True, True]}`. + Duration::ZERO, ) .map_err(|e| format!("Failed to add finalized block to proto_array: {:?}", e))?; @@ -590,6 +605,7 @@ impl ProtoArrayForkChoice { justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), String> { if block.parent_root.is_none() { return Err("Missing parent root".to_string()); @@ -602,6 +618,7 @@ impl ProtoArrayForkChoice { justified_checkpoint, finalized_checkpoint, spec, + time_into_slot, ) .map_err(|e| format!("process_block_error: {:?}", e)) } @@ -705,8 +722,10 @@ impl ProtoArrayForkChoice { .into()); } - // Only re-org if the parent's weight is greater than the parents configured committee fraction. - let parent_weight = info.parent_node.weight(); + // Spec: `is_parent_strong`. Use payload-aware weight matching the + // payload path the head node is on from its parent. + let parent_payload_status = info.head_node.get_parent_payload_status(); + let parent_weight = info.parent_node.attestation_score(parent_payload_status); 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 { @@ -1130,6 +1149,7 @@ fn compute_deltas( delta: 0, empty_delta: 0, full_delta: 0, + equivocating_attestation_delta: 0, }; indices.len() ]; @@ -1171,6 +1191,11 @@ fn compute_deltas( block_slot(current_delta_index)?, ); node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; + + // Track equivocating weight for `is_head_weak` monotonicity. + node_delta.equivocating_attestation_delta = node_delta + .equivocating_attestation_delta + .saturating_add(old_balance); } vote.current_root = Hash256::zero(); @@ -1322,6 +1347,7 @@ mod test_compute_deltas { genesis_checkpoint, genesis_checkpoint, &spec, + Duration::ZERO, ) .unwrap(); @@ -1351,6 +1377,7 @@ mod test_compute_deltas { genesis_checkpoint, genesis_checkpoint, &spec, + Duration::ZERO, ) .unwrap(); @@ -1487,6 +1514,7 @@ mod test_compute_deltas { genesis_checkpoint, genesis_checkpoint, &spec, + Duration::ZERO, ) .unwrap(); };