EIP.tools

EIP.tools

⚠️ DraftStandards Track: Core

EIP-8341: Partial Execution Payload Commitments

Replace the block hash commitment in ePBS builder bids with a partial payload commitment that excludes the state root

Authors
Created2026-07-14
Discussion Linkhttps://ethereum-magicians.org/t/eip-8341-partial-execution-payload-commitments/29030
Requires

Markdown

https://raw.githubusercontent.com/ethereum/EIPs/re...
Pull Request#11936PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationExecution layerEngine APIConsensus layerContainersHelpersBlock processingFork choiceP2PHonest validatorHonest builderFork transitionRationaleWhat the bid still commits toMasking instead of a bespoke commitment containerAn SSZ commitment rather than an execution layer constructChain linking without a block hash in the bidRelation to Delayed State RootRelation to slot auctionsBackwards CompatibilitySecurity ConsiderationsBinding equivalence for valid payloadsEnvelope equivocation on the uncommitted fieldsEnforcement moves from the state transition to fork choicePayload Timeliness CommitteeCopyright

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: Root

Helpers

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_hash
Modified 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_FULL
Modified 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_hash
Modified 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_hash

When 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, and gas_used (via the payload commitment),
  • fee_recipient, extra_data, base_fee_per_gas, blob_gas_used, excess_blob_gas, and every other payload field except state_root and block_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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth