Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions beacon_node/store/src/hot_cold_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,42 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold>
.map(|payload| payload.is_some())
}

/// Store a Gloas (EIP-7732) execution payload envelope in the hot database.
///
/// The key is the execution `block_hash` committed to by the builder in the corresponding
/// `signed_execution_payload_bid`. This allows efficient lookup during block replay using
/// only the information present in each block's bid header.
pub fn put_execution_payload_envelope(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have a function payload_envelope_as_kv_store_ops that does this

&self,
exec_block_hash: &ExecutionBlockHash,
envelope: &SignedExecutionPayloadEnvelope<E>,
ops: &mut Vec<KeyValueStoreOp>,
) {
ops.push(KeyValueStoreOp::PutKeyValue(
DBColumn::ExecPayloadEnvelope,
exec_block_hash.as_ssz_bytes(),
envelope.as_ssz_bytes(),
));
}

/// Load a Gloas (EIP-7732) execution payload envelope by the execution block hash.
///
/// Returns `None` when no envelope has been stored for the given hash (e.g. the builder did
/// not deliver the payload for that slot, or envelope storage is not yet implemented for the
/// current database schema).
pub fn get_execution_payload_envelope(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have a function get_payload_envelope that does this. Note that we fetch execution payload envelopes by block root, not by execution block hash

&self,
exec_block_hash: &ExecutionBlockHash,
) -> Result<Option<SignedExecutionPayloadEnvelope<E>>, Error> {
let key = exec_block_hash.as_ssz_bytes();
match self.hot_db.get_bytes(DBColumn::ExecPayloadEnvelope, &key)? {
Some(bytes) => Ok(Some(SignedExecutionPayloadEnvelope::from_ssz_bytes(
&bytes,
)?)),
None => Ok(None),
}
}

/// Get the sync committee branch for the given block root
/// Note: we only persist sync committee branches for checkpoint slots
pub fn get_sync_committee_branch(
Expand Down Expand Up @@ -2476,6 +2512,28 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold>
block_replayer = block_replayer.pre_slot_hook(pre_slot_hook);
}

// For Gloas (EIP-7732) replay: attempt to load the execution payload envelope for each
// block from the hot DB, keyed by the execution block hash that the builder committed to.
// If any block is a Gloas block, we build the envelope map and wire it into BlockReplayer
// so the look-ahead logic can apply envelopes between consecutive blocks.
let any_gloas = blocks
.iter()
.any(|b| b.fork_name(&self.spec).is_ok_and(|f| f.gloas_enabled()));
if any_gloas {
let mut envelopes = HashMap::new();
for block in &blocks {
if let Ok(ForkName::Gloas) = block.fork_name(&self.spec)
&& let BeaconBlockBodyRef::Gloas(body) = block.message().body()
{
let bid_hash = body.signed_execution_payload_bid.message.block_hash;
if let Some(envelope) = self.get_execution_payload_envelope(&bid_hash)? {
envelopes.insert(bid_hash, envelope.message);
}
}
}
block_replayer = block_replayer.payload_envelopes(envelopes);
}

block_replayer
.apply_blocks(blocks, Some(target_slot))
.map(|block_replayer| {
Expand Down
7 changes: 7 additions & 0 deletions beacon_node/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ pub enum DBColumn {
/// Execution payloads for blocks more recent than the finalized checkpoint.
#[strum(serialize = "exp")]
ExecPayload,
/// Execution payload envelopes for Gloas (EIP-7732) blocks in the hot database.
///
/// - Key: 32-byte execution `block_hash` from the block's `signed_execution_payload_bid`.
/// - Value: SSZ-encoded `SignedExecutionPayloadEnvelope`.
#[strum(serialize = "ppe")]
ExecPayloadEnvelope,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a PayloadEnvelope column defined

/// For persisting in-memory state to the database.
#[strum(serialize = "bch")]
BeaconChain,
Expand Down Expand Up @@ -413,6 +419,7 @@ impl DBColumn {
| Self::BeaconColdStateSummary
| Self::BeaconStateTemporary
| Self::ExecPayload
| Self::ExecPayloadEnvelope
| Self::BeaconChain
| Self::OpPool
| Self::Eth1Cache
Expand Down
81 changes: 78 additions & 3 deletions consensus/state_processing/src/block_replayer.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
use crate::{
BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError,
VerifyBlockRoot, per_block_processing, per_epoch_processing::EpochProcessingSummary,
per_slot_processing,
per_slot_processing, process_execution_payload_envelope,
};
use itertools::Itertools;
use std::collections::HashMap;
use std::iter::Peekable;
use std::marker::PhantomData;
use types::{
BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock,
Slot,
BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, ExecutionBlockHash,
ExecutionPayloadEnvelope, Hash256, SignedBeaconBlock, Slot,
};

pub type PreBlockHook<'a, E, Error> = Box<
Expand Down Expand Up @@ -43,6 +44,13 @@ pub struct BlockReplayer<
post_slot_hook: Option<PostSlotHook<'a, Spec, Error>>,
pub(crate) state_root_iter: Option<Peekable<StateRootIter>>,
state_root_miss: bool,
/// Execution payload envelopes keyed by execution `block_hash` (the hash committed to in the
/// builder's bid for that slot). When the look-ahead determines that block N's payload was
/// committed, the corresponding envelope is applied between block N and block N+1.
payload_envelopes: HashMap<ExecutionBlockHash, ExecutionPayloadEnvelope<Spec>>,
/// The first block *after* the replay range, used as the look-ahead for the last block in
/// `apply_blocks` so that its envelope can also be applied when appropriate.
lookahead_block: Option<SignedBeaconBlock<Spec, BlindedPayload<Spec>>>,
_phantom: PhantomData<Error>,
}

Expand Down Expand Up @@ -96,6 +104,8 @@ where
post_slot_hook: None,
state_root_iter: None,
state_root_miss: false,
payload_envelopes: HashMap::new(),
lookahead_block: None,
_phantom: PhantomData,
}
}
Expand Down Expand Up @@ -161,6 +171,29 @@ where
self
}

/// Supply a map of execution payload envelopes (EIP-7732 / Gloas) to be applied during replay.
///
/// Envelopes are keyed by the execution `block_hash` that the builder committed to in the
/// corresponding slot's `signed_execution_payload_bid`. When the look-ahead logic determines
/// that block N's payload was included, the matching envelope is applied between block N and
/// block N+1 via `process_execution_payload_envelope`.
pub fn payload_envelopes(
mut self,
envelopes: HashMap<ExecutionBlockHash, ExecutionPayloadEnvelope<E>>,
) -> Self {
self.payload_envelopes = envelopes;
self
}

/// Supply the first block *after* the replay range as a look-ahead sentinel.
///
/// This allows the last block in `apply_blocks` to also have its execution payload envelope
/// applied when the next proposer built on it.
pub fn lookahead_block(mut self, block: SignedBeaconBlock<E, BlindedPayload<E>>) -> Self {
self.lookahead_block = Some(block);
self
}

/// Compute the state root for `self.state` as efficiently as possible.
///
/// This function MUST only be called when `self.state` is a post-state, i.e. it MUST not be
Expand Down Expand Up @@ -266,6 +299,48 @@ where
if let Some(ref mut post_block_hook) = self.post_block_hook {
post_block_hook(&mut self.state, block)?;
}

// EIP-7732 (Gloas) look-ahead: determine whether block N's execution payload
// envelope should be applied before we process block N+1.
//
// The decision rule: if the next block's builder bid says its *parent* execution
// hash is equal to the hash that the *current* block's builder promised to deliver,
// then the current block's payload was committed and we must apply the envelope now.
//
// We look at `blocks[i+1]` first; if this is the last block we fall back to
// `self.lookahead_block` (the first block after the replay range).
if self.state.fork_name_unchecked().gloas_enabled()
&& !self.payload_envelopes.is_empty()
{
let next_idx = i.saturating_add(1);
let next_block = blocks.get(next_idx).or(self.lookahead_block.as_ref());

// Extract the next block's bid parent_block_hash, if it is a Gloas block.
let next_parent_block_hash = next_block.and_then(|nb| match nb.message().body() {
types::BeaconBlockBodyRef::Gloas(body) => {
Some(body.signed_execution_payload_bid.message.parent_block_hash)
}
_ => None,
});

// The hash that the current block's builder promised to deliver.
let current_bid_block_hash = if let types::BeaconState::Gloas(gs) = &self.state {
Some(gs.latest_execution_payload_bid.block_hash)
} else {
None
};

// If the next block builds on this slot's promised payload, apply the envelope.
if let (Some(next_parent), Some(bid_hash)) =
(next_parent_block_hash, current_bid_block_hash)
&& next_parent == bid_hash
&& let Some(envelope) = self.payload_envelopes.get(&bid_hash)
{
let envelope = envelope.clone();
process_execution_payload_envelope(&mut self.state, &envelope, self.spec)
.map_err(BlockReplayError::from)?;
}
}
}

if let Some(target_slot) = target_slot {
Expand Down
3 changes: 2 additions & 1 deletion consensus/state_processing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ pub use genesis::{
};
pub use per_block_processing::{
BlockSignatureStrategy, BlockSignatureVerifier, VerifyBlockRoot, VerifySignatures,
block_signature_verifier, errors::BlockProcessingError, per_block_processing, signature_sets,
block_signature_verifier, errors::BlockProcessingError, per_block_processing,
process_execution_payload_envelope, signature_sets,
};
pub use per_epoch_processing::{
errors::EpochProcessingError, process_epoch as per_epoch_processing,
Expand Down
48 changes: 47 additions & 1 deletion consensus/state_processing/src/per_block_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,12 @@ pub fn per_block_processing<E: EthSpec, Payload: AbstractExecPayload<E>>(
// The call to the `process_execution_payload` must happen before the call to the
// `process_randao` as the former depends on the `randao_mix` computed with the reveal of the
// previous block.
if is_execution_enabled(state, block.body()) {
//
// For Gloas (EIP-7732) the execution payload is decoupled from the beacon block: the block
// carries only a `signed_execution_payload_bid`, while the actual payload arrives via a
// separate `SignedExecutionPayloadEnvelope`. The envelope is processed independently (see
// `process_execution_payload_envelope`), so we skip the pre-Gloas inline-payload path here.
if is_execution_enabled(state, block.body()) && !state.fork_name_unchecked().gloas_enabled() {
let body = block.body();
// TODO(EIP-7732): build out process_withdrawals variant for gloas
process_withdrawals::<E, Payload>(state, body.execution_payload()?, spec)?;
Expand Down Expand Up @@ -461,6 +466,47 @@ pub fn process_execution_payload<E: EthSpec, Payload: AbstractExecPayload<E>>(
Ok(())
}

/// Process a `SignedExecutionPayloadEnvelope` for a Gloas state.
///
/// In EIP-7732 (Gloas) the execution payload is decoupled from the beacon block and arrives
/// separately as an `ExecutionPayloadEnvelope`. This function validates the chain-continuity
/// invariant (`envelope.payload.parent_hash == state.latest_block_hash`) and advances
/// `state.latest_block_hash` to the delivered payload's `block_hash`.
///
/// The function must be called **between** block N and block N+1 when it has been determined
/// (via the look-ahead described in `BlockReplayer`) that block N's payload was committed.
///
/// ## Errors
///
/// Returns `BlockProcessingError::ParentBlockHashMismatch` when the envelope does not extend
/// the current execution chain tip recorded in the state. Returns
/// `BlockProcessingError::IncorrectStateType` if called on a non-Gloas state.
pub fn process_execution_payload_envelope<E: EthSpec>(
state: &mut BeaconState<E>,
envelope: &ExecutionPayloadEnvelope<E>,
_spec: &ChainSpec,
) -> Result<(), BlockProcessingError> {
let BeaconState::Gloas(gloas_state) = state else {
return Err(BlockProcessingError::IncorrectStateType);
};

// Verify chain continuity: the envelope must extend the current execution tip.
if envelope.payload.parent_hash != gloas_state.latest_block_hash {
return Err(BlockProcessingError::ExecutionHashChainIncontiguous {
expected: gloas_state.latest_block_hash,
found: envelope.payload.parent_hash,
});
}

// Advance the execution chain tip.
gloas_state.latest_block_hash = envelope.payload.block_hash;

// TODO(EIP-7732): apply withdrawals, execution_requests, and other state changes
// from the envelope (process_withdrawals_gloas, deposit processing, etc.)

Ok(())
}

/// These functions will definitely be called before the merge. Their entire purpose is to check if
/// the merge has happened or if we're on the transition block. Thus we don't want to propagate
/// errors from the `BeaconState` being an earlier variant than `BeaconStateBellatrix` as we'd have to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ pub fn process_operations<E: EthSpec, Payload: AbstractExecPayload<E>>(
process_bls_to_execution_changes(state, bls_to_execution_changes, verify_signatures, spec)?;
}

if state.fork_name_unchecked().electra_enabled() {
// For Gloas (EIP-7732) execution requests are carried in the ExecutionPayloadEnvelope,
// not in the beacon block body. The envelope is processed separately via
// `process_execution_payload_envelope`, so we skip this block for Gloas.
if state.fork_name_unchecked().electra_enabled() && !state.fork_name_unchecked().gloas_enabled()
{
state.update_pubkey_cache()?;
process_deposit_requests(state, &block_body.execution_requests()?.deposits, spec)?;
process_withdrawal_requests(state, &block_body.execution_requests()?.withdrawals, spec)?;
Expand Down
68 changes: 68 additions & 0 deletions consensus/state_processing/src/per_block_processing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1153,3 +1153,71 @@ async fn block_replayer_peeking_state_roots() {
(dummy_state_root, dummy_slot)
);
}

/// Regression test for https://github.com/sigp/lighthouse/issues/8869
///
/// When replaying blocks on a Gloas state, `per_block_processing` previously called
/// `body.execution_payload()` which returns `Err(IncorrectStateVariant)` for Gloas blocks
/// (they use `signed_execution_payload_bid` instead of an inline payload).
///
/// This test creates a minimal Gloas genesis state, advances it one slot, then applies an
/// empty Gloas blinded block via `BlockReplayer::apply_blocks`. Before the fix this
/// returns `BlockProcessingError::BeaconStateError(IncorrectStateVariant)`; after
/// the fix it must succeed.
#[tokio::test]
async fn gloas_block_replay_no_incorrect_state_variant() {
use beacon_chain::test_utils::InteropGenesisBuilder;

type E = MinimalEthSpec;

// Build a Gloas-at-genesis spec using the minimal preset.
let spec = ForkName::Gloas.make_genesis_spec(E::default_spec());

// Build a genesis state with a small set of interop validators.
let keypairs = &KEYPAIRS[0..8];
let genesis_time = 1_000_000_u64;
let genesis_state = InteropGenesisBuilder::<E>::new()
.build_genesis_state(
keypairs,
genesis_time,
Hash256::from_slice(&[0x42; 32]),
&spec,
)
.expect("should build Gloas genesis state");

// Sanity-check: the state must be a Gloas state.
assert_eq!(genesis_state.fork_name_unchecked(), ForkName::Gloas);

// Build a minimal blinded Gloas block at slot 1.
// We use the genesis state (slot 0) as input to BlockReplayer so that the block at slot 1
// is not mistakenly treated as a "state-root-only" sentinel (which happens when
// block.slot() <= state.slot()).
// However, we compute the expected parent_root from the state *after* slot processing,
// because per_slot_processing fills in latest_block_header.state_root.
let mut slot1_state = genesis_state.clone();
crate::per_slot_processing(&mut slot1_state, None, &spec)
.expect("slot processing should succeed");

let mut block = BeaconBlockGloas::<E, BlindedPayload<E>>::empty(&spec);
block.slot = Slot::new(1);
block.proposer_index = slot1_state
.get_beacon_proposer_index(block.slot, &spec)
.expect("should get proposer index") as u64;
// parent_root must equal state.latest_block_header().tree_hash_root() *after* slot processing.
block.parent_root = slot1_state.latest_block_header().canonical_root();

let signed_block = SignedBeaconBlock::from_block(BeaconBlock::Gloas(block), Signature::empty());

// Before the fix this returned Err(BlockProcessing(BeaconStateError(IncorrectStateVariant))).
// After the fix it must succeed (no-signature-verification mode skips the sig checks).
// Pass genesis_state (slot 0) so the block at slot 1 is not skipped by BlockReplayer.
let result = BlockReplayer::<E, BlockReplayError>::new(genesis_state, &spec)
.no_signature_verification()
.apply_blocks(vec![signed_block], None);

assert!(
result.is_ok(),
"Gloas block replay must succeed, got: {:?}",
result.err()
);
}