---
eip: 8025
title: Optional Execution Proofs
description: Introducing optional execution proofs on the consensus layer
author: Kevaundray Wedderburn (@kevaundray), Justin Drake (@JustinDrake) <justin@ethereum.org>, Ignacio Hagopian (@jsign), Han (@han0110), Francesco Risitano (@frisitano), Cody Gunton (@codygunton)
discussions-to: https://ethereum-magicians.org/t/eip-optional-execution-proofs/25500
status: Draft
type: Standards Track
category: Core
created: 2025-09-17
requires: 4844, 6110, 7002, 7251, 7688, 7732, 7928, 8282
---

## Abstract

Execution proofs enable consensus layer (CL) nodes to perform stateless validation of execution payloads. Altruistic _proof-generating nodes_ generate execution proofs and broadcast them over the CL p2p network. _Proof-verifying nodes_ consume gossiped execution proofs and check payload validity by verifying those proofs. Verification is _stateless_ and constant time with respect to the payload's gas limit and EL state size, so a validator's payload-verification requirements are decoupled from the linear cost of re-execution. The mechanism is fully opt-in and does not change consensus validity rules, allowing the proving stack to mature in production; a separate, future EIP could subsequently propose making execution proofs mandatory once they have matured. This EIP does not introduce incentives for proof-generating nodes; for that reason, they are considered altruistic and the mechanism is opt-in.

## Motivation

Today, verifying a beacon block requires re-execution of its execution payload against the execution layer (EL) state — the full set of account and storage trie data. The cost of re-execution scales linearly with the gas limit. This couples a node's resource requirements to the state size and the chain's throughput.

This EIP introduces an opt-in path with the following properties:

- **Constant-time payload verification.** Once an execution proof is available, a proof-verifying node checks payload validity by verifying the proof. Proof verification is constant time with respect to the payload's gas limit and EL state size.
- **Stateless verification.** Verifying an execution proof requires only the proof itself and a public commitment to the payload — not the EL state.
- **Broader verifier participation.** Constant-time, stateless payload verification lowers the resource floor for proof-verifying validators, keeping payload-validity checks tractable for participants with modest hardware and expertise even as the gas limit and state grow.
- **Optional by design.** Deploying execution proofs as opt-in lets clients and operators build operational experience, collect performance metrics, and fine-tune the proving stack against live-network conditions without enshrining untested mechanics in the protocol or impacting validators that have not opted in.

## 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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174).

### Terminology

This EIP uses the following proof-system terms:

- **Proof system.** The cryptographic system used to prove that a computation was performed correctly. Different proof systems may have different proof formats, verifier logic, proving costs, and security assumptions.

- **Prover.** A node that runs the execution-validation computation and generates an execution proof for the result. In this EIP, provers are opt-in, altruistic proof-generating validators.

- **Verifier.** A node that checks an execution proof instead of re-executing payload validation.

- **Guest.** The program whose execution is being proven. Here, the guest program performs stateless validation of an execution payload.

- **Host.** The off-chain logic that prepares the guest input, runs the guest inside the proof system, and packages the resulting proof.

- **Private input.** Data supplied to the guest by the prover but not revealed to verifiers. In this EIP, the serialised `StatelessInput` is private input: it contains the data needed to run stateless payload validation.

- **Execution Witness.** The execution data within the private input that lets the guest validate the payload without the verifier holding the full EL state.

- **Public input.** The data committed to by the proof and visible to verifiers. In this EIP, the public input binds the proof to a specific `NewPayloadRequest`, chain configuration, and validation result.

- **Proof node.** An external component that performs proof-system-specific work such as proof generation and proof verification.

- **Proof engine.** The implementation-dependent consensus-client protocol that encapsulates proof-subsystem state, generation, and verification. `PROOF_ENGINE` is the reference used in the consensus specifications for the client-provided instance of `ProofEngine`.

- **Proof-aware peer.** A consensus-layer peer that advertises optional execution-proof support and participates in the execution-proof networking protocols for its supported proof types.

- **Server-Sent Events (SSE).** An HTML standard for one-way, long-lived HTTP event streams from server to client. In this EIP, SSE is used in two places: by the beacon node to push `Block` events to the prover, and by the proof node to push proof-completion events to the prover.

### Specification references

The normative specifications for this EIP are maintained alongside the rest of the protocol in the consensus-specs and execution-specs repositories. The sections below summarise the changes; the canonical sources are:

- [Consensus Layer](https://github.com/ethereum/consensus-specs/tree/031e521f0a82b0e475ecfc13f79dd251a6284fc2/specs/_features/eip8025)
- Execution Layer:
    - [Stateless validation interfaces](https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless.py)
    - [Guest program](https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_guest.py)
    - [Host-side input assembly](https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_host.py)
    - [Execution witness construction](https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py)
    - [SSZ serialisation schema](https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_ssz.py)
    - [Conformance tests](https://github.com/ethereum/execution-specs/tree/85fc20ca5937719a854472a87cb48d01ef1dffca/tests/amsterdam/eip8025_optional_proofs)

### Consensus Layer

This EIP introduces a new `execution_proof` gossip topic on the
consensus-layer p2p network carrying `SignedExecutionProof` messages,
and a `ProofEngine` interface that beacon nodes use to generate and
verify execution proofs. Proof generation and
verification are delegated to an external proof node; the `ProofEngine`
is the in-client component that mediates communication with it. A node
MAY enable either or both of:

- **Proof-generating mode.** An active validator that opts in to act as
  a _prover_ — generates execution proofs and broadcasts them on the
  `execution_proof` gossip topic.
- **Proof-verifying mode.** A node that consumes gossiped proofs and
  treats a verified proof as a supplementary validity signal for the
  corresponding payload's validity. Proof verification does not require local
  EL state. Proof-verifying nodes still re-execute payloads themselves;
  verified proofs are an additional signal, not a replacement for
  re-execution. This separation lets clients deploy and measure the
  proving stack against live-network conditions without making
  fork-choice dependent on it. A subsequent EIP could propose promoting
  verified proofs to a load-bearing role and dropping re-execution once
  the stack has matured.

The following diagram summarises how proofs move between the two roles:

![Execution-proof flow between proof-generating and proof-verifying nodes via the `execution_proof` gossip topic](../assets/eip-8025/proof-flow.png)

At a high level, the proof lifecycle is:

1. A prover reacts to a new beacon block, constructs the corresponding
   `NewPayloadRequest`, and calls
   `proof_engine.request_proofs(new_payload_request, ProofAttributes(proof_types))`
   to initiate proof generation for its configured `proof_types` on the proof node.
2. The proof node produces an `ExecutionProof` for each requested type.
   The prover signs each proof, wraps it in a `SignedExecutionProof`, and
   broadcasts it on the `execution_proof` topic.
3. Proof-verifying peers receive the gossip message, run the gossip
   validation rules, then call `proof_engine.verify_execution_proof` on
   the inner `ExecutionProof` to confirm payload validity.

This lifecycle uses the execution-validation window introduced by
[EIP-7732](./eip-7732.md), which defers execution validation until the next
beacon block and provides additional time for proof generation.

**Sync support requirement.** Independent of the request/produce/verify
loop above, every proof-aware node MUST retain valid proofs for
canonical-chain blocks back to the finalised checkpoint so that peers
syncing or filling gaps can backfill them via `ExecutionProofsByRange`
and `ExecutionProofsByRoot` (see [Req/resp domain](#reqresp-domain) for
the protocol details).

#### Proof types and containers

The consensus layer introduces a small set of new types and constants to
identify proofs, bound their size, and route gossip signatures:

| Name                               | Value                      |
| ---------------------------------- | -------------------------- |
| `ProofType`                        | `uint8`                    |
| `MAX_PROOF_SIZE`                   | `409600` (= 400 KiB)       |
| `MAX_EXECUTION_PROOFS_PER_PAYLOAD` | `uint64(4)`                |
| `DOMAIN_EXECUTION_PROOF`           | `DomainType('0x0D000000')` |

`MAX_PROOF_SIZE` is set at `400 KiB` as a network-bandwidth budget: at the
gossip target of one proof per `(payload, proof_type)` and the cap of
`MAX_EXECUTION_PROOFS_PER_PAYLOAD = 4` proof types per payload, the
worst-case in-flight proof bandwidth per slot is `4 × 400 KiB = 1.6 MiB`
— of a similar order to existing per-slot blob-sidecar traffic. This
bounds steady-state traffic. Provers using systems that naturally
produce larger proofs are expected to apply proof compression
(recursion, succinct wrappers) before gossiping.

Peers advertise their supported proof types via
`ExecutionProofStatus` (see [Req/resp domain](#reqresp-domain)). The set
of `proof_types` a node speaks is a per-node, dynamic configuration; new
proof systems can be added by socialising a new `ProofType` value
out-of-band and rolling it out to consenting operators. Proof-system
support is local, opt-in configuration and does not change consensus
validity rules. Peers discover which proof types overlap during their
`ExecutionProofStatus` handshake and route requests accordingly.

The top-level gossip message is the `SignedExecutionProof`:

```python
class SignedExecutionProof(Container):
    message: ExecutionProof
    validator_index: ValidatorIndex
    signature: BLSSignature
```

Its inner `ExecutionProof` couples a serialised proof blob to the proof
system and guest program it was generated against, and to the public
input that identifies the payload it certifies:

```python
class ExecutionProof(Container):
    proof_data: ByteList[MAX_PROOF_SIZE]
    proof_type: ProofType
    public_input: PublicInput


class PublicInput(Container):
    new_payload_request_root: Root
    successful_validation: bool
    chain_config: ChainConfig
```

The fields play the following roles:

- `proof_data` is the proof system's serialised output. It is the only field
  whose size scales with the prover output and is bounded by
  `MAX_PROOF_SIZE`.
- `proof_type` selects which proof system and guest program the verifier dispatches to.
- `public_input.new_payload_request_root` is the SSZ `hash_tree_root` of the
  Engine API `NewPayloadRequest` whose execution this proof certifies. It is
  the link between a proof and a specific execution payload; verifiers use
  it to bind the proof to the payload they are verifying.
- `public_input.successful_validation` is `True` if the guest program
  accepted the payload as valid. Verifiers MUST check this field is
  `True` before treating the proof as a positive validity signal.
- `public_input.chain_config` pins the chain ID and fork configuration the
  guest was run against, preventing cross-chain replay and fork
  mismatch. `ChainConfig` is defined in the
  [Execution Layer](#execution-layer) section.
- `validator_index` and `signature` identify the prover and prove that they
  signed the proof under `DOMAIN_EXECUTION_PROOF`, allowing peers to
  attribute invalid proofs to a specific active validator.

#### Proof engine interface

The `ProofEngine` interface encapsulates the proof-system-specific logic
so that the consensus layer stays agnostic of which proof system is in
use. It acts as a payload-validity oracle for the beacon node, and its
API shape is modelled on the Engine API to allow for seamless
integration into the protocol. The `ProofEngine` is realised as an
in-client component that maintains its own proof state and delegates
the cryptographic work of proof generation and verification to an
external proof node, mirroring the way the `ExecutionEngine` delegates
payload execution to the EL client. The
interface comprises four operations and a `ProofAttributes` type:

```python
def verify_execution_proof(
    self: ProofEngine,
    execution_proof: ExecutionProof,
) -> bool: ...


def notify_new_payload(
    self: ProofEngine,
    new_payload_request: NewPayloadRequest,
) -> None: ...


def notify_forkchoice_updated(
    self: ProofEngine,
    head_block_hash: Hash32,
    safe_block_hash: Hash32,
    finalized_block_hash: Hash32,
) -> None: ...


@dataclass
class ProofAttributes:
    proof_types: Sequence[ProofType]


def request_proofs(
    self: ProofEngine,
    new_payload_request: NewPayloadRequest,
    proof_attributes: ProofAttributes,
) -> Root: ...
```

These operations decompose into three concerns:

- **Verification** (`verify_execution_proof`) is called on every incoming
  proof that passes gossip validation. The engine performs the
  proof-system-specific cryptographic verification and binds the result
  to the specific payload identified by
  `proof.public_input.new_payload_request_root`. A payload is considered
  proof-verified once `k` valid `SignedExecutionProof`s for it have been
  verified. The value of `k` will be pinned before this EIP transitions
  to `Review`; it is intentionally left open while the network gathers
  real-world data.
  Peers exchange their current proof-verification view via
  `ExecutionProofStatus`.
- **Lifecycle notifications** (`notify_new_payload`,
  `notify_forkchoice_updated`) keep the engine in sync with the state
  of the beacon node. `notify_new_payload` lets the engine associate
  incoming proofs with the payloads they certify;
  `notify_forkchoice_updated` lets it track the canonical chain to
  drive proof retention and pruning.
- **Generation** (`request_proofs`) is used by proof-generating nodes to
  trigger asynchronous proof generation for a given payload and set of
  proof types. The return value is `new_payload_request.hash_tree_root()`,
  which the prover uses as a request id to correlate the proof-completion
  SSE event the proof node emits when a proof is ready to be fetched.

#### Beacon-chain state transition

EIP-8025 extends `process_block` to forward execution payload processing to
both the execution engine and the proof engine, and introduces a new
`process_execution_proof` handler for incoming proofs.

`process_block` is modified to pass `PROOF_ENGINE` to
`process_execution_payload`:

```python
def process_block(state: BeaconState, block: BeaconBlock) -> None:
    process_block_header(state, block)
    process_withdrawals(state, block.body.execution_payload)
    # [Modified in EIP8025]
    process_execution_payload(state, block.body, EXECUTION_ENGINE, PROOF_ENGINE)
    process_randao(state, block.body)
    process_eth1_data(state, block.body)
    process_operations(state, block.body)
    process_sync_aggregate(state, block.body.sync_aggregate)
```

`process_execution_payload` is modified to additionally notify the proof
engine of the new payload after the execution engine has accepted it.
This hook lets the proof engine learn of each new payload so it
can associate it with the proofs that certify it as they arrive over
gossip:

```python
def process_execution_payload(
    state: BeaconState,
    body: BeaconBlockBody,
    execution_engine: ExecutionEngine,
    proof_engine: ProofEngine,
) -> None:
    ...
    # Verify the execution payload is valid via ExecutionEngine
    assert execution_engine.verify_and_notify_new_payload(
        NewPayloadRequest(...)
    )

    # [New in EIP8025]
    # Notify ProofEngine of the new execution payload
    proof_engine.notify_new_payload(NewPayloadRequest(...))
    ...
```

A new `process_execution_proof` handles a `SignedExecutionProof` once it has
passed gossip validation. It re-checks that the prover is active in the
current state, verifies the BLS signature under `DOMAIN_EXECUTION_PROOF`,
and delegates cryptographic verification to the proof engine:

```python
def process_execution_proof(
    state: BeaconState,
    signed_proof: SignedExecutionProof,
    proof_engine: ProofEngine,
) -> None:
    proof_message = signed_proof.message

    # Verify prover is an active validator
    validator = state.validators[signed_proof.validator_index]
    assert is_active_validator(validator, get_current_epoch(state))

    domain = get_domain(state, DOMAIN_EXECUTION_PROOF, compute_epoch_at_slot(state.slot))
    signing_root = compute_signing_root(proof_message, domain)
    assert bls.Verify(validator.pubkey, signing_root, signed_proof.signature)

    # Verify the execution proof
    assert proof_engine.verify_execution_proof(proof_message)
```

`process_execution_proof` is invoked outside the beacon-block state-transition
function and produces no on-chain state change: it is the operational hook
that lets a node decide whether to treat a gossiped proof as a valid
validity signal for an execution payload. Proof storage is managed by the
`ProofEngine`; retention is bounded by `proof_serve_range` (see
[Req/resp domain](#reqresp-domain)): clients MUST retain proofs for
canonical-chain blocks
back to the finalised checkpoint so they can serve `ExecutionProofsByRange`
and `ExecutionProofsByRoot` requests.

#### Proof gossip

Execution proofs are gossiped over a new global topic, `execution_proof`,
carrying `SignedExecutionProof` messages. Each rule below is a libp2p
gossipsub validation rule, following the same `[IGNORE]` / `[REJECT]`
conventions used by the other beacon-node gossip topics. With
`proof = signed_execution_proof.message`:

- _[IGNORE]_ the proof's `new_payload_request_root` has been seen via
  gossip or non-gossip sources (clients MAY queue proofs until the
  corresponding payload arrives).
- _[IGNORE]_ no _valid_ proof has already been received for the tuple
  `(new_payload_request_root, proof_type)` — i.e. only the first valid
  proof of each type per payload is forwarded.
- _[IGNORE]_ this is the first proof received for the tuple
  `(new_payload_request_root, proof_type, validator_index)` — i.e. each
  prover gets one shot per `(payload, proof_type)`.
- _[REJECT]_ the validator at `validator_index` is an active validator.
- _[REJECT]_ the BLS signature is valid for the prover's public key under
  `DOMAIN_EXECUTION_PROOF`.
- _[REJECT]_ `proof.proof_data` is non-empty and no larger than
  `MAX_PROOF_SIZE`.
- _[REJECT]_ all of the checks in `process_execution_proof` pass.

These rules compose with the existing gossipsub peer-scoring machinery: a
peer relaying messages that hit `[REJECT]` is downscored by the same
mechanism that scores misbehaviour on any other CL gossip topic. The
`[IGNORE]` rules bound steady-state bandwidth — once a node has a valid
proof for a given `(new_payload_request_root, proof_type)`, additional
proofs for the same tuple are dropped silently, regardless of who signs
them.

#### Req/resp domain

Three req/resp protocols let peers backfill, target, and coordinate proof
state:

- **`ExecutionProofsByRange`** — request `(start_slot, count, proof_types)`,
  response `List[SignedExecutionProof]`. Mirrors `BlobSidecarsByRange` and
  `DataColumnSidecarsByRange`: range-based, root-unaware, filtered by proof
  type. Used for bulk backfill of proofs after a syncing window.
- **`ExecutionProofsByRoot`** — request `List[ProofByRootIdentifier]` where
  each identifier carries `(block_root, List[proof_type])`, response
  `List[SignedExecutionProof]`. Used for targeted retrieval when a node
  knows the specific block roots whose proofs it is missing.
- **`ExecutionProofStatus`** — request and response share the same
  container: `(block_root, slot, proof_types)`. Peers exchange the most
  recent block they consider proof-verified, plus their dynamically
  advertised set of supported proof types. The dialing peer MUST send a
  status on first connecting to any proof-aware peer. This is similar in
  nature to the beacon-chain
  [`Status v2`](https://github.com/ethereum/consensus-specs/blob/031e521f0a82b0e475ecfc13f79dd251a6284fc2/specs/fulu/p2p-interface.md#status-v2)
  handshake.

Clients MUST serve both range and root requests for any block on the
canonical chain whose slot is in `proof_serve_range` — defined as
`[finalized_checkpoint.slot, current_slot]`. Peers unable to reply respond
with `3: ResourceUnavailable` and MAY be descored. The shape of these
protocols composes with existing sync flows: a node syncing forward over
blocks pulls proofs by range; a node patching a specific gap pulls them by
root.

#### Discovery

Discovery is extended with a new Ethereum Node Record (ENR) key, `eproof`, encoded as a `uint8`. A
node is considered proof-aware if the field is present and non-zero,
allowing other peers to filter for proof-aware peers during discovery. This
mirrors the existing pattern used to advertise data-column awareness in
Fulu via the
[`cgc` ENR field](https://github.com/ethereum/consensus-specs/blob/031e521f0a82b0e475ecfc13f79dd251a6284fc2/specs/fulu/p2p-interface.md#the-discovery-domain-discv5)
and is intentionally cheap: no fork or fork-version bump is required for a
node to start (or stop) advertising proof support.

#### Prover lifecycle

The prover lifecycle is illustrated below:

![Prover lifecycle: SSE-driven flow between beacon node, prover, and proof node](../assets/eip-8025/prover-lifecycle.png)

A prover's flow, as detailed in the prover guide referenced above, is
fully event-driven:

1. **Subscribe to two SSE event streams** at startup:
   - `Block` events from the beacon node, signalling that a new valid beacon
     block has been received.
   - Proof-completion events from the proof node.
2. **On a `Block` event**, fetch the full `BeaconBlock` via RPC, construct
   the corresponding `NewPayloadRequest`, choose the `ProofAttributes`
   (set of `proof_types` the prover wants to generate), and call
   `proof_engine.request_proofs(...)`. The returned root is used to track
   the request.
3. **On a proof-completion event** matching a tracked root, fetch the
   completed `ExecutionProof` from the proof node, sign it under
   `DOMAIN_EXECUTION_PROOF`, wrap it in a `SignedExecutionProof`, and
   broadcast on the `execution_proof` gossip topic.

The signing helper is:

```python
def get_execution_proof_signature(
    state: BeaconState, proof: ExecutionProof, privkey: int
) -> BLSSignature:
    domain = get_domain(state, DOMAIN_EXECUTION_PROOF, compute_epoch_at_slot(state.slot))
    signing_root = compute_signing_root(proof, domain)
    return bls.Sign(privkey, signing_root)
```

The prover role is scoped to the optional-proof phase. If a future EIP
were to make execution proofs mandatory, proof generation would
plausibly move into block production, at which point the prover role
and the optional gossip topic could be deprecated in favour of
in-block proof commitments.

### Execution Layer

This EIP introduces a stateless execution guest and prover-side logic that
constructs the guest input. The prover runs the guest over private
`StatelessInput` bytes. The proof exposes `StatelessValidationResult` as public
output. This output binds the proof to the payload request and execution schema.

A proof-verifying node accepts the execution-layer result only if:

- the proof verifies for the expected guest program.
- `successful_validation` is `true`.
- `new_payload_request_root` equals the SSZ `hash_tree_root` of the
  `NewPayloadRequest` associated with the payload being validated.
- `chain_id` and `schema_id` match the values expected by the verifier.

At a high level, proof generation follows this flow:

1. A host, usually a stateful execution-layer client, executes or constructs
   the block while recording the account, storage, code, and ancestor-header
   data used during execution.
2. The host builds an `ExecutionWitness` and combines it with the Engine API
   request, chain ID, and transaction public keys.
3. The host serializes this `StatelessInput` as schema-prefixed SSZ bytes.
4. A prover runs the guest program on the serialized `StatelessInput`.
5. The guest validates the witness and executes the payload against
   witness-backed state. It then returns `StatelessValidationResult`.

#### Stateless input and output

The top-level guest input is private prover input:

```python
class StatelessInput:
    new_payload_request: NewPayloadRequest
    witness: ExecutionWitness
    chain_id: U64
    public_keys: Tuple[Bytes, ...]
```

The guest output is public:

```python
class StatelessValidationResult:
    new_payload_request_root: Hash32
    successful_validation: bool
    chain_id: U64
    schema_id: U16
```

At this level:

- `new_payload_request` is the Engine API payload data supplied by the
  consensus layer.
- `witness` is the account, storage, code, and ancestor-header data needed to
  execute the payload without local EL state.
- `chain_id` identifies the chain used during payload validation and execution.
- `public_keys` contains one 65-byte uncompressed secp256k1 public key for
  each transaction signer. The keys use the transaction order from the payload.
  Optimized guests can use these keys to avoid public-key recovery, but
  transaction signatures and supplied public keys MUST still be verified.
- `schema_id` identifies the exact input schema and fork rules that the guest
  executed.

If the guest cannot decode the input, it returns a failed result with sentinel
values. The request root, chain ID, and schema ID are all zero in this result.
If validation fails after decoding, the result retains the decoded request root,
chain ID, and schema ID.

The `NewPayloadRequest` commits to the payload, blob versioned hashes,
`parent_beacon_block_root`, and typed execution requests.
The `versioned_hashes` field carries the versioned hashes introduced by
[EIP-4844](./eip-4844.md). The typed deposit, withdrawal, and consolidation
requests are specified by [EIP-6110](./eip-6110.md),
[EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md), respectively.
Builder deposit and exit requests are specified by
[EIP-8282](./eip-8282.md).
The `block_access_list` field in `ExecutionPayload` is the block-level access
list defined by [EIP-7928](./eip-7928.md).

```python
class NewPayloadRequest:
    execution_payload: ExecutionPayload
    versioned_hashes: Tuple[VersionedHash, ...]
    parent_beacon_block_root: Root
    execution_requests: ExecutionRequests


class ExecutionPayload:
    parent_hash: Hash32
    fee_recipient: Address
    state_root: Root
    receipts_root: Root
    logs_bloom: Bloom
    prev_randao: Bytes32
    block_number: Uint
    gas_limit: Uint
    gas_used: Uint
    timestamp: U256
    extra_data: Bytes
    base_fee_per_gas: Uint
    block_hash: Hash32
    transactions: Tuple[Bytes, ...]
    withdrawals: Tuple[Withdrawal, ...]
    blob_gas_used: U64
    excess_blob_gas: U64
    block_access_list: Bytes
    slot_number: U64
```

The execution requests are represented as typed containers, matching the
consensus-layer specs:

```python
class DepositRequest:
    pubkey: Bytes48
    withdrawal_credentials: Bytes32
    amount: U64
    signature: Bytes96
    index: U64


class WithdrawalRequest:
    source_address: Address
    validator_pubkey: Bytes48
    amount: U64


class ConsolidationRequest:
    source_address: Address
    source_pubkey: Bytes48
    target_pubkey: Bytes48


class BuilderDepositRequest:
    pubkey: Bytes48
    withdrawal_credentials: Bytes32
    amount: U64
    signature: Bytes96


class BuilderExitRequest:
    source_address: Address
    pubkey: Bytes48


class ExecutionRequests:
    deposits: Tuple[DepositRequest, ...]
    withdrawals: Tuple[WithdrawalRequest, ...]
    consolidations: Tuple[ConsolidationRequest, ...]
    builder_deposits: Tuple[BuilderDepositRequest, ...]
    builder_exits: Tuple[BuilderExitRequest, ...]
```

The remaining top-level field defines the witness consumed by the guest:

```python
class ExecutionWitness:
    state: Tuple[Bytes, ...]
    codes: Tuple[Bytes, ...]
    headers: Tuple[Bytes, ...]
```

The `ExecutionWitness` fields have the following roles:

- `state`: RLP-encoded account and storage trie-node preimages needed during
  execution and state-root recomputation.
- `codes`: bytecodes required for code reads from the pre-state. Code created
  in the block being executed is not included because the guest observes it
  during re-execution.
- `headers`: RLP-encoded parent and ancestor headers, ordered by block number
  and ending at the payload parent. These headers provide the parent state root
  and the recent block hashes used by `BLOCKHASH` and system-contract logic.
  The guest checks that the headers form a contiguous chain.

#### Guest validation

The guest entry point decodes schema-prefixed SSZ input and runs stateless
payload validation. It always serializes a result, including for invalid input.

```python
def _default_failed_stateless_output() -> StatelessValidationResult:
    return StatelessValidationResult(
        new_payload_request_root=Hash32(b"\0" * 32),
        successful_validation=False,
        chain_id=U64(0),
        schema_id=U16(0),
    )


def run_stateless_guest(input_bytes: Bytes) -> Bytes:
    try:
        stateless_input = deserialize_stateless_input(input_bytes)
    except Exception:
        stateless_output = _default_failed_stateless_output()
    else:
        stateless_output = verify_stateless_new_payload(stateless_input)
    return serialize_stateless_output(stateless_output)
```

The main validation path first computes the public payload-request commitment.
It then validates the header witness and creates a `WitnessState`. Finally, it
uses the same `new_payload` path as the execution engine.

The reference implementation has one implementation for each fork. Thus, its
Amsterdam guest does not compare the payload timestamp with fork activation
data. Production implementations MUST perform this comparison.

```python
def compute_new_payload_request_root(
    stateless_input: StatelessInput,
) -> Hash32:
    ssz_npr = _new_payload_request_to_ssz(stateless_input.new_payload_request)
    return Hash32(ssz_npr.hash_tree_root())


def validate_headers(
    encoded_headers: Tuple[Bytes, ...],
) -> Tuple[List[Header | PreviousForkHeader], List[Hash32]]:
    assert len(encoded_headers) <= 256
    headers = [_decode_header(header) for header in encoded_headers]
    block_hashes = [keccak256(header) for header in encoded_headers]
    for i in range(1, len(headers)):
        if headers[i].parent_hash != block_hashes[i - 1]:
            raise Exception("Witness headers are not contiguous")
    return headers, block_hashes


def verify_stateless_new_payload(
    stateless_input: StatelessInput,
) -> StatelessValidationResult:
    new_payload_request_root = compute_new_payload_request_root(
        stateless_input
    )
    witness = stateless_input.witness

    try:
        decoded_headers, block_hashes = validate_headers(witness.headers)
        parent_header = decoded_headers[-1]

        chain_context = ChainContext(
            chain_id=stateless_input.chain_id,
            block_hashes=block_hashes,
            parent_header=parent_header,
        )

        pre_state = WitnessState(
            _node_db=build_node_db(witness.state),
            _state_root=parent_header.state_root,
            _code_db=build_code_db(witness.codes),
        )

        execute_new_payload_request(
            stateless_input.new_payload_request,
            pre_state,
            chain_context,
            transaction_public_keys=stateless_input.public_keys,
        )
        successful_validation = True
    except Exception:
        successful_validation = False

    return StatelessValidationResult(
        new_payload_request_root=new_payload_request_root,
        successful_validation=successful_validation,
        chain_id=stateless_input.chain_id,
        schema_id=U16(STATELESS_INPUT_SCHEMA_ID),
    )
```

`WitnessState` is the stateless replacement for a local pre-state database. It
serves account, storage, and code reads from the witness and computes the
post-state root from the execution diff:

```python
class WitnessState:
    _node_db: Dict[Bytes, Bytes]
    _state_root: Root
    _code_db: Dict[Hash32, Bytes]

    def get_account_optional(self, address: Address) -> Optional[Account]: ...
    def get_storage(self, address: Address, key: Bytes32) -> U256: ...
    def get_code(self, code_hash: Hash32) -> Bytes: ...

    def compute_state_root_and_trie_changes(
        self,
        account_changes: Dict[Address, Optional[Account]],
        storage_changes: Dict[Address, Dict[Bytes32, U256]],
    ) -> Tuple[Root, List[InternalNode]]:
        ...
```

The payload execution logic performs the normal payload checks before
executing the block:

```python
def execute_new_payload_request(
    new_payload_request: NewPayloadRequest,
    pre_state: PreState,
    chain_context: ChainContext,
    transaction_public_keys: Optional[Tuple[Bytes, ...]] = None,
) -> Tuple[BlockDiff, Block]:
    payload = new_payload_request.execution_payload

    if b"" in payload.transactions:
        raise InvalidBlock("Empty transaction in payload")
    if not is_valid_block_hash(
        payload,
        new_payload_request.parent_beacon_block_root,
        new_payload_request.execution_requests,
    ):
        raise InvalidBlock("Invalid block hash")
    if not is_valid_versioned_hashes(new_payload_request):
        raise InvalidBlock("Invalid versioned hashes")

    block = _payload_block(
        payload,
        new_payload_request.parent_beacon_block_root,
        new_payload_request.execution_requests,
    )
    block_diff = execute_block(
        block,
        pre_state,
        chain_context,
        transaction_public_keys=transaction_public_keys,
    )
    return block_diff, block
```

During transaction processing, supplied public keys are verified before deriving
sender addresses:

```python
def recover_sender_from_public_key(
    chain_id: U64,
    tx: Transaction,
    public_key: Bytes,
) -> Address:
    if public_key != recover_transaction_public_key(chain_id, tx):
        raise InvalidSignatureError
    return _sender_address_from_public_key(public_key)
```

Optimized guests can avoid full public-key recovery. They MUST still verify that
the supplied key validates the transaction signature. The key MUST also match
the recovery ID or y-parity bit.

#### Host-side input construction

On the host side, `build_stateless_input` receives the data gathered during
block execution or construction and packages them for the guest:

```python
def build_stateless_input(
    block: Block,
    *,
    execution_witness: ExecutionWitness,
    execution_requests: ExecutionRequests,
    block_access_list: BlockAccessList,
    chain_id: U64,
) -> StatelessInput:
    ...

    payload = ExecutionPayload(
        ...,
        block_access_list=Bytes(rlp.encode(block_access_list)),
        slot_number=header.slot_number,
    )

    new_payload = NewPayloadRequest(
        execution_payload=payload,
        versioned_hashes=tuple(versioned_hashes),
        parent_beacon_block_root=header.parent_beacon_block_root,
        execution_requests=execution_requests,
    )

    return StatelessInput(
        new_payload_request=new_payload,
        witness=execution_witness,
        chain_id=chain_id,
        public_keys=tuple(public_keys),
    )
```

The execution witness is built from the block-level read/write tracker and
pre-state trie data. ELs already use this tracker to construct block access
lists (BALs). Conceptually, witness construction:

1. collects the parent header and any older ancestors touched by `BLOCKHASH`.
2. collects pre-state bytecode reads.
3. captures account and storage trie nodes needed for all reads and writes.
4. captures potential sibling nodes that branch compression requires during
   post-state root calculation.

```python
def build_execution_witness(
    block_state: BlockState,
    expected_post_state_root: Root,
    pre_state_accounts_data: Trie[Address, Optional[Account]],
    pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]],
    blockchain_headers: Optional[List[Bytes]] = None,
) -> ExecutionWitness:
    ancestor_headers = get_witness_ancestors(
        blockchain_headers if blockchain_headers is not None else [],
        block_state.oldest_ancestor_offset,
    )
    codes = get_witness_codes(block_state.code_reads, block_state.pre_state)

    incr_storage_mpts = _build_pre_state_storage_mpts(pre_state_storages_data)
    incr_account_mpt = _build_pre_state_account_mpt(
        pre_state_accounts_data, incr_storage_mpts
    )

    all_storage_accesses = _collect_storage_accesses(block_state)
    _capture_pre_state_storage_nodes(incr_storage_mpts, all_storage_accesses)
    _apply_storage_writes(incr_storage_mpts, block_state.storage_writes)

    all_dirty_accounts = _get_all_dirty_accounts(block_state)
    _capture_pre_state_account_nodes(
        incr_account_mpt,
        block_state.account_reads,
        all_dirty_accounts,
    )
    _apply_account_writes(
        incr_account_mpt,
        incr_storage_mpts,
        block_state,
        all_dirty_accounts,
    )

    assert mpt_root(incr_account_mpt) == expected_post_state_root

    accessed_nodes = _collect_accessed_nodes(
        incr_account_mpt, incr_storage_mpts
    )

    return ExecutionWitness(
        state=tuple(sorted(accessed_nodes.values())),
        codes=tuple(codes),
        headers=tuple(ancestor_headers),
    )
```

#### SSZ encoding

The host serializes a `StatelessInput` by prefixing the SSZ bytes with a
two-byte schema ID. The high byte identifies the fork, and the low byte
identifies the schema revision. Amsterdam revision 1 uses schema ID `0x1501`.

```python
class ProtocolFork(IntEnum):
    ...
    Amsterdam = 0x15


STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam
STATELESS_INPUT_SCHEMA_REVISION = 0x01
STATELESS_INPUT_SCHEMA_ID = (
    STATELESS_INPUT_SCHEMA_FORK_INDEX << 8
) | STATELESS_INPUT_SCHEMA_REVISION
STATELESS_INPUT_SCHEMA_ID_SIZE = 2
STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes(
    STATELESS_INPUT_SCHEMA_ID_SIZE,
    "big",
)


def serialize_stateless_input(
    stateless_input: StatelessInput,
) -> Bytes:
    ssz_obj = stateless_input_to_ssz(stateless_input)
    return Bytes(
        STATELESS_INPUT_SCHEMA_ID_BYTES + bytes(ssz_obj.encode_bytes())
    )


def deserialize_stateless_input(data: Bytes) -> StatelessInput:
    if len(data) < STATELESS_INPUT_SCHEMA_ID_SIZE:
        raise ValueError("Stateless input is missing schema id")
    schema_id = int.from_bytes(
        data[:STATELESS_INPUT_SCHEMA_ID_SIZE],
        "big",
    )
    if schema_id != STATELESS_INPUT_SCHEMA_ID:
        raise ValueError(
            f"Unsupported stateless input schema id: 0x{schema_id:04x}"
        )
    ssz_obj = SSZStatelessInput.decode_bytes(
        data[STATELESS_INPUT_SCHEMA_ID_SIZE:]
    )
    return ssz_to_stateless_input(ssz_obj)
```

The guest output is an SSZ-encoded `SSZStatelessValidationResult`. Its
`schema_id` field reports the exact input schema that the guest executed.

The progressive containers and lists follow [EIP-7688](./eip-7688.md).
Progressive lists and progressive byte lists do not use fixed capacity
constants. The schema keeps these fixed bounds and vector sizes:

| Bound | Value |
| - | - |
| `MAX_EXTRA_DATA_BYTES` | `32` |
| `MAX_WITNESS_HEADERS` | `256` |
| `MAX_BYTES_PER_CODE` | `2**16` |
| `MAX_BYTES_PER_HEADER` | `2**10` |
| `MAX_BYTES_PER_WITNESS_NODE` | `2**10` |
| `PUBLIC_KEY_BYTES` | `65` |

The SSZ schema mirrors the dataclasses:

```python
class SSZWithdrawal(Container):
    index: uint64
    validator_index: uint64
    address: ByteVector[20]
    amount: uint64


class SSZExecutionPayload(
    ProgressiveContainer(active_fields=[1] * 19)
):
    parent_hash: Bytes32
    fee_recipient: ByteVector[20]
    state_root: Bytes32
    receipts_root: Bytes32
    logs_bloom: ByteVector[256]
    prev_randao: Bytes32
    block_number: uint64
    gas_limit: uint64
    gas_used: uint64
    timestamp: uint64
    extra_data: ByteList[MAX_EXTRA_DATA_BYTES]
    base_fee_per_gas: uint256
    block_hash: Bytes32
    transactions: ProgressiveList[ProgressiveByteList]
    withdrawals: ProgressiveList[SSZWithdrawal]
    blob_gas_used: uint64
    excess_blob_gas: uint64
    block_access_list: ProgressiveByteList
    slot_number: uint64


class SSZDepositRequest(Container):
    pubkey: ByteVector[48]
    withdrawal_credentials: Bytes32
    amount: uint64
    signature: ByteVector[96]
    index: uint64


class SSZWithdrawalRequest(Container):
    source_address: ByteVector[20]
    validator_pubkey: ByteVector[48]
    amount: uint64


class SSZConsolidationRequest(Container):
    source_address: ByteVector[20]
    source_pubkey: ByteVector[48]
    target_pubkey: ByteVector[48]


class SSZBuilderDepositRequest(Container):
    pubkey: ByteVector[48]
    withdrawal_credentials: Bytes32
    amount: uint64
    signature: ByteVector[96]


class SSZBuilderExitRequest(Container):
    source_address: ByteVector[20]
    pubkey: ByteVector[48]


class SSZExecutionRequests(
    ProgressiveContainer(active_fields=[1] * 5)
):
    deposits: ProgressiveList[SSZDepositRequest]
    withdrawals: ProgressiveList[SSZWithdrawalRequest]
    consolidations: ProgressiveList[SSZConsolidationRequest]
    builder_deposits: ProgressiveList[SSZBuilderDepositRequest]
    builder_exits: ProgressiveList[SSZBuilderExitRequest]


class SSZNewPayloadRequest(Container):
    execution_payload: SSZExecutionPayload
    versioned_hashes: ProgressiveList[Bytes32]
    parent_beacon_block_root: Bytes32
    execution_requests: SSZExecutionRequests


class SSZExecutionWitness(Container):
    state: ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]]
    codes: ProgressiveList[ByteList[MAX_BYTES_PER_CODE]]
    headers: SSZList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]


class SSZStatelessInput(Container):
    new_payload_request: SSZNewPayloadRequest
    witness: SSZExecutionWitness
    chain_id: uint64
    public_keys: ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]]


class SSZStatelessValidationResult(Container):
    new_payload_request_root: Bytes32
    successful_validation: boolean
    chain_id: uint64
    schema_id: uint16
```

## Rationale

### Path to low-resource validation

This step moves the network measurably closer to low-resource validation. Stateless, constant-time payload verification breaks the coupling between a node's hardware requirements and the gas limit or state size, lowering the floor for who can meaningfully run a node and improving decentralisation.

### Build operational experience

The central design choice is to deploy execution proofs as an opt-in feature. Stateless validation, the witness format, and the proof system itself are maturing, but we need operational experience before making execution proofs load-bearing. Treating execution proofs as a non-critical artefact lets the stack mature on a live network — proof sizes, generation latency, verifier throughput, gossip behaviour, prover diversity — without placing any of that on the path of fork choice or attestation. A bug, outage, or poor parameter choice is contained to the nodes that opted in; it cannot fork the chain or affect validators that did not subscribe.

## Backwards Compatibility

This EIP is fully opt-in and does not change consensus validity rules. Validators that do not enable either mode see no change to their behaviour, their bandwidth, or their attestation duties. Nodes that opt in additionally subscribe to the proof gossip topic, advertise themselves via the `eproof` ENR field, and may run a prover; this affects only their local resource profile.

## Test Cases

Conformance tests are maintained in the canonical specification repositories:

- [Execution Layer](https://github.com/ethereum/execution-specs/tree/85fc20ca5937719a854472a87cb48d01ef1dffca/tests/amsterdam/eip8025_optional_proofs)
- [Consensus Layer](https://github.com/ethereum/consensus-specs/tree/031e521f0a82b0e475ecfc13f79dd251a6284fc2/specs/_features/eip8025)

## Reference Implementation

- [Execution Layer](https://github.com/ethereum/execution-specs/tree/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam)

## Security Considerations

**Gossip surface.** Proofs are carried over a new gossipsub topic with payload size bounded by `MAX_PROOF_SIZE`. Validation, anti-DoS rate limits, and peer scoring for invalid proofs follow the same patterns as other CL gossip topics; a misbehaving peer is bounded and can be downscored.

**Soundness and consensus implications.** This EIP does not wire proof verification into fork choice or any other consensus rule: `process_execution_proof` runs outside the beacon-block state-transition function. A forged proof therefore cannot fork the chain, slash a validator, or otherwise affect consensus state — its effect is bounded to a verifying node's local view of payload validity.

**Liveness and critical-path latency.** Proof generation and verification are off the attestation hot path. Validators must not delay block validation or attestation production while waiting for a proof; if a proof is missing or late, the node attests using the fork choice determined by the execution engine's re-execution of payloads.

**Verifier–prover decentralisation asymmetry.** Stateless, constant-time payload verification lowers the resource floor for validators, but proof generation itself remains demanding: a prover must hold the full EL state used during execution and run a non-trivial proving stack whose cost scales as `O(n log² n)` in the size `n` of the witnessed computation. If the proportion of verifying-only nodes grows faster than the proportion of nodes able to generate proofs, the set of actors who maintain the full EL state and produce proofs may become more concentrated even as overall validator participation broadens.

## Copyright

Copyright and related rights waived via [CC0](../LICENSE.md).
