--- eip: 8341 title: Partial Execution Payload Commitments description: Replace the block hash commitment in ePBS builder bids with a partial payload commitment that excludes the state root author: Toni Wahrstätter (@nerolation), Francesco Risitano (@frisitano) discussions-to: https://ethereum-magicians.org/t/eip-8341-partial-execution-payload-commitments/29030 status: Draft type: Standards Track category: Core created: 2026-07-14 requires: 7732 --- ## Abstract Under [EIP-7732](./eip-7732.md) (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](./eip-7862.md) (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](https://github.com/ethereum/consensus-specs/blob/6408b990c0b4e58d4f50ae49b59384e82e5e06d8/specs/gloas/beacon-chain.md), [fork choice](https://github.com/ethereum/consensus-specs/blob/6408b990c0b4e58d4f50ae49b59384e82e5e06d8/specs/gloas/fork-choice.md), [p2p](https://github.com/ethereum/consensus-specs/blob/6408b990c0b4e58d4f50ae49b59384e82e5e06d8/specs/gloas/p2p-interface.md), [honest validator](https://github.com/ethereum/consensus-specs/blob/6408b990c0b4e58d4f50ae49b59384e82e5e06d8/specs/gloas/validator.md), and [honest builder](https://github.com/ethereum/consensus-specs/blob/6408b990c0b4e58d4f50ae49b59384e82e5e06d8/specs/gloas/builder.md) specifications implementing EIP-7732. #### Containers ##### Modified `ExecutionPayloadBid` The `block_hash` field is replaced by `payload_commitment`. ```python 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` ```python 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` ```python 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. ```python 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. ```python 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` ```python 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. ```python # 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`. ```python 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. ```python 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 Copyright and related rights waived via [CC0](../LICENSE.md).