From 34ec4bc8637af0b503545744f494cd968647bc46 Mon Sep 17 00:00:00 2001 From: Luca Donno Date: Tue, 7 Apr 2026 10:29:08 +0200 Subject: [PATCH 1/3] Native proof verification --- src/SUMMARY.md | 1 + src/customization.md | 2 +- src/execute_precompile.md | 6 +- src/native_verification.md | 379 +++++++++++++++++++++++++++++++++++++ 4 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 src/native_verification.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index cbfda4b..16709ea 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -12,6 +12,7 @@ - [L1 vs L2 diff](./l1_vs_l2_diff.md) - [Open problems](./open_problems.md) - [Customization](./customization.md) +- [Native proof verification](./native_verification.md) - [Proofs](./proofs.md) - [Sharding](./sharding_comparison.md) - [Stacks review](./stacks_review.md) diff --git a/src/customization.md b/src/customization.md index f30a6ae..a93d4f0 100644 --- a/src/customization.md +++ b/src/customization.md @@ -163,4 +163,4 @@ function sendMessage( TODO ## Custom VMs -TODO +Rollups with custom VMs (non-EVM) can use L1's proof verification infrastructure through the [native proof verification](./native_verification.md) proposal. Instead of deploying their own onchain verifier contracts, they submit proof-carrying transactions with their custom guest program's hash. The contract pattern is identical to a native rollup, just with a different `program_hash`. See the [native proof verification](./native_verification.md) page for details. diff --git a/src/execute_precompile.md b/src/execute_precompile.md index 37cec8e..746bc60 100644 --- a/src/execute_precompile.md +++ b/src/execute_precompile.md @@ -304,7 +304,7 @@ contract NativeRollup { The ZK variant replaces the `EXECUTE` precompile with **proof-carrying transactions** and a **`PROOFROOT` opcode**. Instead of re-executing the L2 state transition on L1, the rollup operator generates a ZK proof and the consensus layer validates it. The rollup contract computes the expected commitment onchain and checks it against the proof. -This follows the same EL/CL split pattern as [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob transactions: the EL references a commitment (the `validation_result_root`, a hash of the proof's full public output), and the CL validates the corresponding proof. +This follows the same EL/CL split pattern as [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob transactions: the EL references a commitment (the `validation_result_root`), and the CL validates the corresponding proof. The `validation_result_root` is the `hash_tree_root` of the [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126), the proof's public output, which contains the `new_payload_request_root`, whether validation succeeded, and the `chain_config` used during execution. The specification follows the [stateless execution model](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py) from the execution-specs, the [Block-in-Blobs (EIP-8142)](https://eips.ethereum.org/EIPS/eip-8142) pattern for transaction data availability, and the [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) proof validation infrastructure from the consensus-specs. @@ -428,7 +428,7 @@ The blobs use the existing blob gas market. How to price the proof verification **EL: engine API.** [`is_valid_versioned_hashes`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/execution_engine/new_payload.py#L44) validates that versioned hashes from the CL match blob transaction hashes in the payload. It would need to be updated to also extract `blob_versioned_hashes` from `ProofCarryingTransaction` instances (currently it only handles `BlobTransaction`). Beyond this, no additional engine API changes are needed: the EL does not see or validate the proof, just as it does not see blob contents. -**CL: proof validation.** The consensus layer extracts the `execution_proof` from the proof-carrying transaction's sidecar and validates it using [`proof_engine.verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof). Unlike L1 block proofs, which are delivered as [`SignedExecutionProof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-signedexecutionproof) messages signed by active validators and processed via [`process_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-process_execution_proof), L2 proofs are delivered via the transaction sidecar and do not require a validator signature — the CL calls `verify_execution_proof` directly. The proof's public output must match the `validation_result_root` declared in the transaction body. If the proof is invalid, the L1 block is rejected, analogous to how an invalid KZG proof in a blob sidecar invalidates the block. +**CL: proof validation.** The consensus layer extracts the `execution_proof` from the proof-carrying transaction's sidecar and validates it using [`proof_engine.verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof). Unlike L1 block proofs, which are delivered as [`SignedExecutionProof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-signedexecutionproof) messages signed by active validators and processed via [`process_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-process_execution_proof), L2 proofs are delivered via the transaction sidecar and do not require a validator signature; the CL calls `verify_execution_proof` directly. The proof's public output must match the `validation_result_root` declared in the transaction body. If the proof is invalid, the L1 block is rejected, analogous to how an invalid KZG proof in a blob sidecar invalidates the block. **CL: data availability.** Blob availability is handled by the existing DAS mechanism ([EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) / [EIP-7594](https://eips.ethereum.org/EIPS/eip-7594) PeerDAS). The CL treats proof-carrying transaction blobs identically to any other blobs: they are included in `blob_kzg_commitments`, sampled via DAS, and their versioned hashes are passed to the EL for cross-verification. @@ -501,7 +501,7 @@ contract NativeRollup { uint256 baseFeePerGas; bytes32 blockHash; bytes32 transactionsRoot; - uint256 payloadBlobCount; + uint256 payloadBlobCount; // EIP-8142: number of blobs carrying L2 block data // Unconstrained fields (free operator inputs) address feeRecipient; bytes32 prevRandao; diff --git a/src/native_verification.md b/src/native_verification.md new file mode 100644 index 0000000..92a8d09 --- /dev/null +++ b/src/native_verification.md @@ -0,0 +1,379 @@ +# Native proof verification + + + +**Table of Contents** + +- [Motivation](#motivation) +- [How rollups verify proofs today](#how-rollups-verify-proofs-today) + - [Example: Taiko (multi-verifier)](#example-taiko-multi-verifier) +- [Design overview](#design-overview) +- [Changes to EIP-8025](#changes-to-eip-8025) +- [New EIP: Proof-carrying transactions](#new-eip-proof-carrying-transactions) + - [Transaction format](#transaction-format) + - [Opcodes](#opcodes) + - [Multi-proof](#multi-proof) + - [Proof propagation](#proof-propagation) +- [EVM execution proofs](#evm-execution-proofs) +- [Impact on existing rollups](#impact-on-existing-rollups) +- [Impact on native rollups](#impact-on-native-rollups) +- [Open questions](#open-questions) + + + +## Motivation + +Today, every ZK rollup on Ethereum deploys and maintains its own proof verification infrastructure: onchain verifier contracts for each zkVM it uses, adapter contracts, multi-proof dispatchers, and program whitelisting logic. When a bug is found in a zkVM's verification circuit, each rollup must independently deploy a patched verifier contract and coordinate a governance upgrade to point to it. This is slow, risky, and duplicated across the ecosystem. + +[EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) introduces zkVM proof verification on Ethereum's consensus layer, but only for L1's own purposes: verifying execution payloads to enable stateless validation. It does not change the story for rollups. Rollups still need their own onchain verifier contracts. + +However, the infrastructure that EIP-8025 brings to the CL, the `ProofEngine`, proof gossip, and verification logic, is not inherently L1-specific. If generalized to be program-agnostic and exposed to smart contracts via a new transaction type, any rollup could offload proof verification to the CL. Rollups no longer maintain their own verification infrastructure. When a zkVM implementation needs to be patched, Ethereum client teams release updated software, the same way bugs in execution clients like geth or Nethermind are fixed today: through client releases, without requiring a hard fork. Every rollup using native proof verification benefits from the fix automatically, with no onchain upgrade or governance action needed. + +This is the same principle behind native rollups: just as native rollups inherit L1's execution environment and automatically benefit from EVM upgrades, native proof verification lets any rollup inherit L1's proof verification infrastructure and automatically benefit from zkVM fixes and improvements. + +The proposal has two parts: + +1. **Generalize EIP-8025**: make the CL proof verification infrastructure program-agnostic, so it can verify proofs for any guest program, not just EVM execution. +2. **New EIP**: introduce a proof-carrying transaction type and opcodes that let any smart contract leverage EIP-8025's verification infrastructure, replacing onchain verifier contracts entirely. + +## How rollups verify proofs today + +Each zkVM vendor (SP1, Risc0, etc.) provides a Solidity verifier contract that implements the final proof verification step, typically a Groth16 or Plonk pairing check over BN254. The verifier is a universal circuit: it can verify proofs for any guest program. The program identity is passed as a public input alongside the hash of the program's output. For SP1: + +```solidity +interface ISP1Verifier { + function verifyProof( + bytes32 programVKey, // program hash + bytes calldata publicValues, // program output + bytes calldata proofBytes // the proof + ) external view; +} +``` + +**A note on terminology.** SP1 calls `programVKey` a "verification key", but this is different from the zkVM's own verification key. There are two distinct keys: + +- The **program hash** (called `programVKey` by SP1, `imageId` by Risc0): a `bytes32` identifying the guest program. It is a hash of the compiled binary. Since each zkVM compiles to a different target (e.g. RV32IMA vs RV64IMA), the same source program produces a different program hash per zkVM. In [ERE](https://github.com/eth-act/ere), each backend has its own [`ProgramDigest`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) type (`SP1VerifyingKey`, `Digest`, `ProgramVk`, etc.). A program hash therefore identifies a `(guest program, zkVM)` pair, not just a guest program. +- The **verification key**: the cryptographic key used by the zkVM's proof system to verify proofs. This is a large structured object (polynomial commitments, domain parameters, etc.) that is specific to the zkVM circuit, not to the guest program. In onchain verifier contracts, the verification key is hardcoded as constants. All programs share the same verification key for a given zkVM version. + +This document uses "program hash" for the former and "verification key" for the latter. + +### Example: Taiko (multi-verifier) + +Taiko illustrates the complexity that arises when a rollup uses multiple proof systems. Its verification architecture involves five contracts across three tiers: + +**1. Raw zkVM verifiers.** Taiko deploys both an SP1 Plonk verifier (`SP1Verifier.sol`) and a Risc0 Groth16 verifier (`RiscZeroGroth16Verifier.sol`). These are the vendor-provided universal verifier contracts. + +**2. Taiko-specific adapters.** Each raw verifier is wrapped in an adapter contract that implements Taiko's `IVerifier` interface: + +```solidity +// TaikoSP1Verifier: adapter for SP1 +contract TaikoSP1Verifier is IVerifier { + address public sp1RemoteVerifier; // raw SP1 verifier + mapping(bytes32 => bool) public isProgramTrusted; // whitelisted programs + + function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external view { + bytes32 aggregationProgram = bytes32(_proof[:32]); + bytes32 blockProvingProgram = bytes32(_proof[32:64]); + require(isProgramTrusted[aggregationProgram]); + require(isProgramTrusted[blockProvingProgram]); + + bytes memory publicInputs = buildPublicInputs(_ctxs); + ISP1Verifier(sp1RemoteVerifier).verifyProof( + aggregationProgram, publicInputs, _proof[64:] + ); + } +} + +// Risc0Verifier: adapter for Risc0 (same IVerifier interface) +contract Risc0Verifier is IVerifier { + address public riscoGroth16Verifier; // raw Risc0 verifier + mapping(bytes32 => bool) public isImageTrusted; // whitelisted images + + function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external view { + (bytes memory seal, bytes32 blockImageId, bytes32 aggregationImageId) = + abi.decode(_proof, (bytes, bytes32, bytes32)); + require(isImageTrusted[blockImageId]); + require(isImageTrusted[aggregationImageId]); + + bytes32 journalDigest = sha256(buildPublicInputs(_ctxs)); + IRiscZeroVerifier(riscoGroth16Verifier).verify( + seal, aggregationImageId, journalDigest + ); + } +} +``` + +**3. Multi-verifier dispatcher.** A `ComposeVerifier` contract orchestrates multiple verifiers and enforces that a sufficient set has verified each proof: + +```solidity +contract MainnetVerifier is ComposeVerifier { + address public immutable sgxGethVerifier; // SGX verifier (required) + address public immutable risc0RethVerifier; // Risc0 option + address public immutable sp1RethVerifier; // SP1 option + + function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external { + SubProof[] memory subProofs = abi.decode(_proof, (SubProof[])); + for (uint256 i = 0; i < subProofs.length; ++i) { + IVerifier(subProofs[i].verifier).verifyProof(_ctxs, subProofs[i].proof); + } + require(areVerifiersSufficient(verifiers)); + } + + function areVerifiersSufficient(address[] memory _verifiers) internal view override { + // Must have exactly 2: sgxGethVerifier + (risc0 or sp1) + } +} +``` + +This is six deployed contracts (two raw verifiers, two adapters, one dispatcher, one SGX verifier), each with their own upgrade lifecycle, program whitelisting, and failure modes. + +## Design overview + +This document proposes a **new EIP** that introduces a proof-carrying transaction type and three new opcodes (`PROGRAMHASH`, `PUBVALUESHASH`, `PROOFCOUNT`). This new transaction type allows any smart contract to verify zkVM proofs through L1's consensus layer, replacing onchain verifier contracts entirely. + +The new EIP depends on [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) for the underlying CL proof verification infrastructure. However, EIP-8025 as currently specified is tied to EVM execution proofs. For the new EIP to work, EIP-8025 needs to be generalized so that its `ProofEngine`, types, and P2P protocols are program-agnostic rather than execution-specific. This document describes both the new EIP and the required changes to EIP-8025. + +The design mirrors [ERE](https://github.com/eth-act/ere)'s architecture, where the [`zkVM`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) trait is program-agnostic and specific guest programs (like the stateless validators) are built on top. + +## Changes to EIP-8025 + +EIP-8025 introduces optional execution proofs for L1 block validation. That functionality remains unchanged. The changes proposed here are to the underlying primitives so that the same infrastructure can also serve the new proof-carrying transaction type. + +EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#types) is a `uint8` that encodes both the zkVM backend and the guest program as a single value. In the current [Lighthouse implementation](https://github.com/sigp/lighthouse), the mapping is: + +| `ProofType` | Guest program | zkVM backend | +|---|---|---| +| 0 | ethrex | Risc0 | +| 1 | ethrex | SP1 | +| 2 | ethrex | Zisk | +| 3 | reth | OpenVM | +| 4 | reth | Risc0 | +| 5 | reth | SP1 | +| 6 | reth | Zisk | + +This works for L1 execution proofs where the set of guest programs is small and known in advance. But it cannot accommodate arbitrary rollup programs: adding a new guest program requires assigning new `ProofType` values and updating every client. + +The proposed change splits `ProofType` into two independent axes, following [ERE](https://github.com/eth-act/ere)'s design where the [`Compiler`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/compiler.rs) and the [`zkVM`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) backend are independent. `ProofType` is renamed to `BackendType` and becomes purely about the zkVM backend, and a new `program_id: Bytes32` field identifies the guest program. Since each zkVM compiles the same source to a different binary (see [terminology note](#how-rollups-verify-proofs-today)), `program_id` is specific to a `(guest program, zkVM)` pair. The [`verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof) method (which verifies and stores individual proofs arriving via gossip) generalizes accordingly: + +```python +# Before (current EIP-8025): +class PublicInput(Container): + new_payload_request_root: Root + +class ExecutionProof(Container): + proof_data: ByteList[MAX_PROOF_SIZE] + proof_type: ProofType # encodes both program and backend (current) + public_input: PublicInput + +def verify_execution_proof(self: ProofEngine, execution_proof: ExecutionProof) -> bool: ... + +# After (generalized): +class PublicInput(Container): + program_id: Bytes32 # guest program (per zkVM) + public_values: ByteList[MAX_PUBLIC_VALUES_SIZE] + +class Proof(Container): + proof_data: ByteList[MAX_PROOF_SIZE] + backend_type: BackendType # zkVM backend only + public_input: PublicInput + +def verify_proof(self: ProofEngine, proof: Proof) -> bool: ... +``` + +The engine maps `backend_type` to the appropriate verification key (one per zkVM version). The `program_id` is not part of this lookup: it is a public input to the verification circuit, checked during proof verification alongside the `public_values`. + +Today, EIP-8025's [`process_execution_payload`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#modified-process_execution_payload) constructs a [`NewPayloadRequestHeader`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-newpayloadrequestheader) from the block, then calls [`proof_engine.verify_new_payload_request_header(header)`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_new_payload_request_header). With the generalization, `verify_new_payload_request_header` keeps its name but its implementation changes: it constructs the expected [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126) and delegates to the general `has_valid_proof`. The signature gains a `chain_config` parameter since `StatelessValidationResult` includes it. The pseudocode uses types from the [execution-specs](https://github.com/ethereum/execution-specs/tree/projects/zkevm/src/ethereum/forks/amsterdam): + +```python +from ethereum.forks.amsterdam.stateless import ( + StatelessValidationResult, + ChainConfig, +) + +# The well-known program hash for L1's stateless validator. +# Corresponds to ERE's zkVMProgramDigest::program_digest() for the +# compiled verify_stateless_new_payload guest program. +NATIVE_EVM_PROGRAM_HASH = Bytes32(...) # configured per client, per zkVM backend + + +def verify_new_payload_request_header( + self: ProofEngine, + new_payload_request_header: NewPayloadRequestHeader, + chain_config: ChainConfig, +) -> bool: + """ + EVM-specific method on ProofEngine (unchanged interface). + Implementation now delegates to the general has_valid_proof. + """ + expected_result = StatelessValidationResult( + new_payload_request_root=hash_tree_root(new_payload_request_header), + successful_validation=True, + chain_config=chain_config, + ) + + return self.has_valid_proof( + program_id=NATIVE_EVM_PROGRAM_HASH, + public_values=serialize(expected_result), + ) +``` + +`process_execution_payload` calls this exactly as before. Proof-carrying transactions from rollups call the same underlying `has_valid_proof` with their own program hashes (see [EVM execution proofs](#evm-execution-proofs)). + +## New EIP: Proof-carrying transactions + +### Transaction format + +``` +TransactionType: PROOF_TX_TYPE + +TransactionPayloadBody: +[chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + to, value, data, access_list, max_fee_per_blob_gas, + blob_versioned_hashes, proofs, public_values_hash, + y_parity, r, s] +``` + +Where: +- `proofs`: a list of `(program_hash, backend_type)` pairs, one per proof in the sidecar. Each `program_hash` is a `bytes32` identifying the guest program for that specific zkVM backend (see [terminology note](#how-rollups-verify-proofs-today)). Each `backend_type` is a `uint8`. The length of this list determines `proof_count` +- `public_values_hash`: a `bytes32` hash of the program's public output (shared across all proofs, since all backends prove the same statement) + +Note: the CL-level `Proof` container carries the raw `public_values` bytes (needed for proof verification), while the transaction body and EVM opcodes only expose the hash. The contract reconstructs the expected public values and compares hashes. + +The block builder produces a single L1 block proof that recursively verifies all native proof verification transactions within that block. Validators verify only the L1 block proof, not individual rollup proofs. One constraint shaping this design: post-quantum proofs are expected to be large, which may limit L1 to one proof per slot. See [Proof propagation](#proof-propagation) for details on how proofs are delivered to the builder. + +### Opcodes + +New opcodes read the proof-carrying transaction's fields, following the same pattern as `ORIGIN`, `GASPRICE`, and `BLOBBASEFEE` (`G_base` cost). All return zero for non-proof-carrying transactions. + +| Opcode | Input | Output | Description | +|--------|-------|--------|-------------| +| `PROGRAMHASH` | `index` | `program_hash` (`bytes32`) | Program hash for the i-th proof. Indexed like `BLOBHASH` | +| `PUBVALUESHASH` | none | `public_values_hash` (`bytes32`) | Hash of the program's public output (shared across all proofs) | +| `PROOFCOUNT` | none | `proof_count` (`uint8`) | Number of distinct zkVM proofs verified by the CL | + +`PROGRAMHASH` takes an index because each proof in the tx targets a different `(program_hash, backend_type)` pair. A custom rollup iterates with `PROOFCOUNT()` and checks each `PROGRAMHASH(i)` against its own whitelist. + +For native rollups, `PROGRAMHASH(i)` returns a well-known sentinel value (e.g. `bytes32(1)`) when the i-th proof uses a program that L1 currently accepts for its own EVM execution proofs. This way the contract checks `PROGRAMHASH(i) == NATIVE_PROGRAM` without storing specific per-zkVM hashes, and automatically follows L1 upgrades. + +### Multi-proof + +The `backend_types` list allows each rollup to choose its own security/cost trade-off. A rollup that only needs one proof sets `backend_types = [SP1]`. A rollup that wants higher security can require multiple backends, e.g. `backend_types = [SP1, Risc0]`, meaning the same statement must be independently proven by both before the CL accepts the transaction. The contract reads `PROOFCOUNT()` (the length of `backend_types`) and enforces its own minimum. + +This replaces contract-level multi-proof orchestration (like Taiko's `ComposeVerifier` requiring both SGX and a ZK verifier) with a protocol-level mechanism. The `backend_types` are declared in the transaction body and signed by the sender, so they cannot be tampered with. + +### Proof propagation + +> This section is a very early work in progress. The design is not settled. + +A proof-carrying transaction must propagate through the mempool so that any builder can pick it up, without requiring a special relationship between the rollup operator and a specific builder. At the same time, the raw proof bytes are only needed by the builder: validators never need them because the L1 block proof recursively covers all native proof verifications within the block. + +This creates an asymmetry: the proof must travel through the mempool for liveness, but it has no long-term availability requirement. Putting the proof in blobs would guarantee availability via DAS, but nobody needs that availability after the builder has consumed the proof. Putting the proof in calldata would make it persist in history forever, which is equally wasteful. + +The proposed approach is an ephemeral sidecar: the proof travels with the transaction in the mempool but is not included in the final block. The rollup operator builds a proof-carrying transaction with the proof data attached as a sidecar. The transaction and sidecar propagate together through the mempool, similar to how [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob transactions propagate with their blob sidecars. The builder receives both, strips the sidecar, includes only the transaction body in the block, and recursively verifies the proofs as part of the L1 block proof. Validators see only the transaction body (`program_hash`, `public_values_hash`, `proof_count`) and verify the L1 block proof, which covers all proof verifications. They never need the raw proof bytes. + +The difference from blob transactions: blob sidecar data becomes blobs in the block (for DA via DAS), while proof sidecar data is consumed by the builder and discarded. + +**Size considerations.** [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) defines `MAX_PROOF_SIZE` as 300 KiB. The geth blob pool accepts transactions up to 1 MiB (including sidecar). This means `len(backend_types)` is effectively capped at 3 for mempool propagation (3 * 300 KiB = 900 KiB, fitting within 1 MiB with room for the transaction body). + +**Open design questions:** + +- Whether proof sidecars need a new sidecar type or can reuse the blob sidecar format. +- Whether the mempool should validate proofs before propagating (expensive but prevents spam) or propagate optimistically. +- How this interacts with [EIP-8142](https://eips.ethereum.org/EIPS/eip-8142) (Block-in-Blobs) when a proof-carrying transaction also carries blobs for L2 block data. +- Whether the proof should be committed to in the tx body (e.g. a hash of the proof bytes) so the builder can verify the sidecar matches what the sender signed. + +## EVM execution proofs + +This layer defines how the general proof verification infrastructure is used for Ethereum execution payload verification. It is a thin specialization on top of the general layer. + +The well-known `program_hash` for the L1 stateless validator: + +```python +NATIVE_EVM_PROGRAM_HASH = Bytes32(...) # configured per client, per zkVM backend +``` + +Multiple implementations of `verify_stateless_new_payload` may exist (e.g. [ethrex](https://github.com/lambdaclass/ethrex), [reth](https://github.com/paradigmxyz/reth)). Each compiles to a different binary with a different program hash per zkVM backend. The `ProofEngine` is configured with the set of accepted `program_hash` values. + +The public values follow the [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126) format. The beacon chain's `process_execution_payload` constructs the expected public values from the block header and calls `proof_engine.has_valid_proof(program_hash=NATIVE_EVM_PROGRAM_HASH, public_values_hash=hash(expected))`. + +The [honest prover guide](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/prover.md) becomes a thin specialization: extract `NewPayloadRequest` from the beacon block (EVM-specific), then call the general `request_proofs` with `NATIVE_EVM_PROGRAM_HASH`. + +## Impact on existing rollups + +Taiko's entire multi-verifier architecture (six contracts, adapter contracts, dispatcher, program whitelisting) collapses: + +```solidity +contract TaikoInbox { + mapping(bytes32 => bool) public isTrustedProgram; // whitelisted per-zkVM program hashes + uint256 public minProofCount; // multi-proof threshold (e.g. 2) + + function proveBatches( + BatchMetadata[] calldata metas, + Transition[] calldata trans + // _proof parameter removed: verified by the CL + ) external { + // Verify all proofs used trusted programs. + require(PROOFCOUNT() >= minProofCount, "insufficient proofs"); + for (uint256 i = 0; i < PROOFCOUNT(); i++) { + require(isTrustedProgram[PROGRAMHASH(i)], "untrusted program"); + } + + bytes memory publicInputs = buildPublicInputs(metas, trans); + require(PUBVALUESHASH() == sha256(publicInputs), "wrong public values"); + + // Accept the batches. + ... + } +} +``` + +The contract no longer needs to know which zkVM produced the proofs. The `isTrustedProgram` mapping replaces both `isProgramTrusted` (SP1) and `isImageTrusted` (Risc0) with a single unified whitelist that accepts per-zkVM program hashes. The `minProofCount` replaces `areVerifiersSufficient`. + +## Impact on native rollups + +The NativeRollup contract from the [ZK specification](./execute_precompile.md#nativerollup-contract-zk) uses the same pattern. Instead of `PROOFROOT` against a `validation_result_root`, it checks `PROGRAMHASH`, `PUBVALUESHASH`, and `PROOFCOUNT`: + +```solidity +bytes32 constant NATIVE_PROGRAM = bytes32(uint256(1)); +uint256 public minProofCount; + +function advance(BlockParams calldata params) external { + bytes32 l1Anchor = blockhash(block.number - 1); + + bytes32 npRoot = computeNewPayloadRequestRoot( + blockHash, params.feeRecipient, params.stateRoot, + // ... remaining fields ... + getVersionedHashes(params.payloadBlobCount), + l1Anchor, bytes32(0) + ); + + bytes32 expectedPubValuesHash = sha256(abi.encode( + npRoot, true, chainId + )); + + require(PROOFCOUNT() >= minProofCount, "insufficient proofs"); + for (uint256 i = 0; i < PROOFCOUNT(); i++) { + require(PROGRAMHASH(i) == NATIVE_PROGRAM, "not a native program"); + } + require(PUBVALUESHASH() == expectedPubValuesHash, "wrong public values"); + + blockHash = params.blockHash; + stateRoot = params.stateRoot; + blockNumber = blockNumber + 1; + stateRootHistory[blockNumber] = params.stateRoot; +} +``` + +A native rollup is simply a rollup where `programHash` points to whatever program L1 itself uses for block validation. If L1 upgrades its program (e.g. a hard fork changes `verify_stateless_new_payload`), native rollups automatically follow. Rollups with custom VMs use the exact same pattern with a different `programHash`. + +## Open questions + +1. **Program registration**: how does the CL `ProofEngine` learn to verify proofs for a new `program_hash`? Options include static configuration at fork boundaries, an onchain registry, or leaving it implementation-dependent. The current `ProofEngine` is already [implementation-dependent](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md), so the last option preserves that pattern. + +2. **Program identity scheme**: each zkVM compiles the same source to a different binary (RV32IMA vs RV64IMA), so `program_hash` is per `(source, zkVM)` pair, matching [ERE](https://github.com/eth-act/ere)'s per-zkVM `ProgramDigest`. Multi-proof transactions carry one `(program_hash, backend_type)` pair per proof. Whether multiple implementations of the same logical program (e.g. ethrex and reth both implementing `verify_stateless_new_payload`) should be accepted for the same rollup is a contract-level policy decision. + +3. **Public values size**: the raw `public_values` are carried in the CL-level `PublicInput` container. The EVM only sees `public_values_hash` (a `bytes32`). The CL container needs a `MAX_PUBLIC_VALUES_SIZE` limit that accommodates expected use cases without bloating gossip. + +4. **Hash function for `public_values_hash`**: SP1's onchain verifier uses `sha256` truncated to 253 bits (to fit the BN254 scalar field). The native proof verification `public_values_hash` could use `sha256`, `keccak256`, or the hash native to the proof system. + +5. **Proof-carrying transaction pricing**: whether proof verification needs a separate gas dimension or is folded into the existing gas model is TBD. From 35521845df1a77027c915b950884b4a2ed8276e5 Mon Sep 17 00:00:00 2001 From: Luca Donno Date: Tue, 21 Apr 2026 15:49:59 +0200 Subject: [PATCH 2/3] rebase + rewrite --- src/execute_precompile.md | 4 +- src/native_verification.md | 241 +++++++++++++++++++------------------ 2 files changed, 123 insertions(+), 122 deletions(-) diff --git a/src/execute_precompile.md b/src/execute_precompile.md index 746bc60..fd647eb 100644 --- a/src/execute_precompile.md +++ b/src/execute_precompile.md @@ -417,7 +417,7 @@ If the transaction is not a proof-carrying transaction, `PROOFROOT` returns `byt Proof-carrying transactions are processed like blob transactions with one additional field (`validation_result_root`) and one additional CL validation step (proof verification). The EL treats the proof as opaque; the CL handles all proof validation via the existing [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) `ProofEngine`. -**EL: transaction decoding and validation.** A new transaction type (e.g. `PROOF_TX_TYPE = 0x05`) is added to [`transactions.py`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L303). The `ProofCarryingTransaction` class extends [`BlobTransaction`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L303) with `validation_result_root: Hash32`. The [signing hash](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L823) includes `validation_result_root`, so the sender commits to which L2 block is being proven. +**EL: transaction decoding and validation.** A new transaction type (e.g. `PROOF_TX_TYPE = 0x05`) is added to [`transactions.py`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L303). The `ProofCarryingTransaction` class extends [`BlobTransaction`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L303) with `validation_result_root: Hash32`. The [signing hash](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L832) includes `validation_result_root`, so the sender commits to which L2 block is being proven. [`check_transaction`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/fork.py#L481) applies the same validation as blob transactions (blob count, version byte, `max_fee_per_blob_gas >= blob_gas_price`, balance coverage including blob gas) plus: - `validation_result_root != bytes32(0)` @@ -461,7 +461,7 @@ The `validation_result_root` declared in the proof-carrying transaction is a has Together, the EL check (contract reconstructs expected root and matches `PROOFROOT`, see [Root computation](#root-computation)) and the CL check (valid proof for that root) guarantee that the L2 state transition was executed correctly. -**`chain_id` and proof binding.** The `chain_id` is part of [`StatelessInput.chain_config`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L82) but not part of [`NewPayloadRequest`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/execution_engine/types.py#L63) or the block header. If the public output were only the `new_payload_request_root`, the prover could freely choose `chain_id` as a private input, enabling cross-chain transaction replay: for typed transactions ([EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) and later), [`recover_sender`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L656) uses the transaction's own `tx.chain_id` for signature recovery, not `block_env.chain_id`, so transactions from any chain would execute successfully. By including `chain_config` in `StatelessValidationResult` ([PR #2342](https://github.com/ethereum/execution-specs/pull/2342)), the proof attests to which `chain_id` was used, and the contract can verify it matches its stored value by reconstructing the full `StatelessValidationResult` before hashing. +**`chain_id` and proof binding.** The `chain_id` is part of [`StatelessInput.chain_config`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L82) but not part of [`NewPayloadRequest`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/execution_engine/types.py#L63) or the block header. If the public output were only the `new_payload_request_root`, the prover could freely choose `chain_id` as a private input, enabling cross-chain transaction replay: for typed transactions ([EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) and later), [`recover_sender`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/transactions.py#L665) uses the transaction's own `tx.chain_id` for signature recovery, not `block_env.chain_id`, so transactions from any chain would execute successfully. By including `chain_config` in `StatelessValidationResult` ([PR #2342](https://github.com/ethereum/execution-specs/pull/2342)), the proof attests to which `chain_id` was used, and the contract can verify it matches its stored value by reconstructing the full `StatelessValidationResult` before hashing. ### Root computation diff --git a/src/native_verification.md b/src/native_verification.md index 92a8d09..250b42d 100644 --- a/src/native_verification.md +++ b/src/native_verification.md @@ -4,62 +4,61 @@ **Table of Contents** +- [Abstract](#abstract) - [Motivation](#motivation) - [How rollups verify proofs today](#how-rollups-verify-proofs-today) - [Example: Taiko (multi-verifier)](#example-taiko-multi-verifier) -- [Design overview](#design-overview) - [Changes to EIP-8025](#changes-to-eip-8025) +- [Program hash stability (open problem)](#program-hash-stability-open-problem) - [New EIP: Proof-carrying transactions](#new-eip-proof-carrying-transactions) - [Transaction format](#transaction-format) - [Opcodes](#opcodes) - [Multi-proof](#multi-proof) - [Proof propagation](#proof-propagation) -- [EVM execution proofs](#evm-execution-proofs) - [Impact on existing rollups](#impact-on-existing-rollups) - [Impact on native rollups](#impact-on-native-rollups) -- [Open questions](#open-questions) -## Motivation +## Abstract + +The overarching goal of this proposal is to massively derisk and simplify L2 bridges by replacing every rollup's bespoke onchain verifier stack with a standard L1 primitive. This is achieved through two changes: -Today, every ZK rollup on Ethereum deploys and maintains its own proof verification infrastructure: onchain verifier contracts for each zkVM it uses, adapter contracts, multi-proof dispatchers, and program whitelisting logic. When a bug is found in a zkVM's verification circuit, each rollup must independently deploy a patched verifier contract and coordinate a governance upgrade to point to it. This is slow, risky, and duplicated across the ecosystem. +1. **Generalize [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025)** so the consensus-layer proof verification infrastructure becomes program-agnostic, not tied to EVM execution proofs. +2. **A new EIP** that exposes it to smart contracts through a proof-carrying transaction type and three opcodes (`PROGRAMHASH`, `PUBVALUESHASH`, `PROOFCOUNT`). -[EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) introduces zkVM proof verification on Ethereum's consensus layer, but only for L1's own purposes: verifying execution payloads to enable stateless validation. It does not change the story for rollups. Rollups still need their own onchain verifier contracts. +Together, they let any rollup retire its onchain verifier contracts and inherit L1's proof verification infrastructure directly, with zkVM fixes shipping through client releases rather than rollup governance upgrades. -However, the infrastructure that EIP-8025 brings to the CL, the `ProofEngine`, proof gossip, and verification logic, is not inherently L1-specific. If generalized to be program-agnostic and exposed to smart contracts via a new transaction type, any rollup could offload proof verification to the CL. Rollups no longer maintain their own verification infrastructure. When a zkVM implementation needs to be patched, Ethereum client teams release updated software, the same way bugs in execution clients like geth or Nethermind are fixed today: through client releases, without requiring a hard fork. Every rollup using native proof verification benefits from the fix automatically, with no onchain upgrade or governance action needed. +## Motivation -This is the same principle behind native rollups: just as native rollups inherit L1's execution environment and automatically benefit from EVM upgrades, native proof verification lets any rollup inherit L1's proof verification infrastructure and automatically benefit from zkVM fixes and improvements. +Today, every Ethereum rollup maintains bespoke onchain proof verification infrastructure. ZK rollups deploy zkVM verifier contracts, adapter contracts, multi-proof dispatchers, and program whitelisting logic. Optimistic rollups ship their own onchain fraud-proof VMs (Arbitrum's WAVM, Optimism's Cannon MIPS machine) plus the surrounding dispute logic. In either case every contract is maintained, patched, and upgraded independently in response to bugs in its specific proof system or VM, with each upgrade gated by a custom multisig or DAO. This is slow, risky, and duplicated across the ecosystem. -The proposal has two parts: +[EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) introduces zkVM proof verification on Ethereum's consensus layer, but only for L1's own purposes: verifying execution payloads to enable stateless and sublinear validation. Rollups still need their own onchain verifier contracts. -1. **Generalize EIP-8025**: make the CL proof verification infrastructure program-agnostic, so it can verify proofs for any guest program, not just EVM execution. -2. **New EIP**: introduce a proof-carrying transaction type and opcodes that let any smart contract leverage EIP-8025's verification infrastructure, replacing onchain verifier contracts entirely. +However, the infrastructure that EIP-8025 brings to the CL, the `ProofEngine`, proof gossip, and verification logic, is not inherently L1-specific. If generalized to be program-agnostic and exposed to smart contracts via a new transaction type, any rollup, even non-EVM ones, could offload proof verification to the CL. When a zkVM implementation needs to be patched, Ethereum client teams release updated software the same way bugs in geth or Nethermind are fixed today: through client releases, without a hard fork. This is the same principle behind native rollups: just as native rollups inherit L1's execution environment, native proof verification lets any rollup inherit L1's proof verification infrastructure. ## How rollups verify proofs today -Each zkVM vendor (SP1, Risc0, etc.) provides a Solidity verifier contract that implements the final proof verification step, typically a Groth16 or Plonk pairing check over BN254. The verifier is a universal circuit: it can verify proofs for any guest program. The program identity is passed as a public input alongside the hash of the program's output. For SP1: +Each zkVM vendor provides a universal Solidity verifier contract (typically a Groth16 or Plonk check over BN254). The program identity and the public values (any inputs and outputs the circuit commits to) are passed alongside the proof. For SP1: ```solidity interface ISP1Verifier { function verifyProof( - bytes32 programVKey, // program hash - bytes calldata publicValues, // program output + bytes32 programVKey, // program hash + bytes calldata publicValues, // public values (inputs and/or outputs) bytes calldata proofBytes // the proof ) external view; } ``` -**A note on terminology.** SP1 calls `programVKey` a "verification key", but this is different from the zkVM's own verification key. There are two distinct keys: - -- The **program hash** (called `programVKey` by SP1, `imageId` by Risc0): a `bytes32` identifying the guest program. It is a hash of the compiled binary. Since each zkVM compiles to a different target (e.g. RV32IMA vs RV64IMA), the same source program produces a different program hash per zkVM. In [ERE](https://github.com/eth-act/ere), each backend has its own [`ProgramDigest`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) type (`SP1VerifyingKey`, `Digest`, `ProgramVk`, etc.). A program hash therefore identifies a `(guest program, zkVM)` pair, not just a guest program. -- The **verification key**: the cryptographic key used by the zkVM's proof system to verify proofs. This is a large structured object (polynomial commitments, domain parameters, etc.) that is specific to the zkVM circuit, not to the guest program. In onchain verifier contracts, the verification key is hardcoded as constants. All programs share the same verification key for a given zkVM version. +**A note on terminology.** SP1 calls `programVKey` a "verification key", but this collides with the zkVM's own circuit verification key. This document keeps them separate: -This document uses "program hash" for the former and "verification key" for the latter. +- **Program hash** (called `programVKey` by SP1, `imageId` by Risc0): a `bytes32` identifying a compiled guest program. Because each zkVM compiles differently (e.g. RV32IMA vs RV64IMA), it is per-`(source, zkVM)` pair. [ERE](https://github.com/eth-act/ere) expresses this as each backend's [`zkVMVerifier::ProgramVk`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) associated type (wrapping `SP1VerifyingKey`, Risc0's `Digest`, etc.). +- **Verification key**: the zkVM's circuit VK (polynomial commitments, domain parameters). Hardcoded as constants in onchain verifiers, one per zkVM version, shared across all programs. ### Example: Taiko (multi-verifier) -Taiko illustrates the complexity that arises when a rollup uses multiple proof systems. Its verification architecture involves five contracts across three tiers: +Taiko illustrates the complexity that arises when a rollup uses multiple proof systems. Its verification architecture involves six contracts across three tiers (two raw verifiers, two adapters, one dispatcher, one SGX verifier), each independently maintained and upgraded through a custom multisig. **1. Raw zkVM verifiers.** Taiko deploys both an SP1 Plonk verifier (`SP1Verifier.sol`) and a Risc0 Groth16 verifier (`RiscZeroGroth16Verifier.sol`). These are the vendor-provided universal verifier contracts. @@ -83,26 +82,10 @@ contract TaikoSP1Verifier is IVerifier { ); } } - -// Risc0Verifier: adapter for Risc0 (same IVerifier interface) -contract Risc0Verifier is IVerifier { - address public riscoGroth16Verifier; // raw Risc0 verifier - mapping(bytes32 => bool) public isImageTrusted; // whitelisted images - - function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external view { - (bytes memory seal, bytes32 blockImageId, bytes32 aggregationImageId) = - abi.decode(_proof, (bytes, bytes32, bytes32)); - require(isImageTrusted[blockImageId]); - require(isImageTrusted[aggregationImageId]); - - bytes32 journalDigest = sha256(buildPublicInputs(_ctxs)); - IRiscZeroVerifier(riscoGroth16Verifier).verify( - seal, aggregationImageId, journalDigest - ); - } -} ``` +A parallel `Risc0Verifier` has the same shape, with `isImageTrusted` replacing `isProgramTrusted` and `sha256(buildPublicInputs(...))` as the journal digest. + **3. Multi-verifier dispatcher.** A `ComposeVerifier` contract orchestrates multiple verifiers and enforces that a sufficient set has verified each proof: ```solidity @@ -125,21 +108,11 @@ contract MainnetVerifier is ComposeVerifier { } ``` -This is six deployed contracts (two raw verifiers, two adapters, one dispatcher, one SGX verifier), each with their own upgrade lifecycle, program whitelisting, and failure modes. - -## Design overview - -This document proposes a **new EIP** that introduces a proof-carrying transaction type and three new opcodes (`PROGRAMHASH`, `PUBVALUESHASH`, `PROOFCOUNT`). This new transaction type allows any smart contract to verify zkVM proofs through L1's consensus layer, replacing onchain verifier contracts entirely. - -The new EIP depends on [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) for the underlying CL proof verification infrastructure. However, EIP-8025 as currently specified is tied to EVM execution proofs. For the new EIP to work, EIP-8025 needs to be generalized so that its `ProofEngine`, types, and P2P protocols are program-agnostic rather than execution-specific. This document describes both the new EIP and the required changes to EIP-8025. - -The design mirrors [ERE](https://github.com/eth-act/ere)'s architecture, where the [`zkVM`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) trait is program-agnostic and specific guest programs (like the stateless validators) are built on top. - ## Changes to EIP-8025 -EIP-8025 introduces optional execution proofs for L1 block validation. That functionality remains unchanged. The changes proposed here are to the underlying primitives so that the same infrastructure can also serve the new proof-carrying transaction type. +EIP-8025 introduces optional execution proofs for L1 block validation. That functionality remains unchanged. The changes proposed here are to the underlying primitives, so that the same infrastructure can also serve the new proof-carrying transaction type. The generalization mirrors [ERE](https://github.com/eth-act/ere), whose [`zkVMVerifier`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) trait is program-agnostic and specific guest programs are built on top. -EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#types) is a `uint8` that encodes both the zkVM backend and the guest program as a single value. In the current [Lighthouse implementation](https://github.com/sigp/lighthouse), the mapping is: +EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#types) is a `uint8` that encodes both the zkVM backend and the guest program as a single value. In the current [Lighthouse implementation](https://github.com/eth-act/lighthouse/blob/feat/eip8025/beacon_node/execution_layer/src/eip8025/types.rs), the mapping is: | `ProofType` | Guest program | zkVM backend | |---|---|---| @@ -153,7 +126,7 @@ EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blo This works for L1 execution proofs where the set of guest programs is small and known in advance. But it cannot accommodate arbitrary rollup programs: adding a new guest program requires assigning new `ProofType` values and updating every client. -The proposed change splits `ProofType` into two independent axes, following [ERE](https://github.com/eth-act/ere)'s design where the [`Compiler`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/compiler.rs) and the [`zkVM`](https://github.com/eth-act/ere/blob/main/crates/zkvm-interface/src/zkvm.rs) backend are independent. `ProofType` is renamed to `BackendType` and becomes purely about the zkVM backend, and a new `program_id: Bytes32` field identifies the guest program. Since each zkVM compiles the same source to a different binary (see [terminology note](#how-rollups-verify-proofs-today)), `program_id` is specific to a `(guest program, zkVM)` pair. The [`verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof) method (which verifies and stores individual proofs arriving via gossip) generalizes accordingly: +The proposed change splits `ProofType` into two independent axes, following [ERE](https://github.com/eth-act/ere)'s design where the [`Compiler`](https://github.com/eth-act/ere/blob/master/crates/compiler/core/src/lib.rs) and the [`zkVMVerifier`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) backend are independent. `ProofType` is renamed to `BackendType` and becomes purely about the zkVM backend, and a new `program_hash: Bytes32` field identifies the guest program. Since each zkVM compiles the same source to a different binary (see [terminology note](#how-rollups-verify-proofs-today)), `program_hash` is specific to a `(guest program, zkVM)` pair. The [`verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof) method (which verifies and stores individual proofs arriving via gossip) generalizes accordingly: ```python # Before (current EIP-8025): @@ -169,7 +142,7 @@ def verify_execution_proof(self: ProofEngine, execution_proof: ExecutionProof) - # After (generalized): class PublicInput(Container): - program_id: Bytes32 # guest program (per zkVM) + program_hash: Bytes32 # guest program (per zkVM) public_values: ByteList[MAX_PUBLIC_VALUES_SIZE] class Proof(Container): @@ -180,20 +153,43 @@ class Proof(Container): def verify_proof(self: ProofEngine, proof: Proof) -> bool: ... ``` -The engine maps `backend_type` to the appropriate verification key (one per zkVM version). The `program_id` is not part of this lookup: it is a public input to the verification circuit, checked during proof verification alongside the `public_values`. +`BackendType` is `ProofType` renamed (still `uint8`). The engine uses `backend_type` to select the circuit VK; `program_hash` is a public input to the circuit, checked alongside `public_values` during verification. + +A second method, `has_valid_proof`, is a cheap lookup against proofs already verified by `verify_proof`. It is what the block-processing path (via `verify_new_payload_request_header`) calls to confirm a prior verification exists for a given `(program_hash, public_values_hash)` pair. Fresh proofs, whether from gossip or proof-carrying transaction sidecars, are always ingested through `verify_proof`. + +```python +def has_valid_proof( + self: ProofEngine, + program_hash: Bytes32, + public_values_hash: Bytes32, +) -> bool: + """ + Return True if the engine holds a previously-verified Proof whose + (public_input.program_hash, sha256(public_input.public_values)) + equals the given (program_hash, public_values_hash) pair. + """ + ... +``` + +`backend_type` is not part of the lookup: `program_hash` is already per-`(source, zkVM)`, so distinct backends produce distinct hashes and no ambiguity arises. -Today, EIP-8025's [`process_execution_payload`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#modified-process_execution_payload) constructs a [`NewPayloadRequestHeader`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-newpayloadrequestheader) from the block, then calls [`proof_engine.verify_new_payload_request_header(header)`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_new_payload_request_header). With the generalization, `verify_new_payload_request_header` keeps its name but its implementation changes: it constructs the expected [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126) and delegates to the general `has_valid_proof`. The signature gains a `chain_config` parameter since `StatelessValidationResult` includes it. The pseudocode uses types from the [execution-specs](https://github.com/ethereum/execution-specs/tree/projects/zkevm/src/ethereum/forks/amsterdam): +EIP-8025's [`verify_new_payload_request_header`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_new_payload_request_header) (called from [`process_execution_payload`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#modified-process_execution_payload)) now builds the expected [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126), hashes it, and delegates to `has_valid_proof` over the accepted L1 program hashes. The signature gains a `chain_config` parameter since `StatelessValidationResult` includes it; `process_execution_payload` supplies L1's own `ChainConfig` from the beacon state's chain spec. The expression `hash_tree_root(new_payload_request_header)` yields the same value as the root of the full `NewPayloadRequest` by construction, since the header is the roots-only form of the same container. ```python from ethereum.forks.amsterdam.stateless import ( StatelessValidationResult, ChainConfig, ) +from ethereum.forks.amsterdam.stateless_guest import ( + serialize_stateless_output, +) -# The well-known program hash for L1's stateless validator. -# Corresponds to ERE's zkVMProgramDigest::program_digest() for the -# compiled verify_stateless_new_payload guest program. -NATIVE_EVM_PROGRAM_HASH = Bytes32(...) # configured per client, per zkVM backend +# Well-known program hashes for L1's stateless validator. Because each +# `(client implementation, zkVM backend)` pair compiles to a different binary +# (e.g. ethrex-SP1 vs reth-SP1 vs ethrex-Risc0), the engine accepts a *set*, +# not a single value. Corresponds to ERE's zkVMVerifier::program_vk() for +# each compiled variant of verify_stateless_new_payload. +NATIVE_EVM_PROGRAM_HASHES: Set[Bytes32] = {...} # configured per client def verify_new_payload_request_header( @@ -202,22 +198,46 @@ def verify_new_payload_request_header( chain_config: ChainConfig, ) -> bool: """ - EVM-specific method on ProofEngine (unchanged interface). - Implementation now delegates to the general has_valid_proof. + EVM-specific method kept as the entry point `process_execution_payload` + calls. Implementation now delegates to the general has_valid_proof, + iterating over every accepted L1 program hash: any one stored proof + is sufficient. """ expected_result = StatelessValidationResult( new_payload_request_root=hash_tree_root(new_payload_request_header), successful_validation=True, chain_config=chain_config, ) - - return self.has_valid_proof( - program_id=NATIVE_EVM_PROGRAM_HASH, - public_values=serialize(expected_result), + # serialize_stateless_output() SSZ-encodes `expected_result`; the guest + # program emits the same bytes via the same helper, so their sha256 hashes + # agree by construction. + expected_public_values_hash = sha256(serialize_stateless_output(expected_result)) + + return any( + self.has_valid_proof( + program_hash=program_hash, + public_values_hash=expected_public_values_hash, + ) + for program_hash in NATIVE_EVM_PROGRAM_HASHES ) ``` -`process_execution_payload` calls this exactly as before. Proof-carrying transactions from rollups call the same underlying `has_valid_proof` with their own program hashes (see [EVM execution proofs](#evm-execution-proofs)). +On the prover side, the [honest prover guide](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/prover.md) specializes the same way: extract `NewPayloadRequest` from the beacon block and call `request_proofs` with this prover's own `program_hash` (one entry of `NATIVE_EVM_PROGRAM_HASHES` drawn from [ethrex](https://github.com/lambdaclass/ethrex), [reth](https://github.com/paradigmxyz/reth), or any other accepted implementation). + +## Program hash stability (open problem) + +Native proof verification's "fixes ship through client releases, nothing onchain changes" property depends on one non-trivial requirement: the `program_hash` pinned onchain must remain stable across zkVM patches. If any patch moves the hash, rollups that pinned the old value are bricked unless they upgrade, and the upgrade story collapses back onto onchain governance. + +No zkVM today delivers this directly. Both leading candidates fingerprint artifacts that change under normal SDK / dependency / toolchain churn, not just circuit-layer fixes: + +- **Risc0's `imageId`** is a SHA-256 over `SystemState { pc: 0, merkle_root }` with `merkle_root` a Poseidon2 merkle root of the initial memory image, which contains both the user ELF and the kernel ELF ([binfmt/src/elf.rs](https://github.com/risc0/risc0/blob/main/risc0/binfmt/src/elf.rs)). The memory image captures the exact compiled bytes, so a dep bump, toolchain update, or kernel patch all change `imageId` even when STF semantics are unchanged. +- **SP1's `programVKey`** is a Poseidon2 over `(preprocessed_commit, pc_start, ...)` ([hypercube/src/verifier/hashable_key.rs](https://github.com/succinctlabs/sp1/blob/main/crates/hypercube/src/verifier/hashable_key.rs)). `preprocessed_commit` depends on the circuit's AIR preprocessing and `pc_start` on the linker, so circuit changes, SDK bumps, and toolchain changes all move it. + +Using either directly as the onchain `program_hash` would make every zkVM release a rollup-visible event. + +The realistic path is an **indirection layer**: the onchain `program_hash` is a stable, rollup-chosen identifier and the public input to the proof, while the zkVM-internal identifier is a private input, maintained by clients and free to change with every release. The proof must attest that the two are linked, so that the stable `program_hash` genuinely commits to what was executed. The exact mechanism is an open design question. + +Native rollups using the `NATIVE_PROGRAM` sentinel sidestep this entirely: the sentinel just says "whatever L1 currently accepts", and the accepted set is itself a client-side artifact that updates with zkVM releases. ## New EIP: Proof-carrying transactions @@ -234,12 +254,16 @@ TransactionPayloadBody: ``` Where: -- `proofs`: a list of `(program_hash, backend_type)` pairs, one per proof in the sidecar. Each `program_hash` is a `bytes32` identifying the guest program for that specific zkVM backend (see [terminology note](#how-rollups-verify-proofs-today)). Each `backend_type` is a `uint8`. The length of this list determines `proof_count` -- `public_values_hash`: a `bytes32` hash of the program's public output (shared across all proofs, since all backends prove the same statement) +- `proofs`: a list of `(program_hash, backend_type)` pairs, one per proof in the sidecar. Each `program_hash` is a `bytes32` identifying the guest program for that specific zkVM backend (see [terminology note](#how-rollups-verify-proofs-today)). Each `backend_type` is a `uint8`. The length of this list determines `proof_count`. +- `public_values_hash`: a `bytes32` hash of the program's public output (shared across all proofs, since all backends prove the same statement). +- `blob_versioned_hashes`: same semantics as [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844); used when the transaction also carries payload blobs (e.g. [EIP-8142](https://eips.ethereum.org/EIPS/eip-8142) block data). + +The CL-level `Proof` carries raw `public_values` bytes; the transaction body and EVM opcodes only expose `public_values_hash`. The contract reconstructs the expected bytes and compares hashes. Two invariants tie the two views together (checked by any node that handles the sidecar: on mempool propagation, and again by the builder when assembling the block): -Note: the CL-level `Proof` container carries the raw `public_values` bytes (needed for proof verification), while the transaction body and EVM opcodes only expose the hash. The contract reconstructs the expected public values and compares hashes. +- `sidecar[i].public_input.program_hash == proofs[i].program_hash` and `sidecar[i].backend_type == proofs[i].backend_type`. +- `sha256(sidecar[i].public_input.public_values) == public_values_hash`. -The block builder produces a single L1 block proof that recursively verifies all native proof verification transactions within that block. Validators verify only the L1 block proof, not individual rollup proofs. One constraint shaping this design: post-quantum proofs are expected to be large, which may limit L1 to one proof per slot. See [Proof propagation](#proof-propagation) for details on how proofs are delivered to the builder. +These are what let `has_valid_proof(program_hash, public_values_hash)` look up stored proofs by `(program_hash, sha256(public_values))`. See [Proof propagation](#proof-propagation) for how proofs reach the builder and how the L1 block proof covers them. ### Opcodes @@ -247,60 +271,44 @@ New opcodes read the proof-carrying transaction's fields, following the same pat | Opcode | Input | Output | Description | |--------|-------|--------|-------------| -| `PROGRAMHASH` | `index` | `program_hash` (`bytes32`) | Program hash for the i-th proof. Indexed like `BLOBHASH` | +| `PROGRAMHASH` | `index` | `program_hash` (`bytes32`) | Program hash for the i-th proof. Indexed like `BLOBHASH`; returns `bytes32(0)` if `index >= PROOFCOUNT()` | | `PUBVALUESHASH` | none | `public_values_hash` (`bytes32`) | Hash of the program's public output (shared across all proofs) | | `PROOFCOUNT` | none | `proof_count` (`uint8`) | Number of distinct zkVM proofs verified by the CL | -`PROGRAMHASH` takes an index because each proof in the tx targets a different `(program_hash, backend_type)` pair. A custom rollup iterates with `PROOFCOUNT()` and checks each `PROGRAMHASH(i)` against its own whitelist. +A custom rollup iterates with `PROOFCOUNT()` and checks each `PROGRAMHASH(i)` against its own whitelist. -For native rollups, `PROGRAMHASH(i)` returns a well-known sentinel value (e.g. `bytes32(1)`) when the i-th proof uses a program that L1 currently accepts for its own EVM execution proofs. This way the contract checks `PROGRAMHASH(i) == NATIVE_PROGRAM` without storing specific per-zkVM hashes, and automatically follows L1 upgrades. +For native rollups, `PROGRAMHASH(i)` returns a well-known sentinel value (e.g. `bytes32(1)`) when the i-th proof uses a program that L1 currently accepts for its own EVM execution proofs. This way the contract checks `PROGRAMHASH(i) == NATIVE_PROGRAM` without storing specific per-zkVM hashes, and automatically follows L1 upgrades. Whether the sentinel is returned depends on the L1-accepted set at the block being executed; this set is part of the client-side `ProofEngine` configuration and updates via client releases. ### Multi-proof -The `backend_types` list allows each rollup to choose its own security/cost trade-off. A rollup that only needs one proof sets `backend_types = [SP1]`. A rollup that wants higher security can require multiple backends, e.g. `backend_types = [SP1, Risc0]`, meaning the same statement must be independently proven by both before the CL accepts the transaction. The contract reads `PROOFCOUNT()` (the length of `backend_types`) and enforces its own minimum. +The `proofs` list lets each rollup pick its own security/cost trade-off: `[(hash, SP1)]` is a single proof, `[(hash_sp1, SP1), (hash_risc0, Risc0)]` requires the same statement to be independently proven by both before the CL accepts the transaction. The contract reads `PROOFCOUNT()` and enforces its own minimum. -This replaces contract-level multi-proof orchestration (like Taiko's `ComposeVerifier` requiring both SGX and a ZK verifier) with a protocol-level mechanism. The `backend_types` are declared in the transaction body and signed by the sender, so they cannot be tampered with. +This replaces contract-level multi-proof orchestration (like Taiko's `ComposeVerifier` requiring both SGX and a ZK verifier) with a protocol-level mechanism. Because `proofs` is in the signed transaction body, it cannot be tampered with. ### Proof propagation > This section is a very early work in progress. The design is not settled. -A proof-carrying transaction must propagate through the mempool so that any builder can pick it up, without requiring a special relationship between the rollup operator and a specific builder. At the same time, the raw proof bytes are only needed by the builder: validators never need them because the L1 block proof recursively covers all native proof verifications within the block. - -This creates an asymmetry: the proof must travel through the mempool for liveness, but it has no long-term availability requirement. Putting the proof in blobs would guarantee availability via DAS, but nobody needs that availability after the builder has consumed the proof. Putting the proof in calldata would make it persist in history forever, which is equally wasteful. - -The proposed approach is an ephemeral sidecar: the proof travels with the transaction in the mempool but is not included in the final block. The rollup operator builds a proof-carrying transaction with the proof data attached as a sidecar. The transaction and sidecar propagate together through the mempool, similar to how [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob transactions propagate with their blob sidecars. The builder receives both, strips the sidecar, includes only the transaction body in the block, and recursively verifies the proofs as part of the L1 block proof. Validators see only the transaction body (`program_hash`, `public_values_hash`, `proof_count`) and verify the L1 block proof, which covers all proof verifications. They never need the raw proof bytes. - -The difference from blob transactions: blob sidecar data becomes blobs in the block (for DA via DAS), while proof sidecar data is consumed by the builder and discarded. - -**Size considerations.** [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) defines `MAX_PROOF_SIZE` as 300 KiB. The geth blob pool accepts transactions up to 1 MiB (including sidecar). This means `len(backend_types)` is effectively capped at 3 for mempool propagation (3 * 300 KiB = 900 KiB, fitting within 1 MiB with room for the transaction body). - -**Open design questions:** - -- Whether proof sidecars need a new sidecar type or can reuse the blob sidecar format. -- Whether the mempool should validate proofs before propagating (expensive but prevents spam) or propagate optimistically. -- How this interacts with [EIP-8142](https://eips.ethereum.org/EIPS/eip-8142) (Block-in-Blobs) when a proof-carrying transaction also carries blobs for L2 block data. -- Whether the proof should be committed to in the tx body (e.g. a hash of the proof bytes) so the builder can verify the sidecar matches what the sender signed. - -## EVM execution proofs +The proof must reach a builder through the mempool, but needs no long-term availability. The proposed approach is an **ephemeral sidecar**: the proof travels alongside the transaction like an [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob sidecar, but the builder strips it before block inclusion, folds it into the recursive L1 block proof, and discards it. Validators see only the transaction body (the `proofs` list and `public_values_hash`) plus the L1 block proof; they never need the raw proof bytes. The L1 block proof thus recursively covers every proof-carrying transaction in the block (post-quantum proofs may be large enough that L1 is limited to one proof per slot). -This layer defines how the general proof verification infrastructure is used for Ethereum execution payload verification. It is a thin specialization on top of the general layer. +**Size.** EIP-8025 sets `MAX_PROOF_SIZE = 300 KiB`. With geth's 1 MiB blob-pool cap, `len(proofs)` is effectively capped at 3 for mempool propagation. Any blobs the transaction also carries eat into the same 1 MiB budget and lower the effective cap. -The well-known `program_hash` for the L1 stateless validator: - -```python -NATIVE_EVM_PROGRAM_HASH = Bytes32(...) # configured per client, per zkVM backend -``` - -Multiple implementations of `verify_stateless_new_payload` may exist (e.g. [ethrex](https://github.com/lambdaclass/ethrex), [reth](https://github.com/paradigmxyz/reth)). Each compiles to a different binary with a different program hash per zkVM backend. The `ProofEngine` is configured with the set of accepted `program_hash` values. +## Impact on existing rollups -The public values follow the [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126) format. The beacon chain's `process_execution_payload` constructs the expected public values from the block header and calls `proof_engine.has_valid_proof(program_hash=NATIVE_EVM_PROGRAM_HASH, public_values_hash=hash(expected))`. +The table below reports Solidity SLOC (non-blank, non-comment source lines) for each project's onchain contracts, split between "core" rollup logic and the proof verification stack that native proof verification would retire. -The [honest prover guide](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/prover.md) becomes a thin specialization: extract `NewPayloadRequest` from the beacon block (EVM-specific), then call the general `request_proofs` with `NATIVE_EVM_PROGRAM_HASH`. +| Project | Proof system | Core SLOC | Retired SLOC | % retired | +|---|---|---:|---:|---:| +| Arbitrum | Optimistic, WASM VM | 19,034 | 8,181 | 43.0% | +| Base | Optimistic, MIPS VM | 17,426 | 8,907 | 51.1% | +| ZKsync Era | Validity, EraVM | 10,823 | 2,379 | 22.0% | +| Linea | Validity, direct EVM | 8,111 | 2,460 | 30.3% | +| Lighter | Validity, no VM (custom circuits) | 5,417 | 1,699 | 31.4% | +| **Total** | | **60,811** | **23,626** | **38.9%** | -## Impact on existing rollups +These numbers are rough estimates. They cover only on-chain Solidity code and exclude off-chain provers, sequencers, and the guest program behind each `program_hash`. Governance surfaces (multisigs, timelocks, DAO contracts, proxy admins), partner-specific bridges, and proxy boilerplate are excluded from both columns. -Taiko's entire multi-verifier architecture (six contracts, adapter contracts, dispatcher, program whitelisting) collapses: +Taiko's six-contract multi-verifier stack collapses into a single inbox contract: ```solidity contract TaikoInbox { @@ -327,7 +335,7 @@ contract TaikoInbox { } ``` -The contract no longer needs to know which zkVM produced the proofs. The `isTrustedProgram` mapping replaces both `isProgramTrusted` (SP1) and `isImageTrusted` (Risc0) with a single unified whitelist that accepts per-zkVM program hashes. The `minProofCount` replaces `areVerifiersSufficient`. +A single `isTrustedProgram` whitelist replaces both `isProgramTrusted` (SP1) and `isImageTrusted` (Risc0); `minProofCount` replaces `areVerifiersSufficient`. ## Impact on native rollups @@ -347,9 +355,14 @@ function advance(BlockParams calldata params) external { l1Anchor, bytes32(0) ); - bytes32 expectedPubValuesHash = sha256(abi.encode( + // SSZ-encode the StatelessValidationResult container: + // new_payload_request_root (32 bytes) || successful_validation (1 byte) + // || chain_id (8 bytes, little-endian). + // Must match serialize_stateless_output() in execution-specs. + bytes memory expectedPublicValues = SSZ.encodeStatelessValidationResult( npRoot, true, chainId - )); + ); + bytes32 expectedPubValuesHash = sha256(expectedPublicValues); require(PROOFCOUNT() >= minProofCount, "insufficient proofs"); for (uint256 i = 0; i < PROOFCOUNT(); i++) { @@ -364,16 +377,4 @@ function advance(BlockParams calldata params) external { } ``` -A native rollup is simply a rollup where `programHash` points to whatever program L1 itself uses for block validation. If L1 upgrades its program (e.g. a hard fork changes `verify_stateless_new_payload`), native rollups automatically follow. Rollups with custom VMs use the exact same pattern with a different `programHash`. - -## Open questions - -1. **Program registration**: how does the CL `ProofEngine` learn to verify proofs for a new `program_hash`? Options include static configuration at fork boundaries, an onchain registry, or leaving it implementation-dependent. The current `ProofEngine` is already [implementation-dependent](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md), so the last option preserves that pattern. - -2. **Program identity scheme**: each zkVM compiles the same source to a different binary (RV32IMA vs RV64IMA), so `program_hash` is per `(source, zkVM)` pair, matching [ERE](https://github.com/eth-act/ere)'s per-zkVM `ProgramDigest`. Multi-proof transactions carry one `(program_hash, backend_type)` pair per proof. Whether multiple implementations of the same logical program (e.g. ethrex and reth both implementing `verify_stateless_new_payload`) should be accepted for the same rollup is a contract-level policy decision. - -3. **Public values size**: the raw `public_values` are carried in the CL-level `PublicInput` container. The EVM only sees `public_values_hash` (a `bytes32`). The CL container needs a `MAX_PUBLIC_VALUES_SIZE` limit that accommodates expected use cases without bloating gossip. - -4. **Hash function for `public_values_hash`**: SP1's onchain verifier uses `sha256` truncated to 253 bits (to fit the BN254 scalar field). The native proof verification `public_values_hash` could use `sha256`, `keccak256`, or the hash native to the proof system. - -5. **Proof-carrying transaction pricing**: whether proof verification needs a separate gas dimension or is folded into the existing gas model is TBD. +A native rollup is simply one whose `programHash` matches what L1 itself accepts; an L1 upgrade (e.g. a fork changing `verify_stateless_new_payload`) propagates automatically. Rollups with custom VMs use the same pattern with a different `programHash`. From b265e2b4358e6ce590834521271aa8f99fe2df88 Mon Sep 17 00:00:00 2001 From: Luca Donno Date: Mon, 25 May 2026 15:45:04 +0200 Subject: [PATCH 3/3] Simplify EIP-8025 changes, broaden framing Leave EIP-8025's existing surface untouched and add verify_proof as the generic primitive, with verify_execution_proof reimplemented as a thin wrapper over it. Drop the has_valid_proof / verify_new_payload_request_header machinery. Broaden the abstract and motivation from rollups to any onchain application that verifies ZK proofs. --- src/native_verification.md | 143 +++++++++++-------------------------- 1 file changed, 41 insertions(+), 102 deletions(-) diff --git a/src/native_verification.md b/src/native_verification.md index 250b42d..0a45db1 100644 --- a/src/native_verification.md +++ b/src/native_verification.md @@ -22,20 +22,22 @@ ## Abstract -The overarching goal of this proposal is to massively derisk and simplify L2 bridges by replacing every rollup's bespoke onchain verifier stack with a standard L1 primitive. This is achieved through two changes: +The overarching goal of this proposal is to massively derisk and simplify L2 bridges, and more generally any onchain application that verifies ZK proofs, by introducing a standard L1 primitive that any project can adopt in place of its bespoke onchain verifier stack. This is achieved through two changes: 1. **Generalize [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025)** so the consensus-layer proof verification infrastructure becomes program-agnostic, not tied to EVM execution proofs. 2. **A new EIP** that exposes it to smart contracts through a proof-carrying transaction type and three opcodes (`PROGRAMHASH`, `PUBVALUESHASH`, `PROOFCOUNT`). -Together, they let any rollup retire its onchain verifier contracts and inherit L1's proof verification infrastructure directly, with zkVM fixes shipping through client releases rather than rollup governance upgrades. +Together, they let any project inherit L1's proof verification infrastructure directly, with zkVM fixes shipping through client releases rather than per-project governance upgrades. ## Motivation -Today, every Ethereum rollup maintains bespoke onchain proof verification infrastructure. ZK rollups deploy zkVM verifier contracts, adapter contracts, multi-proof dispatchers, and program whitelisting logic. Optimistic rollups ship their own onchain fraud-proof VMs (Arbitrum's WAVM, Optimism's Cannon MIPS machine) plus the surrounding dispute logic. In either case every contract is maintained, patched, and upgraded independently in response to bugs in its specific proof system or VM, with each upgrade gated by a custom multisig or DAO. This is slow, risky, and duplicated across the ecosystem. +Today, every Ethereum rollup maintains bespoke onchain proof verification infrastructure. ZK rollups deploy zkVM verifier contracts, adapter contracts, multi-proof dispatchers, and program whitelisting logic. Optimistic rollups ship their own onchain fraud-proof VMs (Arbitrum's WAVM, Optimism's Cannon MIPS machine) plus the surrounding dispute logic. In both cases every contract is maintained, patched, and upgraded independently in response to bugs in its specific proof system or VM, with each upgrade gated by a custom multisig or DAO. This is slow, risky, and duplicated across the ecosystem. [EIP-8025](https://eips.ethereum.org/EIPS/eip-8025) introduces zkVM proof verification on Ethereum's consensus layer, but only for L1's own purposes: verifying execution payloads to enable stateless and sublinear validation. Rollups still need their own onchain verifier contracts. -However, the infrastructure that EIP-8025 brings to the CL, the `ProofEngine`, proof gossip, and verification logic, is not inherently L1-specific. If generalized to be program-agnostic and exposed to smart contracts via a new transaction type, any rollup, even non-EVM ones, could offload proof verification to the CL. When a zkVM implementation needs to be patched, Ethereum client teams release updated software the same way bugs in geth or Nethermind are fixed today: through client releases, without a hard fork. This is the same principle behind native rollups: just as native rollups inherit L1's execution environment, native proof verification lets any rollup inherit L1's proof verification infrastructure. +However, the infrastructure that EIP-8025 brings to the CL, the `ProofEngine`, proof gossip, and verification logic, is not inherently L1-specific. If generalized to be program-agnostic and exposed to smart contracts via a new transaction type, any rollup, even non-EVM ones, could offload proof verification to the CL. When a zkVM implementation needs to be patched, Ethereum client teams release updated software the same way bugs in geth or Nethermind are fixed today: through client releases, without a hard fork. This is the same principle behind [native rollups](https://eips.ethereum.org/EIPS/eip-8079), but more generalized: just as native rollups inherit L1's execution environment, native proof verification lets any rollup inherit L1's proof verification infrastructure. + +Although this document frames the proposal around rollups, the same primitive serves any contract that verifies a ZK proof onchain: privacy systems, ZK coprocessors, identity, ZK ML, and others. ## How rollups verify proofs today @@ -110,9 +112,7 @@ contract MainnetVerifier is ComposeVerifier { ## Changes to EIP-8025 -EIP-8025 introduces optional execution proofs for L1 block validation. That functionality remains unchanged. The changes proposed here are to the underlying primitives, so that the same infrastructure can also serve the new proof-carrying transaction type. The generalization mirrors [ERE](https://github.com/eth-act/ere), whose [`zkVMVerifier`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) trait is program-agnostic and specific guest programs are built on top. - -EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#types) is a `uint8` that encodes both the zkVM backend and the guest program as a single value. In the current [Lighthouse implementation](https://github.com/eth-act/lighthouse/blob/feat/eip8025/beacon_node/execution_layer/src/eip8025/types.rs), the mapping is: +EIP-8025 introduces optional execution proofs for L1 block validation. The infrastructure it brings to the consensus layer (the `ProofEngine`, gossip, verification logic) is L1-specific only because its types are: [`ExecutionProof.public_input`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#new-publicinput) carries a `new_payload_request_root: Root`, and [`ProofType`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#types) is a `uint8` enumerating a small fixed set of accepted `(client, zkVM)` builds (see [Lighthouse implementation](https://github.com/eth-act/lighthouse/blob/feat/eip8025/beacon_node/execution_layer/src/eip8025/types.rs)): | `ProofType` | Guest program | zkVM backend | |---|---|---| @@ -124,105 +124,47 @@ EIP-8025's current [`ProofType`](https://github.com/ethereum/consensus-specs/blo | 5 | reth | SP1 | | 6 | reth | Zisk | -This works for L1 execution proofs where the set of guest programs is small and known in advance. But it cannot accommodate arbitrary rollup programs: adding a new guest program requires assigning new `ProofType` values and updating every client. +This works while the set of guest programs is small and known in advance, but cannot accommodate arbitrary rollup programs. -The proposed change splits `ProofType` into two independent axes, following [ERE](https://github.com/eth-act/ere)'s design where the [`Compiler`](https://github.com/eth-act/ere/blob/master/crates/compiler/core/src/lib.rs) and the [`zkVMVerifier`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) backend are independent. `ProofType` is renamed to `BackendType` and becomes purely about the zkVM backend, and a new `program_hash: Bytes32` field identifies the guest program. Since each zkVM compiles the same source to a different binary (see [terminology note](#how-rollups-verify-proofs-today)), `program_hash` is specific to a `(guest program, zkVM)` pair. The [`verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof) method (which verifies and stores individual proofs arriving via gossip) generalizes accordingly: +This EIP adds a generic verification primitive alongside, leaving EIP-8025's existing surface (`ExecutionProof`, `ProofType`, `verify_execution_proof`, `notify_new_payload`, `notify_forkchoice_updated`, `process_execution_proof`, `request_proofs`, `ProofAttributes`) untouched. The generalization mirrors [ERE](https://github.com/eth-act/ere), whose [`zkVMVerifier`](https://github.com/eth-act/ere/blob/master/crates/verifier/core/src/verifier.rs) trait is program-agnostic and specific guest programs are built on top. Following ERE's design where the [`Compiler`](https://github.com/eth-act/ere/blob/master/crates/compiler/core/src/compiler.rs) and the `zkVMVerifier` backend are independent traits, the new `Proof` container splits the conflated `ProofType` into two axes: a `BackendType: uint8` that identifies only the zkVM backend, and a `program_hash: Bytes32` that identifies the guest program (specific to a `(guest program, zkVM)` pair, see [terminology note](#how-rollups-verify-proofs-today)). The engine uses `backend_type` to select the circuit VK; `program_hash` is a public input to the circuit, checked alongside `public_values` during verification: ```python -# Before (current EIP-8025): -class PublicInput(Container): - new_payload_request_root: Root - -class ExecutionProof(Container): - proof_data: ByteList[MAX_PROOF_SIZE] - proof_type: ProofType # encodes both program and backend (current) - public_input: PublicInput - -def verify_execution_proof(self: ProofEngine, execution_proof: ExecutionProof) -> bool: ... - -# After (generalized): -class PublicInput(Container): - program_hash: Bytes32 # guest program (per zkVM) +class ProofPublicInput(Container): + program_hash: Bytes32 public_values: ByteList[MAX_PUBLIC_VALUES_SIZE] class Proof(Container): proof_data: ByteList[MAX_PROOF_SIZE] - backend_type: BackendType # zkVM backend only - public_input: PublicInput + backend_type: BackendType + public_input: ProofPublicInput def verify_proof(self: ProofEngine, proof: Proof) -> bool: ... ``` -`BackendType` is `ProofType` renamed (still `uint8`). The engine uses `backend_type` to select the circuit VK; `program_hash` is a public input to the circuit, checked alongside `public_values` during verification. - -A second method, `has_valid_proof`, is a cheap lookup against proofs already verified by `verify_proof`. It is what the block-processing path (via `verify_new_payload_request_header`) calls to confirm a prior verification exists for a given `(program_hash, public_values_hash)` pair. Fresh proofs, whether from gossip or proof-carrying transaction sidecars, are always ingested through `verify_proof`. +EIP-8025's [`verify_execution_proof`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_execution_proof) can be reimplemented as a thin wrapper over `verify_proof` for code sharing, with no observable change at the gossip layer: ```python -def has_valid_proof( - self: ProofEngine, - program_hash: Bytes32, - public_values_hash: Bytes32, -) -> bool: - """ - Return True if the engine holds a previously-verified Proof whose - (public_input.program_hash, sha256(public_input.public_values)) - equals the given (program_hash, public_values_hash) pair. - """ - ... -``` - -`backend_type` is not part of the lookup: `program_hash` is already per-`(source, zkVM)`, so distinct backends produce distinct hashes and no ambiguity arises. - -EIP-8025's [`verify_new_payload_request_header`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/proof-engine.md#new-verify_new_payload_request_header) (called from [`process_execution_payload`](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/beacon-chain.md#modified-process_execution_payload)) now builds the expected [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L126), hashes it, and delegates to `has_valid_proof` over the accepted L1 program hashes. The signature gains a `chain_config` parameter since `StatelessValidationResult` includes it; `process_execution_payload` supplies L1's own `ChainConfig` from the beacon state's chain spec. The expression `hash_tree_root(new_payload_request_header)` yields the same value as the root of the full `NewPayloadRequest` by construction, since the header is the roots-only form of the same container. - -```python -from ethereum.forks.amsterdam.stateless import ( - StatelessValidationResult, - ChainConfig, -) -from ethereum.forks.amsterdam.stateless_guest import ( - serialize_stateless_output, -) - -# Well-known program hashes for L1's stateless validator. Because each -# `(client implementation, zkVM backend)` pair compiles to a different binary -# (e.g. ethrex-SP1 vs reth-SP1 vs ethrex-Risc0), the engine accepts a *set*, -# not a single value. Corresponds to ERE's zkVMVerifier::program_vk() for -# each compiled variant of verify_stateless_new_payload. -NATIVE_EVM_PROGRAM_HASHES: Set[Bytes32] = {...} # configured per client - - -def verify_new_payload_request_header( - self: ProofEngine, - new_payload_request_header: NewPayloadRequestHeader, - chain_config: ChainConfig, -) -> bool: - """ - EVM-specific method kept as the entry point `process_execution_payload` - calls. Implementation now delegates to the general has_valid_proof, - iterating over every accepted L1 program hash: any one stored proof - is sufficient. - """ - expected_result = StatelessValidationResult( - new_payload_request_root=hash_tree_root(new_payload_request_header), +def verify_execution_proof(self: ProofEngine, ep: ExecutionProof) -> bool: + # Client-side mapping from the (client, zkVM) ProofType to the new axes. + # Corresponds to ERE's zkVMVerifier::program_vk() for each compiled variant + # of verify_stateless_new_payload. + backend_type, program_hash = self.resolve_proof_type(ep.proof_type) + expected_public_values = serialize_stateless_output(StatelessValidationResult( + new_payload_request_root=ep.public_input.new_payload_request_root, successful_validation=True, - chain_config=chain_config, - ) - # serialize_stateless_output() SSZ-encodes `expected_result`; the guest - # program emits the same bytes via the same helper, so their sha256 hashes - # agree by construction. - expected_public_values_hash = sha256(serialize_stateless_output(expected_result)) - - return any( - self.has_valid_proof( + chain_config=self.chain_config, + )) + return self.verify_proof(Proof( + proof_data=ep.proof_data, + backend_type=backend_type, + public_input=ProofPublicInput( program_hash=program_hash, - public_values_hash=expected_public_values_hash, - ) - for program_hash in NATIVE_EVM_PROGRAM_HASHES - ) + public_values=expected_public_values, + ), + )) ``` -On the prover side, the [honest prover guide](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/prover.md) specializes the same way: extract `NewPayloadRequest` from the beacon block and call `request_proofs` with this prover's own `program_hash` (one entry of `NATIVE_EVM_PROGRAM_HASHES` drawn from [ethrex](https://github.com/lambdaclass/ethrex), [reth](https://github.com/paradigmxyz/reth), or any other accepted implementation). +The byte-level layout of [`serialize_stateless_output`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless_guest.py#L19) over [`StatelessValidationResult`](https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L123) is shown in [Impact on native rollups](#impact-on-native-rollups), since native-rollup contracts reconstruct it onchain. Block validity remains decoupled from proof verification; the [honest prover guide](https://github.com/ethereum/consensus-specs/blob/master/specs/_features/eip8025/prover.md) is unchanged. Sidecar-arrived proofs (proof-carrying transactions, see [Proof propagation](#proof-propagation)) go through `verify_proof` directly, without the L1 wrapper. ## Program hash stability (open problem) @@ -230,8 +172,8 @@ Native proof verification's "fixes ship through client releases, nothing onchain No zkVM today delivers this directly. Both leading candidates fingerprint artifacts that change under normal SDK / dependency / toolchain churn, not just circuit-layer fixes: -- **Risc0's `imageId`** is a SHA-256 over `SystemState { pc: 0, merkle_root }` with `merkle_root` a Poseidon2 merkle root of the initial memory image, which contains both the user ELF and the kernel ELF ([binfmt/src/elf.rs](https://github.com/risc0/risc0/blob/main/risc0/binfmt/src/elf.rs)). The memory image captures the exact compiled bytes, so a dep bump, toolchain update, or kernel patch all change `imageId` even when STF semantics are unchanged. -- **SP1's `programVKey`** is a Poseidon2 over `(preprocessed_commit, pc_start, ...)` ([hypercube/src/verifier/hashable_key.rs](https://github.com/succinctlabs/sp1/blob/main/crates/hypercube/src/verifier/hashable_key.rs)). `preprocessed_commit` depends on the circuit's AIR preprocessing and `pc_start` on the linker, so circuit changes, SDK bumps, and toolchain changes all move it. +- **Risc0's `imageId`** is a SHA-256 over `SystemState { pc: 0, merkle_root }` with `merkle_root` a Poseidon2 merkle root of the initial memory image, which contains both the user ELF and the kernel ELF ([binfmt/src/elf.rs#L435](https://github.com/risc0/risc0/blob/main/risc0/binfmt/src/elf.rs#L435)). The memory image captures the exact compiled bytes, so a dep bump, toolchain update, or kernel patch all change `imageId` even when STF semantics are unchanged. +- **SP1's `programVKey`** is a Poseidon2 over `(preprocessed_commit, pc_start, ...)` ([hypercube/src/verifier/hashable_key.rs#L107](https://github.com/succinctlabs/sp1/blob/main/crates/hypercube/src/verifier/hashable_key.rs#L107)). Unlike Risc0's `imageId` (a pure hash of compiled bytes), the SP1 vk is a byproduct of running circuit setup over the ELF: `preprocessed_commit` is the AIR's preprocessing commitment and `pc_start` comes from the linker, so circuit changes, SDK bumps, and toolchain changes all move it, even when the user's guest source is byte-identical. Using either directly as the onchain `program_hash` would make every zkVM release a rollup-visible event. @@ -254,30 +196,29 @@ TransactionPayloadBody: ``` Where: -- `proofs`: a list of `(program_hash, backend_type)` pairs, one per proof in the sidecar. Each `program_hash` is a `bytes32` identifying the guest program for that specific zkVM backend (see [terminology note](#how-rollups-verify-proofs-today)). Each `backend_type` is a `uint8`. The length of this list determines `proof_count`. +- `proofs`: a list of `(program_hash, backend_type)` pairs. Each `program_hash` is a `bytes32` identifying the guest program for that specific zkVM backend (see [terminology note](#how-rollups-verify-proofs-today)). Each `backend_type` is a `uint8` and MUST be unique within the list, since two proofs from the same backend add no security. The length of this list determines `proof_count`. - `public_values_hash`: a `bytes32` hash of the program's public output (shared across all proofs, since all backends prove the same statement). -- `blob_versioned_hashes`: same semantics as [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844); used when the transaction also carries payload blobs (e.g. [EIP-8142](https://eips.ethereum.org/EIPS/eip-8142) block data). -The CL-level `Proof` carries raw `public_values` bytes; the transaction body and EVM opcodes only expose `public_values_hash`. The contract reconstructs the expected bytes and compares hashes. Two invariants tie the two views together (checked by any node that handles the sidecar: on mempool propagation, and again by the builder when assembling the block): +The CL-level `Proof` carries the raw `public_values` bytes; the transaction body (and the `PUBVALUESHASH` opcode) only expose their hash. The contract reconstructs the expected bytes and compares hashes. Two invariants tie the two views together (checked by any node that handles the sidecar, on mempool propagation and again by the builder when assembling the block): - `sidecar[i].public_input.program_hash == proofs[i].program_hash` and `sidecar[i].backend_type == proofs[i].backend_type`. - `sha256(sidecar[i].public_input.public_values) == public_values_hash`. -These are what let `has_valid_proof(program_hash, public_values_hash)` look up stored proofs by `(program_hash, sha256(public_values))`. See [Proof propagation](#proof-propagation) for how proofs reach the builder and how the L1 block proof covers them. +These bind the EVM-visible identifiers (`proofs[i].program_hash`, `public_values_hash`) to the underlying `Proof` objects passed to `verify_proof`. See [Proof propagation](#proof-propagation) for how proofs reach the builder and how the L1 block proof covers them. ### Opcodes -New opcodes read the proof-carrying transaction's fields, following the same pattern as `ORIGIN`, `GASPRICE`, and `BLOBBASEFEE` (`G_base` cost). All return zero for non-proof-carrying transactions. +New opcodes read the proof-carrying transaction's fields and return zero for non-proof-carrying transactions. | Opcode | Input | Output | Description | |--------|-------|--------|-------------| | `PROGRAMHASH` | `index` | `program_hash` (`bytes32`) | Program hash for the i-th proof. Indexed like `BLOBHASH`; returns `bytes32(0)` if `index >= PROOFCOUNT()` | | `PUBVALUESHASH` | none | `public_values_hash` (`bytes32`) | Hash of the program's public output (shared across all proofs) | -| `PROOFCOUNT` | none | `proof_count` (`uint8`) | Number of distinct zkVM proofs verified by the CL | +| `PROOFCOUNT` | none | `proof_count` (`uint8`) | Length of the transaction's `proofs` list | A custom rollup iterates with `PROOFCOUNT()` and checks each `PROGRAMHASH(i)` against its own whitelist. -For native rollups, `PROGRAMHASH(i)` returns a well-known sentinel value (e.g. `bytes32(1)`) when the i-th proof uses a program that L1 currently accepts for its own EVM execution proofs. This way the contract checks `PROGRAMHASH(i) == NATIVE_PROGRAM` without storing specific per-zkVM hashes, and automatically follows L1 upgrades. Whether the sentinel is returned depends on the L1-accepted set at the block being executed; this set is part of the client-side `ProofEngine` configuration and updates via client releases. +For native rollups, `PROGRAMHASH(i)` returns a well-known sentinel value (e.g. `bytes32(1)`) when the i-th proof uses a program that L1 currently accepts for its own EVM execution proofs. This way the contract checks `PROGRAMHASH(i) == NATIVE_PROGRAM` without storing specific per-zkVM hashes, and automatically follows L1 upgrades shipped in client releases. ### Multi-proof @@ -287,11 +228,9 @@ This replaces contract-level multi-proof orchestration (like Taiko's `ComposeVer ### Proof propagation -> This section is a very early work in progress. The design is not settled. - -The proof must reach a builder through the mempool, but needs no long-term availability. The proposed approach is an **ephemeral sidecar**: the proof travels alongside the transaction like an [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob sidecar, but the builder strips it before block inclusion, folds it into the recursive L1 block proof, and discards it. Validators see only the transaction body (the `proofs` list and `public_values_hash`) plus the L1 block proof; they never need the raw proof bytes. The L1 block proof thus recursively covers every proof-carrying transaction in the block (post-quantum proofs may be large enough that L1 is limited to one proof per slot). +The proof must reach a builder through the mempool, but needs no long-term availability. The proposed approach is an **ephemeral sidecar**: the proof travels alongside the transaction like an [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) blob sidecar. Mempool nodes and the builder run each sidecar entry through `verify_proof` (and check the invariants from [Transaction format](#transaction-format)) before forwarding or including the transaction. The builder then strips the sidecar before block inclusion, folds it into the recursive L1 block proof, and discards it. Validators see only the transaction body (the `proofs` list and `public_values_hash`) plus the L1 block proof; they never need the raw proof bytes. The L1 block proof thus recursively covers every proof-carrying transaction in the block (post-quantum proofs may be large enough that L1 is limited to one proof per slot). -**Size.** EIP-8025 sets `MAX_PROOF_SIZE = 300 KiB`. With geth's 1 MiB blob-pool cap, `len(proofs)` is effectively capped at 3 for mempool propagation. Any blobs the transaction also carries eat into the same 1 MiB budget and lower the effective cap. +**Size.** EIP-8025 sets `MAX_PROOF_SIZE = 400 KiB` per proof. The spec doesn't bound `len(proofs)`, but mempool client size limits make 2–3 a practical ceiling. ## Impact on existing rollups