Files
lighthouse/beacon_node/http_api/tests/gloas_reorg_tests.rs
Michael Sproul af90f6a496 Remove redundant is_gloas checks in reorg tests (#9529)
Remove some `is_gloas` checks that are unnecessary in the `gloas_reorg_tests.rs`.

I found myself wanting to make this change while tweaking these tests in another PR. Figured it makes sense as a simple standalone PR.


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

Co-Authored-By: hopinheimer <48147533+hopinheimer@users.noreply.github.com>
2026-06-25 01:15:13 +00:00

891 lines
32 KiB
Rust

//! post-gloas payload re-org tests.
//!
//! These tests are deliberately kept separate from `interactive_tests.rs` because they exercise
//! 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::{
ChainConfig,
chain_config::DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR,
custody_context::NodeCustodyType,
test_utils::{
AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions,
MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec,
},
};
use execution_layer::{ForkchoiceState, PayloadAttributes};
use fixed_bytes::FixedBytesExtended;
use http_api::test_utils::InteractiveTester;
use parking_lot::Mutex;
use proto_array::PayloadStatus;
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, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec,
ProposerPreparationData, Slot,
};
type E = MinimalEthSpec;
// 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)]
struct ForkChoiceUpdates {
updates: HashMap<ExecutionBlockHash, Vec<ForkChoiceUpdateMetadata>>,
}
#[derive(Debug, Clone)]
struct ForkChoiceUpdateMetadata {
received_at: Duration,
state: ForkchoiceState,
payload_attributes: Option<PayloadAttributes>,
}
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 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<Hash256>,
slot_number: Option<u64>,
) -> Option<ForkChoiceUpdateMetadata> {
self.updates
.get(&head_block_hash)?
.iter()
.find(|update| {
update
.payload_attributes
.as_ref()
.is_some_and(|payload_attributes| {
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.
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,
/// 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.
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,
expected_first_update_lookahead: ExpectedFirstUpdateLookahead,
/// Whether to expect withdrawals to change on epoch boundaries.
expect_withdrawals_change_on_epoch: bool,
}
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,
head_parent_payload_status: PayloadStatus::Full,
percent_parent_ptc_present_votes: 100,
percent_parent_ptc_absent_votes: 0,
expected_parent_payload_status: PayloadStatus::Full,
should_re_org: true,
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. 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;
}
// 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. 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 {
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,
expected_parent_payload_status: PayloadStatus::Empty,
expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice,
..Default::default()
})
.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.
// 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 {
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,
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_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,
expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction,
..Default::default()
})
.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
/// - `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,
head_parent_payload_status,
percent_parent_ptc_present_votes,
percent_parent_ptc_absent_votes,
expected_parent_payload_status,
should_re_org,
expected_first_update_lookahead,
expect_withdrawals_change_on_epoch,
}: ReOrgTest,
) {
assert!(head_slot > 0);
let spec = test_spec::<E>();
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;
let all_validators = (0..validator_count).collect::<Vec<usize>>();
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);
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;
// 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 chain_config = ChainConfig {
prepare_payload_lookahead: spec.get_slot_duration()
/ DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR,
..Default::default()
};
let tester = InteractiveTester::<E>::new_with_initializer_and_mutator(
Some(spec),
validator_count,
None,
Some(Box::new(move |builder| builder.chain_config(chain_config))),
Default::default(),
false,
NodeCustodyType::Fullnode,
)
.await;
let harness = &tester.harness;
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;
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),
},
None,
)
})
.collect::<Vec<_>>();
harness
.chain
.execution_layer
.as_ref()
.unwrap()
.update_proposer_preparation(
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(
num_initial as usize,
BlockStrategy::OnCanonicalHead,
AttestationStrategy::AllValidators,
SyncCommitteeStrategy::AllValidators,
LightClientStrategy::Disabled,
)
.await;
// 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();
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
}));
// 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"
);
}
harness.advance_slot();
let (block_a_root, block_a, mut state_a) = harness
.add_block_at_slot(slot_a, harness.get_current_state())
.await
.unwrap();
let state_a_root = state_a.canonical_root().unwrap();
// Attest to block A during slot A.
let (block_a_parent_votes, _) = harness.make_attestations_with_limit(
&all_validators,
&state_a,
state_a_root,
block_a_root,
slot_a,
num_parent_votes,
);
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();
}
let (block_a_empty_votes, block_a_empty_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),
},
);
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::<Vec<_>>();
let (block_a_full_votes, block_a_full_attesters) = harness.make_attestations_with_opts(
&remaining_attesters_after_empty,
&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);
let remaining_attesters = remaining_attesters_after_empty
.iter()
.copied()
.filter(|index| !block_a_full_attesters.contains(index))
.collect::<Vec<_>>();
// 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 reveal_block_b_payload = !should_re_org;
let (block_b, block_b_envelope, mut 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
};
let state_b_root = state_b.canonical_root().unwrap();
let block_b_root = block_b.0.canonical_root();
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,
);
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,
&state_b,
state_b_root,
block_b_root.into(),
slot_b,
num_head_votes,
);
harness.process_attestations(block_b_head_votes, &state_b);
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;
// 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();
// 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 (block_c, block_c_blobs) = {
let (response, _) = tester
.client
.get_validator_blocks_v4::<E>(slot_c, &randao_reveal, None, None, None, None)
.await
.unwrap();
(
Arc::new(harness.sign_beacon_block(response.data, &state_b)),
None,
)
};
// 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<E>| -> ExecutionBlockHash {
block
.body()
.signed_execution_payload_bid()
.unwrap()
.message
.block_hash
};
let exec_parent_hash = |block: BeaconBlockRef<E>| -> ExecutionBlockHash {
block
.body()
.signed_execution_payload_bid()
.unwrap()
.message
.parent_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());
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));
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);
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(),
);
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 = harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs();
// 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:?}"
);
// Check the timing of the first fork choice update with payload attributes for block C.
let c_parent_block = if should_re_org {
block_a.0.message()
} else {
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,
Some(block_c.parent_root()),
Some(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 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 {
state_b.clone()
};
let expected_withdrawals = if 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);
// 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);
}
// 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();
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(),
);
}