Abstract
Under EIP-7732 (ePBS), a builder bid commits to the payload's block_hash and therefore to its state_root. This EIP makes the commitment partial: the bid fixes all payload contents except the state_root and the block_hash that depends on it. The full payload, including its state root, is still revealed in the ExecutionPayloadEnvelope and validated as today. Builders and provers gain the whole window between bid and reveal to compute the state root, instead of needing one for every candidate bid. Execution layer state root semantics do not change.
Motivation
Computing a block's post-state root requires writing every state change to the state trie and rehashing all modified trie paths. It is one of the most expensive serial steps of sealing a block, and its cost grows with the gas limit.
The block hash covers the execution block header, and the header contains the state root. An EIP-7732 builder therefore cannot sign a bid without fully sealing the block. Builders update bids many times per slot, so every update pays for a state root that is almost always discarded. Where blocks are proven, for example for stateless clients, the state root transition is also among the most expensive parts of the proof.
With partial commitments, a bid only requires executing the payload. Execution yields the receipts root, logs bloom, gas used, and execution requests; committing the result to the trie is not needed. One state root per slot suffices, produced between bid acceptance and the reveal deadline. Proposers, attesters, and the execution layer are unaffected. Unlike EIP-7862 (Delayed State Root), this proposal does not change execution layer state root semantics and does not delay the state root by one block.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Execution layer
No changes are required.
Engine API
No changes are required.
Consensus layer
The changes below are relative to the Gloas beacon chain, fork choice, p2p, honest validator, and honest builder specifications implementing EIP-7732.
Containers
Modified ExecutionPayloadBid
The block_hash field is replaced by payload_commitment.
class ExecutionPayloadBid(ProgressiveContainer(active_fields=[1] * 12)):
parent_block_hash: Hash32
parent_block_root: Root
# [Modified by this EIP]
# Replaces `block_hash`
payload_commitment: Root
prev_randao: Bytes32
fee_recipient: ExecutionAddress
gas_limit: uint64
builder_index: BuilderIndex
slot: Slot
value: Gwei
execution_payment: Gwei
blob_kzg_commitments: ProgressiveList[KZGCommitment]
execution_requests_root: RootHelpers
New compute_payload_commitment
def compute_payload_commitment(payload: ExecutionPayload) -> Root:
"""
Return the partial commitment to ``payload``: the hash tree root of the
payload with ``state_root`` and ``block_hash`` set to zero. All other
payload fields are committed to unchanged.
"""
committed_payload = copy(payload)
committed_payload.state_root = Bytes32()
committed_payload.block_hash = Hash32()
return hash_tree_root(committed_payload)Block processing
The bid of a block that builds on a full parent MUST set parent_block_hash to the block hash of the parent's revealed payload. Fork choice enforces this (see below). A parent is empty if and only if the bid's parent_block_hash equals the parent bid's parent_block_hash.
Modified process_parent_execution_payload
def process_parent_execution_payload(state: BeaconState, block: BeaconBlock) -> None:
bid = block.body.signed_execution_payload_bid.message
parent_bid = state.latest_execution_payload_bid
requests = block.body.parent_execution_requests
# [Modified by this EIP]
# The parent is empty if and only if the bid's parent block hash equals
# the execution head recorded when the parent bid was processed.
if bid.parent_block_hash == parent_bid.parent_block_hash:
# Parent was EMPTY -- no execution requests expected
assert requests == ExecutionRequests()
return
# Parent was FULL -- verify the bid commitment and apply the payload
assert hash_tree_root(requests) == parent_bid.execution_requests_root
# [Modified by this EIP]
apply_parent_execution_payload(state, requests, bid.parent_block_hash)Modified apply_parent_execution_payload
The parent payload's block hash is passed as a parameter because it can no longer be read from the parent bid. Only the signature and the final assignment change.
def apply_parent_execution_payload(
state: BeaconState,
requests: ExecutionRequests,
# [New by this EIP]
parent_block_hash: Hash32,
) -> None:
parent_bid = state.latest_execution_payload_bid
parent_slot = parent_bid.slot
parent_epoch = compute_epoch_at_slot(parent_slot)
assert len(requests.withdrawals) <= MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD
assert len(requests.consolidations) <= MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD
assert len(requests.builder_deposits) <= MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD
assert len(requests.builder_exits) <= MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD
# Process execution requests from parent's payload. The execution
# requests are processed at state.slot (child's slot), not the parent's slot.
def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None:
for operation in operations:
fn(state, operation)
for_ops(requests.deposits, process_deposit_request)
for_ops(requests.withdrawals, process_withdrawal_request)
for_ops(requests.consolidations, process_consolidation_request)
for_ops(requests.builder_deposits, process_builder_deposit_request)
for_ops(requests.builder_exits, process_builder_exit_request)
# Settle the builder payment
if parent_epoch == get_current_epoch(state):
payment_index = SLOTS_PER_EPOCH + parent_slot % SLOTS_PER_EPOCH
settle_builder_payment(state, payment_index)
elif parent_epoch == get_previous_epoch(state):
payment_index = parent_slot % SLOTS_PER_EPOCH
settle_builder_payment(state, payment_index)
elif parent_bid.value > 0:
# Parent is older than the previous epoch, its payment entry has been
# evicted from builder_pending_payments. Append the withdrawal directly.
state.builder_pending_withdrawals.append(
BuilderPendingWithdrawal(
fee_recipient=parent_bid.fee_recipient,
amount=parent_bid.value,
builder_index=parent_bid.builder_index,
)
)
# Update parent payload availability and latest block hash
state.execution_payload_availability[parent_slot % SLOTS_PER_HISTORICAL_ROOT] = 0b1
# [Modified by this EIP]
state.latest_block_hash = parent_block_hashModified process_withdrawals
Only the empty-parent early return changes.
def process_withdrawals(state: BeaconState) -> None:
# [Modified by this EIP]
# Return early if the parent block is empty
if state.latest_block_hash == state.latest_execution_payload_bid.parent_block_hash:
return
# Get expected withdrawals
expected = get_expected_withdrawals(state)
# Apply expected withdrawals
apply_withdrawals(state, expected.withdrawals)
# Update withdrawals fields in the state
update_next_withdrawal_index(state, expected.withdrawals)
update_payload_expected_withdrawals(state, expected.withdrawals)
update_builder_pending_withdrawals(state, expected.processed_builder_withdrawals_count)
update_pending_partial_withdrawals(state, expected.processed_partial_withdrawals_count)
update_next_withdrawal_builder_index(state, expected.processed_builders_sweep_count)
update_next_withdrawal_validator_index(state, expected.withdrawals)Note: process_execution_payload_bid is unchanged. It still asserts bid.parent_block_hash == state.latest_block_hash, so state.latest_block_hash == state.latest_execution_payload_bid.parent_block_hash holds at every child block.
Fork choice
Modified get_parent_payload_status
def get_parent_payload_status(store: Store, block: BeaconBlock) -> PayloadStatus:
parent = store.blocks[block.parent_root]
parent_block_hash = block.body.signed_execution_payload_bid.message.parent_block_hash
# [Modified by this EIP]
# The parent is empty if and only if the bid's parent block hash equals
# the execution head recorded when the parent bid was processed.
empty_parent_block_hash = parent.body.signed_execution_payload_bid.message.parent_block_hash
if parent_block_hash == empty_parent_block_hash:
return PAYLOAD_STATUS_EMPTY
return PAYLOAD_STATUS_FULLModified on_block
The existing check that the parent's envelope has been verified is extended by one assertion: the bid's parent_block_hash must match the block hash of that verified payload. The rest of the handler is unchanged.
# If this block builds on the parent's full payload, that payload must
# have been verified by on_execution_payload_envelope
if is_parent_node_full(store, block):
assert is_payload_verified(store, block.parent_root)
# [New by this EIP]
# The parent block hash named by the bid must be the block hash of
# the parent's verified payload
bid = block.body.signed_execution_payload_bid.message
assert bid.parent_block_hash == store.payloads[block.parent_root].payload.block_hashModified verify_execution_payload_envelope
The block hash check against the bid is replaced by the payload commitment check. All other checks are unchanged. In particular, verify_and_notify_new_payload still verifies the full payload, including its state_root and block_hash.
def verify_execution_payload_envelope(
state: BeaconState,
signed_envelope: SignedExecutionPayloadEnvelope,
execution_engine: ExecutionEngine,
) -> None:
envelope = signed_envelope.message
payload = envelope.payload
# Verify signature
assert verify_execution_payload_envelope_signature(state, signed_envelope)
# Verify consistency with the beacon block
header = copy(state.latest_block_header)
header.state_root = hash_tree_root(state)
assert envelope.beacon_block_root == hash_tree_root(header)
assert envelope.parent_beacon_block_root == state.latest_block_header.parent_root
# Verify consistency with the committed bid
bid = state.latest_execution_payload_bid
assert envelope.builder_index == bid.builder_index
assert payload.prev_randao == bid.prev_randao
assert payload.gas_limit == bid.gas_limit
# [Modified by this EIP]
assert compute_payload_commitment(payload) == bid.payload_commitment
assert hash_tree_root(envelope.execution_requests) == bid.execution_requests_root
# Verify the execution payload is valid
assert payload.slot_number == state.slot
assert payload.parent_hash == state.latest_block_hash
assert payload.timestamp == compute_time_at_slot(state, state.slot)
assert hash_tree_root(payload.withdrawals) == hash_tree_root(state.payload_expected_withdrawals)
assert execution_engine.verify_and_notify_new_payload(
NewPayloadRequest(
execution_payload=payload,
versioned_hashes=[
kzg_commitment_to_versioned_hash(commitment)
for commitment in bid.blob_kzg_commitments
],
parent_beacon_block_root=envelope.parent_beacon_block_root,
execution_requests=envelope.execution_requests,
)
)P2P
In the gossip conditions for the execution_payload topic, the validation
- [REJECT]
payload.block_hash == bid.block_hash
is replaced by
- [REJECT]
compute_payload_commitment(payload) == bid.payload_commitment
All other gossip conditions, including those for the execution_payload_bid and beacon_block topics, are unchanged.
Honest validator
In prepare_execution_payload, the execution head when building on a full parent is read from the verified envelope instead of the parent bid. The empty branch is unchanged.
parent_bid = state.latest_execution_payload_bid
if should_build_on_full(store, head):
envelope = store.payloads[head.root]
# Make a copy of the state to avoid mutability issues
state = copy(state)
# Apply parent payload before computing withdrawals
# [Modified by this EIP]
apply_parent_execution_payload(
state, envelope.execution_requests, envelope.payload.block_hash
)
withdrawals = get_expected_withdrawals(state).withdrawals
# [Modified by this EIP]
head_block_hash = envelope.payload.block_hash
else:
withdrawals = state.payload_expected_withdrawals
head_block_hash = parent_bid.parent_block_hashWhen selecting a bid, the proposer MUST verify that bid.parent_block_hash equals store.payloads[head.root].payload.block_hash if should_build_on_full(store, head) is true, and state.latest_execution_payload_bid.parent_block_hash otherwise.
Honest builder
When constructing the SignedExecutionPayloadBid, the builder sets bid.payload_commitment = compute_payload_commitment(payload) instead of setting bid.block_hash. The payload's state_root and block_hash MAY be unknown at bid time. The builder MUST have executed the payload, because the commitment covers execution outputs such as receipts_root, logs_bloom, and gas_used, and bid.execution_requests_root covers the execution requests.
When constructing the ExecutionPayloadEnvelope, the builder MUST set envelope.payload to a fully sealed payload that satisfies compute_payload_commitment(payload) == bid.payload_commitment and carries the correct state_root and block_hash.
Fork transition
In the state upgrade, the initial latest_execution_payload_bid no longer sets a block_hash. Its payload_commitment and parent_block_hash keep their zero defaults. The first post-fork block's bid carries the pre-fork execution head as parent_block_hash, which differs from zero, so process_parent_execution_payload takes the full-parent path with an empty ExecutionRequests(), matching current fork behavior.
Rationale
What the bid still commits to
The bid must fix everything a proposer and the attesters need pinned down at bid time: the payload's contents and resource usage. Together with its existing fields, the bid still commits to:
- all transactions and withdrawals (via the payload commitment),
- the execution outputs
receipts_root,logs_bloom, andgas_used(via the payload commitment), fee_recipient,extra_data,base_fee_per_gas,blob_gas_used,excess_blob_gas, and every other payload field exceptstate_rootandblock_hash(via the payload commitment),- the execution requests (via
execution_requests_root), and - the blob KZG commitments (directly in the bid, so data column sidecars remain verifiable against the bid before the payload reveal).
Execution is deterministic for a fixed parent block and fixed payload contents. Exactly one state_root, and hence one block_hash, is consistent with the commitment. For valid payloads the partial commitment binds as tightly as the block hash. block_hash must be excluded together with state_root because it hashes the execution block header, which contains the state root.
Masking instead of a bespoke commitment container
The commitment is the hash tree root of the payload with two fields zeroed, not a new container that lists the committed fields. This guarantees that coverage is exactly everything except state_root and block_hash, follows future changes to ExecutionPayload automatically, and is cheap: clients that cache SSZ hash trees obtain the commitment from hash_tree_root(payload) by replacing two leaves.
An SSZ commitment rather than an execution layer construct
An alternative is a commitment to the execution block header with a zeroed stateRoot, for example its keccak/RLP hash. The consensus layer cannot compute keccak, RLP, or MPT constructs natively, so such a commitment could only be verified through the execution engine. Envelope gossip validation and fork choice would become execution dependent, and the Engine API would have to change. The SSZ masking keeps verification consensus native and execution free. The integrity of the revealed block_hash and state_root is verified by verify_and_notify_new_payload, as under EIP-7732.
Chain linking without a block hash in the bid
Under EIP-7732 the bid's block_hash has a second role: the beacon state learns the execution head from it, and a child block proves that its parent was full by matching it.
This EIP derives the execution head from the child bid's parent_block_hash and binds that claim in fork choice. on_block accepts a block on a full parent only if the parent's envelope has been verified locally, which Gloas already requires, and if the claimed hash matches that envelope's payload.block_hash. Full and empty parents are told apart by comparing the child's parent_block_hash with the parent bid's parent_block_hash. The two values can only collide if an execution block's hash equals its own parent's hash, which is cryptographically infeasible. The required data is exactly what nodes already hold for full chains, so no new data dependencies arise.
Relation to Delayed State Root
EIP-7862 targets the same bottleneck by changing execution layer semantics: each execution block header carries the post-state root of its parent. That removes state root computation from block sealing everywhere, but it touches every consumer of execution headers and delays state proofs by one block. This EIP only changes what the consensus layer bid commits to. Payload and state root are revealed together, and light client timing is untouched. The two proposals are compatible; under EIP-7862 the block hash would again be computable at bid time.
Relation to slot auctions
The EIP-7732 rationale discusses slot auctions, which drop the payload commitment from the bid entirely. This EIP is a midpoint. It keeps block auctions, so payload contents are fixed at bid time and same-slot unbundling remains impossible, and removes only the state trie binding from the auction's critical path.
Backwards Compatibility
This EIP changes the consensus layer block validation rules and requires a hard fork. It amends EIP-7732 and is meant to be part of the ePBS fork itself.
The ExecutionPayloadBid container keeps its shape: a 32-byte field is replaced by a 32-byte field at the same position. Generalized indices into the bid, including the light client path to parent_block_hash, are unchanged. The execution layer, the Engine API, and execution layer tooling are unaffected.
Security Considerations
Binding equivalence for valid payloads
Execution is deterministic, so the committed contents fix the unique valid state_root and block_hash. For payloads the execution layer accepts, the partial commitment binds as tightly as a block hash commitment. A revealed payload that matches the commitment but carries a wrong state_root is execution invalid: verify_and_notify_new_payload rejects it, the slot is treated as empty, and the builder still owes the payment. This is identical to revealing any other invalid payload under EIP-7732. Honest builders only take on a timing requirement, namely one state root before the reveal deadline. Missing it equals withholding, which is already penalized.
Envelope equivocation on the uncommitted fields
A malicious builder can sign several envelopes for the same bid that differ only in state_root and block_hash. All of them pass gossip validation, but at most one is execution valid. The first-seen gossip rule keeps honest nodes from forwarding more than one, yet an invalid envelope seen first can delay the valid one. Clients SHOULD drop envelopes that fail execution layer verification from their seen cache and accept a later envelope for the same (beacon_block_root, builder_index). The equivocation is attributable, since it produces two signed envelopes for one beacon block root, and a slashing condition could be added. This matches the equivocation discussion in EIP-7732.
Enforcement moves from the state transition to fork choice
Under EIP-7732, a block that claims a full parent with a fabricated parent_block_hash is invalid in the state transition. Under this EIP such a block passes the state transition but is rejected by every honest node's fork choice, which demands the verified parent envelope and an exact hash match. A chain built on a fabricated execution head can also never contain another valid payload, because the next payload would have to reference the fabricated hash as its parent. ePBS already handles payload presence and availability through fork choice and honest majority attestation rather than intrinsic block validity, so no new trust assumptions are added.
Payload Timeliness Committee
PTC semantics are unchanged. A payload counts as present only if the local node has verified the envelope, including full execution and state root verification.
Copyright
Copyright and related rights waived via CC0.
