Skip to content

Gloas Forkchoice#8906

Closed
hopinheimer wants to merge 29 commits intosigp:unstablefrom
hopinheimer:gloas-fc-proto
Closed

Gloas Forkchoice#8906
hopinheimer wants to merge 29 commits intosigp:unstablefrom
hopinheimer:gloas-fc-proto

Conversation

@hopinheimer
Copy link
Copy Markdown
Member

Issue Addressed

#8675

This PR implements fork choice change for Gloas. note that the forkchoice tests for post-Gloas are ignored for the testing harness lacks support for new block production flow.

@hopinheimer hopinheimer added consensus An issue/PR that touches consensus code, such as state_processing or block verification. gloas fork-choice labels Feb 26, 2026
justified_checkpoint,
finalized_checkpoint,
execution_payload_parent_hash,
execution_payload_block_hash,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload should maybe have its own variant in the Operation enum so we can test interleaving of blocks, payloads, and other events, e.g.

  1. Process block 1
  2. Process attestations
  3. Process block 2 building on 1
  4. Process payload for block 1

etc

Comment thread consensus/proto_array/src/ssz_container.rs Outdated
Comment thread beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs
Comment thread consensus/proto_array/src/proto_array.rs
@hopinheimer hopinheimer mentioned this pull request Mar 5, 2026
40 tasks
Copy link
Copy Markdown
Member

@eserilev eserilev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reviewed proto_array.rs. Looks pretty good so far. Will continue reviewing the rest of this PR

Comment thread consensus/proto_array/src/proto_array.rs
Comment on lines +153 to +157
pub struct NodeDelta {
pub delta: i64,
pub empty_delta: i64,
pub full_delta: i64,
pub payload_tiebreaker: Option<PayloadTiebreak>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add doc comments about each field

Comment thread consensus/proto_array/src/proto_array.rs
Comment on lines +503 to +505
} else {
PayloadStatus::Full
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can find two conditions where this could occur

  1. starting a network w/ gloas at genesis.
  2. the parent was pruned due to finalization

for (1) not sure how we're supposed to handle gloas at genesis. for (2) this situation could only happen if we've called on_block for a block that violates finality. Should never make it into this code path in that case.

So I guess we just need to understand what a gloas genesis block should do, either build on empty or full. Full probably makes sense? might be worth a comment here

// `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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.

Comment on lines 296 to 301
let node_deltas = deltas
.get(node_index)
.copied()
.ok_or(Error::InvalidNodeDelta(node_index))?;

let mut node_delta = if execution_status_is_invalid {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we have a node_deltas and a node_delta variable which is a bit confusing from a readability standpoint.

I think node_deltas should be renamed to node_delta (to match the struct name)

and node_delta should be renamed to delta (to match the field name its referencing)

Comment on lines +874 to +876
if let Ok(execution_status) = justified_node.execution_status()
&& execution_status.is_invalid()
{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this would help make it clearer that this check is only relevant for v17 nodes

Suggested change
if let Ok(execution_status) = justified_node.execution_status()
&& execution_status.is_invalid()
{
if let Ok(node) = justified_node.as_v17()
&& node.execution_status.is_invalid()
{

Comment on lines +214 to +221
impl PartialEq<i64> for NodeDelta {
fn eq(&self, other: &i64) -> bool {
self.delta == *other
&& self.empty_delta == 0
&& self.full_delta == 0
&& self.payload_tiebreaker.is_none()
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this dead code? are we using this anywhere?

Comment on lines +1062 to +1074
} 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 {
} else if !child_matches && best_child_matches {
no_change
}
} else {
// Choose the winner by weight.
if child.weight > best_child.weight {
} else if *child.root() >= *best_child.root() {
// Final tie-breaker of equal weights by root.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add comments under each if statement to clarify what each condition means.

also should this code path have additional gloas related tie breaker logic from should_extend_payload?

Comment on lines +1338 to +1339
/// When equal, the tiebreaker uses the parent's `payload_tiebreak`: prefer Full if the block
/// was timely and data is available; otherwise prefer Empty.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't the full tie breaker this? :

(is_payload_timely(store, root) and is_payload_data_available(store, root))
  or proposer_root == Root()
  or store.blocks[proposer_root].parent_root != root
  or is_parent_node_full(store, store.blocks[proposer_root])

Copy link
Copy Markdown
Member

@eserilev eserilev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made some more progress here. I'm about halfway through.


/// 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).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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).

} else if let Ok(signed_bid) =
anchor_block.message().body().signed_execution_payload_bid()
{
// Gloas: hashes come from the execution payload bid.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExecutionStatus is always irrelevant post gloas right?

}

// Post-GLOAS: same-slot attestations with index != 0 indicate a payload-present vote.
// These must go through `on_payload_attestation`, not `on_attestation`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by these must go through on_payload_attestation. aren't these just invalid attestations? a PayloadAttestation seems to be a different concept

I think you can just reword the comment to say that same slot attestations with index !=0 are invalid

&& indexed_attestation.data().slot == block.slot
&& indexed_attestation.data().index != 0
{
return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this error variant is misleading as well, since these are not PayloadAttestations, these are just regular attestations

})?;

if block.slot > indexed_payload_attestation.data.slot {
return Err(InvalidAttestation::AttestsToFutureBlock {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if instead of re-using InvalidAttestation error variants we should look at introducing InvalidPayloadAttestation error variants

&& indexed_payload_attestation.data.slot != self.fc_store.get_current_slot()
{
return Err(InvalidAttestation::PayloadAttestationNotCurrentSlot {
attestation_slot: indexed_payload_attestation.data.slot,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we introduced a separate InvalidPayloadAttestation error enum, we won't have to mix payload attestation specific error variants with attestation specific error variants

});
}

if self.fc_store.get_current_slot() == block.slot
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this also check is_from_block == true? I think we might be inadvertently rejecting valid gossip payload attestations?


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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// while non-block payload attestations are delayed one extra slot.
// while gossiped payload attestations are delayed one extra slot.

Comment thread consensus/proto_array/src/error.rs Outdated
},
InvalidEpochOffset(u64),
Arith(ArithError),
GloasNotImplemented,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temp variant?

variants(V17),
variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)),
no_enum
variants(V17, V29),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to keep both versions implemented? Why not migrate all V17 nodes to V29 nodes once and not superstruct this? Then the API of the ProtoNode remains unchanged

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V17 variants will be gone once the Gloas fork epoch is finalized anyway. So they can be removed in a future schema migration.

}

#[derive(Clone, PartialEq, Debug, Copy)]
pub struct NodeDelta {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also docs for the struct itself

Comment on lines +1004 to +1027
pub fn head_payload_status<E: EthSpec>(
&self,
head_root: &Hash256,
_current_slot: Slot,
) -> Option<PayloadStatus> {
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)
} else {
Some(PayloadStatus::Empty)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think were missing the payload tiebreaker logic here when node.slot + 1 == current_slot. I added this locally and got the on_execution_payload ef test to pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh it seems like this function is only being used in tests

#[superstruct(only(V29), partial_getter(copy))]
pub full_payload_weight: u64,
#[superstruct(only(V29), partial_getter(copy))]
pub execution_payload_block_hash: ExecutionBlockHash,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a test that this equals the expected value from EthSpec.

Comment on lines +657 to +661
let mut bv = BitVector::new();
for i in 0..bv.len() {
let _ = bv.set(i, true);
}
bv
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: looks like we generate a full bit vector in the genesis case for both payload_timeliness_votes and payload_data_availability_votes. Would be nice to extract this out into separate helper method. Maybe even within the BitVector impl inside the ssz crate (e.g.BitVector::full())

Comment on lines +749 to +760
// 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::<E>(
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.
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this isn't consistent with the spec. is_head_weak counts attestation weight from equivocating validators. parent.weight() reflects post-equivocation and post-delta weight calculations.

I think this makes us consider the parent weaker than the spec intended, which can cause us to skip proposer boosts in certain edge cases. Don't think we need to fix it in this PR but worth a TODO

@hopinheimer
Copy link
Copy Markdown
Member Author

closing this for now in favor of #9025

@hopinheimer hopinheimer closed this Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

consensus An issue/PR that touches consensus code, such as state_processing or block verification. fork-choice gloas

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants