From 68ad9758a3e596ff99825bdf2fe4afcbe1894c3a Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 13 Feb 2026 13:39:56 -0800 Subject: [PATCH 1/5] Gloas attestation verification (#8705) https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#attestation-subnets Implements attestation verification logic for Gloas and adds a few gloas related tests. Note that a few of these tests rely on gloas test harness block production which hasn't been built out yet. So for now those tests are ignored. Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi --- .../src/attestation_verification.rs | 68 ++++- .../tests/attestation_verification.rs | 259 ++++++++++++++++++ .../gossip_methods.rs | 19 ++ 3 files changed, 336 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index faa396966f..667bafe445 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -61,8 +61,9 @@ use tracing::{debug, error}; use tree_hash::TreeHash; use types::{ Attestation, AttestationData, AttestationRef, BeaconCommittee, - BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, Hash256, - IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, SubnetId, + BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, ForkName, + Hash256, IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, + SubnetId, }; pub use batch::{batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations}; @@ -160,6 +161,12 @@ pub enum Error { /// /// The peer has sent an invalid message. CommitteeIndexNonZero(usize), + /// The validator index is set to an invalid value after Gloas. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + CommitteeIndexInvalid, /// The `attestation.data.beacon_block_root` block is unknown. /// /// ## Peer scoring @@ -550,8 +557,12 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { } .tree_hash_root(); + let fork_name = chain + .spec + .fork_name_at_slot::(attestation.data().slot); + // [New in Electra:EIP7549] - verify_committee_index(attestation)?; + verify_committee_index(attestation, fork_name)?; if chain .observed_attestations @@ -595,6 +606,17 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { // attestation and do not delay consideration for later. let head_block = verify_head_block_is_known(chain, attestation.data(), None)?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data().slot + && attestation.data().index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data().index as usize, + )); + } + // Check the attestation target root is consistent with the head root. // // This check is not in the specification, however we guard against it since it opens us up @@ -871,7 +893,12 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { let fork_name = chain .spec .fork_name_at_slot::(attestation.data.slot); - if fork_name.electra_enabled() { + if fork_name.gloas_enabled() { + // [New in Gloas] + if attestation.data.index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else if fork_name.electra_enabled() { // [New in Electra:EIP7549] if attestation.data.index != 0 { return Err(Error::CommitteeIndexNonZero( @@ -890,6 +917,17 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { chain.config.import_max_skip_slots, )?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data.slot + && attestation.data.index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data.index as usize, + )); + } + // Check the attestation target root is consistent with the head root. verify_attestation_target_root::(&head_block, &attestation.data)?; @@ -1404,7 +1442,10 @@ pub fn verify_signed_aggregate_signatures( /// Verify that the `attestation` committee index is properly set for the attestation's fork. /// This function will only apply verification post-Electra. -pub fn verify_committee_index(attestation: AttestationRef) -> Result<(), Error> { +pub fn verify_committee_index( + attestation: AttestationRef, + fork_name: ForkName, +) -> Result<(), Error> { if let Ok(committee_bits) = attestation.committee_bits() { // Check to ensure that the attestation is for a single committee. let num_committee_bits = get_committee_indices::(committee_bits); @@ -1414,11 +1455,18 @@ pub fn verify_committee_index(attestation: AttestationRef) -> Res )); } - // Ensure the attestation index is set to zero post Electra. - if attestation.data().index != 0 { - return Err(Error::CommitteeIndexNonZero( - attestation.data().index as usize, - )); + // Ensure the attestation index is valid for the fork. + let index = attestation.data().index; + if fork_name.gloas_enabled() { + // [New in Gloas]: index must be < 2. + if index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else { + // [New in Electra:EIP7549]: index must be 0. + if index != 0 { + return Err(Error::CommitteeIndexNonZero(index as usize)); + } } } Ok(()) diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 8aeb881aa4..96071be89f 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -368,6 +368,13 @@ impl GossipTester { self.harness.chain.epoch().unwrap() } + pub fn is_gloas(&self) -> bool { + self.harness + .spec + .fork_name_at_slot::(self.valid_attestation.data.slot) + .gloas_enabled() + } + pub fn earliest_valid_attestation_slot(&self) -> Slot { let offset = if self .harness @@ -522,6 +529,44 @@ impl GossipTester { self } + + /// Like `inspect_aggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_aggregate_err_if_gloas( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SignedAggregateAndProof), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_aggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } + + /// Like `inspect_unaggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_unaggregate_err_if_gloas( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SingleAttestation, &mut SubnetId, &ChainSpec), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_unaggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } } /// Tests verification of `SignedAggregateAndProof` from the gossip network. #[tokio::test] @@ -854,6 +899,27 @@ async fn aggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_aggregate_err_if_gloas( + "gloas: aggregate with index >= 2", + |_, a| match a.to_mut() { + SignedAggregateAndProofRefMut::Base(_) => { + panic!("Expected Electra attestation variant"); + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 2; + } + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_aggregate() @@ -1071,6 +1137,22 @@ async fn unaggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_unaggregate_err_if_gloas( + "gloas: attestation with index >= 2", + |_, a, _, _| { + a.data.index = 2; + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_unaggregate() @@ -1700,3 +1782,180 @@ async fn aggregated_attestation_verification_use_head_state_fork() { ); } } + +/// [New in Gloas]: Tests that unaggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_unaggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (mut attestation, _attester_sk, subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + attestation.data.index = 1; + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&attestation, Some(subnet_id)); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: attestation with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} + +/// [New in Gloas]: Tests that aggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (valid_attestation, _attester_sk, _subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&valid_attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, valid_attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // Convert to aggregate + let committee = head + .beacon_state + .get_beacon_committee(current_slot, valid_attestation.committee_index) + .expect("should get committee"); + let fork_name = harness + .spec + .fork_name_at_slot::(valid_attestation.data.slot); + let aggregate_attestation = + single_attestation_to_attestation(&valid_attestation, committee.committee, fork_name) + .unwrap(); + + let (mut valid_aggregate, _, _) = + get_valid_aggregated_attestation(&harness.chain, aggregate_attestation); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + match valid_aggregate.to_mut() { + SignedAggregateAndProofRefMut::Base(att) => { + att.message.aggregate.data.index = 1; + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 1; + } + } + + let result = harness + .chain + .verify_aggregated_attestation_for_gossip(&valid_aggregate); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: aggregate with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index a4125f3df0..a9198f1943 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -2415,6 +2415,25 @@ impl NetworkBeaconProcessor { "attn_comm_index_non_zero", ); } + AttnError::CommitteeIndexInvalid => { + /* + * The committee index is invalid after Gloas. + * + * The peer has published an invalid consensus message. + */ + debug!( + %peer_id, + block = ?beacon_block_root, + ?attestation_type, + "Committee index invalid" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "attn_comm_index_invalid", + ); + } AttnError::UnknownHeadBlock { beacon_block_root } => { trace!( %peer_id, From d1401452193bcb3aa8b4b9ac74df88f0d7aa8a91 Mon Sep 17 00:00:00 2001 From: Eitan Seri- Levi Date: Fri, 13 Feb 2026 17:26:38 -0800 Subject: [PATCH 2/5] Fix --- beacon_node/beacon_chain/src/execution_payload.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index bdf3ab9594..1c0fd505e7 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -367,9 +367,18 @@ pub fn get_execution_payload( let timestamp = compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; - let latest_execution_payload_header = state.latest_execution_payload_header()?; - let latest_execution_payload_header_block_hash = latest_execution_payload_header.block_hash(); - let latest_execution_payload_header_gas_limit = latest_execution_payload_header.gas_limit(); + // In GLOAS (ePBS), the execution payload header is replaced by + // `latest_block_hash` and `latest_execution_payload_bid`. + let (latest_execution_payload_header_block_hash, latest_execution_payload_header_gas_limit) = + if state.fork_name_unchecked() == ForkName::Gloas { + ( + *state.latest_block_hash()?, + state.latest_execution_payload_bid()?.gas_limit, + ) + } else { + let header = state.latest_execution_payload_header()?; + (header.block_hash(), header.gas_limit()) + }; let withdrawals = if state.fork_name_unchecked().capella_enabled() { Some(Withdrawals::::from(get_expected_withdrawals(state, spec)?).into()) } else { From 9bbcfe34acee000650f25115aebec3a209bf6307 Mon Sep 17 00:00:00 2001 From: Eitan Seri- Levi Date: Fri, 13 Feb 2026 21:22:10 -0800 Subject: [PATCH 3/5] Separate payload construction for gloas --- .../src/block_production/gloas.rs | 223 ++++++++++++++---- .../beacon_chain/src/execution_payload.rs | 13 +- beacon_node/execution_layer/src/lib.rs | 60 ++++- 3 files changed, 234 insertions(+), 62 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 306e1c549e..f08bf3897e 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -3,33 +3,37 @@ use std::marker::PhantomData; use std::sync::Arc; use bls::Signature; -use execution_layer::{BlockProposalContentsType, BuilderParams}; +use execution_layer::{ + BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, +}; use operation_pool::CompactAttestationRef; use ssz::Encode; use state_processing::common::get_attesting_indices_from_state; use state_processing::envelope_processing::{VerifyStateRoot, process_execution_payload_envelope}; use state_processing::epoch_cache::initialize_epoch_cache; -use state_processing::per_block_processing::verify_attestation_for_block_inclusion; +use state_processing::per_block_processing::{ + compute_timestamp_at_slot, get_expected_withdrawals, verify_attestation_for_block_inclusion, +}; use state_processing::{ BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, VerifySignatures, }; use state_processing::{VerifyOperation, state_advance::complete_state_advance}; -use tracing::{Span, debug, debug_span, error, instrument, trace, warn}; +use task_executor::JoinHandle; +use tracing::{Instrument, Span, debug, debug_span, error, instrument, trace, warn}; use tree_hash::TreeHash; use types::consts::gloas::{ BID_VALUE_SELF_BUILD, BUILDER_INDEX_SELF_BUILD, EXECUTION_PAYMENT_TRUSTLESS_BUILD, }; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, - BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BlockProductionVersion, - BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionPayloadBid, ExecutionPayloadEnvelope, - ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, Hash256, PayloadAttestation, - ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, - SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, - SyncAggregate, + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, + BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, + ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, + Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, }; -use crate::execution_payload::get_execution_payload; use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionError, ProduceBlockVerification, graffiti_calculator::GraffitiSettings, metrics, @@ -38,6 +42,9 @@ use crate::{ type ConsensusBlockValue = u64; type BlockProductionResult = (BeaconBlock>, ConsensusBlockValue); +pub type PreparePayloadResult = Result, BlockProductionError>; +pub type PreparePayloadHandle = JoinHandle>>; + pub struct PartialBeaconBlock { slot: Slot, proposer_index: u64, @@ -687,53 +694,26 @@ impl BeaconChain { // TODO(gloas) this should be BlockProductionVersion::V4 // V3 is okay for now as long as we're not connected to a builder // TODO(gloas) add builder boost factor - let prepare_payload_handle = get_execution_payload( + let prepare_payload_handle = get_execution_payload_gloas( self.clone(), &state, parent_root, proposer_index, builder_params, - None, - BlockProductionVersion::V3, )?; - let block_contents_type = prepare_payload_handle + let block_proposal_contents = prepare_payload_handle .await .map_err(BlockProductionError::TokioJoin)? .ok_or(BlockProductionError::ShuttingDown)??; - let (execution_payload, blob_kzg_commitments, execution_requests) = - match block_contents_type { - BlockProposalContentsType::Full(block_proposal_contents) => { - let (payload, blob_kzg_commitments, _, execution_requests, _) = - block_proposal_contents.deconstruct(); - - if let Some(blob_kzg_commitments) = blob_kzg_commitments - && let Some(execution_requests) = execution_requests - { - ( - payload.execution_payload(), - blob_kzg_commitments, - execution_requests, - ) - } else { - return Err(BlockProductionError::MissingKzgCommitment( - "No KZG commitments from the payload".to_owned(), - )); - } - } - BlockProposalContentsType::Blinded(_) => { - return Err(BlockProductionError::Unexpected( - "Should never produce a blinded block post-Gloas".to_owned(), - )); - } - }; - - // TODO(gloas) this is just a dummy error variant for now - let execution_payload_gloas = execution_payload - .as_gloas() - .map_err(|_| BlockProductionError::GloasNotImplemented)? - .to_owned(); + let BlockProposalContentsGloas { + payload, + payload_value: _, + execution_requests, + blob_kzg_commitments, + blobs_and_proofs: _, + } = block_proposal_contents; let state_root = state.update_tree_hash_cache()?; @@ -742,10 +722,10 @@ impl BeaconChain { let bid = ExecutionPayloadBid:: { parent_block_hash: state.latest_block_hash()?.to_owned(), parent_block_root: state.get_latest_block_root(state_root), - block_hash: execution_payload.block_hash(), - prev_randao: execution_payload.prev_randao(), + block_hash: payload.block_hash, + prev_randao: payload.prev_randao, fee_recipient: Address::ZERO, - gas_limit: execution_payload.gas_limit(), + gas_limit: payload.gas_limit, builder_index, slot: produce_at_slot, value: bid_value, @@ -755,7 +735,7 @@ impl BeaconChain { // Store payload data for envelope construction after block is created let payload_data = ExecutionPayloadData { - payload: execution_payload_gloas, + payload, execution_requests, builder_index, slot: produce_at_slot, @@ -777,3 +757,146 @@ impl BeaconChain { )) } } + +/// Gets an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas `state`. Ensure to only run this function +/// after the Gloas fork. +/// +/// ## Specification +/// +/// Equivalent to the `get_execution_payload` function in the Validator Guide: +/// +/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal +fn get_execution_payload_gloas( + chain: Arc>, + state: &BeaconState, + parent_beacon_block_root: Hash256, + proposer_index: u64, + builder_params: BuilderParams, +) -> Result, BlockProductionError> { + // Compute all required values from the `state` now to avoid needing to pass it into a spawned + // task. + let spec = &chain.spec; + let current_epoch = state.current_epoch(); + let timestamp = + compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; + let random = *state.get_randao_mix(current_epoch)?; + + let latest_execution_block_hash = *state.latest_block_hash()?; + let latest_gas_limit = state.latest_execution_payload_bid()?.gas_limit; + + let withdrawals = + Withdrawals::::from(get_expected_withdrawals(state, spec)?).into(); + + // Spawn a task to obtain the execution payload from the EL via a series of async calls. The + // `join_handle` can be used to await the result of the function. + let join_handle = chain + .task_executor + .clone() + .spawn_handle( + async move { + prepare_execution_payload::( + &chain, + timestamp, + random, + proposer_index, + latest_execution_block_hash, + latest_gas_limit, + builder_params, + withdrawals, + parent_beacon_block_root, + ) + .await + } + .instrument(debug_span!("prepare_execution_payload")), + "prepare_execution_payload", + ) + .ok_or(BlockProductionError::ShuttingDown)?; + + Ok(join_handle) +} + +/// Prepares an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas fork `state`. Ensure to only run this function +/// after the Gloas fork. +/// +/// ## Specification +/// +/// Equivalent to the `prepare_execution_payload` function in the Validator Guide: +/// +/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal +#[allow(clippy::too_many_arguments)] +async fn prepare_execution_payload( + chain: &Arc>, + timestamp: u64, + random: Hash256, + proposer_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_gas_limit: u64, + builder_params: BuilderParams, + withdrawals: Vec, + parent_beacon_block_root: Hash256, +) -> Result, BlockProductionError> +where + T: BeaconChainTypes, +{ + let spec = &chain.spec; + let fork = spec.fork_name_at_slot::(builder_params.slot); + let execution_layer = chain + .execution_layer + .as_ref() + .ok_or(BlockProductionError::ExecutionLayerMissing)?; + + // Try to obtain the fork choice update parameters from the cached head. + // + // Use a blocking task to interact with the `canonical_head` lock otherwise we risk blocking the + // core `tokio` executor. + let inner_chain = chain.clone(); + let forkchoice_update_params = chain + .spawn_blocking_handle( + move || { + inner_chain + .canonical_head + .cached_head() + .forkchoice_update_parameters() + }, + "prepare_execution_payload_forkchoice_update_params", + ) + .instrument(debug_span!("forkchoice_update_params")) + .await + .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?; + + let suggested_fee_recipient = execution_layer + .get_suggested_fee_recipient(proposer_index) + .await; + let payload_attributes = PayloadAttributes::new( + timestamp, + random, + suggested_fee_recipient, + Some(withdrawals), + Some(parent_beacon_block_root), + ); + + let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; + let payload_parameters = PayloadParameters { + parent_hash: parent_block_hash, + parent_gas_limit, + proposer_gas_limit: target_gas_limit, + payload_attributes: &payload_attributes, + forkchoice_update_params: &forkchoice_update_params, + current_fork: fork, + }; + + let block_contents = execution_layer + .get_payload_gloas(payload_parameters) + .await + .map_err(BlockProductionError::GetPayloadFailed)?; + + Ok(block_contents) +} diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index 1c0fd505e7..5caf0105e4 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -367,18 +367,9 @@ pub fn get_execution_payload( let timestamp = compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; - // In GLOAS (ePBS), the execution payload header is replaced by - // `latest_block_hash` and `latest_execution_payload_bid`. + let header = state.latest_execution_payload_header()?; let (latest_execution_payload_header_block_hash, latest_execution_payload_header_gas_limit) = - if state.fork_name_unchecked() == ForkName::Gloas { - ( - *state.latest_block_hash()?, - state.latest_execution_payload_bid()?.gas_limit, - ) - } else { - let header = state.latest_execution_payload_header()?; - (header.block_hash(), header.gas_limit()) - }; + (header.block_hash(), header.gas_limit()); let withdrawals = if state.fork_name_unchecked().capella_enabled() { Some(Withdrawals::::from(get_expected_withdrawals(state, spec)?).into()) } else { diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 33b83aab09..ad2486a4ad 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -48,7 +48,6 @@ use tree_hash::TreeHash; use types::builder::BuilderBid; use types::execution::BlockProductionVersion; use types::kzg_ext::KzgCommitments; -use types::new_non_zero_usize; use types::{ AbstractExecPayload, BlobsList, ExecutionPayloadDeneb, ExecutionRequests, KzgProofs, SignedBlindedBeaconBlock, @@ -58,6 +57,7 @@ use types::{ ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, ProposerPreparationData, Slot, }; +use types::{ExecutionPayloadGloas, new_non_zero_usize}; mod block_hash; mod engine_api; @@ -168,6 +168,7 @@ pub enum Error { BeaconStateError(BeaconStateError), PayloadTypeMismatch, VerifyingVersionedHashes(versioned_hashes::Error), + Unexpected(String), } impl From for Error { @@ -204,6 +205,26 @@ pub enum BlockProposalContentsType { Blinded(BlockProposalContents>), } +pub struct BlockProposalContentsGloas { + pub payload: ExecutionPayloadGloas, + pub payload_value: Uint256, + pub blob_kzg_commitments: KzgCommitments, + pub blobs_and_proofs: (BlobsList, KzgProofs), + pub execution_requests: ExecutionRequests, +} + +impl From> for BlockProposalContentsGloas { + fn from(response: GetPayloadResponseGloas) -> Self { + Self { + payload: response.execution_payload, + payload_value: response.block_value, + blob_kzg_commitments: response.blobs_bundle.commitments, + blobs_and_proofs: (response.blobs_bundle.blobs, response.blobs_bundle.proofs), + execution_requests: response.requests, + } + } +} + pub enum BlockProposalContents> { Payload { payload: Payload, @@ -884,6 +905,43 @@ impl ExecutionLayer { .and_then(|entry| entry.gas_limit) } + /// Maps to the `engine_getPayload` JSON-RPC call for post-Gloas payload construction. + /// + /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing + /// payload id for the given parameters. + /// + /// ## Fallback Behavior + /// + /// The result will be returned from the first node that returns successfully. No more nodes + /// will be contacted. + pub async fn get_payload_gloas( + &self, + payload_parameters: PayloadParameters<'_>, + ) -> Result, Error> { + let payload_response_type = self.get_full_payload_caching(payload_parameters).await?; + let GetPayloadResponseType::Full(payload_response) = payload_response_type else { + return Err(Error::Unexpected( + "get_payload_gloas should never return a blinded payload".to_owned(), + )); + }; + let GetPayloadResponse::Gloas(payload_response) = payload_response else { + return Err(Error::Unexpected( + "get_payload_gloas should always return a gloas `GetPayloadResponse` variant" + .to_owned(), + )); + }; + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_OUTCOME, + &[metrics::SUCCESS], + ); + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_SOURCE, + &[metrics::LOCAL], + ); + + Ok(payload_response.into()) + } + /// Maps to the `engine_getPayload` JSON-RPC call. /// /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing From 9db99657383046ab68ed0273a5879bdaa0040517 Mon Sep 17 00:00:00 2001 From: Eitan Seri- Levi Date: Fri, 13 Feb 2026 21:42:33 -0800 Subject: [PATCH 4/5] Revert --- beacon_node/beacon_chain/src/execution_payload.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index 5caf0105e4..bdf3ab9594 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -367,9 +367,9 @@ pub fn get_execution_payload( let timestamp = compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; - let header = state.latest_execution_payload_header()?; - let (latest_execution_payload_header_block_hash, latest_execution_payload_header_gas_limit) = - (header.block_hash(), header.gas_limit()); + let latest_execution_payload_header = state.latest_execution_payload_header()?; + let latest_execution_payload_header_block_hash = latest_execution_payload_header.block_hash(); + let latest_execution_payload_header_gas_limit = latest_execution_payload_header.gas_limit(); let withdrawals = if state.fork_name_unchecked().capella_enabled() { Some(Withdrawals::::from(get_expected_withdrawals(state, spec)?).into()) } else { From 022ee4e968638dde37da32341e66c46e1c606d7a Mon Sep 17 00:00:00 2001 From: Eitan Seri- Levi Date: Fri, 13 Feb 2026 22:13:02 -0800 Subject: [PATCH 5/5] Special case signed beacon block ssz decoding --- common/eth2/src/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index ca16fcd28a..45a2e4a103 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1989,7 +1989,7 @@ impl PublishBlockRequest { /// SSZ decode with fork variant determined by `fork_name`. pub fn from_ssz_bytes(bytes: &[u8], fork_name: ForkName) -> Result { - if fork_name.deneb_enabled() { + if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let mut builder = ssz::SszDecoderBuilder::new(bytes); builder.register_anonymous_variable_length_item()?; builder.register_type::>()?;