From 09a8573148e6ac46c382933cd9ac6fe59e2c1b47 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 1 Jun 2026 21:36:32 -0400 Subject: [PATCH 01/32] enable re-orgs and post-gloas re-orgs tests --- beacon_node/beacon_chain/src/beacon_chain.rs | 26 +- .../beacon_chain/src/block_production/mod.rs | 82 +++-- beacon_node/beacon_chain/src/test_utils.rs | 91 +++++- .../http_api/tests/gloas_reorg_tests.rs | 283 ++++++++++++++++++ beacon_node/http_api/tests/main.rs | 1 + .../src/proto_array_fork_choice.rs | 14 +- 6 files changed, 455 insertions(+), 42 deletions(-) create mode 100644 beacon_node/http_api/tests/gloas_reorg_tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f3f6cd299e5..a411b251de7 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5210,6 +5210,15 @@ impl BeaconChain { &self, canonical_forkchoice_params: ForkchoiceUpdateParameters, ) -> Result { + let current_slot = self.slot()?; + if self + .spec + .fork_name_at_slot::(current_slot) + .gloas_enabled() + { + return Ok(canonical_forkchoice_params); + } + self.overridden_forkchoice_update_params_or_failure_reason(&canonical_forkchoice_params) .or_else(|e| match *e { ProposerHeadError::DoNotReOrg(reason) => { @@ -5321,10 +5330,19 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // TODO(gloas): reorg weight logic needs updating for Gloas. For now use - // total weight which is correct for pre-Gloas and conservative for post-Gloas. - let head_weight = info.head_node.weight(); - let parent_weight = info.parent_node.weight(); + // Spec-aligned re-org weight checks. For Gloas (V29) nodes this uses + // payload-aware bucket weights matching `is_parent_strong`/`is_head_weak`; + // for pre-Gloas (V17) nodes `attestation_score` falls back to `weight()`. + let parent_payload_status = info.head_node.get_parent_payload_status(); + let parent_weight = info.parent_node.attestation_score(parent_payload_status); + let head_weight = info + .head_node + .attestation_score(fork_choice::PayloadStatus::Pending) + .saturating_add( + info.head_node + .equivocating_attestation_score() + .unwrap_or(0), + ); let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index fd5e3810232..479364f541b 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -21,6 +21,14 @@ pub(crate) struct BlockProductionState { pub parent_envelope: Option>>, } +/// Inputs assembled for producing a block via a proposer re-org. +struct ReOrgInputs { + state: BeaconState, + state_root: Hash256, + parent_payload_status: PayloadStatus, + parent_envelope: Option>>, +} + impl BeaconChain { /// Load a beacon state from the database for block production. This is a long-running process /// that should not be performed in an `async` context. @@ -40,54 +48,47 @@ impl BeaconChain { // Atomically read some values from the head whilst avoiding holding cached head `Arc` any // longer than necessary. If the head has a payload envelope (Gloas full head), cheaply // clone the `Arc` so we can pass it to block production without a DB load. - let (head_slot, head_block_root, head_state_root, head_payload_status, head_envelope) = { + let (head_slot, head_block_root, head_state_root) = { let head = self.canonical_head.cached_head(); ( head.head_slot(), head.head_block_root(), head.head_state_root(), - head.head_payload_status(), - head.snapshot.execution_envelope.clone(), ) }; + let result = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. - // TODO(gloas): re-enable reorgs - let gloas_enabled = self - .spec - .fork_name_at_slot::(slot) - .gloas_enabled(); - if !gloas_enabled - && let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) - { + if let Some(inputs) = self.get_state_for_re_org(slot, head_slot, head_block_root) { info!( %slot, head_to_reorg = %head_block_root, "Proposing block to re-org current head" ); - // TODO(gloas): ensure we use a sensible payload status when we enable reorgs - // for Gloas BlockProductionState { - state: re_org_state, - state_root: Some(re_org_state_root), - parent_payload_status: PayloadStatus::Pending, - parent_envelope: None, + state: inputs.state, + state_root: Some(inputs.state_root), + parent_payload_status: inputs.parent_payload_status, + parent_envelope: inputs.parent_envelope, } } else { - // Fetch the head state advanced through to `slot`, which should be present in the - // state cache thanks to the state advance timer. + // Continuation: the new block builds on the current head. Fetch the head state + // advanced through to `slot`, which should be present in the state cache thanks to + // the state advance timer. let parent_state_root = head_state_root; let (state_root, state) = self .store .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; + + + BlockProductionState { state, state_root: Some(state_root), - parent_payload_status: head_payload_status, - parent_envelope: head_envelope, + parent_payload_status: PayloadStatus::Pending, + parent_envelope: None, } } } else { @@ -173,7 +174,7 @@ impl BeaconChain { slot: Slot, head_slot: Slot, canonical_head: Hash256, - ) -> Option<(BeaconState, Hash256)> { + ) -> Option> { let re_org_head_threshold = self.config.re_org_head_threshold?; let re_org_parent_threshold = self.config.re_org_parent_threshold?; @@ -240,9 +241,27 @@ impl BeaconChain { } }) .ok()?; + drop(proposer_head_timer); let re_org_parent_block = proposer_head.parent_node.root(); + let parent_payload_status = match self + .canonical_head + .fork_choice_read_lock() + .should_extend_payload(&re_org_parent_block) + { + Ok(true) => PayloadStatus::Full, + Ok(false) => PayloadStatus::Empty, + Err(e) => { + warn!( + error = ?e, + parent = ?re_org_parent_block, + "Not attempting re-org: failed to resolve parent payload status" + ); + return None; + } + }; + let (state_root, state) = self .store .get_advanced_hot_state_from_cache(re_org_parent_block, slot) @@ -251,6 +270,16 @@ impl BeaconChain { None })?; + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + self.store + .get_payload_envelope(&re_org_parent_block) + .ok() + .flatten() + .map(Arc::new) + } else { + None + }; + info!( weak_head = ?canonical_head, parent = ?re_org_parent_block, @@ -259,6 +288,11 @@ impl BeaconChain { "Attempting re-org due to weak head" ); - Some((state, state_root)) + Some(ReOrgInputs { + state, + state_root, + parent_payload_status, + parent_envelope, + }) } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 8e9cc612080..4fab421bcf4 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1569,6 +1569,7 @@ where beacon_block_root: Hash256, mut state: Cow>, state_root: Hash256, + payload_present_override: Option, ) -> Result, BeaconChainError> { assert_eq!( state.get_latest_block_root(state_root), @@ -1603,12 +1604,17 @@ where *state.get_block_root(target_slot)? }; - let payload_present = state.fork_name_unchecked().gloas_enabled() - && state.latest_block_header().slot != slot - && self - .chain - .canonical_head - .block_has_canonical_payload(&beacon_block_root, &self.spec)?; + let payload_present = match payload_present_override { + Some(payload_present) => payload_present, + None => { + state.fork_name_unchecked().gloas_enabled() + && state.latest_block_header().slot != slot + && self + .chain + .canonical_head + .block_has_canonical_payload(&beacon_block_root, &self.spec)? + } + }; Ok(Attestation::empty_for_signing( index, @@ -1688,7 +1694,7 @@ where attestation_slot: Slot, opts: MakeAttestationOptions, ) -> (Vec, Vec) { - let MakeAttestationOptions { limit, fork } = opts; + let MakeAttestationOptions { limit, fork, .. } = opts; let committee_count = state.get_committee_count_at_slot(state.slot()).unwrap(); let num_attesters = AtomicUsize::new(0); @@ -1780,6 +1786,27 @@ where head_block_root: SignedBeaconBlockHash, attestation_slot: Slot, opts: MakeAttestationOptions, + ) -> (Vec>, Vec) { + self.make_unaggregated_attestations_impl( + attesting_validators, + state, + state_root, + head_block_root, + attestation_slot, + opts, + None, + ) + } + + fn make_unaggregated_attestations_impl( + &self, + attesting_validators: &[usize], + state: &BeaconState, + state_root: Hash256, + head_block_root: SignedBeaconBlockHash, + attestation_slot: Slot, + opts: MakeAttestationOptions, + payload_present_override: Option, ) -> (Vec>, Vec) { let MakeAttestationOptions { limit, fork } = opts; let committee_count = state.get_committee_count_at_slot(state.slot()).unwrap(); @@ -1814,6 +1841,7 @@ where head_block_root.into(), Cow::Borrowed(state), state_root, + payload_present_override, ) .unwrap(); @@ -2028,15 +2056,62 @@ where block_hash: SignedBeaconBlockHash, slot: Slot, opts: MakeAttestationOptions, + ) -> (HarnessAttestations, Vec) { + self.make_attestations_impl( + attesting_validators, + state, + state_root, + block_hash, + slot, + opts, + None, + ) + } + + /// Like `make_attestations_with_opts`, but forces every produced attestation's + /// `payload_present` field to the supplied value. Use only for Gloas tests that need to + /// simulate validators voting `payload_present = false` despite the block's payload being + /// canonical (or vice versa). + pub fn make_attestations_with_payload_present_override( + &self, + attesting_validators: &[usize], + state: &BeaconState, + state_root: Hash256, + block_hash: SignedBeaconBlockHash, + slot: Slot, + fork: Fork, + payload_present: bool, + ) -> (HarnessAttestations, Vec) { + self.make_attestations_impl( + attesting_validators, + state, + state_root, + block_hash, + slot, + MakeAttestationOptions { limit: None, fork }, + Some(payload_present), + ) + } + + fn make_attestations_impl( + &self, + attesting_validators: &[usize], + state: &BeaconState, + state_root: Hash256, + block_hash: SignedBeaconBlockHash, + slot: Slot, + opts: MakeAttestationOptions, + payload_present_override: Option, ) -> (HarnessAttestations, Vec) { let MakeAttestationOptions { fork, .. } = opts; - let (unaggregated_attestations, attesters) = self.make_unaggregated_attestations_with_opts( + let (unaggregated_attestations, attesters) = self.make_unaggregated_attestations_impl( attesting_validators, state, state_root, block_hash, slot, opts, + payload_present_override, ); let aggregated_attestations: Vec>> = diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs new file mode 100644 index 00000000000..44dee9fb65d --- /dev/null +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -0,0 +1,283 @@ +//! Gloas (ePBS / EIP-7732) payload re-org tests. +//! +//! These tests are deliberately kept separate from `interactive_tests.rs` because they exercise +//! Gloas-only fork-choice behaviour: the head is a `ForkChoiceNode` = (block root, payload status), +//! and a block's *payload* can be re-orged (head flips `FULL` -> `EMPTY`) independently of the +//! beacon block, when later-slot voters attest the block with `payload_present = false`. +use beacon_chain::{ + test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, + custody_context::NodeCustodyType, +}; +use fixed_bytes::FixedBytesExtended; +use http_api::test_utils::InteractiveTester; +use proto_array::PayloadStatus; +use state_processing::state_advance::complete_state_advance; +use std::sync::Arc; +use types::{ + Address, EthSpec, ForkName, Hash256, MainnetEthSpec, ProposerPreparationData, Slot, Uint256, +}; + +type E = MainnetEthSpec; + +const ATTESTERS_PER_SLOT: usize = 10; + +/// Gloas-from-genesis spec used by all tests in this module. +fn gloas_test_spec() -> types::ChainSpec { + let mut spec = ForkName::latest().make_genesis_spec(E::default_spec()); + spec.terminal_total_difficulty = Uint256::from(1); + spec +} + +/// Common harness preparation shared by the Gloas re-org tests: mark mock payloads valid, register +/// proposer preparation data for all validators, then build `num_initial` blocks of chain depth. +/// +/// `prep_slot` is the slot of the block the test cares about; proposer preparation is registered for +/// its epoch + 1 (matching the lookahead the real node uses). +async fn prepare_gloas_chain( + tester: &InteractiveTester, + validator_count: usize, + num_initial: u64, + prep_slot: Slot, +) { + let harness = &tester.harness; + harness + .mock_execution_layer + .as_ref() + .unwrap() + .server + .all_payloads_valid(); + + let proposer_preparation_data = (0..validator_count) + .map(|i| { + ( + ProposerPreparationData { + validator_index: i as u64, + fee_recipient: Address::from_low_u64_be(i as u64), + }, + None, + ) + }) + .collect::>(); + harness + .chain + .execution_layer + .as_ref() + .unwrap() + .update_proposer_preparation( + prep_slot.epoch(E::slots_per_epoch()) + 1, + proposer_preparation_data.iter().map(|(a, b)| (a, b)), + ) + .await; + + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; +} + +/// Parameters for a single Gloas re-org scenario. +/// Payload re-org (flavor A): a block `B` whose execution payload *is* delivered (so its `FULL` +/// node exists in fork choice) can still have its payload orphaned if later-slot voters attest to +/// `B` with `payload_present = false`. Those votes land in `B`'s `EMPTY` payload bucket, so +/// `get_head` prefers `(B, EMPTY)` over `(B, FULL)` — the beacon block stays canonical but its +/// payload is re-orged. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gloas_payload_reorg_head_flips_to_empty_when_voters_attest_empty() { + let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; + let all_validators = (0..validator_count).collect::>(); + + // Keep B and the later votes comfortably inside one epoch. + let slot_b = Slot::new(E::slots_per_epoch() - 4); + let num_initial = slot_b.as_u64() - 1; + + let tester = InteractiveTester::::new_with_initializer_and_mutator( + Some(gloas_test_spec()), + validator_count, + None, + None, + Default::default(), + false, + NodeCustodyType::Fullnode, + ) + .await; + prepare_gloas_chain(&tester, validator_count, num_initial, slot_b).await; + let harness = &tester.harness; + + // Produce B at `slot_b`. `add_block_at_slot` also delivers and verifies B's payload envelope, so + // B's `FULL` node exists in fork choice and B is the canonical head on the `FULL` path. + harness.advance_slot(); + let (block_b_root, _block_b, mut state_b) = harness + .add_block_at_slot(slot_b, harness.get_current_state()) + .await + .unwrap(); + let state_b_root = state_b.canonical_root().unwrap(); + + assert_eq!(harness.head_block_root(), Hash256::from(block_b_root)); + assert_eq!( + harness.chain.canonical_head.cached_head().head_payload_status(), + PayloadStatus::Full, + "B's delivered payload should make the head FULL before any EMPTY votes" + ); + + // Cast later-slot (slot_b + 1) votes for B with `payload_present = false`, forcing them into + // B's EMPTY payload bucket. + let slot_b1 = slot_b + 1; + harness.advance_slot(); + let fork = harness + .spec + .fork_at_epoch(slot_b1.epoch(E::slots_per_epoch())); + let (empty_votes, _) = harness.make_attestations_with_payload_present_override( + &all_validators, + &state_b, + state_b_root, + block_b_root.into(), + slot_b1, + fork, + false, + ); + harness.process_attestations(empty_votes, &state_b); + + // Advance one more slot so the `slot_b + 1` votes are applied to fork choice, then recompute. + harness.advance_slot(); + harness + .chain + .recompute_head_at_slot(slot_b + 2) + .await; + + assert_eq!( + harness.head_block_root(), + Hash256::from(block_b_root), + "B should remain the canonical beacon block (only the payload is re-orged)" + ); + assert_eq!( + harness.chain.canonical_head.cached_head().head_payload_status(), + PayloadStatus::Empty, + "EMPTY-bucket votes should orphan B's payload (payload re-org)" + ); + + //TODO(manas): produce block +} + +/// Payload re-org (flavor B): once `B`'s payload is orphaned (head is `(B, EMPTY)` despite the +/// payload being delivered, as in flavor A), the next proposer `C` builds on `B`'s EMPTY path. The +/// beacon block `B` is kept as `C`'s parent, but `C`'s bid does not extend `B`'s execution payload — +/// it points back at `B`'s parent's payload, i.e. the payload is re-orged out by the proposer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gloas_payload_reorg_proposer_builds_on_empty_path() { + let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; + let all_validators = (0..validator_count).collect::>(); + + let slot_b = Slot::new(E::slots_per_epoch() - 4); + let num_initial = slot_b.as_u64() - 1; + let slot_c = slot_b + 2; + + let tester = InteractiveTester::::new_with_initializer_and_mutator( + Some(gloas_test_spec()), + validator_count, + None, + None, + Default::default(), + false, + NodeCustodyType::Fullnode, + ) + .await; + prepare_gloas_chain(&tester, validator_count, num_initial, slot_b).await; + let harness = &tester.harness; + + // Produce B with its payload delivered (FULL node exists, B is the FULL head). + harness.advance_slot(); + let (block_b_root, block_b, mut state_b) = harness + .add_block_at_slot(slot_b, harness.get_current_state()) + .await + .unwrap(); + let state_b_root = state_b.canonical_root().unwrap(); + + // `B`'s own committed execution payload hash. If `C` extends `B`'s payload its bid would point + // at this; a payload re-org means it must not. + let block_b_payload_hash = block_b + .0 + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message + .block_hash; + + // Orphan B's payload: later-slot voters attest B with `payload_present = false`. + let slot_b1 = slot_b + 1; + harness.advance_slot(); + let fork = harness + .spec + .fork_at_epoch(slot_b1.epoch(E::slots_per_epoch())); + let (empty_votes, _) = harness.make_attestations_with_payload_present_override( + &all_validators, + &state_b, + state_b_root, + block_b_root.into(), + slot_b1, + fork, + false, + ); + harness.process_attestations(empty_votes, &state_b); + + harness.advance_slot(); + harness.chain.recompute_head_at_slot(slot_c).await; + + // Sanity: head is `(B, EMPTY)`. + assert_eq!(harness.head_block_root(), Hash256::from(block_b_root)); + assert_eq!( + harness.chain.canonical_head.cached_head().head_payload_status(), + PayloadStatus::Empty, + ); + + // Produce C at `slot_c`. + complete_state_advance(&mut state_b, None, slot_c, &harness.chain.spec).unwrap(); + let proposer_index = state_b + .get_beacon_proposer_index(slot_c, &harness.chain.spec) + .unwrap(); + let randao_reveal = harness + .sign_randao_reveal(&state_b, proposer_index, slot_c) + .into(); + let (response, _) = tester + .client + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .await + .unwrap(); + let block_c = Arc::new(harness.sign_beacon_block(response.data, &state_b)); + + // C keeps B as its beacon-block parent (no *block* re-org)... + assert_eq!( + block_c.parent_root(), + Hash256::from(block_b_root), + "C should still build on beacon block B" + ); + + // ...but builds on B's EMPTY payload path: its bid does not extend B's execution payload. + let block_c_parent_payload_hash = block_c + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message + .parent_block_hash; + assert_ne!( + block_c_parent_payload_hash, block_b_payload_hash, + "C must not extend B's payload — B's payload is re-orged" + ); + + // TODO: + // 1. get_proposer_head=grand-parent (do-reorg) + payload_status=empty + // 1. get_proposer_head=grant-parent (do-reorg) + payload_status=full + // 1. get_proposer_head=parent (do-reorg) + payload_status=full + // 1. get_proposer_head=parent (do-reorg) + payload_status=empty + // + // should make sure that all existing test pass + // - run each for gloas and understand what changes are needed to make them pass for gloas + // - some tests might require gloas-specfic setup changes: +} diff --git a/beacon_node/http_api/tests/main.rs b/beacon_node/http_api/tests/main.rs index e0636424e48..35400a912ef 100644 --- a/beacon_node/http_api/tests/main.rs +++ b/beacon_node/http_api/tests/main.rs @@ -2,6 +2,7 @@ pub mod broadcast_validation_tests; pub mod fork_tests; +pub mod gloas_reorg_tests; pub mod interactive_tests; pub mod status_tests; pub mod tests; diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 7abba8a1f65..2cf3cbb3147 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -751,15 +751,17 @@ impl ProtoArrayForkChoice { .into()); } - // 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); + // Spec: `is_parent_strong`. Use `PayloadStatus::Pending` to avoid weight split + // between payload statuses. https://github.com/ethereum/consensus-specs/issues/5305 + + let parent_pending_weight = info.parent_node.attestation_score(PayloadStatus::Pending); + let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; - let parent_strong = parent_weight > re_org_parent_weight_threshold; + + let parent_strong = parent_pending_weight > re_org_parent_weight_threshold; if !parent_strong { return Err(DoNotReOrg::ParentNotStrong { - parent_weight, + parent_weight: parent_pending_weight, re_org_parent_weight_threshold, } .into()); From 9f3499b9a3b92f8bc83e1a81a1c62635acbbd2f6 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Tue, 2 Jun 2026 13:59:17 -0400 Subject: [PATCH 02/32] fix merge changes --- .../beacon_chain/src/block_production/mod.rs | 8 +-- beacon_node/beacon_chain/src/test_utils.rs | 49 +------------------ 2 files changed, 5 insertions(+), 52 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index ee785f77784..07f51c28854 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -4,7 +4,7 @@ use fork_choice::PayloadStatus; use proto_array::{ProposerHeadError, ReOrgThreshold}; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot}; +use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot, EthSpec}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -14,7 +14,7 @@ use crate::{ mod gloas; /// State loaded from the database for block production. -pub(crate) struct BlockProductionState { +pub(crate) struct BlockProductionState { pub state: BeaconState, pub state_root: Option, pub parent_payload_status: PayloadStatus, @@ -22,7 +22,7 @@ pub(crate) struct BlockProductionState { } /// Inputs assembled for producing a block via a proposer re-org. -struct ReOrgInputs { +struct ReOrgInputs { state: BeaconState, state_root: Hash256, parent_payload_status: PayloadStatus, @@ -174,7 +174,7 @@ impl BeaconChain { slot: Slot, head_slot: Slot, canonical_head: Hash256, - ) -> Option<(BeaconState, Hash256)> { + ) -> Option> { let re_org_head_threshold = ReOrgThreshold(self.spec.reorg_head_weight_threshold); let re_org_parent_threshold = ReOrgThreshold(self.spec.reorg_parent_weight_threshold); let re_org_max_epochs_since_finalization = diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index bdcd36dd1b5..db2a9a902d9 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -2050,62 +2050,15 @@ where block_hash: SignedBeaconBlockHash, slot: Slot, opts: MakeAttestationOptions, - ) -> (HarnessAttestations, Vec) { - self.make_attestations_impl( - attesting_validators, - state, - state_root, - block_hash, - slot, - opts, - None, - ) - } - - /// Like `make_attestations_with_opts`, but forces every produced attestation's - /// `payload_present` field to the supplied value. Use only for Gloas tests that need to - /// simulate validators voting `payload_present = false` despite the block's payload being - /// canonical (or vice versa). - pub fn make_attestations_with_payload_present_override( - &self, - attesting_validators: &[usize], - state: &BeaconState, - state_root: Hash256, - block_hash: SignedBeaconBlockHash, - slot: Slot, - fork: Fork, - payload_present: bool, - ) -> (HarnessAttestations, Vec) { - self.make_attestations_impl( - attesting_validators, - state, - state_root, - block_hash, - slot, - MakeAttestationOptions { limit: None, fork }, - Some(payload_present), - ) - } - - fn make_attestations_impl( - &self, - attesting_validators: &[usize], - state: &BeaconState, - state_root: Hash256, - block_hash: SignedBeaconBlockHash, - slot: Slot, - opts: MakeAttestationOptions, - payload_present_override: Option, ) -> (HarnessAttestations, Vec) { let MakeAttestationOptions { fork, .. } = opts; - let (unaggregated_attestations, attesters) = self.make_unaggregated_attestations_impl( + let (unaggregated_attestations, attesters) = self.make_unaggregated_attestations_with_opts( attesting_validators, state, state_root, block_hash, slot, opts, - payload_present_override, ); let aggregated_attestations: Vec>> = From 1ffe508e4a4019da87618f610e2a10b282ce6f71 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 3 Jun 2026 18:25:54 -0400 Subject: [PATCH 03/32] working interactive tests --- beacon_node/beacon_chain/src/beacon_chain.rs | 25 +--- .../beacon_chain/src/block_production/mod.rs | 10 +- .../http_api/tests/gloas_reorg_tests.rs | 9 +- .../http_api/tests/interactive_tests.rs | 118 +++++++++++++----- consensus/proto_array/src/proto_array.rs | 12 +- .../src/proto_array_fork_choice.rs | 3 - 6 files changed, 106 insertions(+), 71 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f353ab52054..86d8c6bd548 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5137,15 +5137,6 @@ impl BeaconChain { &self, canonical_forkchoice_params: ForkchoiceUpdateParameters, ) -> Result { - let current_slot = self.slot()?; - if self - .spec - .fork_name_at_slot::(current_slot) - .gloas_enabled() - { - return Ok(canonical_forkchoice_params); - } - self.overridden_forkchoice_update_params_or_failure_reason(&canonical_forkchoice_params) .or_else(|e| match *e { ProposerHeadError::DoNotReOrg(reason) => { @@ -5159,7 +5150,6 @@ impl BeaconChain { }) } - // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5269,11 +5259,7 @@ impl BeaconChain { let head_weight = info .head_node .attestation_score(fork_choice::PayloadStatus::Pending) - .saturating_add( - info.head_node - .equivocating_attestation_score() - .unwrap_or(0), - ); + .saturating_add(info.head_node.equivocating_attestation_score().unwrap_or(0)); let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( @@ -5313,14 +5299,15 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::HeadNotLate.into())); } - // TODO(gloas): V29 nodes don't carry execution_status, so this returns - // None for post-Gloas re-orgs. Need to source the EL block hash from - // the bid's block_hash instead. Re-org is disabled for Gloas for now. + // Pre-Gloas the EL block hash lives in the node's `execution_status`. Post-Gloas (V29) the + // payload is decoupled from the beacon block, so source the EL head the re-org block will + // build on from the parent's committed bid block hash instead. let parent_head_hash = info .parent_node .execution_status() .ok() - .and_then(|execution_status| execution_status.block_hash()); + .and_then(|execution_status| execution_status.block_hash()) + .or_else(|| info.parent_node.execution_payload_block_hash().ok()); let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: info.parent_node.root(), head_hash: parent_head_hash, diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 07f51c28854..100dd47ae9a 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -4,7 +4,7 @@ use fork_choice::PayloadStatus; use proto_array::{ProposerHeadError, ReOrgThreshold}; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot, EthSpec}; +use types::{BeaconState, Epoch, EthSpec, Hash256, SignedExecutionPayloadEnvelope, Slot}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -81,13 +81,11 @@ impl BeaconChain { .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; - - - + //TODO(manas): deal with this weird shit here with the parent_payload_status BlockProductionState { state, state_root: Some(state_root), - parent_payload_status: PayloadStatus::Pending, + parent_payload_status: PayloadStatus::Empty, parent_envelope: None, } } @@ -101,7 +99,7 @@ impl BeaconChain { .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - // TODO(gloas): update this to read payload canonicity from fork choice once ready + // TODO(gloasxmanas): update this to read payload canonicity from fork choice once ready let parent_payload_status = PayloadStatus::Pending; BlockProductionState { state, diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 44dee9fb65d..51e7b598d1d 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -1,9 +1,10 @@ -//! Gloas (ePBS / EIP-7732) payload re-org tests. +//! post-gloas payload re-org tests. //! //! These tests are deliberately kept separate from `interactive_tests.rs` because they exercise -//! Gloas-only fork-choice behaviour: the head is a `ForkChoiceNode` = (block root, payload status), +//! post-gloas fork-choice behaviour: the head is a `ForkChoiceNode` = (block root, payload status), //! and a block's *payload* can be re-orged (head flips `FULL` -> `EMPTY`) independently of the //! beacon block, when later-slot voters attest the block with `payload_present = false`. +//! use beacon_chain::{ test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, custody_context::NodeCustodyType, @@ -274,8 +275,8 @@ async fn gloas_payload_reorg_proposer_builds_on_empty_path() { // TODO: // 1. get_proposer_head=grand-parent (do-reorg) + payload_status=empty // 1. get_proposer_head=grant-parent (do-reorg) + payload_status=full - // 1. get_proposer_head=parent (do-reorg) + payload_status=full - // 1. get_proposer_head=parent (do-reorg) + payload_status=empty + // 1. get_proposer_head=parent (don't-reorg) + payload_status=full + // 1. get_proposer_head=parent (don't-reorg) + payload_status=empty // // should make sure that all existing test pass // - run each for gloas and understand what changes are needed to make them pass for gloas diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 7b5fb027144..8a265647b34 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -22,8 +22,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, - MinimalEthSpec, ProposerPreparationData, Slot, + Address, BeaconBlockRef, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, + MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, }; type E = MainnetEthSpec; @@ -395,9 +395,7 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - // TODO(EIP-7732): extend test for Gloas — `get_validator_blocks_v3` is missing the - // `Eth-Execution-Payload-Blinded` header for Gloas block production responses. - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + let spec = ForkName::latest().make_genesis_spec(E::default_spec()); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; @@ -553,7 +551,25 @@ pub async fn proposer_boost_re_org_test( .collect::>(); // Produce block B and process it halfway through the slot. - let (block_b, mut state_b) = harness.make_block(state_a.clone(), slot_b).await; + // When B is expected to remain canonical (no re-org), capture its Gloas payload envelope so we + // can reveal B's execution payload to fork choice below. Without this, B's payload status stays + // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's + // own execution block hash. We skip this when B will be re-orged, since the execution layer + // must never be told about a block that is about to be re-orged away. + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_b) + .gloas_enabled(); + let reveal_block_b_payload = is_gloas && !should_re_org; + let (block_b, block_b_envelope, mut state_b) = if reveal_block_b_payload { + harness + .make_block_with_envelope(state_a.clone(), slot_b) + .await + } else { + let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; + (block_b, None, state_b) + }; let state_b_root = state_b.canonical_root().unwrap(); let block_b_root = block_b.0.canonical_root(); @@ -568,6 +584,14 @@ pub async fn proposer_boost_re_org_test( ); harness.process_block_result(block_b.clone()).await.unwrap(); + // Reveal B's execution payload so fork choice marks the payload as received and the + // forkchoiceUpdated head hash references B's own execution block hash. + if let Some(block_b_envelope) = block_b_envelope { + harness + .process_envelope(block_b_root, block_b_envelope, &state_b, state_b_root) + .await; + } + // Add attestations to block B. let (block_b_head_votes, _) = harness.make_attestations_with_limit( &remaining_attesters, @@ -622,21 +646,42 @@ pub async fn proposer_boost_re_org_test( let randao_reveal = harness .sign_randao_reveal(&state_b, proposer_index, slot_c) .into(); - let (unsigned_block_type, _) = tester - .client - .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) - .await - .unwrap(); + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_c) + .gloas_enabled(); + + let (block_c, block_c_blobs) = if is_gloas { + let (response, _) = tester + .client + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .await + .unwrap(); + ( + Arc::new(harness.sign_beacon_block(response.data, &state_b)), + None, + ) + } else { + let (unsigned_block_type, _) = tester + .client + .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) + .await + .unwrap(); - let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { - ProduceBlockV3Response::Full(unsigned_block_contents_c) => { - unsigned_block_contents_c.deconstruct() - } - ProduceBlockV3Response::Blinded(_) => { - panic!("Should not be a blinded block"); - } + let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { + ProduceBlockV3Response::Full(unsigned_block_contents_c) => { + unsigned_block_contents_c.deconstruct() + } + ProduceBlockV3Response::Blinded(_) => { + panic!("Should not be a blinded block"); + } + }; + ( + Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)), + block_c_blobs, + ) }; - let block_c = Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)); if should_re_org { // Block C should build on A. @@ -659,20 +704,29 @@ pub async fn proposer_boost_re_org_test( // Check the fork choice updates that were sent. let forkchoice_updates = forkchoice_updates.lock(); - let block_a_exec_hash = block_a - .0 - .message() - .execution_payload() - .unwrap() - .block_hash(); - let block_b_exec_hash = block_b - .0 - .message() - .execution_payload() - .unwrap() - .block_hash(); + // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash + // lives in the execution payload bid, and the payload timestamp is derived from the slot. + let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + if is_gloas { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .block_hash + } else { + block.execution_payload().unwrap().block_hash() + } + }; - let block_c_timestamp = block_c.message().execution_payload().unwrap().timestamp(); + let block_a_exec_hash = exec_block_hash(block_a.0.message()); + let block_b_exec_hash = exec_block_hash(block_b.0.message()); + + let block_c_timestamp = if is_gloas { + harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs() + } else { + block_c.message().execution_payload().unwrap().timestamp() + }; // If we re-orged then no fork choice update for B should have been sent. assert_eq!( diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 1e3303afbbb..0e820aeaf4f 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1539,14 +1539,12 @@ impl ProtoArray { proto_node: &ProtoNode, proposer_boost_root: Hash256, ) -> Result { - let Ok(node) = proto_node.as_v29() else { - return Err(Error::InvalidNodeVariant { - block_root: fc_node.root, - }); - }; - // Spec equivalent to `if not is_payload_verified(store, root): return False` - if !node.payload_received { + // this also overload on the condition where the pre-gloas nodes are considered + // `PayloadStatus::Empty`. + if let Ok(node) = proto_node.as_v29() + && !node.payload_received + { return Ok(false); } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 0e21790273a..815e1a0f105 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -756,11 +756,8 @@ impl ProtoArrayForkChoice { // Spec: `is_parent_strong`. Use `PayloadStatus::Pending` to avoid weight split // between payload statuses. https://github.com/ethereum/consensus-specs/issues/5305 - let parent_pending_weight = info.parent_node.attestation_score(PayloadStatus::Pending); - let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; - let parent_strong = parent_pending_weight > re_org_parent_weight_threshold; if !parent_strong { return Err(DoNotReOrg::ParentNotStrong { From 5d353294a99598d233e5a91076cc55298f39c545 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 3 Jun 2026 20:41:06 -0400 Subject: [PATCH 04/32] fix `parent_payload_status` in block production state --- .../beacon_chain/src/block_production/mod.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 100dd47ae9a..7704770fc90 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -48,12 +48,14 @@ impl BeaconChain { // Atomically read some values from the head whilst avoiding holding cached head `Arc` any // longer than necessary. If the head has a payload envelope (Gloas full head), cheaply // clone the `Arc` so we can pass it to block production without a DB load. - let (head_slot, head_block_root, head_state_root) = { + let (head_slot, head_block_root, head_state_root, head_payload_status, head_envelope) = { let head = self.canonical_head.cached_head(); ( head.head_slot(), head.head_block_root(), head.head_state_root(), + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), ) }; @@ -81,12 +83,12 @@ impl BeaconChain { .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; - //TODO(manas): deal with this weird shit here with the parent_payload_status + BlockProductionState { state, state_root: Some(state_root), - parent_payload_status: PayloadStatus::Empty, - parent_envelope: None, + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, } } } else { @@ -99,13 +101,11 @@ impl BeaconChain { .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - // TODO(gloasxmanas): update this to read payload canonicity from fork choice once ready - let parent_payload_status = PayloadStatus::Pending; BlockProductionState { state, state_root: None, - parent_payload_status, - parent_envelope: None, + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, } }; From fc2753cf441aa4b420351e20cc89d6938a6250b3 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 10:42:09 +1000 Subject: [PATCH 05/32] Use canonical payload status in reorg helper --- beacon_node/beacon_chain/src/block_production/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 7704770fc90..607607feb79 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -244,10 +244,9 @@ impl BeaconChain { let parent_payload_status = match self .canonical_head .fork_choice_read_lock() - .should_extend_payload(&re_org_parent_block) + .get_canonical_payload_status(&re_org_parent_block, &self.spec) { - Ok(true) => PayloadStatus::Full, - Ok(false) => PayloadStatus::Empty, + Ok(status) => status, Err(e) => { warn!( error = ?e, From ad16845728ffafa4bd85c9d302411371e38416df Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 11:37:08 +1000 Subject: [PATCH 06/32] WIP gloas reorg tests --- .../http_api/tests/gloas_reorg_tests.rs | 708 +++++++++++++----- 1 file changed, 520 insertions(+), 188 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 51e7b598d1d..98dca7f6597 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -6,54 +6,224 @@ //! beacon block, when later-slot voters attest the block with `payload_present = false`. //! use beacon_chain::{ - test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, + ChainConfig, + chain_config::DisallowedReOrgOffsets, custody_context::NodeCustodyType, + test_utils::{ + AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, + SyncCommitteeStrategy, test_spec, + }, }; +use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; +use eth2::types::{DepositContractData, ProduceBlockV3Response, StateId}; +use execution_layer::{ForkchoiceState, PayloadAttributes}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; +use parking_lot::Mutex; use proto_array::PayloadStatus; -use state_processing::state_advance::complete_state_advance; +use slot_clock::SlotClock; +use state_processing::{ + per_block_processing::get_expected_withdrawals, state_advance::complete_state_advance, +}; +use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use types::{ - Address, EthSpec, ForkName, Hash256, MainnetEthSpec, ProposerPreparationData, Slot, Uint256, + Address, BeaconBlockRef, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, + MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, }; type E = MainnetEthSpec; const ATTESTERS_PER_SLOT: usize = 10; -/// Gloas-from-genesis spec used by all tests in this module. -fn gloas_test_spec() -> types::ChainSpec { - let mut spec = ForkName::latest().make_genesis_spec(E::default_spec()); - spec.terminal_total_difficulty = Uint256::from(1); - spec +/// Data structure for tracking fork choice updates received by the mock execution layer. +#[derive(Debug, Default)] +struct ForkChoiceUpdates { + updates: HashMap>, +} + +#[derive(Debug, Clone)] +struct ForkChoiceUpdateMetadata { + received_at: Duration, + state: ForkchoiceState, + payload_attributes: Option, +} + +impl ForkChoiceUpdates { + fn insert(&mut self, update: ForkChoiceUpdateMetadata) { + self.updates + .entry(update.state.head_block_hash) + .or_default() + .push(update); + } + + fn contains_update_for(&self, block_hash: ExecutionBlockHash) -> bool { + self.updates.contains_key(&block_hash) + } + + /// Find the first fork choice update for `head_block_hash` with payload attributes for a + /// block proposal at `proposal_timestamp`. + fn first_update_with_payload_attributes( + &self, + head_block_hash: ExecutionBlockHash, + proposal_timestamp: u64, + ) -> Option { + self.updates + .get(&head_block_hash)? + .iter() + .find(|update| { + update + .payload_attributes + .as_ref() + .is_some_and(|payload_attributes| { + payload_attributes.timestamp() == proposal_timestamp + }) + }) + .cloned() + } +} + +pub struct ReOrgTest { + head_slot: Slot, + /// Number of slots between parent block and canonical head. + parent_distance: u64, + /// Number of slots between head block and block proposal slot. + head_distance: u64, + /// Fraction of parent (A)'s committee that votes for A (always with payload_present=0). + percent_parent_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=0. + percent_skip_empty_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=1. + percent_skip_full_votes: usize, + /// Fraction of B's committee that votes for B (always with payload_present=0). + percent_head_votes: usize, + /// Fraction of A's PTC that vote for A's payload being present. + percent_parent_ptc_present_votes: usize, + /// Fraction of A's PTC that vote for A's payload being absent. + percent_parent_ptc_absent_votes: usize, + /// Expected parent payload status of our proposed block (C). + /// + /// This can be the payload status of A or B depending on whether we reorged or not. + expected_parent_payload_status: PayloadStatus, + should_re_org: bool, + misprediction: bool, + /// Whether to expect withdrawals to change on epoch boundaries. + expect_withdrawals_change_on_epoch: bool, + /// Epoch offsets to avoid proposing reorg blocks at. + disallowed_offsets: Vec, +} + +impl Default for ReOrgTest { + /// Default config represents a regular easy re-org. + fn default() -> Self { + Self { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 1, + head_distance: 1, + percent_parent_votes: 100, + percent_skip_empty_votes: 0, + percent_skip_full_votes: 100, + percent_head_votes: 0, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + should_re_org: true, + misprediction: false, + expect_withdrawals_change_on_epoch: false, + disallowed_offsets: vec![], + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_easy() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 100, + percent_skip_full_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + ..Default::default() + }) + .await; } -/// Common harness preparation shared by the Gloas re-org tests: mark mock payloads valid, register -/// proposer preparation data for all validators, then build `num_initial` blocks of chain depth. +/// Run a proposer boost re-org test. /// -/// `prep_slot` is the slot of the block the test cares about; proposer preparation is registered for -/// its epoch + 1 (matching the lookahead the real node uses). -async fn prepare_gloas_chain( - tester: &InteractiveTester, - validator_count: usize, - num_initial: u64, - prep_slot: Slot, +/// - `head_slot`: the slot of the canonical head to be reorged +/// - `reorg_threshold`: committee percentage value for reorging +/// - `num_empty_votes`: percentage of comm of attestations for the parent block +/// - `num_head_votes`: number of attestations for the head block +/// - `should_re_org`: whether the proposer should build on the parent rather than the head +#[allow(clippy::large_stack_frames)] +pub async fn proposer_boost_re_org_test( + ReOrgTest { + head_slot, + parent_distance, + head_distance, + percent_parent_votes, + percent_skip_empty_votes, + percent_skip_full_votes, + percent_head_votes, + percent_parent_ptc_present_votes, + percent_parent_ptc_absent_votes, + expected_parent_payload_status, + should_re_org, + misprediction, + expect_withdrawals_change_on_epoch, + disallowed_offsets, + }: ReOrgTest, ) { + assert!(head_slot > 0); + + let spec = ForkName::latest().make_genesis_spec(E::default_spec()); + + // Ensure there are enough validators to have `attesters_per_slot`. + let attesters_per_slot = 10; + let validator_count = E::slots_per_epoch() as usize * attesters_per_slot; + let all_validators = (0..validator_count).collect::>(); + let num_initial = head_slot.as_u64().checked_sub(parent_distance + 1).unwrap(); + + // Check that the required vote percentages can be satisfied exactly using `attesters_per_slot`. + assert_eq!(100 % attesters_per_slot, 0); + let percent_per_attester = 100 / attesters_per_slot; + assert_eq!(percent_parent_votes % percent_per_attester, 0); + assert_eq!(percent_skip_empty_votes % percent_per_attester, 0); + assert_eq!(percent_skip_full_votes % percent_per_attester, 0); + assert_eq!(percent_head_votes % percent_per_attester, 0); + let num_parent_votes = Some(attesters_per_slot * percent_parent_votes / 100); + let num_skip_empty_votes = Some(attesters_per_slot * percent_skip_empty_votes / 100); + let num_skip_full_votes = Some(attesters_per_slot * percent_skip_full_votes / 100); + let num_head_votes = Some(attesters_per_slot * percent_head_votes / 100); + + let tester = InteractiveTester::::new_with_initializer_and_mutator( + Some(spec), + validator_count, + None, + Some(Box::new(move |builder| { + builder.proposer_re_org_disallowed_offsets( + DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), + ) + })), + Default::default(), + false, + NodeCustodyType::Fullnode, + ) + .await; let harness = &tester.harness; - harness - .mock_execution_layer - .as_ref() - .unwrap() - .server - .all_payloads_valid(); + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let execution_ctx = mock_el.server.ctx.clone(); + let slot_clock = &harness.chain.slot_clock; - let proposer_preparation_data = (0..validator_count) + mock_el.server.all_payloads_valid(); + + // Send proposer preparation data for all validators. + let proposer_preparation_data = all_validators + .iter() .map(|i| { ( ProposerPreparationData { - validator_index: i as u64, - fee_recipient: Address::from_low_u64_be(i as u64), + validator_index: *i as u64, + fee_recipient: Address::from_low_u64_be(*i as u64), }, None, ) @@ -65,11 +235,13 @@ async fn prepare_gloas_chain( .as_ref() .unwrap() .update_proposer_preparation( - prep_slot.epoch(E::slots_per_epoch()) + 1, + head_slot.epoch(E::slots_per_epoch()) + 1, proposer_preparation_data.iter().map(|(a, b)| (a, b)), ) .await; + // Create some chain depth. Sign sync committee signatures so validator balances don't dip + // below 32 ETH and become ineligible for withdrawals. harness.advance_slot(); harness .extend_chain_with_sync( @@ -80,205 +252,365 @@ async fn prepare_gloas_chain( LightClientStrategy::Disabled, ) .await; -} -/// Parameters for a single Gloas re-org scenario. -/// Payload re-org (flavor A): a block `B` whose execution payload *is* delivered (so its `FULL` -/// node exists in fork choice) can still have its payload orphaned if later-slot voters attest to -/// `B` with `payload_present = false`. Those votes land in `B`'s `EMPTY` payload bucket, so -/// `get_head` prefers `(B, EMPTY)` over `(B, FULL)` — the beacon block stays canonical but its -/// payload is re-orged. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn gloas_payload_reorg_head_flips_to_empty_when_voters_attest_empty() { - let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; - let all_validators = (0..validator_count).collect::>(); + // Start collecting fork choice updates. + let forkchoice_updates = Arc::new(Mutex::new(ForkChoiceUpdates::default())); + let forkchoice_updates_inner = forkchoice_updates.clone(); + let chain_inner = harness.chain.clone(); - // Keep B and the later votes comfortably inside one epoch. - let slot_b = Slot::new(E::slots_per_epoch() - 4); - let num_initial = slot_b.as_u64() - 1; + execution_ctx + .hook + .lock() + .set_forkchoice_updated_hook(Box::new(move |state, payload_attributes| { + let received_at = chain_inner.slot_clock.now_duration().unwrap(); + let state = ForkchoiceState::from(state); + let payload_attributes = payload_attributes.map(Into::into); + let update = ForkChoiceUpdateMetadata { + received_at, + state, + payload_attributes, + }; + forkchoice_updates_inner.lock().insert(update); + None + })); - let tester = InteractiveTester::::new_with_initializer_and_mutator( - Some(gloas_test_spec()), - validator_count, - None, - None, - Default::default(), - false, - NodeCustodyType::Fullnode, - ) - .await; - prepare_gloas_chain(&tester, validator_count, num_initial, slot_b).await; - let harness = &tester.harness; + // We set up the following block graph, where B is a block that arrives late and is re-orged + // by C. + // + // A | B | - | + // ^ | - | C | + + let slot_a = Slot::new(num_initial + 1); + let slot_b = slot_a + parent_distance; + let slot_c = slot_b + head_distance; + + // We need to transition to at least epoch 2 in order to trigger + // `process_rewards_and_penalties`. This allows us to test withdrawals changes at epoch + // boundaries. + if expect_withdrawals_change_on_epoch { + assert!( + slot_c.epoch(E::slots_per_epoch()) >= 2, + "for withdrawals to change, test must end at an epoch >= 2" + ); + } - // Produce B at `slot_b`. `add_block_at_slot` also delivers and verifies B's payload envelope, so - // B's `FULL` node exists in fork choice and B is the canonical head on the `FULL` path. harness.advance_slot(); - let (block_b_root, _block_b, mut state_b) = harness - .add_block_at_slot(slot_b, harness.get_current_state()) + let (block_a_root, block_a, mut state_a) = harness + .add_block_at_slot(slot_a, harness.get_current_state()) .await .unwrap(); - let state_b_root = state_b.canonical_root().unwrap(); - - assert_eq!(harness.head_block_root(), Hash256::from(block_b_root)); - assert_eq!( - harness.chain.canonical_head.cached_head().head_payload_status(), - PayloadStatus::Full, - "B's delivered payload should make the head FULL before any EMPTY votes" - ); + let state_a_root = state_a.canonical_root().unwrap(); - // Cast later-slot (slot_b + 1) votes for B with `payload_present = false`, forcing them into - // B's EMPTY payload bucket. - let slot_b1 = slot_b + 1; - harness.advance_slot(); - let fork = harness - .spec - .fork_at_epoch(slot_b1.epoch(E::slots_per_epoch())); - let (empty_votes, _) = harness.make_attestations_with_payload_present_override( + // Attest to block A during slot A. + let (block_a_parent_votes, _) = harness.make_attestations_with_limit( &all_validators, - &state_b, - state_b_root, - block_b_root.into(), - slot_b1, - fork, - false, + &state_a, + state_a_root, + block_a_root, + slot_a, + num_parent_votes, ); - harness.process_attestations(empty_votes, &state_b); - - // Advance one more slot so the `slot_b + 1` votes are applied to fork choice, then recompute. - harness.advance_slot(); - harness - .chain - .recompute_head_at_slot(slot_b + 2) - .await; + harness.process_attestations(block_a_parent_votes, &state_a); - assert_eq!( - harness.head_block_root(), - Hash256::from(block_b_root), - "B should remain the canonical beacon block (only the payload is re-orged)" + // Attest to block A during slot B. + for _ in 0..parent_distance { + harness.advance_slot(); + } + let (block_a_empty_votes, block_a_attesters) = harness.make_attestations_with_opts( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_empty_votes, + fork: state_a.fork(), + payload_present_override: Some(false), + }, ); - assert_eq!( - harness.chain.canonical_head.cached_head().head_payload_status(), - PayloadStatus::Empty, - "EMPTY-bucket votes should orphan B's payload (payload re-org)" + harness.process_attestations(block_a_empty_votes, &state_a); + let (block_a_full_votes, block_a_attesters) = harness.make_attestations_with_opts( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_full_votes, + fork: state_a.fork(), + payload_present_override: Some(true), + }, ); + harness.process_attestations(block_a_full_votes, &state_a); - //TODO(manas): produce block -} + let remaining_attesters = all_validators + .iter() + .copied() + .filter(|index| !block_a_attesters.contains(index)) + .collect::>(); -/// Payload re-org (flavor B): once `B`'s payload is orphaned (head is `(B, EMPTY)` despite the -/// payload being delivered, as in flavor A), the next proposer `C` builds on `B`'s EMPTY path. The -/// beacon block `B` is kept as `C`'s parent, but `C`'s bid does not extend `B`'s execution payload — -/// it points back at `B`'s parent's payload, i.e. the payload is re-orged out by the proposer. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn gloas_payload_reorg_proposer_builds_on_empty_path() { - let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; - let all_validators = (0..validator_count).collect::>(); + // Produce block B and process it halfway through the slot. + // When B is expected to remain canonical (no re-org), capture its Gloas payload envelope so we + // can reveal B's execution payload to fork choice below. Without this, B's payload status stays + // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's + // own execution block hash. We skip this when B will be re-orged, since the execution layer + // must never be told about a block that is about to be re-orged away. + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_b) + .gloas_enabled(); + let reveal_block_b_payload = is_gloas && !should_re_org; + let (block_b, block_b_envelope, mut state_b) = if reveal_block_b_payload { + harness + .make_block_with_envelope(state_a.clone(), slot_b) + .await + } else { + let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; + (block_b, None, state_b) + }; + let state_b_root = state_b.canonical_root().unwrap(); + let block_b_root = block_b.0.canonical_root(); - let slot_b = Slot::new(E::slots_per_epoch() - 4); - let num_initial = slot_b.as_u64() - 1; - let slot_c = slot_b + 2; + // TODO(sproul): assert block B's parent? - let tester = InteractiveTester::::new_with_initializer_and_mutator( - Some(gloas_test_spec()), - validator_count, + let obs_time = slot_clock.start_of(slot_b).unwrap() + slot_clock.slot_duration() / 2; + slot_clock.set_current_time(obs_time); + harness.chain.block_times_cache.write().set_time_observed( + block_b_root, + slot_b, + obs_time, None, None, - Default::default(), - false, - NodeCustodyType::Fullnode, - ) - .await; - prepare_gloas_chain(&tester, validator_count, num_initial, slot_b).await; - let harness = &tester.harness; + ); + harness.process_block_result(block_b.clone()).await.unwrap(); - // Produce B with its payload delivered (FULL node exists, B is the FULL head). - harness.advance_slot(); - let (block_b_root, block_b, mut state_b) = harness - .add_block_at_slot(slot_b, harness.get_current_state()) - .await - .unwrap(); - let state_b_root = state_b.canonical_root().unwrap(); + // Reveal B's execution payload so fork choice marks the payload as received and the + // forkchoiceUpdated head hash references B's own execution block hash. + if let Some(block_b_envelope) = block_b_envelope { + harness + .process_envelope(block_b_root, block_b_envelope, &state_b, state_b_root) + .await; + } - // `B`'s own committed execution payload hash. If `C` extends `B`'s payload its bid would point - // at this; a payload re-org means it must not. - let block_b_payload_hash = block_b - .0 - .message() - .body() - .signed_execution_payload_bid() - .expect("Gloas block should have a payload bid") - .message - .block_hash; - - // Orphan B's payload: later-slot voters attest B with `payload_present = false`. - let slot_b1 = slot_b + 1; - harness.advance_slot(); - let fork = harness - .spec - .fork_at_epoch(slot_b1.epoch(E::slots_per_epoch())); - let (empty_votes, _) = harness.make_attestations_with_payload_present_override( - &all_validators, + // Add attestations to block B. + let (block_b_head_votes, _) = harness.make_attestations_with_limit( + &remaining_attesters, &state_b, state_b_root, block_b_root.into(), - slot_b1, - fork, - false, + slot_b, + num_head_votes, ); - harness.process_attestations(empty_votes, &state_b); + harness.process_attestations(block_b_head_votes, &state_b); - harness.advance_slot(); - harness.chain.recompute_head_at_slot(slot_c).await; + let payload_lookahead = harness.chain.config.prepare_payload_lookahead; + let fork_choice_lookahead = Duration::from_millis(500); + while harness.get_current_slot() != slot_c { + let current_slot = harness.get_current_slot(); + let next_slot = current_slot + 1; - // Sanity: head is `(B, EMPTY)`. - assert_eq!(harness.head_block_root(), Hash256::from(block_b_root)); - assert_eq!( - harness.chain.canonical_head.cached_head().head_payload_status(), - PayloadStatus::Empty, - ); + // Simulate the scheduled call to prepare proposers at 8 seconds into the slot. + harness.advance_to_slot_lookahead(next_slot, payload_lookahead); + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); - // Produce C at `slot_c`. + // Simulate the scheduled call to fork choice + prepare proposers 500ms before the + // next slot. + harness.advance_to_slot_lookahead(next_slot, fork_choice_lookahead); + harness.chain.recompute_head_at_slot(next_slot).await; + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + harness.advance_slot(); + harness.chain.per_slot_task().await; + } + + // Produce block C. + // Advance state_b so we can get the proposer. + assert_eq!(state_b.slot(), slot_b); + let pre_advance_withdrawals = get_expected_withdrawals(&state_b, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec(); complete_state_advance(&mut state_b, None, slot_c, &harness.chain.spec).unwrap(); + let proposer_index = state_b .get_beacon_proposer_index(slot_c, &harness.chain.spec) .unwrap(); let randao_reveal = harness .sign_randao_reveal(&state_b, proposer_index, slot_c) .into(); - let (response, _) = tester - .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) - .await - .unwrap(); - let block_c = Arc::new(harness.sign_beacon_block(response.data, &state_b)); + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_c) + .gloas_enabled(); - // C keeps B as its beacon-block parent (no *block* re-org)... - assert_eq!( - block_c.parent_root(), - Hash256::from(block_b_root), - "C should still build on beacon block B" + let (block_c, block_c_blobs) = if is_gloas { + let (response, _) = tester + .client + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .await + .unwrap(); + ( + Arc::new(harness.sign_beacon_block(response.data, &state_b)), + None, + ) + } else { + let (unsigned_block_type, _) = tester + .client + .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) + .await + .unwrap(); + + let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { + ProduceBlockV3Response::Full(unsigned_block_contents_c) => { + unsigned_block_contents_c.deconstruct() + } + ProduceBlockV3Response::Blinded(_) => { + panic!("Should not be a blinded block"); + } + }; + ( + Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)), + block_c_blobs, + ) + }; + + // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash + // lives in the execution payload bid, and the payload timestamp is derived from the slot. + let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + if is_gloas { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .block_hash + } else { + block.execution_payload().unwrap().block_hash() + } + }; + + let block_a_exec_hash = exec_block_hash(block_a.0.message()); + let block_b_exec_hash = exec_block_hash(block_b.0.message()); + + if should_re_org { + // Block C should build on A. + assert_eq!(block_c.parent_root(), Hash256::from(block_a_root)); + + if is_gloas { + assert_eq!( + block_c.is_parent_block_full(block_a_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } + } else { + // Block C should build on B. + assert_eq!(block_c.parent_root(), block_b_root); + + if is_gloas { + assert_eq!( + block_c.is_parent_block_full(block_b_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } + } + + // Applying block C should cause it to become head regardless (re-org or continuation). + let block_root_c = Hash256::from( + harness + .process_block_result((block_c.clone(), block_c_blobs)) + .await + .unwrap(), ); - // ...but builds on B's EMPTY payload path: its bid does not extend B's execution payload. - let block_c_parent_payload_hash = block_c - .message() - .body() - .signed_execution_payload_bid() - .expect("Gloas block should have a payload bid") - .message - .parent_block_hash; - assert_ne!( - block_c_parent_payload_hash, block_b_payload_hash, - "C must not extend B's payload — B's payload is re-orged" + assert_eq!(harness.head_block_root(), block_root_c); + + // Check the fork choice updates that were sent. + let forkchoice_updates = forkchoice_updates.lock(); + + let block_c_timestamp = if is_gloas { + harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs() + } else { + block_c.message().execution_payload().unwrap().timestamp() + }; + + // If we re-orged then no fork choice update for B should have been sent. + assert_eq!( + should_re_org, + !forkchoice_updates.contains_update_for(block_b_exec_hash), + "{block_b_exec_hash:?}" ); - // TODO: - // 1. get_proposer_head=grand-parent (do-reorg) + payload_status=empty - // 1. get_proposer_head=grant-parent (do-reorg) + payload_status=full - // 1. get_proposer_head=parent (don't-reorg) + payload_status=full - // 1. get_proposer_head=parent (don't-reorg) + payload_status=empty - // - // should make sure that all existing test pass - // - run each for gloas and understand what changes are needed to make them pass for gloas - // - some tests might require gloas-specfic setup changes: + // Check the timing of the first fork choice update with payload attributes for block C. + let c_parent_hash = if should_re_org { + block_a_exec_hash + } else { + block_b_exec_hash + }; + let first_update = forkchoice_updates + .first_update_with_payload_attributes(c_parent_hash, block_c_timestamp) + .unwrap(); + let payload_attribs = first_update.payload_attributes.as_ref().unwrap(); + + // Check that withdrawals from the payload attributes match those computed from the parent's + // advanced state. + let expected_withdrawals = if should_re_org { + let mut state_a_advanced = state_a.clone(); + complete_state_advance(&mut state_a_advanced, None, slot_c, &harness.chain.spec).unwrap(); + get_expected_withdrawals(&state_a_advanced, &harness.chain.spec) + } else { + get_expected_withdrawals(&state_b, &harness.chain.spec) + } + .unwrap() + .withdrawals() + .to_vec(); + let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); + assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); + assert!(!expected_withdrawals.is_empty()); + + if should_re_org + || expect_withdrawals_change_on_epoch + && slot_c.epoch(E::slots_per_epoch()) != slot_b.epoch(E::slots_per_epoch()) + { + assert_ne!(expected_withdrawals, pre_advance_withdrawals); + } + + // Check that the `parent_beacon_block_root` of the payload attributes are correct. + if let Ok(parent_beacon_block_root) = payload_attribs.parent_beacon_block_root() { + assert_eq!(parent_beacon_block_root, block_c.parent_root()); + } + + let lookahead = slot_clock + .start_of(slot_c) + .unwrap() + .checked_sub(first_update.received_at) + .unwrap(); + + if !misprediction { + assert_eq!( + lookahead, + payload_lookahead, + "lookahead={lookahead:?}, timestamp={}, prev_randao={:?}", + payload_attribs.timestamp(), + payload_attribs.prev_randao(), + ); + } else { + // On a misprediction we issue the first fcU 500ms before creating a block! + assert_eq!( + lookahead, + fork_choice_lookahead, + "timestamp={}, prev_randao={:?}", + payload_attribs.timestamp(), + payload_attribs.prev_randao(), + ); + } } From 76eb7abae0e4d74cf895961e202d337826f7107b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 11:54:57 +1000 Subject: [PATCH 07/32] BeaconChainHarness support for payload attestation messages --- .../payload_attestation_verification/tests.rs | 72 +++++++- beacon_node/beacon_chain/src/test_utils.rs | 162 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index d4b82c41fc6..3810ca9116f 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -16,7 +16,10 @@ use crate::{ GossipVerificationContext, VerifiedPayloadAttestationMessage, }, }, - test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, + test_utils::{ + BeaconChainHarness, EphemeralHarnessType, MakePayloadAttestationOptions, + PayloadAttestationVote, fork_name_from_env, test_spec, + }, }; type E = MinimalEthSpec; @@ -271,6 +274,73 @@ fn duplicate_after_valid() { )); } +#[test] +fn harness_builds_and_imports_payload_attestation_messages() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let state = &ctx.harness.chain.head_snapshot().beacon_state; + let votes = vec![ + PayloadAttestationVote { + validator_count: 2, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: 3, + payload_present: false, + blob_data_available: false, + }, + ]; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + ctx.genesis_block_root, + slot, + MakePayloadAttestationOptions { + votes, + fork: state.fork(), + }, + ); + + assert_eq!(messages.len(), 5); + assert_eq!(attesters.len(), 5); + assert_eq!( + attesters + .iter() + .copied() + .collect::>() + .len(), + 5 + ); + assert_eq!( + messages + .iter() + .filter(|message| message.data.payload_present && message.data.blob_data_available) + .count(), + 2 + ); + assert_eq!( + messages + .iter() + .filter(|message| !message.data.payload_present && !message.data.blob_data_available) + .count(), + 3 + ); + + let pool_count_before = ctx.harness.chain.op_pool.num_payload_attestation_messages(); + ctx.harness + .import_payload_attestation_messages(messages) + .expect("payload attestation messages should import"); + assert_eq!( + ctx.harness.chain.op_pool.num_payload_attestation_messages(), + pool_count_before + 5 + ); +} + #[tokio::test] async fn ptc_cache_is_primed_at_gloas_fork_boundary() { // Only run this test once, when FORK_NAME=gloas exactly. diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index db2a9a902d9..c0daaa6a8a2 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -751,6 +751,8 @@ pub type HarnessSingleAttestations = Vec<( Option>, )>; +pub type HarnessPayloadAttestationMessages = Vec; + pub type HarnessSyncContributions = Vec<( Vec<(SyncCommitteeMessage, usize)>, Option>, @@ -2150,6 +2152,144 @@ where ) } + pub fn make_payload_attestation_message( + &self, + validator_index: usize, + data: PayloadAttestationData, + fork: &Fork, + ) -> PayloadAttestationMessage { + let epoch = data.slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::PTCAttester, + fork, + self.chain.genesis_validators_root, + ); + let signing_root = data.signing_root(domain); + let signature = self.validator_keypairs[validator_index] + .sk + .sign(signing_root); + + PayloadAttestationMessage { + validator_index: validator_index as u64, + data, + signature, + } + } + + pub fn make_payload_attestation_messages( + &self, + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + votes: Vec, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let fork = self.spec.fork_at_epoch(slot.epoch(E::slots_per_epoch())); + self.make_payload_attestation_messages_with_opts( + &self.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { votes, fork }, + ) + } + + pub fn make_payload_attestation_messages_with_opts( + &self, + attesting_validators: &[usize], + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + opts: MakePayloadAttestationOptions, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let MakePayloadAttestationOptions { votes, fork } = opts; + let requested_message_count = votes.iter().map(|vote| vote.validator_count).sum::(); + let ptc = state + .get_ptc(slot, &self.spec) + .expect("should get payload timeliness committee"); + + let mut seen = HashSet::new(); + let ptc_validators = ptc + .0 + .iter() + .copied() + .filter(|validator_index| { + seen.insert(*validator_index) && attesting_validators.contains(validator_index) + }) + .collect::>(); + + assert!( + requested_message_count <= ptc_validators.len(), + "requested {requested_message_count} payload attestation messages, but only {} \ + distinct selected validators are in the PTC", + ptc_validators.len() + ); + + let mut messages = Vec::with_capacity(requested_message_count); + let mut attesters = Vec::with_capacity(requested_message_count); + let mut validator_offset = 0; + + for vote in votes { + let data = PayloadAttestationData { + beacon_block_root, + slot, + payload_present: vote.payload_present, + blob_data_available: vote.blob_data_available, + }; + + for validator_index in ptc_validators + [validator_offset..validator_offset + vote.validator_count] + .iter() + .copied() + { + messages.push(self.make_payload_attestation_message( + validator_index, + data.clone(), + &fork, + )); + attesters.push(validator_index); + } + + validator_offset += vote.validator_count; + } + + (messages, attesters) + } + + pub fn import_payload_attestation_message( + &self, + message: PayloadAttestationMessage, + ) -> Result<(), PayloadAttestationImportError> { + let verified = self + .chain + .verify_payload_attestation_message_for_gossip(message) + .map_err(PayloadAttestationImportError::Verification)?; + + self.chain + .apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) + .map_err(PayloadAttestationImportError::ForkChoice)?; + + self.chain + .add_payload_attestation_to_pool(&verified) + .map_err(PayloadAttestationImportError::Pool)?; + + Ok(()) + } + + pub fn import_payload_attestation_messages( + &self, + messages: impl IntoIterator, + ) -> Result<(), PayloadAttestationImportError> { + for message in messages { + self.import_payload_attestation_message(message)?; + } + + Ok(()) + } + pub fn make_sync_contributions( &self, state: &BeaconState, @@ -3771,6 +3911,28 @@ pub struct MakeAttestationOptions { pub payload_present_override: Option, } +#[derive(Debug, Clone, Copy)] +pub struct PayloadAttestationVote { + /// Number of distinct selected PTC validators to produce messages for this vote. + pub validator_count: usize, + pub payload_present: bool, + pub blob_data_available: bool, +} + +pub struct MakePayloadAttestationOptions { + /// Vote groups to produce. Each group becomes `validator_count` individual messages. + pub votes: Vec, + /// Fork to use for signing payload attestation messages. + pub fork: Fork, +} + +#[derive(Debug)] +pub enum PayloadAttestationImportError { + Verification(crate::payload_attestation_verification::Error), + ForkChoice(BeaconChainError), + Pool(BeaconChainError), +} + pub enum NumBlobs { Random, Number(usize), From 21bb53fc7214feae5eb118a4374ccad121860c68 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 14:02:54 +1000 Subject: [PATCH 08/32] WIP --- .../payload_attestation_verification/tests.rs | 102 ++++++++++++++++-- beacon_node/beacon_chain/src/test_utils.rs | 93 ++++++++++++---- .../http_api/tests/gloas_reorg_tests.rs | 63 ++++++++--- 3 files changed, 214 insertions(+), 44 deletions(-) diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index 3810ca9116f..2b4b49034cb 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -34,6 +34,10 @@ struct TestContext { impl TestContext { fn new() -> Self { + Self::with_validator_count(NUM_VALIDATORS) + } + + fn with_validator_count(num_validators: usize) -> Self { let spec = Arc::new(test_spec::()); let slot_clock = TestingSlotClock::new( Slot::new(0), @@ -42,7 +46,7 @@ impl TestContext { ); let harness = BeaconChainHarness::builder(E::default()) .spec(spec) - .deterministic_keypairs(NUM_VALIDATORS) + .deterministic_keypairs(num_validators) .fresh_ephemeral_store() .testing_slot_clock(slot_clock) .build(); @@ -282,6 +286,11 @@ fn harness_builds_and_imports_payload_attestation_messages() { let ctx = TestContext::new(); let slot = Slot::new(1); let state = &ctx.harness.chain.head_snapshot().beacon_state; + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + for validator_index in ptc.0.iter().copied() { + *ptc_weights.entry(validator_index).or_insert(0usize) += 1; + } let votes = vec![ PayloadAttestationVote { validator_count: 2, @@ -306,28 +315,29 @@ fn harness_builds_and_imports_payload_attestation_messages() { }, ); - assert_eq!(messages.len(), 5); - assert_eq!(attesters.len(), 5); + assert_eq!(messages.len(), attesters.len()); assert_eq!( attesters .iter() .copied() .collect::>() .len(), - 5 + attesters.len() ); assert_eq!( messages .iter() .filter(|message| message.data.payload_present && message.data.blob_data_available) - .count(), + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), 2 ); assert_eq!( messages .iter() .filter(|message| !message.data.payload_present && !message.data.blob_data_available) - .count(), + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), 3 ); @@ -337,10 +347,88 @@ fn harness_builds_and_imports_payload_attestation_messages() { .expect("payload attestation messages should import"); assert_eq!( ctx.harness.chain.op_pool.num_payload_attestation_messages(), - pool_count_before + 5 + pool_count_before + attesters.len() ); } +#[test] +fn harness_packs_payload_attestation_messages_by_ptc_weight() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let state = &ctx.harness.chain.head_snapshot().beacon_state; + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc.0.iter().copied() { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + let mut sorted_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| (validator_index, ptc_weights[&validator_index], order)) + .collect::>(); + sorted_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); + let first_weight = sorted_ptc_validators + .first() + .map(|(_, weight, _)| *weight) + .expect("PTC should have at least one validator"); + assert!(first_weight > 1, "test requires a duplicate PTC member"); + let second_weight = sorted_ptc_validators + .iter() + .skip(1) + .map(|(_, weight, _)| *weight) + .next() + .expect("PTC should have at least two distinct validators"); + let requested_weight = first_weight + second_weight; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + ctx.genesis_block_root, + slot, + MakePayloadAttestationOptions { + votes: vec![PayloadAttestationVote { + validator_count: requested_weight, + payload_present: true, + blob_data_available: true, + }], + fork: state.fork(), + }, + ); + + assert!( + messages.len() < requested_weight, + "duplicate PTC positions should pack into fewer messages" + ); + assert_eq!(messages.len(), attesters.len()); + assert_eq!( + attesters + .iter() + .map(|validator_index| ptc_weights[validator_index]) + .sum::(), + requested_weight + ); + assert!( + attesters + .iter() + .any(|validator_index| ptc_weights[validator_index] > 1) + ); + + ctx.harness + .import_payload_attestation_messages(messages) + .expect("weighted payload attestation messages should import"); +} + #[tokio::test] async fn ptc_cache_is_primed_at_gloas_fork_boundary() { // Only run this test once, when FORK_NAME=gloas exactly. diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index c0daaa6a8a2..6f62bd711a4 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -758,6 +758,30 @@ pub type HarnessSyncContributions = Vec<( Option>, )>; +fn pack_payload_attestation_vote( + available_ptc_validators: &[(usize, usize, usize)], + requested_weight: usize, +) -> Option> { + let mut packs = vec![None::>; requested_weight.checked_add(1)?]; + packs[0] = Some(vec![]); + + for (offset, (_, weight, _)) in available_ptc_validators.iter().enumerate() { + if *weight > requested_weight { + continue; + } + + for weight_so_far in (0..=requested_weight - *weight).rev() { + if packs[weight_so_far].is_some() && packs[weight_so_far + *weight].is_none() { + let mut pack = packs[weight_so_far].as_ref()?.clone(); + pack.push(offset); + packs[weight_so_far + *weight] = Some(pack); + } + } + } + + packs.pop().flatten() +} + impl BeaconChainHarness> where E: EthSpec, @@ -2203,31 +2227,43 @@ where opts: MakePayloadAttestationOptions, ) -> (HarnessPayloadAttestationMessages, Vec) { let MakePayloadAttestationOptions { votes, fork } = opts; - let requested_message_count = votes.iter().map(|vote| vote.validator_count).sum::(); let ptc = state .get_ptc(slot, &self.spec) .expect("should get payload timeliness committee"); - let mut seen = HashSet::new(); - let ptc_validators = ptc + debug!("PTC is {:?}", ptc.0.to_vec()); + + let attesting_validators = attesting_validators.iter().copied().collect::>(); + let mut ptc_weights = HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc .0 .iter() .copied() - .filter(|validator_index| { - seen.insert(*validator_index) && attesting_validators.contains(validator_index) + .filter(|validator_index| attesting_validators.contains(validator_index)) + { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + + let mut available_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| { + let weight = ptc_weights[&validator_index]; + (validator_index, weight, order) }) .collect::>(); + available_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); - assert!( - requested_message_count <= ptc_validators.len(), - "requested {requested_message_count} payload attestation messages, but only {} \ - distinct selected validators are in the PTC", - ptc_validators.len() - ); - - let mut messages = Vec::with_capacity(requested_message_count); - let mut attesters = Vec::with_capacity(requested_message_count); - let mut validator_offset = 0; + let mut messages = Vec::new(); + let mut attesters = Vec::new(); for vote in votes { let data = PayloadAttestationData { @@ -2237,11 +2273,22 @@ where blob_data_available: vote.blob_data_available, }; - for validator_index in ptc_validators - [validator_offset..validator_offset + vote.validator_count] - .iter() - .copied() - { + let Some(packed_validator_offsets) = + pack_payload_attestation_vote(&available_ptc_validators, vote.validator_count) + else { + let available_weights = available_ptc_validators + .iter() + .map(|(validator_index, weight, _)| (*validator_index, *weight)) + .collect::>(); + panic!( + "requested packing couldn't be formed for payload attestation vote {vote:?}; \ + requested PTC weight {}, available PTC weights {:?}", + vote.validator_count, available_weights + ); + }; + + for &offset in &packed_validator_offsets { + let validator_index = available_ptc_validators[offset].0; messages.push(self.make_payload_attestation_message( validator_index, data.clone(), @@ -2250,7 +2297,9 @@ where attesters.push(validator_index); } - validator_offset += vote.validator_count; + for offset in packed_validator_offsets.into_iter().rev() { + available_ptc_validators.remove(offset); + } } (messages, attesters) @@ -3913,7 +3962,7 @@ pub struct MakeAttestationOptions { #[derive(Debug, Clone, Copy)] pub struct PayloadAttestationVote { - /// Number of distinct selected PTC validators to produce messages for this vote. + /// Amount of PTC weight to produce messages for this vote. pub validator_count: usize, pub payload_present: bool, pub blob_data_available: bool, diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 98dca7f6597..c1b1e2eff5e 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -11,7 +11,7 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, - SyncCommitteeStrategy, test_spec, + MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, }, }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; @@ -30,12 +30,13 @@ use std::sync::Arc; use std::time::Duration; use types::{ Address, BeaconBlockRef, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, - MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, + MinimalEthSpec, ProposerPreparationData, Slot, }; -type E = MainnetEthSpec; +type E = MinimalEthSpec; -const ATTESTERS_PER_SLOT: usize = 10; +// Must be at least PTC size to simplify PTC reasoning (unique PTC members per slot). +const ATTESTERS_PER_SLOT: usize = 20; /// Data structure for tracking fork choice updates received by the mock execution layer. #[derive(Debug, Default)] @@ -177,23 +178,28 @@ pub async fn proposer_boost_re_org_test( let spec = ForkName::latest().make_genesis_spec(E::default_spec()); - // Ensure there are enough validators to have `attesters_per_slot`. - let attesters_per_slot = 10; - let validator_count = E::slots_per_epoch() as usize * attesters_per_slot; + // Ensure there are enough validators to have `ATTESTERS_PER_SLOT`. + assert!(ATTESTERS_PER_SLOT >= E::ptc_size()); + let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; let all_validators = (0..validator_count).collect::>(); let num_initial = head_slot.as_u64().checked_sub(parent_distance + 1).unwrap(); - // Check that the required vote percentages can be satisfied exactly using `attesters_per_slot`. - assert_eq!(100 % attesters_per_slot, 0); - let percent_per_attester = 100 / attesters_per_slot; + // Check that the required vote percentages can be satisfied exactly using `ATTESTERS_PER_SLOT`. + assert_eq!(100 % ATTESTERS_PER_SLOT, 0); + let percent_per_attester = 100 / ATTESTERS_PER_SLOT; assert_eq!(percent_parent_votes % percent_per_attester, 0); assert_eq!(percent_skip_empty_votes % percent_per_attester, 0); assert_eq!(percent_skip_full_votes % percent_per_attester, 0); assert_eq!(percent_head_votes % percent_per_attester, 0); - let num_parent_votes = Some(attesters_per_slot * percent_parent_votes / 100); - let num_skip_empty_votes = Some(attesters_per_slot * percent_skip_empty_votes / 100); - let num_skip_full_votes = Some(attesters_per_slot * percent_skip_full_votes / 100); - let num_head_votes = Some(attesters_per_slot * percent_head_votes / 100); + let num_parent_votes = Some(ATTESTERS_PER_SLOT * percent_parent_votes / 100); + let num_skip_empty_votes = Some(ATTESTERS_PER_SLOT * percent_skip_empty_votes / 100); + let num_skip_full_votes = Some(ATTESTERS_PER_SLOT * percent_skip_full_votes / 100); + let num_head_votes = Some(ATTESTERS_PER_SLOT * percent_head_votes / 100); + + assert_eq!((percent_parent_ptc_present_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_present_votes = percent_parent_ptc_present_votes * E::ptc_size() / 100; + assert_eq!((percent_parent_ptc_absent_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_absent_votes = percent_parent_ptc_absent_votes * E::ptc_size() / 100; let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec), @@ -312,6 +318,33 @@ pub async fn proposer_boost_re_org_test( ); harness.process_attestations(block_a_parent_votes, &state_a); + // Produce PTC messages for slot A. + let a_ptc_votes = vec![ + PayloadAttestationVote { + validator_count: num_parent_ptc_present_votes, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: num_parent_ptc_absent_votes, + payload_present: false, + blob_data_available: false, + }, + ]; + let (a_ptc_messages, _) = harness.make_payload_attestation_messages_with_opts( + &all_validators, + &state_a, + block_a_root.into(), + slot_a, + MakePayloadAttestationOptions { + votes: a_ptc_votes, + fork: state_a.fork(), + }, + ); + harness + .import_payload_attestation_messages(a_ptc_messages) + .unwrap(); + // Attest to block A during slot B. for _ in 0..parent_distance { harness.advance_slot(); @@ -599,7 +632,7 @@ pub async fn proposer_boost_re_org_test( assert_eq!( lookahead, payload_lookahead, - "lookahead={lookahead:?}, timestamp={}, prev_randao={:?}", + "observed_lookahead={lookahead:?}, expected={payload_lookahead:?}, timestamp={}, prev_randao={:?}", payload_attribs.timestamp(), payload_attribs.prev_randao(), ); From d829d374c37efd44984d5f409eba5864f8d0b42a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 14:55:57 +1000 Subject: [PATCH 09/32] Fix prepare payload lookahead --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index c1b1e2eff5e..56002ef21a2 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -7,7 +7,7 @@ //! use beacon_chain::{ ChainConfig, - chain_config::DisallowedReOrgOffsets, + chain_config::{DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, DisallowedReOrgOffsets}, custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, @@ -201,14 +201,23 @@ pub async fn proposer_boost_re_org_test( assert_eq!((percent_parent_ptc_absent_votes * E::ptc_size()) % 100, 0); let num_parent_ptc_absent_votes = percent_parent_ptc_absent_votes * E::ptc_size() / 100; + // We must configure the prepare payload lookahead so it scales with the minimal config, + // otherwise the late block reveal for A halfway through the slot can end up being *after* + // the payload lookahead, which messes up our measurement of timings. + let mut chain_config = ChainConfig::default(); + chain_config.prepare_payload_lookahead = + spec.get_slot_duration() / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR; + let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec), validator_count, None, Some(Box::new(move |builder| { - builder.proposer_re_org_disallowed_offsets( - DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), - ) + builder + .chain_config(chain_config) + .proposer_re_org_disallowed_offsets( + DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), + ) })), Default::default(), false, From 79b4f76237410c226a51d7aaece65945620fe267 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 15:30:23 +1000 Subject: [PATCH 10/32] Harder test case --- .../http_api/tests/gloas_reorg_tests.rs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 56002ef21a2..520c65b771d 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -11,11 +11,10 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, - MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, + MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, }, }; -use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; -use eth2::types::{DepositContractData, ProduceBlockV3Response, StateId}; +use eth2::types::ProduceBlockV3Response; use execution_layer::{ForkchoiceState, PayloadAttributes}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; @@ -29,7 +28,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, + Address, BeaconBlockRef, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -148,6 +147,26 @@ pub async fn re_org_parent_is_empty_easy() { .await; } +// A-Empty chain has 50% of one committee supporting it A-Full chain has 55% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Empty. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 50, + percent_skip_full_votes: 40, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + ..Default::default() + }) + .await; +} + /// Run a proposer boost re-org test. /// /// - `head_slot`: the slot of the canonical head to be reorged @@ -358,7 +377,7 @@ pub async fn proposer_boost_re_org_test( for _ in 0..parent_distance { harness.advance_slot(); } - let (block_a_empty_votes, block_a_attesters) = harness.make_attestations_with_opts( + let (block_a_empty_votes, block_a_empty_attesters) = harness.make_attestations_with_opts( &all_validators, &state_a, state_a_root, @@ -371,7 +390,7 @@ pub async fn proposer_boost_re_org_test( }, ); harness.process_attestations(block_a_empty_votes, &state_a); - let (block_a_full_votes, block_a_attesters) = harness.make_attestations_with_opts( + let (block_a_full_votes, block_a_full_attesters) = harness.make_attestations_with_opts( &all_validators, &state_a, state_a_root, @@ -388,7 +407,9 @@ pub async fn proposer_boost_re_org_test( let remaining_attesters = all_validators .iter() .copied() - .filter(|index| !block_a_attesters.contains(index)) + .filter(|index| { + !block_a_empty_attesters.contains(index) && !block_a_full_attesters.contains(index) + }) .collect::>(); // Produce block B and process it halfway through the slot. From 8096b6599bf08cb54960564ba4be94f6e129f1f1 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 18:13:23 +1000 Subject: [PATCH 11/32] Three variants of test (but they use natural reorgs) --- .../http_api/tests/gloas_reorg_tests.rs | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 520c65b771d..57880942fd5 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -136,6 +136,8 @@ impl Default for ReOrgTest { } } +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn re_org_parent_is_empty_easy() { proposer_boost_re_org_test(ReOrgTest { @@ -147,17 +149,20 @@ pub async fn re_org_parent_is_empty_easy() { .await; } -// A-Empty chain has 50% of one committee supporting it A-Full chain has 55% of one committee +// A-Empty chain has 55% of one committee supporting it A-Full chain has 45% of one committee // supporting it, including 15% for descendant B that is late and re-orgable. // // A-Full has 100% PTC support, but this should be completely ignored. // // We should re-org B and build on A-Empty. +// +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn re_org_parent_is_empty_marginal_win() { proposer_boost_re_org_test(ReOrgTest { - percent_skip_empty_votes: 50, - percent_skip_full_votes: 40, + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, percent_head_votes: 15, percent_parent_ptc_present_votes: 100, percent_parent_ptc_absent_votes: 0, @@ -167,6 +172,28 @@ pub async fn re_org_parent_is_empty_marginal_win() { .await; } +// A-Empty chain has 45% of one committee supporting it A-Full chain has 55% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Full. +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_full_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 45, + percent_skip_full_votes: 40, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + ..Default::default() + }) + .await; +} + /// Run a proposer boost re-org test. /// /// - `head_slot`: the slot of the canonical head to be reorged @@ -390,8 +417,13 @@ pub async fn proposer_boost_re_org_test( }, ); harness.process_attestations(block_a_empty_votes, &state_a); + let remaining_attesters_after_empty = all_validators + .iter() + .copied() + .filter(|index| !block_a_empty_attesters.contains(index)) + .collect::>(); let (block_a_full_votes, block_a_full_attesters) = harness.make_attestations_with_opts( - &all_validators, + &remaining_attesters_after_empty, &state_a, state_a_root, block_a_root, @@ -404,12 +436,10 @@ pub async fn proposer_boost_re_org_test( ); harness.process_attestations(block_a_full_votes, &state_a); - let remaining_attesters = all_validators + let remaining_attesters = remaining_attesters_after_empty .iter() .copied() - .filter(|index| { - !block_a_empty_attesters.contains(index) && !block_a_full_attesters.contains(index) - }) + .filter(|index| !block_a_full_attesters.contains(index)) .collect::>(); // Produce block B and process it halfway through the slot. From 0d15b052b646737f7ee1eac56ed7a04af39da561 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 20:41:10 +1000 Subject: [PATCH 12/32] Allow setting parent payload status --- beacon_node/beacon_chain/src/test_utils.rs | 57 ++++++++++++++----- .../http_api/tests/gloas_reorg_tests.rs | 33 ++++++++--- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 6f62bd711a4..7a3c9236b7c 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -42,6 +42,7 @@ use logging::create_test_tracing_subscriber; use merkle_proof::MerkleTree; use operation_pool::ReceivedPreCapella; use parking_lot::{Mutex, RwLockWriteGuard}; +use proto_array::PayloadStatus; use rand::Rng; use rand::SeedableRng; use rand::rngs::StdRng; @@ -1189,9 +1190,33 @@ where /// /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. pub async fn make_block_with_envelope( + &self, + state: BeaconState, + slot: Slot, + ) -> ( + SignedBlockContentsTuple, + Option>, + BeaconState, + ) { + let parent_payload_status = self + .chain + .canonical_head + .cached_head() + .head_payload_status(); + self.make_block_with_envelope_on(state, slot, parent_payload_status) + .await + } + + /// Returns a newly created block built with the given parent payload status, + /// signed by the proposer for the given slot, along with the execution + /// payload envelope (for Gloas) and the post-block state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope_on( &self, mut state: BeaconState, slot: Slot, + parent_payload_status: PayloadStatus, ) -> ( SignedBlockContentsTuple, Option>, @@ -1214,15 +1239,21 @@ where GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); - // Load the parent's payload envelope and status from the cached head. - // TODO(gloas): we may want to pass these as arguments to support cases where we build - // on alternate chains to the head. - let (parent_payload_status, parent_envelope) = { - let head = self.chain.canonical_head.cached_head(); - ( - head.head_payload_status(), - head.snapshot.execution_envelope.clone(), - ) + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .expect("should get parent block root") + } else { + state.latest_block_header().canonical_root() + }; + self.chain + .store + .get_payload_envelope(&parent_root) + .expect("should load parent payload envelope") + .map(Arc::new) + } else { + None }; let (block, post_block_state, _consensus_block_value) = self @@ -2319,11 +2350,11 @@ where verified.indexed_payload_attestation(), verified.ptc(), ) - .map_err(PayloadAttestationImportError::ForkChoice)?; + .map_err(|e| PayloadAttestationImportError::ForkChoice(Box::new(e)))?; self.chain .add_payload_attestation_to_pool(&verified) - .map_err(PayloadAttestationImportError::Pool)?; + .map_err(|e| PayloadAttestationImportError::Pool(Box::new(e)))?; Ok(()) } @@ -3978,8 +4009,8 @@ pub struct MakePayloadAttestationOptions { #[derive(Debug)] pub enum PayloadAttestationImportError { Verification(crate::payload_attestation_verification::Error), - ForkChoice(BeaconChainError), - Pool(BeaconChainError), + ForkChoice(Box), + Pool(Box), } pub enum NumBlobs { diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 57880942fd5..02a5b9524dc 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -98,6 +98,8 @@ pub struct ReOrgTest { percent_skip_full_votes: usize, /// Fraction of B's committee that votes for B (always with payload_present=0). percent_head_votes: usize, + /// Parent payload status of block B. + head_parent_payload_status: PayloadStatus, /// Fraction of A's PTC that vote for A's payload being present. percent_parent_ptc_present_votes: usize, /// Fraction of A's PTC that vote for A's payload being absent. @@ -125,6 +127,7 @@ impl Default for ReOrgTest { percent_skip_empty_votes: 0, percent_skip_full_votes: 100, percent_head_votes: 0, + head_parent_payload_status: PayloadStatus::Full, percent_parent_ptc_present_votes: 100, percent_parent_ptc_absent_votes: 0, expected_parent_payload_status: PayloadStatus::Full, @@ -211,6 +214,7 @@ pub async fn proposer_boost_re_org_test( percent_skip_empty_votes, percent_skip_full_votes, percent_head_votes, + head_parent_payload_status, percent_parent_ptc_present_votes, percent_parent_ptc_absent_votes, expected_parent_payload_status, @@ -250,9 +254,11 @@ pub async fn proposer_boost_re_org_test( // We must configure the prepare payload lookahead so it scales with the minimal config, // otherwise the late block reveal for A halfway through the slot can end up being *after* // the payload lookahead, which messes up our measurement of timings. - let mut chain_config = ChainConfig::default(); - chain_config.prepare_payload_lookahead = - spec.get_slot_duration() / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR; + let chain_config = ChainConfig { + prepare_payload_lookahead: spec.get_slot_duration() + / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, + ..Default::default() + }; let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec), @@ -454,10 +460,16 @@ pub async fn proposer_boost_re_org_test( .fork_name_at_slot::(slot_b) .gloas_enabled(); let reveal_block_b_payload = is_gloas && !should_re_org; - let (block_b, block_b_envelope, mut state_b) = if reveal_block_b_payload { - harness - .make_block_with_envelope(state_a.clone(), slot_b) - .await + let (block_b, block_b_envelope, mut state_b) = if is_gloas { + let (block_b, block_b_envelope, state_b) = harness + .make_block_with_envelope_on(state_a.clone(), slot_b, head_parent_payload_status) + .await; + let block_b_envelope = if reveal_block_b_payload { + block_b_envelope + } else { + None + }; + (block_b, block_b_envelope, state_b) } else { let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; (block_b, None, state_b) @@ -595,6 +607,13 @@ pub async fn proposer_boost_re_org_test( let block_a_exec_hash = exec_block_hash(block_a.0.message()); let block_b_exec_hash = exec_block_hash(block_b.0.message()); + if is_gloas { + assert_eq!( + block_b.0.is_parent_block_full(block_a_exec_hash), + head_parent_payload_status == PayloadStatus::Full + ); + } + if should_re_org { // Block C should build on A. assert_eq!(block_c.parent_root(), Hash256::from(block_a_root)); From 43cf850a7eb2afa3881cc7329423bf1034225e0d Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 21:21:53 +1000 Subject: [PATCH 13/32] Just use parent payload status! --- .../beacon_chain/src/block_production/mod.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 607607feb79..ec7f7cc8c1d 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -241,21 +241,10 @@ impl BeaconChain { drop(proposer_head_timer); let re_org_parent_block = proposer_head.parent_node.root(); - let parent_payload_status = match self - .canonical_head - .fork_choice_read_lock() - .get_canonical_payload_status(&re_org_parent_block, &self.spec) - { - Ok(status) => status, - Err(e) => { - warn!( - error = ?e, - parent = ?re_org_parent_block, - "Not attempting re-org: failed to resolve parent payload status" - ); - return None; - } - }; + // The head uniquely determines the parent payload status for the re-org block, whichever + // variant (full or empty) it builds on must have more weight, or else we would have already + // re-orged away from this block naturally, and it would not be the head, by definition. + let parent_payload_status = proposer_head.head_node.get_parent_payload_status(); let (state_root, state) = self .store From 7162a30cf5eef9b69cffaf96417be99f64a26580 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 4 Jun 2026 21:22:11 +1000 Subject: [PATCH 14/32] Don't swallow payload load errors --- beacon_node/beacon_chain/src/block_production/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index ec7f7cc8c1d..57defe8cfcc 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -255,11 +255,20 @@ impl BeaconChain { })?; let parent_envelope = if parent_payload_status == PayloadStatus::Full { - self.store + let envelope = self + .store .get_payload_envelope(&re_org_parent_block) .ok() .flatten() .map(Arc::new) + .or_else(|| { + warn!( + reason = "missing execution payload envelope", + "Not attempting re-org" + ); + None + })?; + Some(envelope) } else { None }; From 7f21431ca65cf8b5af43e5ba48ccb97150a5d40a Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 10 Jun 2026 13:16:29 -0400 Subject: [PATCH 15/32] empty parent `proposer-re-org-test` added --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 02a5b9524dc..2bc762dd110 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -197,6 +197,21 @@ pub async fn re_org_parent_is_full_marginal_win() { .await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_empty() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + head_parent_payload_status: PayloadStatus::Empty, + expected_parent_payload_status: PayloadStatus::Empty, + ..Default::default() + }) + .await; +} /// Run a proposer boost re-org test. /// /// - `head_slot`: the slot of the canonical head to be reorged From 8ace75d78093225c70b842dbc818e93f20e7f981 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 15 Jun 2026 03:13:34 -0400 Subject: [PATCH 16/32] linty happy --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 2bc762dd110..de4a21506c7 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -197,7 +197,6 @@ pub async fn re_org_parent_is_full_marginal_win() { .await; } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn proposer_boost_re_org_parent_empty() { proposer_boost_re_org_test(ReOrgTest { From 3b768f404bef3e17dc19aec9caba8d5e36967139 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 15 Jun 2026 04:44:29 -0400 Subject: [PATCH 17/32] linty linty happy --- beacon_node/beacon_chain/src/beacon_chain.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 8a570cfdf01..ab05f8b8f2f 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5328,7 +5328,6 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::HeadNotLate.into())); } - // Only attempt a re-org if we have a proposer registered for the re-org slot. This check // runs after the cheaper checks above because it may compute (and cache) the proposer // shuffling for the re-org slot's epoch on a cache miss. From e45599ef488008ade234bce8869c1c63c15f5e75 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 15 Jun 2026 05:00:56 -0400 Subject: [PATCH 18/32] my bad --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index de4a21506c7..adcc09d6f49 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -7,7 +7,7 @@ //! use beacon_chain::{ ChainConfig, - chain_config::{DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, DisallowedReOrgOffsets}, + chain_config::{DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR}, custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, @@ -112,8 +112,6 @@ pub struct ReOrgTest { misprediction: bool, /// Whether to expect withdrawals to change on epoch boundaries. expect_withdrawals_change_on_epoch: bool, - /// Epoch offsets to avoid proposing reorg blocks at. - disallowed_offsets: Vec, } impl Default for ReOrgTest { @@ -134,7 +132,6 @@ impl Default for ReOrgTest { should_re_org: true, misprediction: false, expect_withdrawals_change_on_epoch: false, - disallowed_offsets: vec![], } } } @@ -235,7 +232,6 @@ pub async fn proposer_boost_re_org_test( should_re_org, misprediction, expect_withdrawals_change_on_epoch, - disallowed_offsets, }: ReOrgTest, ) { assert!(head_slot > 0); @@ -281,9 +277,6 @@ pub async fn proposer_boost_re_org_test( Some(Box::new(move |builder| { builder .chain_config(chain_config) - .proposer_re_org_disallowed_offsets( - DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), - ) })), Default::default(), false, From 83b89f46024532f30eef34ed2c53f5ac1f67f5b5 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 15 Jun 2026 05:03:34 -0400 Subject: [PATCH 19/32] lint --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index adcc09d6f49..fb73bc6b0c1 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -7,7 +7,7 @@ //! use beacon_chain::{ ChainConfig, - chain_config::{DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR}, + chain_config::DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, @@ -274,10 +274,7 @@ pub async fn proposer_boost_re_org_test( Some(spec), validator_count, None, - Some(Box::new(move |builder| { - builder - .chain_config(chain_config) - })), + Some(Box::new(move |builder| builder.chain_config(chain_config))), Default::default(), false, NodeCustodyType::Fullnode, From a57328920cda582df0d281ee084c2e48735d648a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 14:31:44 +1000 Subject: [PATCH 20/32] Revert overridden fork choice changes --- beacon_node/beacon_chain/src/beacon_chain.rs | 55 +++----------------- 1 file changed, 6 insertions(+), 49 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 49fd9ccfe35..5a521d18e60 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5137,6 +5137,7 @@ impl BeaconChain { }) } + // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5199,53 +5200,10 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::HeadDistance.into())); } - // Only attempt a re-org if we have a proposer registered for the re-org slot. - let proposing_at_re_org_slot = { - // We know our re-org block is not on the epoch boundary, so it has the same proposer - // shuffling as the head (but not necessarily the parent which may lie in the previous - // epoch). - let shuffling_decision_root = if self - .spec - .fork_name_at_slot::(re_org_block_slot) - .fulu_enabled() - { - info.head_node.current_epoch_shuffling_id() - } else { - info.head_node.next_epoch_shuffling_id() - } - .shuffling_decision_block; - let proposer_index = self - .beacon_proposer_cache - .lock() - .get_slot::(shuffling_decision_root, re_org_block_slot) - .ok_or_else(|| { - debug!( - slot = %re_org_block_slot, - decision_root = ?shuffling_decision_root, - "Fork choice override proposer shuffling miss" - ); - Box::new(DoNotReOrg::NotProposing.into()) - })? - .index as u64; - - self.execution_layer - .as_ref() - .ok_or(ProposerHeadError::Error(Error::ExecutionLayerMissing))? - .has_proposer_preparation_data_blocking(proposer_index) - }; - if !proposing_at_re_org_slot { - return Err(Box::new(DoNotReOrg::NotProposing.into())); - } - - // Spec-aligned re-org weight checks. For Gloas (V29) nodes this uses - // payload-aware bucket weights matching `is_parent_strong`/`is_head_weak`; - // for pre-Gloas (V17) nodes `attestation_score` falls back to `weight()`. - let parent_payload_status = info.head_node.get_parent_payload_status(); - let parent_weight = info.parent_node.attestation_score(parent_payload_status); - let head_weight = info - .head_node - .attestation_score(fork_choice::PayloadStatus::Pending) - .saturating_add(info.head_node.equivocating_attestation_score().unwrap_or(0)); + // TODO(gloas): reorg weight logic needs updating for Gloas. For now use + // total weight which is correct for pre-Gloas and conservative for post-Gloas. + let head_weight = info.head_node.weight(); + let parent_weight = info.parent_node.weight(); let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( @@ -5350,8 +5308,7 @@ impl BeaconChain { .parent_node .execution_status() .ok() - .and_then(|execution_status| execution_status.block_hash()) - .or_else(|| info.parent_node.execution_payload_block_hash().ok()); + .and_then(|execution_status| execution_status.block_hash()); let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: info.parent_node.root(), head_hash: parent_head_hash, From c1d15540c1031fb4d0e1044b9ede9dae168bf29f Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 15:23:46 +1000 Subject: [PATCH 21/32] Disable overriding fork choice update for Gloas --- beacon_node/beacon_chain/src/beacon_chain.rs | 21 ++++++++++++++------ consensus/proto_array/src/proto_array.rs | 4 ++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 5a521d18e60..adce78e02ae 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5137,7 +5137,6 @@ impl BeaconChain { }) } - // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5168,6 +5167,11 @@ impl BeaconChain { ) .map_err(|e| e.map_inner_error(Error::ProposerHeadForkChoiceError))?; + // We don't need to override fork choice updates for Gloas. + if info.head_node.is_gloas() { + return Ok(*canonical_forkchoice_params); + } + // 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(); @@ -5301,9 +5305,7 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // TODO(gloas): V29 nodes don't carry execution_status, so this returns - // None for post-Gloas re-orgs. Need to source the EL block hash from - // the bid's block_hash instead. Re-org is disabled for Gloas for now. + // This only works pre-Gloas, but we don't run this code for Gloas anyway. let parent_head_hash = info .parent_node .execution_status() @@ -6340,8 +6342,15 @@ impl BeaconChain { } let canonical_fcu_params = cached_head.forkchoice_update_parameters(); - let fcu_params = - chain.overridden_forkchoice_update_params(canonical_fcu_params)?; + let fcu_params = if chain + .spec + .fork_name_at_slot::(head_slot) + .gloas_enabled() + { + canonical_fcu_params + } else { + chain.overridden_forkchoice_update_params(canonical_fcu_params)? + }; let pre_payload_attributes = chain.get_pre_payload_attributes( prepare_slot, fcu_params.head_root, diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 9d627917f84..5549cb5459d 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -174,6 +174,10 @@ pub struct ProtoNode { } impl ProtoNode { + pub fn is_gloas(&self) -> bool { + self.as_v29().is_ok() + } + /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by /// considering their parents Empty. pub fn get_parent_payload_status(&self) -> PayloadStatus { From 7401697e72899c0d337007fc9a794d2782c047a8 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 15:38:06 +1000 Subject: [PATCH 22/32] Revert changes to should_extend_payload --- consensus/proto_array/src/proto_array.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 5549cb5459d..04113e2c0e5 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1616,12 +1616,14 @@ impl ProtoArray { proto_node: &ProtoNode, proposer_boost_root: Hash256, ) -> Result { + let Ok(node) = proto_node.as_v29() else { + return Err(Error::InvalidNodeVariant { + block_root: fc_node.root, + }); + }; + // Spec equivalent to `if not is_payload_verified(store, root): return False` - // this also overload on the condition where the pre-gloas nodes are considered - // `PayloadStatus::Empty`. - if let Ok(node) = proto_node.as_v29() - && !node.payload_received - { + if !node.payload_received { return Ok(false); } From 557bb9b731a5841046dc1cfb567afa84e318565e Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 15:52:52 +1000 Subject: [PATCH 23/32] Fix payload attestation tests for latest spec --- .../payload_attestation_verification/tests.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index f4969e72d99..01cee2cdb67 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -47,6 +47,7 @@ impl TestContext { .spec(spec) .deterministic_keypairs(num_validators) .fresh_ephemeral_store() + .mock_execution_layer() .testing_slot_clock(slot_clock) .build(); @@ -296,14 +297,16 @@ fn duplicate_after_valid() { )); } -#[test] -fn harness_builds_and_imports_payload_attestation_messages() { +#[tokio::test] +async fn harness_builds_and_imports_payload_attestation_messages() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let ctx = TestContext::new(); let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); let mut ptc_weights = std::collections::HashMap::new(); for validator_index in ptc.0.iter().copied() { @@ -325,7 +328,7 @@ fn harness_builds_and_imports_payload_attestation_messages() { let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( &ctx.harness.get_all_validators(), state, - ctx.genesis_block_root, + beacon_block_root, slot, MakePayloadAttestationOptions { votes, @@ -369,14 +372,16 @@ fn harness_builds_and_imports_payload_attestation_messages() { ); } -#[test] -fn harness_packs_payload_attestation_messages_by_ptc_weight() { +#[tokio::test] +async fn harness_packs_payload_attestation_messages_by_ptc_weight() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let ctx = TestContext::new(); let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); let mut ptc_weights = std::collections::HashMap::new(); let mut ptc_validator_order = vec![]; @@ -412,7 +417,7 @@ fn harness_packs_payload_attestation_messages_by_ptc_weight() { let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( &ctx.harness.get_all_validators(), state, - ctx.genesis_block_root, + beacon_block_root, slot, MakePayloadAttestationOptions { votes: vec![PayloadAttestationVote { From 759d83e26f16f8554af0843c38d421040cfba288 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 15:57:14 +1000 Subject: [PATCH 24/32] Remove TODO --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index fb73bc6b0c1..99ddc22b084 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -481,8 +481,6 @@ pub async fn proposer_boost_re_org_test( let state_b_root = state_b.canonical_root().unwrap(); let block_b_root = block_b.0.canonical_root(); - // TODO(sproul): assert block B's parent? - let obs_time = slot_clock.start_of(slot_b).unwrap() + slot_clock.slot_duration() / 2; slot_clock.set_current_time(obs_time); harness.chain.block_times_cache.write().set_time_observed( From 1d728cf744dfa78f54d50b26c87f19ec90cde4ec Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 17:41:20 +1000 Subject: [PATCH 25/32] Codex test fix (needs review) --- .../http_api/tests/gloas_reorg_tests.rs | 148 +++++++++++++----- 1 file changed, 105 insertions(+), 43 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 99ddc22b084..6855bb4f65e 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -62,12 +62,14 @@ impl ForkChoiceUpdates { self.updates.contains_key(&block_hash) } - /// Find the first fork choice update for `head_block_hash` with payload attributes for a - /// block proposal at `proposal_timestamp`. + /// Find the first fork choice update for `head_block_hash` with payload attributes matching + /// the proposal and parent being tested. fn first_update_with_payload_attributes( &self, head_block_hash: ExecutionBlockHash, proposal_timestamp: u64, + parent_beacon_block_root: Option, + slot_number: Option, ) -> Option { self.updates .get(&head_block_hash)? @@ -77,13 +79,37 @@ impl ForkChoiceUpdates { .payload_attributes .as_ref() .is_some_and(|payload_attributes| { - payload_attributes.timestamp() == proposal_timestamp + if payload_attributes.timestamp() != proposal_timestamp { + return false; + } + + if let Some(parent_beacon_block_root) = parent_beacon_block_root + && payload_attributes.parent_beacon_block_root().ok() + != Some(parent_beacon_block_root) + { + return false; + } + + if let Some(slot_number) = slot_number + && payload_attributes.slot_number().ok() != Some(slot_number) + { + return false; + } + + true }) }) .cloned() } } +#[derive(Clone, Copy)] +enum ExpectedFirstUpdateLookahead { + Payload, + ForkChoice, + BlockProduction, +} + pub struct ReOrgTest { head_slot: Slot, /// Number of slots between parent block and canonical head. @@ -109,7 +135,7 @@ pub struct ReOrgTest { /// This can be the payload status of A or B depending on whether we reorged or not. expected_parent_payload_status: PayloadStatus, should_re_org: bool, - misprediction: bool, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead, /// Whether to expect withdrawals to change on epoch boundaries. expect_withdrawals_change_on_epoch: bool, } @@ -130,20 +156,22 @@ impl Default for ReOrgTest { percent_parent_ptc_absent_votes: 0, expected_parent_payload_status: PayloadStatus::Full, should_re_org: true, - misprediction: false, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::Payload, expect_withdrawals_change_on_epoch: false, } } } // This test doesn't actually exercise the re-org code path because the chain just naturally -// re-orgs to A-empty at the start of slot C anyway. +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn re_org_parent_is_empty_easy() { proposer_boost_re_org_test(ReOrgTest { percent_skip_empty_votes: 100, percent_skip_full_votes: 0, expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, ..Default::default() }) .await; @@ -157,7 +185,8 @@ pub async fn re_org_parent_is_empty_easy() { // We should re-org B and build on A-Empty. // // This test doesn't actually exercise the re-org code path because the chain just naturally -// re-orgs to A-empty at the start of slot C anyway. +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn re_org_parent_is_empty_marginal_win() { proposer_boost_re_org_test(ReOrgTest { @@ -167,6 +196,7 @@ pub async fn re_org_parent_is_empty_marginal_win() { percent_parent_ptc_present_votes: 100, percent_parent_ptc_absent_votes: 0, expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, ..Default::default() }) .await; @@ -178,8 +208,8 @@ pub async fn re_org_parent_is_empty_marginal_win() { // A-Full has 100% PTC support, but this should be completely ignored. // // We should re-org B and build on A-Full. -// This test doesn't actually exercise the re-org code path because the chain just naturally -// re-orgs to A-empty at the start of slot C anyway. +// Since Gloas fork choice updates are not overridden for proposer re-orgs, the first fcU for this +// parent is sent during block production. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub async fn re_org_parent_is_full_marginal_win() { proposer_boost_re_org_test(ReOrgTest { @@ -189,6 +219,7 @@ pub async fn re_org_parent_is_full_marginal_win() { percent_parent_ptc_present_votes: 100, percent_parent_ptc_absent_votes: 0, expected_parent_payload_status: PayloadStatus::Full, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, ..Default::default() }) .await; @@ -204,6 +235,7 @@ pub async fn proposer_boost_re_org_parent_empty() { percent_parent_ptc_absent_votes: 0, head_parent_payload_status: PayloadStatus::Empty, expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, ..Default::default() }) .await; @@ -230,7 +262,7 @@ pub async fn proposer_boost_re_org_test( percent_parent_ptc_absent_votes, expected_parent_payload_status, should_re_org, - misprediction, + expected_first_update_lookahead, expect_withdrawals_change_on_epoch, }: ReOrgTest, ) { @@ -605,6 +637,18 @@ pub async fn proposer_boost_re_org_test( block.execution_payload().unwrap().block_hash() } }; + let exec_parent_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + if is_gloas { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .parent_block_hash + } else { + block.execution_payload().unwrap().parent_hash() + } + }; let block_a_exec_hash = exec_block_hash(block_a.0.message()); let block_b_exec_hash = exec_block_hash(block_b.0.message()); @@ -665,28 +709,52 @@ pub async fn proposer_boost_re_org_test( ); // Check the timing of the first fork choice update with payload attributes for block C. - let c_parent_hash = if should_re_org { - block_a_exec_hash + let c_parent_block = if should_re_org { + block_a.0.message() } else { - block_b_exec_hash + block_b.0.message() + }; + let c_parent_hash = if expected_parent_payload_status == PayloadStatus::Full { + exec_block_hash(c_parent_block) + } else { + exec_parent_hash(c_parent_block) }; let first_update = forkchoice_updates - .first_update_with_payload_attributes(c_parent_hash, block_c_timestamp) + .first_update_with_payload_attributes( + c_parent_hash, + block_c_timestamp, + is_gloas.then(|| block_c.parent_root()), + is_gloas.then(|| slot_c.as_u64()), + ) .unwrap(); let payload_attribs = first_update.payload_attributes.as_ref().unwrap(); - // Check that withdrawals from the payload attributes match those computed from the parent's - // advanced state. - let expected_withdrawals = if should_re_org { - let mut state_a_advanced = state_a.clone(); - complete_state_advance(&mut state_a_advanced, None, slot_c, &harness.chain.spec).unwrap(); - get_expected_withdrawals(&state_a_advanced, &harness.chain.spec) + // Check that withdrawals from the payload attributes match those computed from the state used + // by the path that produced the matching fcU. + let parent_state_advanced = if should_re_org { + let mut state = state_a.clone(); + complete_state_advance(&mut state, None, slot_c, &harness.chain.spec).unwrap(); + state } else { - get_expected_withdrawals(&state_b, &harness.chain.spec) - } - .unwrap() - .withdrawals() - .to_vec(); + state_b.clone() + }; + let expected_withdrawals = if is_gloas + && matches!( + expected_first_update_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction + ) + && expected_parent_payload_status == PayloadStatus::Empty + { + parent_state_advanced + .payload_expected_withdrawals() + .unwrap() + .to_vec() + } else { + get_expected_withdrawals(&parent_state_advanced, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec() + }; let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); assert!(!expected_withdrawals.is_empty()); @@ -709,22 +777,16 @@ pub async fn proposer_boost_re_org_test( .checked_sub(first_update.received_at) .unwrap(); - if !misprediction { - assert_eq!( - lookahead, - payload_lookahead, - "observed_lookahead={lookahead:?}, expected={payload_lookahead:?}, timestamp={}, prev_randao={:?}", - payload_attribs.timestamp(), - payload_attribs.prev_randao(), - ); - } else { - // On a misprediction we issue the first fcU 500ms before creating a block! - assert_eq!( - lookahead, - fork_choice_lookahead, - "timestamp={}, prev_randao={:?}", - payload_attribs.timestamp(), - payload_attribs.prev_randao(), - ); - } + let expected_lookahead = match expected_first_update_lookahead { + ExpectedFirstUpdateLookahead::Payload => payload_lookahead, + ExpectedFirstUpdateLookahead::ForkChoice => fork_choice_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction => Duration::ZERO, + }; + assert_eq!( + lookahead, + expected_lookahead, + "observed_lookahead={lookahead:?}, expected={expected_lookahead:?}, timestamp={}, prev_randao={:?}", + payload_attribs.timestamp(), + payload_attribs.prev_randao(), + ); } From 21198293c7c7283320a51e6ba0f561b2cb204ba9 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 17 Jun 2026 18:33:47 -0400 Subject: [PATCH 26/32] Fork gate interactive_tests to run pre-gloas forks only. --- beacon_node/http_api/tests/interactive_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 1e629f9e756..03ee03c6df8 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -366,7 +366,7 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - let spec = ForkName::latest().make_genesis_spec(E::default_spec()); + let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; From 8527b76423dc74e85191ee2e21590b58791dea3c Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 17 Jun 2026 21:46:06 -0400 Subject: [PATCH 27/32] Fix overlapping sync committee bug in testing harness --- beacon_node/beacon_chain/src/test_utils.rs | 38 ++++- .../http_api/tests/gloas_reorg_tests.rs | 160 +++++++++++++++++- .../http_api/tests/interactive_tests.rs | 119 ++++--------- 3 files changed, 220 insertions(+), 97 deletions(-) diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 146b2f8276f..2adfe26d4b8 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -2378,6 +2378,21 @@ where slot: Slot, relative_sync_committee: RelativeSyncCommittee, ) -> HarnessSyncContributions { + // Resolve the committee for aggregator selection using the same relative committee as the + // messages. Selecting from `current_sync_committee` unconditionally would pick an + // aggregator outside the verifying committee at sync committee period boundaries (where + // `Next` is used), causing `AggregatorNotInCommittee`. + let sync_committee: Arc> = match relative_sync_committee { + RelativeSyncCommittee::Current => state + .current_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + RelativeSyncCommittee::Next => state + .next_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + }; + let sync_messages = self.make_sync_committee_messages(state, block_hash, slot, relative_sync_committee); @@ -2387,10 +2402,7 @@ where .map(|(subnet_id, committee_messages)| { // If there are any sync messages in this committee, create an aggregate. if let Some((sync_message, subcommittee_position)) = committee_messages.first() { - let sync_committee: Arc> = state - .current_sync_committee() - .expect("should be called on altair beacon state") - .clone(); + let sync_committee = sync_committee.clone(); let aggregator_index = sync_committee .get_subcommittee_pubkeys(subnet_id) @@ -3459,14 +3471,24 @@ where if sync_committee_strategy == SyncCommitteeStrategy::AllValidators && new_state.current_sync_committee().is_ok() { + // A sync message for `slot` is verified against the committee of `epoch(slot + 1)` + // (see `BeaconChain::sync_committee_at_next_slot`), so we must sign with `Next` only + // when `slot + 1` crosses into a new sync committee period, not for the whole first + // epoch of the period. + let slots_per_epoch = E::slots_per_epoch(); + let crosses_period = slot + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap() + != (slot + 1) + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap(); self.sync_committee_sign_block( &new_state, block_hash.into(), slot, - if (slot + 1).epoch(E::slots_per_epoch()) - % self.spec.epochs_per_sync_committee_period - == 0 - { + if crosses_period { RelativeSyncCommittee::Next } else { RelativeSyncCommittee::Current diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 6855bb4f65e..34faa5198c7 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -240,6 +240,151 @@ pub async fn proposer_boost_re_org_parent_empty() { }) .await; } + +// Test that the beacon node will try to perform proposer boost re-orgs on late blocks when +// configured. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_zero_weight() { + proposer_boost_re_org_test(ReOrgTest { + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +// Since Fulu, proposer shuffling is stable across epoch boundaries, so re-orgs of the last block +// in an epoch are permitted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + should_re_org: true, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip1() { + // Proposing a block on a boundary after a skip will change the set of expected withdrawals + // sent in the payload attributes. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(2 * E::slots_per_epoch() - 2), + head_distance: 2, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip32() { + // Propose a block at 64 after a whole epoch of skipped slots. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + head_distance: E::slots_per_epoch() + 1, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_slot_after_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(33), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_bad_ffg() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(64 + 22), + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_no_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(96), + percent_parent_votes: 100, + percent_skip_full_votes: 0, + percent_head_votes: 100, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(129), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 3), + head_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_very_unhealthy() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + parent_distance: 2, + head_distance: 2, + percent_parent_votes: 10, + percent_skip_full_votes: 10, + percent_head_votes: 10, + should_re_org: false, + ..Default::default() + }) + .await; +} + +/// The head block is late but still receives 30% of the committee vote, making it strong enough +/// that we do not re-org it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_too_strong() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_full_votes: 70, + percent_head_votes: 30, + should_re_org: false, + ..Default::default() + }) + .await; +} + /// Run a proposer boost re-org test. /// /// - `head_slot`: the slot of the canonical head to be reorged @@ -757,11 +902,16 @@ pub async fn proposer_boost_re_org_test( }; let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); - assert!(!expected_withdrawals.is_empty()); - - if should_re_org - || expect_withdrawals_change_on_epoch - && slot_c.epoch(E::slots_per_epoch()) != slot_b.epoch(E::slots_per_epoch()) + // The validator withdrawal sweep is positional: it scans a rotating window of + // `max_validators_per_withdrawals_sweep` validators starting at `next_withdrawal_validator_index`. + // For a given proposal slot that window can legitimately contain no withdrawal-eligible + // validators (with empty partial/builder withdrawal queues), so an empty withdrawals list is + // valid. Withdrawal correctness is covered by the equality check above; we only assert the + // re-org/epoch-boundary withdrawals change when there are withdrawals to compare. + if !expected_withdrawals.is_empty() + && (should_re_org + || expect_withdrawals_change_on_epoch + && slot_c.epoch(E::slots_per_epoch()) != slot_b.epoch(E::slots_per_epoch())) { assert_ne!(expected_withdrawals, pre_advance_withdrawals); } diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index ed3cd040991..0b8f61a7455 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -3,7 +3,8 @@ use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ ChainConfig, test_utils::{ - AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, test_spec, + AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, + fork_name_from_env, test_spec, }, }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; @@ -366,6 +367,12 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); + // We don't run these test for post-Gloas forks beacause of the FcU changes that were + // applied in the gloas. Gloas adopted tests can be found in gloas_re_org_test.rs. + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); // Ensure there are enough validators to have `attesters_per_slot`. @@ -518,25 +525,7 @@ pub async fn proposer_boost_re_org_test( .collect::>(); // Produce block B and process it halfway through the slot. - // When B is expected to remain canonical (no re-org), capture its Gloas payload envelope so we - // can reveal B's execution payload to fork choice below. Without this, B's payload status stays - // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's - // own execution block hash. We skip this when B will be re-orged, since the execution layer - // must never be told about a block that is about to be re-orged away. - let is_gloas = harness - .chain - .spec - .fork_name_at_slot::(slot_b) - .gloas_enabled(); - let reveal_block_b_payload = is_gloas && !should_re_org; - let (block_b, block_b_envelope, mut state_b) = if reveal_block_b_payload { - harness - .make_block_with_envelope(state_a.clone(), slot_b) - .await - } else { - let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; - (block_b, None, state_b) - }; + let (block_b, mut state_b) = harness.make_block(state_a.clone(), slot_b).await; let state_b_root = state_b.canonical_root().unwrap(); let block_b_root = block_b.0.canonical_root(); @@ -551,14 +540,6 @@ pub async fn proposer_boost_re_org_test( ); harness.process_block_result(block_b.clone()).await.unwrap(); - // Reveal B's execution payload so fork choice marks the payload as received and the - // forkchoiceUpdated head hash references B's own execution block hash. - if let Some(block_b_envelope) = block_b_envelope { - harness - .process_envelope(block_b_root, block_b_envelope, &state_b, state_b_root) - .await; - } - // Add attestations to block B. let (block_b_head_votes, _) = harness.make_attestations_with_limit( &remaining_attesters, @@ -613,42 +594,21 @@ pub async fn proposer_boost_re_org_test( let randao_reveal = harness .sign_randao_reveal(&state_b, proposer_index, slot_c) .into(); - let is_gloas = harness - .chain - .spec - .fork_name_at_slot::(slot_c) - .gloas_enabled(); - - let (block_c, block_c_blobs) = if is_gloas { - let (response, _) = tester - .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) - .await - .unwrap(); - ( - Arc::new(harness.sign_beacon_block(response.data, &state_b)), - None, - ) - } else { - let (unsigned_block_type, _) = tester - .client - .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) - .await - .unwrap(); + let (unsigned_block_type, _) = tester + .client + .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) + .await + .unwrap(); - let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { - ProduceBlockV3Response::Full(unsigned_block_contents_c) => { - unsigned_block_contents_c.deconstruct() - } - ProduceBlockV3Response::Blinded(_) => { - panic!("Should not be a blinded block"); - } - }; - ( - Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)), - block_c_blobs, - ) + let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { + ProduceBlockV3Response::Full(unsigned_block_contents_c) => { + unsigned_block_contents_c.deconstruct() + } + ProduceBlockV3Response::Blinded(_) => { + panic!("Should not be a blinded block"); + } }; + let block_c = Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)); if should_re_org { // Block C should build on A. @@ -671,29 +631,20 @@ pub async fn proposer_boost_re_org_test( // Check the fork choice updates that were sent. let forkchoice_updates = forkchoice_updates.lock(); - // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash - // lives in the execution payload bid, and the payload timestamp is derived from the slot. - let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { - if is_gloas { - block - .body() - .signed_execution_payload_bid() - .unwrap() - .message - .block_hash - } else { - block.execution_payload().unwrap().block_hash() - } - }; - - let block_a_exec_hash = exec_block_hash(block_a.0.message()); - let block_b_exec_hash = exec_block_hash(block_b.0.message()); + let block_a_exec_hash = block_a + .0 + .message() + .execution_payload() + .unwrap() + .block_hash(); + let block_b_exec_hash = block_b + .0 + .message() + .execution_payload() + .unwrap() + .block_hash(); - let block_c_timestamp = if is_gloas { - harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs() - } else { - block_c.message().execution_payload().unwrap().timestamp() - }; + let block_c_timestamp = block_c.message().execution_payload().unwrap().timestamp(); // If we re-orged then no fork choice update for B should have been sent. assert_eq!( From bc2bcd5a5ddab52acf3ec3a9a4cf4671853bb712 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 17 Jun 2026 21:53:46 -0400 Subject: [PATCH 28/32] Lint --- beacon_node/http_api/tests/interactive_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 0b8f61a7455..b2a1fb514de 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -22,8 +22,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, - MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, + Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, + MinimalEthSpec, ProposerPreparationData, Slot, }; type E = MainnetEthSpec; From 0d461cba845590a5071be6a4a71d9b2a9d8a8f55 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 17 Jun 2026 22:12:00 -0400 Subject: [PATCH 29/32] Addressing comments --- beacon_node/http_api/tests/interactive_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index b2a1fb514de..da1d95c0abc 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -367,9 +367,9 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - // We don't run these test for post-Gloas forks beacause of the FcU changes that were - // applied in the gloas. Gloas adopted tests can be found in gloas_re_org_test.rs. - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + // We don't run these test for post-Gloas forks because of the FcU changes that were + // applied in the gloas. Gloas adopted tests can be found in `gloas_re_org_test.rs` + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } From 082f3250b8dbf5c0389b639ecdf80864549d0342 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 17 Jun 2026 23:08:39 -0400 Subject: [PATCH 30/32] Use test_spec in `proposer_reorg_test` --- beacon_node/http_api/tests/interactive_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index da1d95c0abc..d2a28da0f56 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -373,7 +373,7 @@ pub async fn proposer_boost_re_org_test( return; } - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + let spec = test_spec::(); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; From 0f9601c78d0b0ac8bf06d5ce7975856f39ce4152 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 18 Jun 2026 17:46:51 +1000 Subject: [PATCH 31/32] Use test_spec in Gloas tests so we run on future forks --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 34faa5198c7..10384afb924 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -11,7 +11,7 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, - MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, + MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, }, }; use eth2::types::ProduceBlockV3Response; @@ -28,8 +28,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, - MinimalEthSpec, ProposerPreparationData, Slot, + Address, BeaconBlockRef, EthSpec, ExecPayload, ExecutionBlockHash, Hash256, MinimalEthSpec, + ProposerPreparationData, Slot, }; type E = MinimalEthSpec; @@ -413,7 +413,7 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - let spec = ForkName::latest().make_genesis_spec(E::default_spec()); + let spec = test_spec::(); // Ensure there are enough validators to have `ATTESTERS_PER_SLOT`. assert!(ATTESTERS_PER_SLOT >= E::ptc_size()); From 80a3cfc6db1cda29c9410b53f757990b3cb82090 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 18 Jun 2026 18:11:06 +1000 Subject: [PATCH 32/32] Skip Gloas tests before Gloas --- beacon_node/http_api/tests/gloas_reorg_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 10384afb924..6bbca727f96 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -415,6 +415,10 @@ pub async fn proposer_boost_re_org_test( let spec = test_spec::(); + if !spec.is_gloas_scheduled() { + return; + } + // Ensure there are enough validators to have `ATTESTERS_PER_SLOT`. assert!(ATTESTERS_PER_SLOT >= E::ptc_size()); let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT;