Merge branch 'gloas-walk-always' into epbs-devnet-1

This commit is contained in:
Eitan Seri-Levi
2026-04-04 01:18:38 +09:00
committed by GitHub
16 changed files with 277 additions and 200 deletions

View File

@@ -98,7 +98,7 @@ pub enum Operation {
},
/// Simulate receiving and validating an execution payload for `block_root`.
/// Sets `payload_received = true` on the V29 node via the live validation path.
ProcessExecutionPayload {
ProcessExecutionPayloadEnvelope {
block_root: Hash256,
},
AssertPayloadReceived {
@@ -500,9 +500,9 @@ impl ForkChoiceTestDefinition {
// the payload to be in payload_states (payload_received).
node_v29.payload_received = is_timely || is_data_available;
}
Operation::ProcessExecutionPayload { block_root } => {
Operation::ProcessExecutionPayloadEnvelope { block_root } => {
fork_choice
.on_execution_payload(block_root)
.on_valid_payload_envelope_received(block_root)
.unwrap_or_else(|e| {
panic!(
"on_execution_payload op at index {} returned error: {}",

View File

@@ -53,7 +53,7 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition {
// 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 {
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(1),
});
@@ -263,7 +263,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe
// 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 {
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(1),
});
@@ -368,7 +368,7 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition()
// 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 {
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(1),
});
@@ -538,7 +538,7 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef
// 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 {
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(1),
});
@@ -674,8 +674,8 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe
expected_payload_status: None,
});
// ProcessExecutionPayload on genesis is a no-op (already received at init).
ops.push(Operation::ProcessExecutionPayload {
// ProcessExecutionPayloadEnvelope on genesis is a no-op (already received at init).
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(0),
});
@@ -778,7 +778,7 @@ mod tests {
// Mark root 2's execution payload as received so the Full virtual child exists.
if first_gloas_block_full {
ops.push(Operation::ProcessExecutionPayload {
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(2),
});
}

View File

@@ -165,8 +165,6 @@ pub struct ProtoNode {
pub payload_data_availability_votes: BitVector<U512>,
/// 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,
/// The proposer index for this block, used by `should_apply_proposer_boost`
@@ -369,7 +367,6 @@ pub struct ProtoArray {
pub prune_threshold: usize,
pub nodes: Vec<ProtoNode>,
pub indices: HashMap<Hash256, usize>,
pub previous_proposer_boost: ProposerBoost,
}
impl ProtoArray {
@@ -492,20 +489,14 @@ impl ProtoArray {
.ok_or(Error::DeltaOverflow(parent_index))?;
}
} 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))?;
// This is a v17 node with a v17 parent.
// There is no empty or full weight for v17 nodes, so nothing to propagate.
// In the tree walk, the v17 nodes have an empty child with 0 weight, which
// wins by default (it is the only child).
}
}
}
// Proposer boost is now applied on-the-fly in `get_weight` during the
// walk, so clear any stale boost from a prior call.
self.previous_proposer_boost = ProposerBoost::default();
Ok(())
}
@@ -641,11 +632,9 @@ impl ProtoArray {
// 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.
&& time_into_slot < spec.get_attestation_due::<E>(current_slot)),
block_timeliness_ptc_threshold: is_genesis
|| (is_current_slot && time_into_slot < spec.get_slot_duration() / 2),
|| (is_current_slot && time_into_slot < spec.get_payload_attestation_due()),
equivocating_attestation_score: 0,
})
};
@@ -682,11 +671,17 @@ impl ProtoArray {
}
/// 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).
// TODO(gloas): the spec adds weight from equivocating validators in the
// head slot's *committees*, regardless of who they voted for. We approximate
// with `equivocating_attestation_score` which only tracks equivocating
// validators whose vote pointed at this block. This under-counts when an
// equivocating validator is in the committee but voted for a different fork,
// which could allow a re-org the spec wouldn't. In practice the deviation
// is small — it requires equivocating validators voting for competing forks
// AND the head weight to be exactly at the reorg threshold boundary.
// Fixing this properly requires committee computation from BeaconState,
// which is not available in proto_array. The fix would be to pass
// pre-computed equivocating committee weight from the beacon_chain caller.
fn is_head_weak<E: EthSpec>(
&self,
head_node: &ProtoNode,
@@ -729,7 +724,6 @@ impl ProtoArray {
.nodes
.get(block_index)
.ok_or(Error::InvalidNodeIndex(block_index))?;
// TODO(gloas): handle parent unknown case?
let parent_index = block
.parent()
.ok_or(Error::NodeUnknown(proposer_boost_root))?;
@@ -753,7 +747,6 @@ impl ProtoArray {
// the parent's slot from the same proposer.
let parent_slot = parent.slot();
let parent_root = parent.root();
// TODO(gloas): handle proposer index for pre-Gloas blocks?
let parent_proposer = parent.proposer_index();
let has_equivocation = self.nodes.iter().any(|node| {
@@ -773,12 +766,10 @@ impl ProtoArray {
Ok(!has_equivocation)
}
/// Process an execution payload for a Gloas block.
/// Process a valid execution payload envelope for a Gloas block.
///
/// 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> {
/// Sets `payload_received` to true.
pub fn on_valid_payload_envelope_received(&mut self, block_root: Hash256) -> Result<(), Error> {
let index = *self
.indices
.get(&block_root)
@@ -814,6 +805,8 @@ impl ProtoArray {
/// Updates the `verified_node_index` and all ancestors to have validated execution payloads.
///
/// This function is a no-op if called for a Gloas block.
///
/// Returns an error if:
///
/// - The `verified_node_index` is unknown.
@@ -857,18 +850,10 @@ impl ProtoArray {
});
}
},
// 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 {
return Ok(());
}
// Gloas nodes should not be marked valid by this function, which exists only
// for pre-Gloas fork choice.
ProtoNode::V29(_) => {
return Ok(());
}
};
@@ -879,6 +864,7 @@ impl ProtoArray {
/// Invalidate zero or more blocks, as specified by the `InvalidationOperation`.
///
/// See the documentation of `InvalidationOperation` for usage.
// TODO(gloas): this needs some tests for the mixed Gloas/pre-Gloas case.
pub fn propagate_execution_payload_invalidation<E: EthSpec>(
&mut self,
op: &InvalidationOperation,
@@ -978,7 +964,7 @@ impl ProtoArray {
// This block is pre-merge, therefore it has no execution status. Nor do its
// ancestors.
Ok(ExecutionStatus::Irrelevant(_)) => break,
Err(_) => (),
Err(_) => break,
}
}
@@ -1087,9 +1073,6 @@ impl ProtoArray {
});
}
// In the post-Gloas world, always use a virtual tree walk.
//
// Best child/best descendant is dead.
let best_fc_node = self.find_head_walk::<E>(
justified_index,
current_slot,
@@ -1125,26 +1108,6 @@ impl ProtoArray {
Ok((best_fc_node.root, best_fc_node.payload_status))
}
/// Build a parent->children index. Invalid nodes are excluded
/// (they aren't in store.blocks in the spec).
fn build_children_index(&self) -> Vec<Vec<usize>> {
let mut children = vec![vec![]; self.nodes.len()];
for (i, node) in self.nodes.iter().enumerate() {
if node
.execution_status()
.is_ok_and(|status| status.is_invalid())
{
continue;
}
if let Some(parent) = node.parent()
&& parent < children.len()
{
children[parent].push(i);
}
}
children
}
/// Spec: `get_filtered_block_tree`.
///
/// Returns the set of node indices on viable branches — those with at least
@@ -1155,7 +1118,6 @@ impl ProtoArray {
current_slot: Slot,
best_justified_checkpoint: Checkpoint,
best_finalized_checkpoint: Checkpoint,
children_index: &[Vec<usize>],
) -> HashSet<usize> {
let mut viable = HashSet::new();
self.filter_block_tree::<E>(
@@ -1163,7 +1125,6 @@ impl ProtoArray {
current_slot,
best_justified_checkpoint,
best_finalized_checkpoint,
children_index,
&mut viable,
);
viable
@@ -1176,17 +1137,25 @@ impl ProtoArray {
current_slot: Slot,
best_justified_checkpoint: Checkpoint,
best_finalized_checkpoint: Checkpoint,
children_index: &[Vec<usize>],
viable: &mut HashSet<usize>,
) -> bool {
let Some(node) = self.nodes.get(node_index) else {
return false;
};
let children = children_index
.get(node_index)
.map(|c| c.as_slice())
.unwrap_or(&[]);
// Skip invalid children — they aren't in store.blocks in the spec.
let children: Vec<usize> = self
.nodes
.iter()
.enumerate()
.filter(|(_, child)| {
child.parent() == Some(node_index)
&& !child
.execution_status()
.is_ok_and(|status| status.is_invalid())
})
.map(|(i, _)| i)
.collect();
if !children.is_empty() {
// Evaluate ALL children (no short-circuit) to mark all viable branches.
@@ -1198,7 +1167,6 @@ impl ProtoArray {
current_slot,
best_justified_checkpoint,
best_finalized_checkpoint,
children_index,
viable,
)
})
@@ -1243,16 +1211,12 @@ impl ProtoArray {
payload_status: PayloadStatus::Pending,
};
// Build parent->children index once for O(1) lookups.
let children_index = self.build_children_index();
// Spec: `get_filtered_block_tree`.
let viable_nodes = self.get_filtered_block_tree::<E>(
start_index,
current_slot,
best_justified_checkpoint,
best_finalized_checkpoint,
&children_index,
);
// Compute once rather than per-child per-level.
@@ -1261,7 +1225,7 @@ impl ProtoArray {
loop {
let children: Vec<_> = self
.get_node_children(&head, &children_index)?
.get_node_children(&head)?
.into_iter()
.filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index))
.collect();
@@ -1272,11 +1236,7 @@ impl ProtoArray {
head = children
.into_iter()
.map(|(child, _)| -> Result<_, Error> {
let proto_node = self
.nodes
.get(child.proto_node_index)
.ok_or(Error::InvalidNodeIndex(child.proto_node_index))?;
.map(|(child, ref proto_node)| -> Result<_, Error> {
let weight = self.get_weight::<E>(
&child,
proto_node,
@@ -1424,7 +1384,6 @@ impl ProtoArray {
fn get_node_children(
&self,
node: &IndexedForkChoiceNode,
children_index: &[Vec<usize>],
) -> Result<Vec<(IndexedForkChoiceNode, ProtoNode)>, Error> {
if node.payload_status == PayloadStatus::Pending {
let proto_node = self
@@ -1451,25 +1410,23 @@ impl ProtoArray {
Ok(children)
} else {
let child_indices = children_index
.get(node.proto_node_index)
.map(|c| c.as_slice())
.unwrap_or(&[]);
Ok(child_indices
Ok(self
.nodes
.iter()
.filter_map(|&child_index| {
let child_node = self.nodes.get(child_index)?;
if child_node.get_parent_payload_status() != node.payload_status {
return None;
}
Some((
.enumerate()
.filter(|(_, child_node)| {
child_node.parent() == Some(node.proto_node_index)
&& child_node.get_parent_payload_status() == node.payload_status
})
.map(|(child_index, child_node)| {
(
IndexedForkChoiceNode {
root: child_node.root(),
proto_node_index: child_index,
payload_status: PayloadStatus::Pending,
},
child_node.clone(),
))
)
})
.collect())
}

View File

@@ -2,8 +2,7 @@ use crate::{
JustifiedBalances,
error::Error,
proto_array::{
InvalidationOperation, Iter, NodeDelta, ProposerBoost, ProtoArray, ProtoNode,
calculate_committee_fraction,
InvalidationOperation, Iter, NodeDelta, ProtoArray, ProtoNode, calculate_committee_fraction,
},
ssz_container::SszContainer,
};
@@ -74,6 +73,7 @@ impl From<VoteTracker> for VoteTrackerV28 {
}
}
/// Spec's `LatestMessage` type. Only used in tests.
pub struct LatestMessage {
pub slot: Slot,
pub root: Hash256,
@@ -527,7 +527,6 @@ impl ProtoArrayForkChoice {
prune_threshold: DEFAULT_PRUNE_THRESHOLD,
nodes: Vec::with_capacity(1),
indices: HashMap::with_capacity(1),
previous_proposer_boost: ProposerBoost::default(),
};
let block = Block {
@@ -569,11 +568,18 @@ impl ProtoArrayForkChoice {
})
}
pub fn on_execution_payload(&mut self, block_root: Hash256) -> Result<(), String> {
/// Mark a Gloas payload envelope as valid and received.
///
/// This must only be called for valid Gloas payloads.
pub fn on_valid_payload_envelope_received(
&mut self,
block_root: Hash256,
) -> Result<(), String> {
self.proto_array
.on_valid_execution_payload(block_root)
.on_valid_payload_envelope_received(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,
@@ -880,10 +886,7 @@ impl ProtoArrayForkChoice {
/// status to be optimistic.
///
/// In practice this means forgetting any `VALID` or `INVALID` statuses.
pub fn set_all_blocks_to_optimistic<E: EthSpec>(
&mut self,
spec: &ChainSpec,
) -> Result<(), String> {
pub fn set_all_blocks_to_optimistic<E: EthSpec>(&mut self) -> Result<(), String> {
// Iterate backwards through all nodes in the `proto_array`. Whilst it's not strictly
// required to do this process in reverse, it seems natural when we consider how LMD votes
// are counted.
@@ -906,7 +909,7 @@ impl ProtoArrayForkChoice {
// Restore the weight of the node, it would have been set to `0` in
// `apply_score_changes` when it was invalidated.
let mut restored_weight: u64 = self
let restored_weight: u64 = self
.votes
.0
.iter()
@@ -922,26 +925,6 @@ impl ProtoArrayForkChoice {
})
.sum();
// 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()
{
// 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
// invalid node.
let proposer_score =
calculate_committee_fraction::<E>(&self.balances, proposer_score_boost)
.ok_or("Failed to compute proposer boost")?;
// Store the score we've applied here so it can be removed in
// a later call to `apply_score_changes`.
self.proto_array.previous_proposer_boost.score = proposer_score;
// Apply this boost to this node.
restored_weight = restored_weight
.checked_add(proposer_score)
.ok_or("Overflow when adding boost to weight")?;
}
// Add the restored weight to the node and all ancestors.
if restored_weight > 0 {
let mut node_or_ancestor = node;
@@ -1082,10 +1065,9 @@ impl ProtoArrayForkChoice {
.is_finalized_checkpoint_or_descendant::<E>(descendant_root, best_finalized_checkpoint)
}
/// NOTE: only used in tests.
pub fn latest_message(&self, validator_index: usize) -> Option<LatestMessage> {
if validator_index < self.votes.0.len() {
let vote = &self.votes.0[validator_index];
if let Some(vote) = self.votes.0.get(validator_index) {
if *vote == VoteTracker::default() {
None
} else {

View File

@@ -38,6 +38,7 @@ pub struct SszContainer {
#[superstruct(only(V29))]
pub nodes: Vec<ProtoNode>,
pub indices: Vec<(Hash256, usize)>,
#[superstruct(only(V28))]
pub previous_proposer_boost: ProposerBoost,
}
@@ -50,7 +51,6 @@ impl SszContainerV29 {
prune_threshold: proto_array.prune_threshold,
nodes: proto_array.nodes.clone(),
indices: proto_array.indices.iter().map(|(k, v)| (*k, *v)).collect(),
previous_proposer_boost: proto_array.previous_proposer_boost,
}
}
}
@@ -63,7 +63,6 @@ impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice {
prune_threshold: from.prune_threshold,
nodes: from.nodes,
indices: from.indices.into_iter().collect::<HashMap<_, _>>(),
previous_proposer_boost: from.previous_proposer_boost,
};
Ok(Self {
@@ -92,7 +91,6 @@ impl From<SszContainerV28> for SszContainerV29 {
})
.collect(),
indices: v28.indices,
previous_proposer_boost: v28.previous_proposer_boost,
}
}
}
@@ -116,7 +114,8 @@ impl From<SszContainerV29> for SszContainerV28 {
})
.collect(),
indices: v29.indices,
previous_proposer_boost: v29.previous_proposer_boost,
// Proposer boost is not tracked in V29 (computed on-the-fly), so reset it.
previous_proposer_boost: ProposerBoost::default(),
}
}
}