Gloas spec v1.7.0-alpha.5 and beacon_chain tests (#8998)

Fix database pruning post-Gloas


  - Fix DB pruning logic (and state summaries DAG)
- Get the `beacon_chain` tests running with `FORK_NAME=gloas` 🎉


Co-Authored-By: Michael Sproul <michael@sigmaprime.io>

Co-Authored-By: Jimmy Chen <jchen.tc@gmail.com>

Co-Authored-By: Eitan Seri- Levi <eserilev@gmail.com>

Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com>

Co-Authored-By: Eitan Seri-Levi <eserilev@ucsc.edu>
This commit is contained in:
Michael Sproul
2026-04-21 16:29:15 +10:00
committed by GitHub
parent c028bac28d
commit cf3d5e285e
82 changed files with 1513 additions and 1391 deletions

View File

@@ -109,6 +109,8 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition {
pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition {
let mut ops = vec![];
// Block 1 at slot 1: child of genesis. Genesis has execution_payload_block_hash=zero
// (no execution payload at genesis), so all children have parent_payload_status=Empty.
ops.push(Operation::ProcessBlock {
slot: Slot::new(1),
root: get_root(1),
@@ -212,8 +214,10 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition {
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)),
// Genesis has zero execution block hash (no payload at genesis), which
// ensures all children get parent_payload_status=Empty.
execution_payload_parent_hash: Some(ExecutionBlockHash::zero()),
execution_payload_block_hash: Some(ExecutionBlockHash::zero()),
spec: Some(gloas_spec()),
}
}
@@ -600,18 +604,20 @@ 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
/// Scenario (branching at block 1 since genesis has no payload):
/// - Genesis block (slot 0) with zero execution block hash
/// - Block 1 (slot 1) child of genesis (Empty parent status since genesis hash=zero)
/// - Block 2 (slot 2) extends block 1 Full chain (parent_hash matches block 1's block_hash)
/// - Block 3 (slot 2) extends block 1 Empty chain (parent_hash doesn't match)
/// - Before payload arrives: payload_received is false for block 1, only Empty reachable
/// - 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)
/// - Both Full and Empty directions from block 1 become available
/// - With equal weight, tiebreaker prefers Full → Block 2 wins
pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTestDefinition {
let mut ops = vec![];
// Block 1 at slot 1: extends genesis Full chain.
// Block 1 at slot 1: child of genesis. Genesis has zero block hash, so
// parent_payload_status = Empty regardless of block 1's execution_payload_parent_hash.
ops.push(Operation::ProcessBlock {
slot: Slot::new(1),
root: get_root(1),
@@ -622,83 +628,94 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe
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).
// Block 2 at slot 2: Full child of block 1 (parent_hash matches block 1's block_hash).
ops.push(Operation::ProcessBlock {
slot: Slot::new(1),
slot: Slot::new(2),
root: get_root(2),
parent_root: get_root(0),
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)),
});
// Block 3 at slot 2: Empty child of block 1 (parent_hash doesn't match block 1's block_hash).
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(99)),
execution_payload_block_hash: Some(get_hash(100)),
execution_payload_block_hash: Some(get_hash(3)),
});
// Both children have parent_payload_status set correctly.
// Verify parent_payload_status is set correctly.
ops.push(Operation::AssertParentPayloadStatus {
block_root: get_root(1),
expected_status: PayloadStatus::Empty,
});
ops.push(Operation::AssertParentPayloadStatus {
block_root: get_root(2),
expected_status: PayloadStatus::Full,
});
ops.push(Operation::AssertParentPayloadStatus {
block_root: get_root(2),
block_root: get_root(3),
expected_status: PayloadStatus::Empty,
});
// Per spec `get_forkchoice_store`: genesis starts with payload_received=true
// (anchor block is in `payload_states`).
// Genesis does NOT have payload_received (no payload at genesis).
ops.push(Operation::AssertPayloadReceived {
block_root: get_root(0),
expected: true,
expected: false,
});
// Give one vote to each child so they have equal weight.
// Block 1 does not have payload_received yet.
ops.push(Operation::AssertPayloadReceived {
block_root: get_root(1),
expected: false,
});
// Give one vote to each competing child so they have equal weight.
ops.push(Operation::ProcessAttestation {
validator_index: 0,
block_root: get_root(1),
attestation_slot: Slot::new(1),
block_root: get_root(2),
attestation_slot: Slot::new(2),
});
ops.push(Operation::ProcessAttestation {
validator_index: 1,
block_root: get_root(2),
attestation_slot: Slot::new(1),
block_root: get_root(3),
attestation_slot: Slot::new(2),
});
// 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.
// Before payload_received on block 1: only Empty direction available.
// Block 3 (Empty child) is reachable, Block 2 (Full child) is not.
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),
expected_head: get_root(3),
current_slot: Slot::new(100),
expected_payload_status: None,
});
// ProcessExecutionPayloadEnvelope on genesis is a no-op (already received at init).
// Process execution payload envelope for block 1 → payload_received becomes true.
ops.push(Operation::ProcessExecutionPayloadEnvelope {
block_root: get_root(0),
block_root: get_root(1),
});
ops.push(Operation::AssertPayloadReceived {
block_root: get_root(0),
block_root: get_root(1),
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,
});
// Still prefers Full via payload_received tiebreaker → Block 1 (Full) wins.
// After payload_received on block 1: both Full and Empty directions available.
// Equal weight, tiebreaker prefers Full → Block 2 (Full child) wins.
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),
expected_head: get_root(2),
current_slot: Slot::new(100),
expected_payload_status: None,
});
@@ -708,8 +725,9 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe
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)),
// Genesis has zero execution block hash (no payload at genesis).
execution_payload_parent_hash: Some(ExecutionBlockHash::zero()),
execution_payload_block_hash: Some(ExecutionBlockHash::zero()),
spec: Some(gloas_spec()),
}
}

View File

@@ -568,8 +568,10 @@ impl ProtoArray {
ProtoNode::V29(v29) => {
// Both parent and child are Gloas blocks. The parent is full if the
// block hash in the parent node matches the parent block hash in the
// child bid.
if execution_payload_parent_hash == v29.execution_payload_block_hash {
// child bid and the parent block isn't the genesis block.
if v29.execution_payload_block_hash != ExecutionBlockHash::zero()
&& execution_payload_parent_hash == v29.execution_payload_block_hash
{
PayloadStatus::Full
} else {
PayloadStatus::Empty
@@ -582,18 +584,16 @@ impl ProtoArray {
}
}
} else {
// TODO(gloas): re-assess this assumption
// Parent is missing (genesis or pruned due to finalization). Default to Full
// since this path should only be hit at Gloas genesis.
PayloadStatus::Full
// Parent is missing (genesis or pruned due to finalization). This code path
// should only be hit at Gloas genesis. Default to empty, the genesis block
// has no payload enevelope.
PayloadStatus::Empty
};
// 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();
// The spec does something slightly strange where it initialises the payload timeliness
// votes and payload data availability votes for the anchor block to all true, but never
// adds the anchor to `store.payloads`, so it is never considered full.
let is_anchor = parent_index.is_none();
ProtoNode::V29(ProtoNodeV29 {
slot: block.slot,
@@ -614,26 +614,25 @@ impl ProtoArray {
execution_payload_block_hash,
execution_payload_parent_hash,
// 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 {
// initialized to all-True.
payload_timeliness_votes: if is_anchor {
all_true_bitvector()
} else {
BitVector::default()
},
payload_data_availability_votes: if is_genesis {
payload_data_availability_votes: if is_anchor {
all_true_bitvector()
} else {
BitVector::default()
},
payload_received: is_genesis,
payload_received: false,
proposer_index,
// Spec: `record_block_timeliness` + `get_forkchoice_store`.
// Anchor gets [True, True]. Others computed from time_into_slot.
block_timeliness_attestation_threshold: is_genesis
block_timeliness_attestation_threshold: is_anchor
|| (is_current_slot
&& time_into_slot < spec.get_attestation_due::<E>(current_slot)),
block_timeliness_ptc_threshold: is_genesis
block_timeliness_ptc_threshold: is_anchor
|| (is_current_slot && time_into_slot < spec.get_payload_attestation_due()),
equivocating_attestation_score: 0,
})
@@ -1438,7 +1437,7 @@ impl ProtoArray {
}
}
fn should_extend_payload<E: EthSpec>(
pub fn should_extend_payload<E: EthSpec>(
&self,
fc_node: &IndexedForkChoiceNode,
proto_node: &ProtoNode,

View File

@@ -17,7 +17,7 @@ use std::{
};
use types::{
AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256,
Slot, StatePayloadStatus,
Slot,
};
pub const DEFAULT_PRUNE_THRESHOLD: usize = 256;
@@ -110,19 +110,6 @@ pub enum PayloadStatus {
Pending = 2,
}
impl PayloadStatus {
/// Convert a `PayloadStatus` into the equivalent `StatePayloadStatus`.
///
/// This maps `Empty` onto `StatePayloadStatus::Pending` because empty and pending fork choice
/// nodes correspond to the exact same state.
pub fn as_state_payload_status(self) -> StatePayloadStatus {
match self {
Self::Empty | Self::Pending => StatePayloadStatus::Pending,
Self::Full => StatePayloadStatus::Full,
}
}
}
/// Spec's `ForkChoiceNode` augmented with ProtoNode index.
pub struct IndexedForkChoiceNode {
pub root: Hash256,
@@ -1019,6 +1006,34 @@ impl ProtoArrayForkChoice {
})
}
/// Returns whether the proposer should extend the parent's execution payload chain.
///
/// This checks timeliness, data availability, and proposer boost conditions per the spec.
pub fn should_extend_payload<E: EthSpec>(
&self,
block_root: &Hash256,
proposer_boost_root: Hash256,
) -> Result<bool, String> {
let block_index = self
.proto_array
.indices
.get(block_root)
.ok_or_else(|| format!("Unknown block root: {block_root:?}"))?;
let proto_node = self
.proto_array
.nodes
.get(*block_index)
.ok_or_else(|| format!("Missing node at index: {block_index}"))?;
let fc_node = IndexedForkChoiceNode {
root: proto_node.root(),
proto_node_index: *block_index,
payload_status: proto_node.get_parent_payload_status(),
};
self.proto_array
.should_extend_payload::<E>(&fc_node, proto_node, proposer_boost_root)
.map_err(|e| format!("{e:?}"))
}
/// Returns the `block.execution_status` field, if the block is present.
pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option<ExecutionStatus> {
let block = self.get_proto_node(block_root)?;