From 4415cf050693a8205dd12e1cbd61b394bebb3e4e Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 28 Apr 2026 14:45:03 +0200 Subject: [PATCH] Gloas filter conflicting voluntary exits (#9183) Parent envelope execution requests can invalidate voluntary exits. We should filter out any conflicting voluntary exits during block production to avoid triggering failures. Spec change: https://github.com/ethereum/consensus-specs/pull/5176 Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../src/block_production/gloas.rs | 211 ++++++++++++++++-- 1 file changed, 198 insertions(+), 13 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 4bc4b9862c..a6ebc2fefa 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; -use bls::Signature; +use bls::{PublicKeyBytes, Signature}; use execution_layer::{ BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, }; @@ -28,7 +28,7 @@ use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, - BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, + BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, @@ -137,6 +137,16 @@ impl BeaconChain { graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, ) -> Result, BlockProductionError> { + // Extract the parent's execution requests from the envelope (if parent was full). + let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { + parent_envelope + .as_ref() + .map(|env| env.message.execution_requests.clone()) + .ok_or(BlockProductionError::MissingParentExecutionPayload)? + } else { + ExecutionRequests::default() + }; + // Part 1/3 (blocking) // // Perform the state advance and block-packing functions. @@ -145,6 +155,7 @@ impl BeaconChain { .graffiti_calculator .get_graffiti(graffiti_settings) .await; + let parent_execution_requests_ref = parent_execution_requests.clone(); let (partial_beacon_block, state) = self .task_executor .spawn_blocking_handle( @@ -155,6 +166,7 @@ impl BeaconChain { produce_at_slot, randao_reveal, graffiti, + &parent_execution_requests_ref, ) }, "produce_partial_beacon_block_gloas", @@ -163,16 +175,6 @@ impl BeaconChain { .await .map_err(BlockProductionError::TokioJoin)??; - // Extract the parent's execution requests from the envelope (if parent was full). - let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { - parent_envelope - .as_ref() - .map(|env| env.message.execution_requests.clone()) - .ok_or(BlockProductionError::MissingParentExecutionPayload)? - } else { - ExecutionRequests::default() - }; - // Part 2/3 (async) // // Produce the execution payload bid. @@ -223,6 +225,7 @@ impl BeaconChain { produce_at_slot: Slot, randao_reveal: Signature, graffiti: Graffiti, + parent_execution_requests: &ExecutionRequests, ) -> Result<(PartialBeaconBlock, BeaconState), BlockProductionError> { // It is invalid to try to produce a block using a state from a future slot. @@ -257,6 +260,13 @@ impl BeaconChain { let (mut proposer_slashings, mut attester_slashings, mut voluntary_exits) = self.op_pool.get_slashings_and_exits(&state, &self.spec); + filter_voluntary_exits_for_parent_execution_requests( + &mut voluntary_exits, + parent_execution_requests, + |idx| state.validators().get(idx as usize).map(|v| v.pubkey), + &self.spec, + ); + drop(slashings_and_exits_span); let eth1_data = state.eth1_data().clone(); @@ -958,3 +968,178 @@ where Ok(block_contents) } + +/// Drop voluntary exits whose target validators will be exited by the parent envelope's +/// execution requests. +/// +/// In Gloas the parent execution payload is processed before voluntary exits during block +/// processing. EL-triggered withdrawal-full-exit requests (EIP-7002) and cross-pubkey +/// consolidation requests (EIP-7251) call `initiate_validator_exit`, setting the target's +/// `exit_epoch`. A voluntary exit for the same validator would then fail with `AlreadyExited`. +fn filter_voluntary_exits_for_parent_execution_requests( + voluntary_exits: &mut Vec, + parent_execution_requests: &ExecutionRequests, + pubkey_at_index: impl Fn(u64) -> Option, + spec: &ChainSpec, +) { + let mut exited_pubkeys = HashSet::with_capacity( + parent_execution_requests.withdrawals.len() + + parent_execution_requests.consolidations.len(), + ); + for req in &parent_execution_requests.withdrawals { + if req.amount == spec.full_exit_request_amount { + exited_pubkeys.insert(req.validator_pubkey); + } + } + for req in &parent_execution_requests.consolidations { + if req.source_pubkey != req.target_pubkey { + exited_pubkeys.insert(req.source_pubkey); + } + } + if !exited_pubkeys.is_empty() { + voluntary_exits.retain(|exit| { + pubkey_at_index(exit.message.validator_index) + .map(|pk| !exited_pubkeys.contains(&pk)) + .unwrap_or(false) + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz_types::VariableList; + use types::{ConsolidationRequest, Epoch, MainnetEthSpec, VoluntaryExit, WithdrawalRequest}; + + type TestSpec = MainnetEthSpec; + + fn pubkey(byte: u8) -> PublicKeyBytes { + PublicKeyBytes::deserialize(&[byte; 48]).expect("valid pubkey byte length") + } + + fn exit(validator_index: u64) -> SignedVoluntaryExit { + SignedVoluntaryExit { + message: VoluntaryExit { + epoch: Epoch::new(0), + validator_index, + }, + signature: Signature::empty(), + } + } + + fn requests( + withdrawals: Vec, + consolidations: Vec, + ) -> ExecutionRequests { + ExecutionRequests { + deposits: VariableList::empty(), + withdrawals: VariableList::new(withdrawals).unwrap(), + consolidations: VariableList::new(consolidations).unwrap(), + } + } + + fn run_filter( + exits: &mut Vec, + requests: &ExecutionRequests, + validator_pubkeys: &[PublicKeyBytes], + spec: &ChainSpec, + ) { + filter_voluntary_exits_for_parent_execution_requests( + exits, + requests, + |idx| validator_pubkeys.get(idx as usize).copied(), + spec, + ); + } + + #[test] + fn full_exit_withdrawal_request_filters_matching_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + assert_eq!(exits[0].message.validator_index, 1); + } + + #[test] + fn partial_withdrawal_request_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount + 1, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn cross_pubkey_consolidation_filters_voluntary_exit_for_source_only() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2), pubkey(3)]; + let mut exits = vec![exit(0), exit(1), exit(2)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[1], + target_pubkey: validators[2], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + // The source (validator 1) is exited; the target (validator 2) is not. + let remaining: Vec = exits.iter().map(|e| e.message.validator_index).collect(); + assert_eq!(remaining, vec![0, 2]); + } + + #[test] + fn self_consolidation_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[0], + target_pubkey: validators[0], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn empty_parent_requests_preserve_voluntary_exits() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests(vec![], vec![]); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 2); + } +}