Tau Lepton

zkEVM @ Ethereum Foundation

Slides and writeups on Ethereum scaling, optional execution proofs, zkEVM infrastructure, data availability, and proof-system design.

EIP-8025

Deck
Overview Deck

Visual resource deck covering the execution proof flow.

Deck
May 14, 2026
ACDC Proposal

Proposal for optional execution proofs in Hegota.

Progress
Feb 11, 2026
Feb 11 Progress

Consensus layer integration, proof engine, and proof gossip protocol.

Progress
Mar 11, 2026
Mar 11 Progress

Optional proof design and implementation progress.

Progress
Apr 8, 2026
Apr 8 Progress

Protocol updates, implementation status, and open questions.

Progress
May 13, 2026
May 13 Progress

Latest EIP-8025 progress, implementation work, and devnet status.

Progress
Jun 10, 2026
Jun 10 Progress

Weak-subjectivity proof sync, BiB PoC, Lighthouse upstreaming, and EL-IR specification.

Writeup
Lighthouse Architecture

Maintainer-facing architecture writeup for the Lighthouse EIP-8025 implementation.

Writeup
Re-signing note

Design note explaining why validator proof re-signing was deprecated.

Writeup
Proof gossip

Network writeup covering announcement, fetch, and proof-gossip tradeoffs.

Writeup
Checkpoint execution proof sync

Design note for recursive execution-proof sync from a weak-subjectivity checkpoint using BeaconChainProof and execution-proof bindings.

Post Quantum Data Availability

Deck
May 8, 2026
PQ RS Proofs with LeanVM

Proof systems for Reed-Solomon codes and data-availability sampling.

Writeup
PQ RS Proofs with LeanAIR

LeanAIR experiment proving a post-quantum Reed-Solomon data-availability commitment with only the essential Poseidon, WHIR, wiring, and row-code checks.

Writeup
Pipelined PQ blob dissemination

Short bandwidth analysis using a concrete 100 kB proof example to compare pipelined column-sample diffs against an end-of-slot burst.

Formal Verification

Deck
Jun 9, 2026
Proving the EL State Machine — EL-IR

Specifying the EL state machine as an EL-IR intermediate representation for execution proving.

Deck
Jun 2026
evm-sail — A formal specification of the EVM

Formal, executable EVM specification in Sail — one source of truth, with a kernel-interface design and extraction to Lean, Islaris, C, RISC-V and Rocq for proofs, conformance and a zkEVM guest.

EIP-8025 Lighthouse Fork — Implementation & Upstreaming Plan


1. Context

EIP-8025 introduces the full stack of components required to drive the beacon chain using execution proofs as a source of execution-payload validity. The eth-act/lighthouse optional-proofs branch is the reference CL implementation.

Specs. The CL-side spec lives in consensus-specs/specs/_features/eip8025/:

  • beacon-chain.md — beacon-chain modifications (state fields, block processing).
  • proof-engine.md — proof-engine interface (verify_execution_proof, notify_new_payload, notify_forkchoice_updated, request_proofs).
  • p2p-interface.md — gossip topic, RPC methods, ENR field.
  • prover.md — validator implements a subsystem to generate proofs.

This document describes what was implemented, how the components compose, and why the key design choices were made. A short upstreaming-plan section at the end proposes how we'd land this work in sigp/lighthouse.

Status. The implementation is feature-complete against the latest consensus-specs draft, with comprehensive unit and integration test coverage in the Rust sources and end-to-end validation on a Kurtosis devnet — including Prysm interop — using the ZKBoost GPU prover as the proof node.

2. Top-level architecture

component-map

3.1 SSZ types & domain constant

EIP-8025 SSZ types and the new Domain::ExecutionProof constant (0x0D) live in consensus/types/src/execution/eip8025.rs and consensus/types/src/core/chain_spec.rs. They follow the EIP directly.

3.2 Validity model

EIP-8025 introduces the proof engine (PE) as a second oracle for execution-payload validity, sitting alongside the existing execution engine (EE). The PE derives its verdict from execution proofs instead of re-execution. A node can run with either oracle alone or with both attached; when both verdicts are present and disagree, the EE's verdict wins — re-execution remains the canonical source of truth for soundness, and the PE is treated as an optimisation / availability path that can be disabled without compromising correctness. A misbehaving prover or buggy PE cannot fork a node that also has an EE attached.

Whether to allow a node to be operated without the EE is being formalised in two open consensus-specs PRs — LH maintainer input would be welcome:

Our fork already supports operation without an EE, but we will not propose that capability for upstream until the open question is firmly decided — making the EE optional in lighthouse touches fork-choice, sync, the engine API surface, and validation paths that today assume an EE is always attached, and the implementation overhead is high enough that we do not want to land it speculatively. Optional-EE follows once the spec is settled.

3.3 Proof-node client (execution_layer)

Purpose. Transport abstraction for the external proof node. The BN talks to a proof node over HTTP + SSZ + SSE; the trait exists primarily so the engine can be exercised in tests against a fully-controllable MockProofNodeClient.

The mock is in-process rather than a separate mock-prover binary. A standalone HTTP mock would have been a viable alternative, but it forces every test (and every devnet) to orchestrate a second service — extra ports, lifecycles, log streams. Keeping the mock behind the trait means tests run as a single binary and the network simulator (§4.3) can spin up a multi-node topology without external moving parts. To activate the in-process mock at runtime, set --proof-engine-endpoint=http://mock/{N}/ where {N} is a slot index in the mock registry — the BN startup path detects the http://mock/ prefix and looks up the pre-registered MockProofNodeClient for that slot.

Trait, HTTP impl, mock impl (execution_layer/src/eip8025/proof_node_client.rs, execution_layer/src/test_utils/mock_proof_node_client.rs):

#![allow(unused)]
fn main() {
/// Default timeout for proof node requests (1 second per spec).
pub const PROOF_ENGINE_TIMEOUT: Duration = Duration::from_secs(1);

const PATH_PROOF_REQUESTS: &str     = "/v1/execution_proof_requests";
const PATH_PROOF_VERIFICATIONS: &str = "/v1/execution_proof_verifications";
const PATH_PROOFS: &str             = "/v1/execution_proofs";

#[async_trait::async_trait]
pub trait ProofNodeClient: Send + Sync {
    /// Submit an SSZ-encoded NewPayloadRequest; returns the new_payload_request_root.
    async fn request_proofs(
        &self,
        ssz_body: Vec<u8>,
        proof_attributes: ProofAttributes,
    ) -> Result<Hash256, ProofEngineError>;

    /// Verify a single proof via the proof node.
    async fn verify_proof(
        &self,
        root: Hash256,
        proof_type: u8,
        proof_data: &[u8],
    ) -> Result<ProofStatus, ProofEngineError>;

    /// Download a completed proof by root and proof type.
    async fn get_proof(&self, root: Hash256, proof_type: u8) -> Result<Bytes, ProofEngineError>;

    /// Subscribe to SSE proof events from the proof node.
    fn subscribe_proof_events(
        &self,
        filter_root: Option<Hash256>,
    ) -> Pin<Box<dyn Stream<Item = Result<ProofEvent, ProofEngineError>> + Send + '_>>;
}

pub struct HttpProofNodeClient { /* reqwest-backed impl */ }
}

Files.

  • proof_node_client.rsProofNodeClient trait + HttpProofNodeClient reqwest impl, SSE stream parsing.
  • types.rsProofType enum, SSE event types.
  • errors.rsProofEngineError and ProofEngineStateError taxonomies (numeric error codes).
  • mock_proof_node_client.rsMockProofNodeClient + MockClientEvent for in-process tests; see §4.1, §4.3.

3.4 Proof engine (execution_layer)

Purpose. In-process owner of proof state. Consumes new payloads and execution proofs, derives a payload-validity verdict from each proof via ProofNodeClient, maintains a tree of payloads and their associated proofs, and persists state across restarts. Conceptually the proof engine is an execution-payload validity oracle in the same role as the execution engine, with the validity verdict derived from execution proofs instead of re-execution.

Engine (execution_layer/src/eip8025/proof_engine.rs):

#![allow(unused)]
fn main() {
/// Proof engine with internal proof storage.
///
/// - Stores ALL unfinalized proofs indexed by new_payload_request_root (unbounded)
/// - Delegates transport to a ProofNodeClient implementation
/// - Prunes proofs when finalization events occur
pub struct HttpProofEngine {
    proof_node: Box<dyn ProofNodeClient>,
    /// Internal state: tree-structured proof storage + buffered proofs.
    state: RwLock<State>,
    /// Buffered proofs for request roots not yet seen (arrive-before-request races).
    buffered_proofs: RwLock<HashMap<Hash256, Vec<SignedExecutionProof>>>,
}
}

Interface. The four spec-mandated methods (additional methods redacted from this document) from specs/_features/eip8025/proof-engine.md — invoked by the beacon chain's block-import and fork-choice paths:

#![allow(unused)]
fn main() {
impl HttpProofEngine {
    // (other methods elided)

    pub async fn verify_execution_proof(
        &self,
        proof: &SignedExecutionProof,
    ) -> Result<ProofStatus, ProofEngineError>;

    pub async fn new_payload<E: EthSpec>(
        &self,
        request: &NewPayloadRequest<'_, E>,
    ) -> Result<PayloadStatusV1, ProofEngineError>;

    pub fn forkchoice_updated(
        &self,
        forkchoice_state: ForkchoiceState,
    ) -> Result<ForkchoiceUpdatedResponse, ProofEngineError>;

    pub async fn request_proofs<E: EthSpec>(
        &self,
        new_payload_request: NewPayloadRequest<'_, E>,
        proof_attributes: ProofAttributes,
    ) -> Result<Hash256, ProofEngineError>;
}
}

State machine (state.rs, 1,754 LOC):

#![allow(unused)]
fn main() {
pub struct State {
    /// Latest fork-choice state received that has not yet been marked valid.
    pub latest_fcs: Option<ForkchoiceState>,
    /// The last fork-choice state that was marked valid (drives pruning).
    pub last_valid_fcs: ForkchoiceState,
    /// Tree of execution proofs over parent/children block lineage —
    /// keyed by execution block hash, with a request-root → block-hash index
    /// and a block-number → block-hashes secondary index.
    pub tree: TreeState,
    /// Buffer of unassociated proofs / requests; entries promote into `tree`
    /// once a request and ≥ `min_required_proofs` matching proofs have arrived.
    pub buffer: RequestBuffer,
    /// Minimum proofs needed to promote a request from `buffer` to `tree`.
    pub min_required_proofs: usize,
}
}

Payloads and proofs land in the buffer first; once a payload has accumulated enough matching proofs to be considered valid, it is promoted into the tree along with those proofs. The BN's existing fork-choice store could possibly back part of this more efficiently, but we kept it as an isolated tree to avoid perturbing the standard EL flow.

Persisted form (persisted_state.rs, 493 LOC):

#![allow(unused)]
fn main() {
pub const PROOF_ENGINE_STATE_VERSION: u64 = 1;
pub const PROOF_ENGINE_DB_KEY: Hash256 = Hash256::ZERO;

#[derive(Clone, Debug, PartialEq, Encode, Decode)]
pub struct PersistedProofEngineState {
    pub version: u64,
    pub tree:            PersistedTreeState,
    pub block_proofs:    PersistedBlockProofs,
    pub request_root_mapping:   RequestRootMapping,
    pub parent_children:        PersistedParentChildren,
    pub block_number_mapping:   PersistedBlockNumberMapping,
    pub request_buffer:         PersistedRequestBuffer,
}
impl StoreItem for PersistedProofEngineState { /* single SSZ blob under PROOF_ENGINE_DB_KEY */ }
}

3.5 Observed-proofs cache (beacon_chain)

Purpose. In-memory dedup cache that implements the IGNORE-2 / IGNORE-3 rules from the EIP-8025 p2p-interface spec. Checked before BLS / proof-node verification to avoid redundant work.

API (beacon_chain/src/observed_execution_proofs.rs):

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
pub struct ObservedExecutionProofs {
    /// IGNORE-2: we already have a valid proof for (request_root, proof_type).
    valid_proofs: HashMap<(Hash256, ProofType), ()>,
    /// IGNORE-3: we have already attempted verification for (root, type, pubkey).
    seen_from_validator: HashSet<(Hash256, ProofType, PublicKeyBytes)>,
    /// Slot → request-roots observed, for eviction at finalization.
    slot_to_request_roots: HashMap<Slot, HashSet<Hash256>>,
}

pub enum ProofObservation {
    /// We already have a valid proof for this (request_root, proof_type) — IGNORE-2.
    AlreadyHaveValidProof,
    /// We already saw a proof from this validator for this (request_root, proof_type) — IGNORE-3.
    DuplicateFromValidator,
    /// First time seeing this proof — proceed with verification.
    New,
}

impl ObservedExecutionProofs {
    pub fn check(
        &self,
        request_root: Hash256,
        proof_type: ProofType,
        validator_pubkey: &PublicKeyBytes,
    ) -> ProofObservation { /* ... */ }
}
}

Design decisions.

  • Slot-indexed eviction keeps the cache bounded: proofs for finalized blocks are dropped whole-slot at a time, matching existing observation-cache patterns (observed_proposers, observed_attestations).
  • Check is pure — call sites observe_verification_attempt / observe_valid_proof after verification completes. Avoids lock-holding during BLS work.
  • In-memory only — no persistence; finalization eviction means a restart costs at most one finality cycle of warm-up before the cache is repopulated by gossip.

3.6 Proof storage (beacon_node/store)

Purpose. Give the proof engine a place to checkpoint its state across restarts. A new hot/cold column, a single well-known key (Hash256::ZERO), and a schema bump.

Changes. New column added to beacon_node/store/src/hot_cold_store.rs; schema version bumped to v29. The migration (migration_schema_v29.rs) is a no-op — it just reserves the column slot. Downgrade discards the column.

Retention. Proofs older than the finalized head are not retained. The finalized head serves as the proof engine's trust anchor — anything below it is assumed valid by virtue of finality, matching the EL's finality assumption. This keeps the column bounded, the persisted state small, and the data model simple.

3.7 Gossip topic + pubsub (lighthouse_network + network)

Purpose. Single global gossip topic for execution proofs. Incoming proof messages go through the same classification path whether they arrive via pubsub or RPC.

Topic constant (lighthouse_network/src/types/topics.rs):

#![allow(unused)]
fn main() {
pub const EXECUTION_PROOF_TOPIC: &str = "execution_proof";
}

Handlers (network/src/network_beacon_processor/gossip_methods.rs):

Design

  • Single global topic, not sharded based on proof type.
  • Shared classifier between gossip and RPC ingress avoids validation drift.

3.8 RPC protocols (lighthouse_network)

Purpose. Three new libp2p RPC protocols for execution-proof request/response, decomposed on the same pattern as blocks_by_range / blocks_by_root / status.

Protocols (lighthouse_network/src/rpc/protocol.rs):

#![allow(unused)]
fn main() {
#[strum(serialize = "execution_proofs_by_range")] ExecutionProofsByRange,
#[strum(serialize = "execution_proofs_by_root")]  ExecutionProofsByRoot,
#[strum(serialize = "execution_proof_status")]    ExecutionProofStatus,

// All V1:
SupportedProtocol::ExecutionProofsByRangeV1,
SupportedProtocol::ExecutionProofsByRootV1,
SupportedProtocol::ExecutionProofStatusV1,

/// Minimum SSZ size of a SignedExecutionProof (empty proof_data):
pub const SIGNED_EXECUTION_PROOF_MIN_SIZE: usize = 4 + 8 + 96 + 37;
/// Maximum SSZ size: fixed header + MaxProofSize (409600 bytes).
pub const SIGNED_EXECUTION_PROOF_MAX_SIZE: usize = 4 + 8 + 96 + 37 + 409600;
}

Request types (lighthouse_network/src/rpc/methods.rs):

#![allow(unused)]
fn main() {
pub struct ExecutionProofsByRangeRequest {
    pub start_slot: u64,
    pub count: u64,
    /// Per-block proof-type filters. Empty means "return all proof types for every block."
    /// Blocks listed with specific types get only those types — lets the server skip proofs
    /// the requester already has.
    pub proof_filters: RuntimeVariableList<ProofByRootIdentifier>,
}

pub struct ExecutionProofsByRootRequest {
    /// Each entry identifies a block root and the proof types the requester currently
    /// has for it; the server returns only the missing types.
    pub identifiers: RuntimeVariableList<ProofByRootIdentifier>,
}

pub struct ExecutionProofStatus {
    /// Block root of the latest block verified by this peer.
    pub block_root: Hash256,
    /// Slot of the latest block verified by this peer.
    pub slot: u64,
}
}

Design decisions.

  • Three protocols, not one. Mirrors the existing blocks-family decomposition; each protocol has its own rate limiter and response-termination semantics.
  • proof_filters on ByRange lets the server skip proof types the requester already holds — critical because each proof can be up to 400 KiB. Without per-block filtering, a requester with 3 of 4 proof types would re-download the 3 they already have.
  • ExecutionProofStatus is a separate handshake from the existing Status RPC. Upstream Status advertises the peer's latest head as determined by re-execution — its view of the canonical chain. ExecutionProofStatus advertises the peer's view as determined by execution-proof verification, which proof sync uses to pick peers to fetch proofs from. Keeping the two as distinct protocols avoids any conflict with peers that don't speak the optional-proofs feature: such peers continue to participate in regular Status exchanges unchanged, and only proof-capable peers exchange ExecutionProofStatus.

3.9 Peer scoring + ENR (lighthouse_network)

Purpose. Advertise proof-node capability via an ENR field; apply appropriate score penalties for proof-protocol abuse; maintain a minimum number of connected proof-capable peers via discovery pressure.

ENR advertisement (lighthouse_network/src/discovery/enr.rs):

#![allow(unused)]
fn main() {
/// ENR field indicating execution proof node support.
pub const EXECUTION_PROOF_ENR_KEY: &str = "eproof";

pub trait Eth2Enr {
    // ... existing methods ...
    /// Whether this node has an execution proof node configured.
    fn execution_proof_enabled(&self) -> bool;
}
}

Peer manager (lighthouse_network/src/peer_manager/mod.rs):

pub const MIN_EXECUTION_PROOF_PEERS: u64 = 1;

// In the protocol-violation dispatcher:
Protocol::ExecutionProofsByRange  => PeerAction::MidToleranceError,
Protocol::ExecutionProofsByRoot   => PeerAction::MidToleranceError,
// ExecutionProofStatus is a soft informational request; rate-limiting is fine.
Protocol::ExecutionProofStatus    => return,

fn maintain_proof_capable_peers(&mut self) {
    // If we have < MIN_EXECUTION_PROOF_PEERS proof-capable peers connected,
    // trigger discovery with Subnet::ExecutionProof as the target.
}

Design decisions.

  • ENR is a single capability bool. Keeps the ENR small; proof-type negotiation happens at RPC time, not discovery.
  • MidToleranceError for ByRange/ByRoot abuse borrows the severity tier from blocks-by-range.
  • ExecutionProofStatus violations do not affect score. It's an informational exchange; abusers are rate-limited, not penalized.
  • Subnet::ExecutionProof is a capability flag, not a real gossip subnet. Type-system cleanliness argument: a Capability enum would be clearer, but reusing Subnet keeps peer bookkeeping uniform with attestation/sync-committee tracking.

3.10 Proof-sync subsystem (network)

Purpose. Dedicated catch-up mechanism for execution proofs missing from the local proof engine after block range-sync completes. Runs parallel to historical blob sync.

How "missing" is determined. The proof engine is the source of truth: each poll, the sync loop calls ProofEngine::missing_proofs(), which returns the buffer entries that don't yet have enough validated proofs to promote into the tree (i.e. fewer than min_required_proofs). The sync subsystem then chooses range vs. by-root requests over the resulting set; once promoted entries fall out of missing_proofs(), the loop drains naturally.

Core types (network/src/sync/proof_sync.rs):

#![allow(unused)]
fn main() {
/// Tracks the single in-flight ExecutionProofsByRange request.
pub(crate) struct ByRangeRequest {
    pub(crate) id: ExecutionProofsByRangeRequestId,
    pub(crate) peer_id: PeerId,
}

/// Tracks the single in-flight ExecutionProofsByRoot batch request.
pub(crate) struct ByRootRequest {
    pub(crate) id: ExecutionProofsByRootRequestId,
    pub(crate) peer_id: PeerId,
}

/// Operating mode for the proof sync subsystem.
pub enum ProofSyncState {
    /// Range sync is active; proof sync is paused.
    Idle,
    /// Proof sync is active. Each poll queries the proof engine for missing proofs
    /// and chooses between range or by-root requests based on byte-efficiency.
    Syncing,
}

/// Number of slot ticks to skip after a response stream completes before issuing
/// the next request. Lets the beacon processor import received proofs first.
const POST_REQUEST_COOLDOWN_SLOTS: u64 = 1;

pub struct ProofSync<T: BeaconChainTypes> {
    chain: Arc<BeaconChain<T>>,
    state: ProofSyncState,
    range_request: Option<ByRangeRequest>,
    root_request:  Option<ByRootRequest>,
    post_request_cooldown: u64,
    peer_statuses: HashMap<PeerId, CachedExecutionProofStatus>,
    status_in_flight: HashMap<PeerId, ExecutionProofStatusRequestId>,
    // ...
}
}

Design decisions.

  • Dedicated subsystem. Block sync is latency-critical and on the fork-choice path; stapling proof requests into its I/O budget would couple two unrelated failure modes.
  • Byte-efficiency-driven strategy. Each poll compares the SSZ size of an ExecutionProofsByRange request (20-byte fixed header + proof_filters for partially-held blocks) against an ExecutionProofsByRoot request (one identifier per missing block). Whichever encodes smaller wins. This leans on proof_filters for partial-coverage cases.
  • Post-request cooldown (POST_REQUEST_COOLDOWN_SLOTS = 1) prevents immediate re-requesting after a response stream completes — gives the beacon processor a slot to import proofs so they stop appearing in missing_proofs().
  • Two-state FSM. Idle while block range sync is active; Syncing once block range sync is complete. In-flight responses are always processed regardless of state.

3.11 HTTP API (beacon_node/http_api)

Purpose. BN endpoints for reading proof state and accepting validator-signed proofs for re-broadcast.

Handlers (beacon_node/http_api/src/eip8025.rs):

  • GET /eth/v1/beacon/proofs/execution_proofs/{block_id}get_execution_proofs — returns ExecutionProofsResponse { execution_optimistic, finalized, data: Vec<SignedExecutionProof> }.
  • POST /eth/v1/beacon/execution_proofssubmit_execution_proofs — accepts SubmitExecutionProofsRequest { proofs } and re-gossips after validation.

Design decisions.

  • Presence of a proof-node endpoint is the only gate. Both endpoints require --proof-engine-endpoint to be set — if absent, they return a clear error rather than silently succeeding with empty results.
  • Submit endpoint re-gossips after validation. Accepting a proof via HTTP semantically equals receiving it over gossip; the BN propagates it so the local validator's signature reaches the rest of the network.

3.12 Proof-signing service (validator_client)

Purpose. Watches beacon-node events and proof-node events over SSE; when a signing opportunity arrives, signs the proof with the validator's key and submits back to the BN for gossip.

API and tasks (validator_client/validator_services/src/proof_service.rs):

#![allow(unused)]
fn main() {
const PROOF_REQUEST_STALE_TIMEOUT: Duration = Duration::from_secs(300);

struct OutstandingProofRequest {
    pending_proof_types: HashSet<u8>,
    slot: Slot,
    requested_at: Instant,
}

pub struct ProofService<S: ValidatorStore, T: SlotClock> {
    inner: Arc<Inner<S, T>>,
}

struct Inner<S: ValidatorStore, T: SlotClock> {
    validator_store: Arc<S>,
    beacon_nodes: Arc<BeaconNodeFallback<T>>,
    proof_engine: Arc<HttpProofEngine>,
    slot_clock: T,
    executor: TaskExecutor,
    proof_types: Vec<u8>,
    /// Outstanding proof requests keyed by new_payload_request_root.
    outstanding_requests: RwLock<HashMap<Hash256, OutstandingProofRequest>>,
}

impl<S: ValidatorStore + 'static, T: 'static + SlotClock + Clone> ProofService<S, T> {
    pub fn start_service(self: Arc<Self>) -> Result<(), String> {
        // Spawn two SSE consumers:
        //   monitor_events_task              — BN block events
        //   monitor_proof_engine_events_task — proof-node completion events
        // ...
    }
}
}

Two concurrent tasks:

  1. Beacon event monitor subscribes to BN SSE for new blocks. On each new block: request proofs from the proof engine.
  2. Proof node event monitor subscribes to the proof node's SSE stream. On ProofComplete: fetch the completed proof, sign it, and submit to the BN. On ProofFailure: log + metric.

4. Testing & devnet evidence

4.1 Unit coverage

SSZ round-trips for every new type in consensus/types/src/execution/eip8025.rs; proof-engine behaviour against MockProofNodeClient (tests.rs, 287 LOC); verification/domain tests in proof_verification.rs; cache round-trips in observed_execution_proofs.rs; PersistedProofEngineState SSZ round-trips in persisted_state.rs.

4.2 RPC + sync integration tests

rpc_tests.rs (+208 LOC on the new protocols — size bounds, malformed input, termination semantics); range.rs sync tests (+1,001 LOC covering range + proof-sync concurrency with partial-coverage filters).

4.3 In-process integration test rig

The headline test surface is ProofEngineTestRig (testing/proof_engine/src/rig.rs), a thin wrapper over TestNetworkFixture (testing/simulator/src/test_utils/) that spins up a multi-node beacon network — proof generators, verifiers, vanilla nodes — entirely in-process, with mock execution layers and a MockProofNodeClient per node.

#![allow(unused)]
fn main() {
pub type E = MinimalEthSpec;

/// Test harness for EIP-8025 proof engine integration tests.
pub struct ProofEngineTestRig {
    pub fixture: TestNetworkFixture<E>,
}
}

TestNetworkFixture is the same machinery as the existing simulator binary, restructured so the network can be brought up inside a Rust test instead of as a separate process. Maintainers currently relying on the simulator can treat this as a drop-in replacement: same node types, same execution mock — but driven from #[tokio::test] with full async/await access. ProofEngineTestRig constructors just preset LocalNetworkParams topology fields and call .build().

#![allow(unused)]
fn main() {
pub struct TestNetworkFixture<E: EthSpec = MinimalEthSpec> {
    pub env:     TestEnvironment<E>,
    pub network: LocalNetwork<E>,
    pub config:  TestConfig,
}
}

Two parallel event streams are exposed for assertions, designed to be joined with try_join! to prove concurrent behaviour across the mock and chain sides.

1. Mock proof-node events — emitted by MockProofNodeClient per method invocation; subscribe via subscribe_client_events() (mock_proof_node_client.rs). Wrapped by MockEventStream with expect_proof_requests / _verified / _fetched helpers.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum MockClientEvent {
    ProofRequested { ssz_body: Vec<u8>, proof_attributes: ProofAttributes, root: Hash256 },
    ProofVerified  { root: Hash256, proof_type: u8 },
    ProofFetched   { root: Hash256, proof_type: u8 },
}
}

2. Internal beacon-chain events — emitted from BeaconChain ingress / sync / verification sites; subscribe via BeaconChain::subscribe_internal_events() (internal_events.rs). Wrapped by EventStream::collect_n(n, predicate, timeout).

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum InternalBeaconNodeEvent {
    /// Arrived via gossip, before dedup/verification.
    GossipExecutionProof(Arc<SignedExecutionProof>),
    /// Arrived via RPC sync, before dedup/verification.
    RpcExecutionProof(Arc<SignedExecutionProof>),
    /// Outbound `ExecutionProofsByRange` request sent to a peer.
    OutboundExecutionProofsByRange { start_slot: Slot, count: u64 },
    /// Outbound `ExecutionProofsByRoot` request sent to a peer.
    OutboundExecutionProofsByRoot { identifiers: Vec<ProofByRootIdentifier> },
    /// `verify_execution_proof` completed; carries status and (when known) block root + slot.
    ExecutionProofVerified {
        request_root: Hash256,
        status: ProofStatus,
        block: Option<(Hash256, Slot)>,
    },
    /// `verify_execution_proof` returned an error.
    ExecutionProofVerificationFailed { request_root: Hash256, error: String },
}
}

Built-in topologies (base_builder() defaults: 4 validators, 1-second slots, fulu at genesis):

ConstructorComposition
standard()1 vanilla + 1 generator + 1 verifier
sync_topology()1 vanilla + 1 generator + 1 delayed node (verifier added mid-test)
multi_generator()1 vanilla + 2 generators + 1 verifier
builder()escape hatch — full TestNetworkFixtureBuilder access

Mid-test additions via rig.add_proof_verifier_and_subscribe() — used for late-joiner sync recovery tests.

Idiomatic test shape:

#![allow(unused)]
fn main() {
let mut rig = ProofEngineTestRig::standard().await?;
rig.fixture.payloads_valid();
rig.fixture.wait_for_genesis().await?;

let mut gen_mock = rig.proof_generator_events(0)?;       // MockClientEvent stream
let mut verifier = rig.proof_verifier_chain_events(0)?;  // InternalBeaconNodeEvent stream

gen_mock.expect_proof_requests(1, Duration::from_secs(30)).await?;
verifier.collect_n(
    1,
    |e| matches!(e, InternalBeaconNodeEvent::ExecutionProofVerified { .. }),
    Duration::from_secs(60),
).await?;
}

Full coverage in testing/proof_engine/src/lib.rs: basic gossip → verify path, mid-test verifier joins (proof sync via RPC), multi-generator request fan-out, and full-network finalization with the proof pipeline running.

4.4 Devnet (Kurtosis)

Overlay config via ethpandaops/ethereum-package; full validator-signed → gossip → chain-finalized flow demonstrated with ZKBoost as the proof node.

FileRole
scripts/local_testnet/network_params_eip8025.yamlTopology fixture — baseline EIP-8025 network params
scripts/local_testnet/network_params_eip8025_zkboost.yamlTopology fixture — ZKBoost proof node (CPU)
scripts/local_testnet/network_params_eip8025_zkboost_gpu.yamlTopology fixture — ZKBoost proof node (GPU)
scripts/local_testnet/start_eip8025_testnet.shLauncher — spins up the Kurtosis devnet

5. Upstreaming plan

Fork point & drift. Merge base with upstream/unstable is 58b153cac (2026-01-16, 3 months old). Our delta is 194 files / +26,560 / −2,052 LOC across 109 commits. Upstream has advanced 466 files / +36,773 / −15,959 LOC across 180 commits, 120 of which overlap with our changes. Hottest zones by overlap: beacon_chain (21 files, 81%), network (16, 89%), lighthouse_network (16, 73%), http_api (8, 73%).

Proposed PR sequence — a rebase onto current upstream first (PR 0), then component-scoped PRs in dependency order:

#TitleScope
0RebaseRebase optional-proofs onto upstream/unstable; verify with full test suite + Kurtosis devnet before opening any follow-up PR.
1Types foundationconsensus/types::execution::eip8025 SSZ types and Domain::ExecutionProof.
2Proof-node clientProofNodeClient trait, HttpProofNodeClient, transport types, error taxonomy, MockProofNodeClient.
3Proof engine + storageHttpProofEngine, tree-structured state machine, store column + schema v29 (migration co-located with its first writer).
4HTTP APIBN execution-proof read/submit endpoints.
5Gossip + verificationPubsub topic + routing, shared classifier, BLS verification, observed-proofs cache, beacon_chain block-import hooks — receive-side ingress wired end-to-end.
6Validator proof-signing serviceProofService, SSE event loop, signing-method extension.
7In-process integration test rigProofEngineTestRig, TestNetworkFixture, InternalBeaconNodeEvent broadcast channel, EventStream helpers, built-in multi-node topologies.
8RPC + peer scoring + ENRThree new req/resp protocols, ENR capability bit, peer scoring, discovery pressure.
9Proof-sync subsystemProofSync poller, sync-manager integration, network-context plumbing.

6. References

Concrete code surface for maintainers. All paths are at eth-act/lighthouse @ c4215e57f; the § column points back to where each file is discussed in this document.

CrateFileDescription§
consensus/typessrc/execution/eip8025.rsExecutionProof, SignedExecutionProof, PublicInput, ProofByRootIdentifier, MIN_REQUIRED_EXECUTION_PROOFS§3.1
consensus/typessrc/core/chain_spec.rsDomain::ExecutionProof (0x0D) constant§3.1
execution_layersrc/eip8025/proof_node_client.rsProofNodeClient trait + HttpProofNodeClient§3.3
execution_layersrc/eip8025/proof_engine.rsHttpProofEngine + spec-mandated methods§3.4
execution_layersrc/eip8025/state.rsState, TreeState, RequestBuffer§3.4
execution_layersrc/eip8025/persisted_state.rsPersistedProofEngineState, SSZ round-trip§3.4
execution_layersrc/eip8025/types.rsProofType enum, SSE event types§3.3
execution_layersrc/eip8025/errors.rsProofEngineError, ProofEngineStateError§3.3
execution_layersrc/eip8025/tests.rsengine-vs-mock unit tests§4.1
execution_layersrc/test_utils/mock_proof_node_client.rsMockProofNodeClient, MockClientEvent§3.3, §4.1, §4.3
execution_layersrc/test_utils/mock_event_stream.rsMockEventStream test helper§4.3
beacon_chainsrc/eip8025/proof_verification.rscompute_execution_proof_domain, verify_signed_execution_proof_signature§3.5
beacon_chainsrc/observed_execution_proofs.rsIn-memory dedup cache§3.5
beacon_chainsrc/internal_events.rsInternalBeaconNodeEvent broadcast channel for tests§4.3
beacon_chainsrc/schema_change/migration_schema_v29.rsStore schema bump§3.6
storesrc/hot_cold_store.rsNew DBColumn::ProofEngine for PersistedProofEngineState§3.6
lighthouse_networksrc/rpc/methods.rsExecutionProofsByRangeRequest, ExecutionProofsByRootRequest, ExecutionProofStatus§3.8
lighthouse_networktests/rpc_tests.rsRPC protocol coverage for the three new methods§4.2
networksrc/network_beacon_processor/gossip_methods.rsprocess_gossip_execution_proof (L1882), process_rpc_execution_proof (L2092), classify_execution_proof_error (L2203), should_process_execution_proof (L2284)§3.7
networksrc/sync/proof_sync.rsProofSync, ProofSyncState, byte-efficiency strategy§3.10
networksrc/sync/tests/range.rsRange-sync + proof-sync concurrency tests§4.2
http_apisrc/eip8025.rsget_execution_proofs, submit_execution_proofs handlers§3.11
validator_clientvalidator_services/src/proof_service.rsProofService, SSE consumers, signing-method extension§3.12
testing/proof_enginesrc/rig.rsProofEngineTestRig + standard() / sync_topology() / multi_generator() / builder() topologies§4.3
testing/proof_enginesrc/lib.rstest_proof_engine_basic, test_proof_engine_sync, test_multi_generator_proof_requests, test_network_finalizes_with_proofs§4.3
testing/simulatorsrc/test_utils/TestNetworkFixture + builder, per-node mock execution layer plumbing§4.3
testing/simulatorsrc/test_utils/event_stream.rsEventStream::collect_n(n, predicate, timeout) helper§4.3

EIP-8025: The Missing Trust Anchor for Proof Load

Status: draft for discussion Related: consensus-specs#5055 Author: Frankie (@frisitano)


TL;DR

Current Ethereum has an implicit trust anchor — a single proposer per slot — that bounds how much block-validation work a peer can be forced to do per slot. EIP-8025 introduces optional execution proofs and anchors them via validator signatures (each proof is stake-bound to a validator key). The open question this doc raises is whether that is sufficient: because any validator can sign any proof, there is no equivalent per-slot cap on proofs, and an attacker with multiple validators can amplify proof-validation load at will — creating a denial-of-service vector against proof-verifying nodes.

This doc frames the problem, lays out two candidate answers — p2p peer-level banning and validator-level banning — and argues that the key open question is whether peer-layer banning alone is sufficient.


Terminology

  • Trust anchor — an in-protocol mechanism that bounds a per-slot quantity by tying it to a stake-bound, rate-limited choke point.
  • Proof validating node — a validator that opts into validating execution proofs.
  • Resigning — replacing the signature carried with a gossiped proof so that the propagating validator, not the original signer, is accountable for what they forwarded.

1. Background: the implicit trust anchor

In the current Ethereum protocol:

  • Each slot has exactly one designated proposer.
  • Honest nodes only accept blocks from that proposer for the slot.
  • Therefore, the number of distinct blocks a peer expects to process per slot is bounded at ≈1 (with occasional forks).

The proposer selection mechanism is the trust anchor: it is in-protocol, it is bound to stake, and it is rate-limited by the slot schedule. Everything downstream — block validation, state transition , etc— inherits a bound from this single choke point.

Current Ethereum: proposer is the trust anchor

Figure 1 — One proposer per slot → one block per slot → bounded peer work per slot.


2. What changes under EIP-8025

EIP-8025 makes proof generation optional:

  • Block builders are not required to produce proofs.
  • Any validator may produce and sign a proof for any block.
  • Proofs propagate over the p2p network and are consumed by proof validating nodes (and other interested consumers).

Critically, there is no protocol-level constraint on how many distinct proofs exist per slot. If the trust anchor in §1 bounded the number of blocks, nothing bounds the number of proofs.


3. The attack

An adversary with access to stake can:

  1. Acquire M validators by depositing stake.
  2. Use each validator to sign invalid proofs for blocks.
  3. Gossip those invalid proofs over the p2p network.
  4. Honest proof validating nodes who receive these proofs must verify them (~80-200ms each)

The attacker's cost is O(stake); the victims cost scales with proof validation time.

EIP-8025 attack surface: unbounded proofs per slot

Figure 2 — N validators at the top, each producing its own envelope (p_i, sig_Vi) that fans into the proof-validating node. With no in-protocol cap, an attacker controlling many validators can amplify this load at will.


4. Candidate answer A: p2p peer-level banning

Mechanism:

  • Invalid proofs are detected at the p2p layer.
  • The peer (not the validator) is scored down / banned.
  • Attacker gains no persistent ability to force work; they can churn peer identities but not validator stake.

Argument for sufficiency:

  • Peer-level banning is already the standard tool for gossip-layer abuse.
  • No new consensus-layer machinery is needed.
  • Validator-level accountability is heavier than the problem calls for.

Open question this leaves:

  • Can an attacker with M validators produce invalid proofs faster than peer-banning can propagate identity bans? If peer identity is cheap to rotate but validator-to-peer binding is not enforced, the attacker may simply rotate peer identities while continuing to abuse validator signatures.

5. Candidate answer B: validator-level banning

Mechanism. If a validator signs an invalid proof, peers ignore further proofs signed by that validator key (possibly with time-based expiry). Attacker cost now scales with invalid proofs issued — each validator burns its key after one.

Problem — partition via selective gossip. Because banning keys off the original signature, an attacker can weaponise it: Alice sends (p_bad, sig_A) to Bob (Bob bans key A), then (p_good, sig_A) to Carol; when Carol forwards Alice's valid envelope to Bob, Bob drops it — sig_A is banned. One invalid proof has censored a valid one, and scaled up this partitions which honest peers see which valid proofs.

Selective-partition attack on naive validator-banning

Figure 3 — Alice sends (p_bad, sig_A) to Bob (invalid, red envelope) and (p_good, sig_A) to Carol (valid, green envelope). When Carol forwards (p_good, sig_A) to Bob, Bob drops it: the ban is keyed on sig_A, not on the proof data.

Proposed fix — resigning at each hop. Each forwarding validator replaces the signature on the gossiped envelope with their own before forwarding. Bob evaluates the proof against Carol's signature, not Alice's, so a ban on the originator cannot suppress valid content an honest validator chose to forward. Accountability follows the last signer, and validators stake their own key on what they re-sign.

Resigning fix: Carol re-signs before forwarding

Figure 4 — Same scenario with resigning. Carol strips sig_A and adds sig_C, so the envelope Bob receives is (p_good, sig_C). Proof data is unchanged; only the signature flips, and Bob's ban on key A no longer censors it.

Open questions:

  • Protocol complexity cost.
  • Per-hop latency and bandwidth overhead.
  • Net effect on signature-verification load vs the DoS surface it closes.

6. Future context: mandatory proofs restore the trust anchor

Under mandatory proofs, proof generation moves into the block-building pipeline itself: the builder constructing a block for a given slot is also responsible for producing its execution proof. With exactly one builder per slot, the builder key becomes the trust anchor — analogous to how the single proposer bounds blocks per slot today. Honest nodes accept only proofs signed by the slot's builder key, and the number of proofs a peer expects to attempt to verify per slot collapses back to O(1).

The DoS surface described in §3 therefore exists only while EIP-8025 is optional. It dissolves naturally once proofs are mandatory: without the ability for an arbitrary validator to sign a proof for any block, the attacker can no longer amplify verification load. Both candidate answers — peer-level banning (§4) and validator-level banning (§5) — should be read as transitional mechanisms that buy time between the optional-proof rollout and mandatory proofs, not as long-term protocol additions.

EIP-8025: Bandwidth and the Announce+Fetch Question

Status: draft for discussion Related: consensus-specs#5077 · kev's HackMD summary Source threads (Eth R&D, #l1-zkevm-protocol): Prover whitelist · Consensus specs PR and discussion · Consensus specs update Author: Frankie (@frisitano)


TL;DR

EIP-8025 proofs are large — up to ~400 KiB each, with up to four proof types per payload. The current (v1) gossip design floods every proof to every peer subscribed to the topic, which forces nodes to download the full payload before they can decide it was irrelevant or duplicate. The proposed alternative is an announce+fetch pattern: gossip a small announcement ("I have proof X for block Y"), and let interested peers pull the proof body via RPC only if they need it.

This doc frames the bandwidth problem and enumerates the concrete design options (A–C) that emerged on Discord. Tracking issue: consensus-specs#5077.


1. Background: the full-gossip design

The consensus-specs for EIP-8025 places each SignedExecutionProof on a gossipsub topic, flooded to every peer subscribed to proofs. This gives the simplest possible property: if a proof exists on the network, every subscribed peer sees it.

The cost is that every peer downloads every proof, regardless of whether they already have a valid proof for that block, whether they care about that proof type, or whether the proof is duplicate / redundant. With proof sizes in the 100s of KiB and up to four proof types per payload, a naïve estimate of the steady-state bandwidth per peer is:

bw_peer ≈ proofs_per_slot × avg_proof_size × fanout

For a healthy network with multiple competing provers per slot, this can eat a significant share of the bandwidth budget — and most of those bytes are discarded. The motivation for announce+fetch is exactly this: move the decision "do I want this proof?" to before the body transfer, not after.


2. The announce+fetch idea

Rather than flood every proof body, peers broadcast a small announcement — (proof_id, block_root, proof_type, …) — and interested peers pull the body via RPC or an IWANT-style request. The body transfers point-to-point rather than on the broadcast topic. The framing was introduced by @raulvk: "the main problem is that proofs are large and the gossip, defined as-is, forces nodes to download the whole message before they can decide whether it was useful or not."

The design question is how to shape this. Three concrete options (plus one alternative that reaches the same bandwidth goal by a different route) emerged on Discord; none was picked, and the viable ones were acknowledged as reasonable starting points for a post-ethp2p design.

Option A — hack gossipsub's partial-messages extension

Use the existing gossipsub partial-messages feature to advertise proof types per slot. Ruled out by @raulvk: partial-messages is designed to reconcile inner parts of one canonical message (e.g. blob chunks of a block), not to announce independent full-size payloads. Wrong tool for the job.

Option B — IHAVEMETA + IWANT (Raúl's preferred direction)

Proposed by @raulvk: introduce a new gossipsub message variant, IHAVEMETA, that behaves like IHAVE but carries application-defined metadata. Peers inspect the metadata (e.g. proof_id, block root, proof type) and use existing IWANT to pull only the proofs they want.

  • Pros: closest to gossipsub's native idiom; scales with the gossipsub mesh; generic enough to serve other large-payload use cases (blob refs, DA samples).
  • Cons: needs an upstream gossipsub spec change

Option C — share-gossip (split-and-distribute)

An alternative flagged during the v1-freeze discussion (@taulepton_): rather than announce+fetch, share-split each proof (analogous to execution-payload distribution plans) and gossip the shares. Each peer receives and forwards a small fraction of each proof; peers reconstruct the full body from enough shares.

  • Pros: bounded per-peer bandwidth by construction; no pull round-trip on the critical path; aligns with the share-gossip direction execution payloads are already heading.
  • Cons: different tradeoff space from A/B; reconstruction latency and share-availability become the dominant concerns; entangles the proof layer with whichever share-gossip primitives execution-payload distribution ends up using.

Weak-Subjectivity Checkpoint Execution Proof Sync

Status: draft for discussion Related: EIP-8025 Optional Proofs, EIP-8237 Author: Tau Lepton


TL;DR

Upon joining a beacon chain from a trusted weak-subjectivity checkpoint, a node must verify that the post-checkpoint chain it syncs has valid execution payloads. EIP-8025 gives us per-payload execution proofs over new_payload_request_root. We propose using recursive proofs to provide CL / EL binding and weak-subjectivity checkpoint to head chain linking.

The suggested approach is:

  • Keep the base EIP-8025 execution proof interface unchanged: an execution proof proves Engine API validation for new_payload_request_root.
  • Add a BeaconChainProof layer above the execution proof: each step verifies one parent BeaconChainProof, one execution proof, and one compact beacon/execution binding.
  • Bind the execution proof to the beacon chain by reconstructing NewPayloadRequestHeader from BeaconBlockExecutionBinding and checking that its root equals the execution proof's new_payload_request_root.

1. The Problem

Checkpoint sync gives a node a trusted weak-subjectivity checkpoint and lets it sync consensus history after that checkpoint. Under Gloas / EIP-7732, a node may be able to range-sync beacon blocks without downloading all historical execution payload data.

That removes the ordinary local check:

CL block commits to execution payload
EL computes the execution block hash
engine_newPayload validates the payload

EIP-8025 gives us the execution side of that story. A per-payload proof can prove:

NewPayloadRequest
  + execution witness
  + chain config
  -> successful Engine API validation

A joining node needs to know that the validated payload belongs to the beacon chain extending from the weak-subjectivity checkpoint. The recursive proof should therefore sit above the execution proof: it verifies the execution proof and binds its public input to a beacon chain.


2. The Sync Design Space

The weak-subjectivity checkpoint is the trust base. Everything before it is accepted under the normal weak-subjectivity model. Everything after it needs an execution-validity check.

Option A: per-block verification

The direct path is to verify execution proofs block by block from the checkpoint to the head:

for each block from checkpoint to head:
    verify accepted execution proof(s)
    check the proof binds to the beacon chain

This can use k of n proofs per payload if the sync policy wants multiple proof systems. It preserves the base EIP-8025 interface, but it makes the syncing node perform work proportional to the length of the range.

Option B: beacon-chain proof recursion

A prover can aggregate the same block-by-block checks into a rolling BeaconChainProof:

BeaconChainProof_i =
    extend_chain(BeaconChainProof_{i-1}, ExecutionProof_i, BeaconBlockExecutionBinding_i)

The proof commits to the following public input:

class BeaconChainProofPublicInput(Container):
    ws_checkpoint_root: Root
    ws_checkpoint_slot: Slot
    head_root: Root
    head_slot: Slot

This public input states that the proof covers a beacon-chain range from the weak-subjectivity checkpoint to the head.


3. Quantifying The Naive Path

The naive sync path is expensive even with a moderate proof-size assumption.

Using the Electra weak-subjectivity reference table, a mainnet-scale active validator set gives a weak-subjectivity period of 3,532 epochs. Mainnet has 32 slots per epoch, so a full weak-subjectivity window covers:

3,532 epochs * 32 slots/epoch = 113,024 slots

At 12 seconds per slot this is about 15.7 days of beacon-chain history. Not every slot has a block, but treating every slot as carrying one payload is a useful upper-bound planning estimate.

If each execution proof is 250 KiB, then fetching one proof per payload for the full window costs:

113,024 payloads * 250 KiB/proof = 28,256,000 KiB ~= 26.9 GiB

The cost scales linearly with the proof policy:

Sync policyProof bytes over one WS window
1 proof per payload~26.9 GiB
2 proofs per payload~53.9 GiB
4 proofs per payload~107.8 GiB

4. Gossip-Only Recursive Proofs

One possible simplification is to remove the request/response protocol for individual execution proofs entirely. In that model, proof-syncing nodes do not request or gossip per-payload ExecutionProof objects. The network gossips recursive BeaconChainProof objects instead.

This has useful implications for proof sync. The execution proofs become prover-side inputs to the recursive proof construction, not sync artifacts that every verifier must download:

beacon block gossip
    + BeaconChainProof gossip
    -> verify BeaconChainProof
    -> accept public input:
       ws_checkpoint_root, ws_checkpoint_slot, head_root, head_slot

Long-range proof sync then does not require requesting every execution proof from the weak-subjectivity checkpoint to head. A verifier only needs a gossiped BeaconChainProof whose public input covers the weak-subjectivity checkpoint and the claimed head. The recursive proof internally attests that each step verified the required execution proof and binding.

This removes most requirements for an execution-proof request/response protocol. The remaining network requirements are a gossip topic for BeaconChainProof, validation/ignore rules for recursive proofs, and the CL/EL binding inside extend_chain.


5. Base And Recursive Boundaries

The base execution proof remains an Engine API proof. Its public input continues to include new_payload_request_root:

ExecutionProof:
    public input: new_payload_request_root
    statement: engine_newPayload(request) succeeds

The recursive layer is a beacon-chain proof. It does not re-execute the payload. It verifies the execution proof, checks beacon parent chaining, and checks that the execution proof's request root is the request committed by the beacon block being added.

BeaconChainProof step:
    parent BeaconChainProof
    + one ExecutionProof
    + one BeaconBlockExecutionBinding
    -> BeaconChainProofPublicInput

6. Binding The Execution Proof To The Beacon Block

An exact binding scheme still needs to be refined but the following data types are of primary relevance:

class BeaconBlockExecutionBinding(Container):
    beacon_header: BeaconBlockHeader
    execution_payload_header: ExecutionPayloadHeader
    signed_execution_payload_bid: SignedExecutionPayloadBid

ExecutionPayloadHeader is intentionally a header. It carries transactions_root, not the transaction bytes. The recursive guest should not open all transaction data. The execution proof handles execution validity; the recursive proof only needs enough beacon-committed data to bind that execution proof to the beacon block.

The guest reconstructs the request header:

class NewPayloadRequestHeader(Container):
    execution_payload_header: ExecutionPayloadHeader
    versioned_hashes: List[VersionedHash, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    parent_beacon_block_root: Root
    execution_requests_root: Root

Then it checks:

new_payload_request_root = hash_tree_root(new_payload_request_header)
assert new_payload_request_root == execution_proof.public_input.new_payload_request_root

7. extend_chain

extend_chain is the one-step recursive transition:

def extend_chain(
    parent_beacon_chain_proof: BeaconChainProof,
    execution_proof: ExecutionProof,
    execution_binding: BeaconBlockExecutionBinding,
) -> BeaconChainProofPublicInput:
    parent = proof_engine.verify_beacon_chain_proof(parent_beacon_chain_proof)
    execution = proof_engine.verify_execution_proof(execution_proof)

    header = execution_binding.beacon_header
    header_root = hash_tree_root(header)

    assert execution.execution_status == ExecutionStatus.SUCCESS
    assert header.parent_root == parent.head_root
    assert header.slot > parent.head_slot

    # TODO: refine this into the exact SSZ body-root opening check.
    # The binding should be proven as data committed by header.body_root.
    assert is_execution_binding_committed_by_body_root(
        header.body_root,
        execution_binding,
    )

    new_payload_request_header = NewPayloadRequestHeader(
        execution_payload_header=execution_binding.execution_payload_header,
        versioned_hashes=compute_versioned_hashes(execution_binding),
        parent_beacon_block_root=parent.head_root,
        execution_requests_root=compute_execution_requests_root(execution_binding),
    )

    assert (
        hash_tree_root(new_payload_request_header)
        == execution.public_input.new_payload_request_root
    )

    return BeaconChainProofPublicInput(
        ws_checkpoint_root=parent.ws_checkpoint_root,
        ws_checkpoint_slot=parent.ws_checkpoint_slot,
        head_root=header_root,
        head_slot=header.slot,
    )

8. update_checkpoint

update_checkpoint moves the weak-subjectivity checkpoint forward to a block already covered by the beacon-chain proof:

def update_checkpoint(
    beacon_chain_proof: BeaconChainProof,
    checkpoint_root: Root,
    checkpoint_slot: Slot,
    membership_proof: CheckpointMembershipProof,
) -> BeaconChainProofPublicInput:
    public_input = proof_engine.verify_beacon_chain_proof(beacon_chain_proof)

    assert is_checkpoint_in_beacon_chain_proof_range(
        beacon_chain_proof,
        checkpoint_root,
        checkpoint_slot,
        membership_proof,
    )

    return BeaconChainProofPublicInput(
        ws_checkpoint_root=checkpoint_root,
        ws_checkpoint_slot=checkpoint_slot,
        head_root=public_input.head_root,
        head_slot=public_input.head_slot,
    )

The range-membership helper may need an MMR over proven beacon roots to avoid linear membership witnesses:

def is_checkpoint_in_beacon_chain_proof_range(
    beacon_chain_proof: BeaconChainProof,
    checkpoint_root: Root,
    checkpoint_slot: Slot,
    membership_proof: CheckpointMembershipProof,
) -> bool:
    # TODO: Consider using an MMR over proven beacon roots to make this
    # membership check efficient for long ranges.
    raise NotImplementedError

MMR suitability

A Merkle Mountain Range may be a good fit for this part of the scheme because the beacon chain appends one new block root at a time. extend_chain can append block_root to an accumulator, and update_checkpoint can use an inclusion proof to show that (checkpoint_slot, checkpoint_root) is inside the accumulated range. Efficient ancestry lookups is a useful feature that users can benefit from outside of this specific use case.

This gives efficient dynamic ancestry checks:

  • appending a new beacon root does not require rebuilding the accumulator;
  • proving that a checkpoint root appears in the proven range is logarithmic in the range length;
  • the recursive parent-root checks prove chain order; the MMR gives efficient lookup.

9. Fork And Config Requirements

The execution proof still owns Engine API semantics and fork/config correctness.

The recursive guest needs to know how to constrain the forks at fork boundaries. The fork-specific may belong at the binding layer in the recursive guest.

Execution layer maintainers should propose an interface aligned with the current data model to support the requirements of a beacon chain proof.

Post Quantum Proofs of Reed-Solomon Codes with LeanAIR

Status: research writeup Code: crates/lean-air Implementation: frisitano/leanMultisig@232308f2 Author: Tau Lepton


TL;DR

LeanAIR evaluates a direct prover for a post-quantum Reed-Solomon data-availability commitment. On a 192-vCPU Graviton4 instance, the LeanAIR single-proof measurement is about 7.5 MiB/s, compared with the available single-proof lean-da measurements of 1.37 MiB/s for the LeanVM baseline parity check and 1.19 MiB/s for the LeanVM column-commit parity check. LeanAIR also reaches about 25 MiB/s aggregate proving throughput using parallel batching, but parallelized lean-da proof generation was not measured here. The prover is organized around the commitment-level operations:

  1. hash each fixed-size codeword cell;
  2. chain systematic cell digests into row commitments;
  3. Merkle-commit the cell columns;
  4. bind row and column roots into one public commitment;
  5. prove each row is a Reed-Solomon codeword using the barycentric parity identity.

LeanVM Construction 3 commitment shape

The resulting proof uses a dedicated Poseidon table, WHIR openings of that table, and two families of virtual linear consistency claims. The row low-degree test is expressed as a sparse linear claim over the same committed trace columns that feed the cell hashes, so the values checked for Reed-Solomon validity are exactly the values committed by the cell and column commitments.


1. The Experiment

The earlier implementation used a LeanVM program:

  • it witnesses row codewords;
  • calls Poseidon precompiles for cell hashes and commitments;
  • computes barycentric row checks inside VM execution;
  • proves the whole VM trace.

In that implementation, the proof statement includes general VM execution components in addition to the commitment-specific computation:

  • instruction decoding;
  • bytecode accumulation;
  • memory indirection;
  • generic precompile request routing;
  • repeated extension arithmetic through the VM execution model.

LeanAIR keeps the same proof backend family, field stack, Poseidon permutation, WHIR PCS, and Fiat-Shamir transcript shape, but removes the VM layer. The witness is lowered directly into a dedicated table whose rows are Poseidon16 compressions and whose columns are the Poseidon round witnesses.

The design represents the cryptographic and coding-theoretic work directly in the proof trace. Once the table layout is public, the proof can be organized around three ingredients: local Poseidon constraints, global linear wiring constraints, and a Reed-Solomon row check.

LeanAIR pipeline

The current implementation is centered on:

crates/lean-air/src/hash_table.rs
crates/lean-air/src/proof.rs

The command-line benchmark entry point is:

crates/lean-air/src/main.rs

2. Commitment Shape

LeanVM Construction 3 commitment shape

LeanAIR proves the commitment shape shown above. Each Reed-Solomon row is split into fixed cells, each cell is hashed into the digest grid entry q[i,j], and only the systematic cell digests feed the row commitments rowCommit_i -> R_rows.

Column commitments are built over every cell-digest column, giving col_j -> R_col. The public commitment is the final binding D = H(R_rows, R_col). A cell opening verifies one cell through its column-tree path, while a column opening recomputes the column root from the full opened digest column.


3. Trace, WHIR, and Virtual Claims

LeanAIR proves the diagrammed commitment by lowering every required hash into one deterministic Poseidon16 trace. The AIR constrains each hash row as a valid Poseidon compression, WHIR commits to the resulting trace columns, and sumcheck enforces the non-local wiring that connects cell digests into row commitments, column Merkle roots, the final commitment D, and the Reed-Solomon row check.

The public row schedule is fixed by the commitment shape:

sectionmeaningrow count
Cellhash every row cell, including incremental chunksn_rows * num_cells * chunks_per_cell
SystematicRowchain systematic cell digests into each row digestn_rows * num_systematic_cells
RowRootchain row digests into R_rowsn_rows
ColumnMerkleMerkle tree per cell columnnum_cells * (padded_rows - 1)
ColumnRootMerkle tree over column rootsnum_cells - 1
FinalRootbind row and column roots into D1

LeanAIR Poseidon trace chip

The committed table columns are flattened into one global multilinear polynomial:

global[column * padded_rows + row] = trace[column][row]

WHIR commits to this polynomial. The verifier samples a row-domain point, receives all column evaluations at that point, and checks one batched claim:

  1. local Poseidon AIR constraints;
  2. dense cell-chain links;
  3. sparse virtual linear constraints for schedule wiring and row parity.
claim = AIR_claim + eta * cell_link_claim + eta^2 * sparse_linear_claim

The dense links cover incremental cell hashes:

output(prev_chunk)[limb] = input(next_chunk)[limb]

The sparse links cover the public hash schedule:

  • systematic row digests read cell hash outputs;
  • row-root rows read row digest outputs;
  • column Merkle parents read child digest outputs;
  • the column-root tree reads column roots;
  • the final root reads R_rows and R_col.

The row low-degree test is another sparse linear identity over the same committed columns. For each row codeword C_i, let w be a primitive 2M-th root of unity and u = w^2. The even and odd halves define:

L_i(X): interpolates {(u^j,       C_i[2j])}
R_i(X): interpolates {(w * u^j,   C_i[2j+1])}

A valid row satisfies L_i(X) = R_i(X). At a verifier challenge r, the barycentric form is:

sum_j slice_L[j](r) * C_i[2j]  -  sum_j slice_R[j](r) * C_i[2j+1] = 0

with:

slice_L[j](r) =  (r^M - 1) / (r * u^{-j} - 1)
slice_R[j](r) = -(r^M + 1) / (r * w^{-1} * u^{-j} - 1)

LeanAIR aggregates all rows using a challenge derived from the public commitment:

alpha = Poseidon16("LEANAIR row alpha" || log_m || n_rows || cell_len_ext || D)

sum_i alpha^i * (
    sum_j slice_L[j](r) * C_i[2j]
  - sum_j slice_R[j](r) * C_i[2j+1]
) = 0

The binding detail is that this parity identity does not read a separate codeword witness. The implementation maps each codeword limb:

C_i[t].limb_k

to the exact (trace_row, input_column) where that limb is absorbed by the Cell hash row. The parity check therefore reads the same values that are hashed into cell digests, then chained into row roots and column Merkle roots.

Parity linear claim

The sparse schedule and parity checks are combined as:

sparse_linear_claim =
    parity_claim + link_mix * hash_link_claim

This gives lookup-like binding without a generic logup or VM memory table. The schedule is public, so both prover and verifier derive the linear coefficients from public shape and transcript challenges. The values being checked are WHIR-committed trace columns.

This representation does not include:

  • a separate lookup table;
  • memory index columns;
  • table-inclusion checks for every cell and digest edge.

This is a specialized representation for a fixed public schedule rather than a general lookup system.


4. Minimal Proving Machinery

The proof has three layers.

4.1 Poseidon permutation constraints

The LeanAirDedicatedHashAir table constrains each row to be a valid Poseidon16 compression:

input[16] -> Poseidon1-16 -> output[8]

It stores enough intermediate round state to keep constraints low degree:

  • beginning full-round post-states;
  • partial-round first-lane values;
  • ending full-round post-states;
  • final output limbs.

The partial-round block uses the backend low-degree AIR path:

low_degree_air() = Some((3, DEDICATED_HASH_PARTIAL_ROUNDS))

This keeps the partial-round constraints at degree 3 inside the AIR sumcheck.

4.2 Public root constraints

The final row of the Poseidon schedule must output the public commitment:

trace[output_limb][final_row] = D[limb]

These are passed to WHIR as SparseStatement::unique_value(...) constraints.

4.3 Virtual linear schedule constraints

The schedule constraints are not local AIR transitions. They are global linear identities over the committed columns:

source_digest_limb - sink_input_limb = 0
zero_padding_limb = 0
parity_weighted_codeword_limb_sum = 0

They are proved by the dense and sparse OuterSumcheckSessions and then tied back to WHIR openings at the sumcheck point.

The split is:

  • Poseidon arithmetic is local and belongs in AIR;
  • deterministic schedule wiring is global and represented as a virtual linear claim;
  • row low-degree testing is naturally a global linear identity.

5. Benchmark Snapshot

The latest Graviton runs give the current performance shape.

machinevCPUhighest observed aggregate wall throughputhighest observed aggregate prove throughput
c8g.16xlarge6412,774 KiB/s13,692 KiB/s
c8g.48xlarge19225,117 KiB/s27,020 KiB/s

At the matching 101 row/blob payload on the 48xlarge, the available measurements cover different execution modes:

measured configurationexecution modethroughput
LeanVM baseline parity checksingle proof1,370 KiB/s
LeanVM column-commit parity checksingle proof1,186 KiB/s
LeanAIR direct proversingle proof~7.5 MiB/s
LeanAIR highest-throughput batchparallel batch25,117 KiB/s

The LeanAIR aggregate result uses parallel batching across independent proofs. Parallelized lean-da proof generation was not measured here, so the table reports the observed configurations rather than an intrinsic speedup ratio between the two implementations. The LeanAIR single-proof value is recorded as approximate because the exact run artifact is not present in the local repo.


6. Commands

Single proof:

target/release/lean-air \
  --log-m 13 \
  --n-rows 101 \
  --cell-len-ext 512 \
  --prove \
  --runs 5 \
  --table

48xlarge batch shape used for highest wall throughput:

target/release/lean-air bench-parallel \
  --log-m 13 \
  --n-rows 101 \
  --cell-len-ext 128 \
  --concurrency 32 \
  --rayon-threads 6 \
  --runs-per-worker 3 \
  --pin

LeanVM parity-check comparisons:

target/release/lean-da --n-blobs 101 --construction baseline
target/release/lean-da --n-blobs 101 --construction column-commit

Pipelined PQ blob dissemination

Status: research note Related: Ethereum DAS blob proof dissemination Author: Tau Lepton


1. Problem

Proving blobs and disseminating them at the end of the slot results in a bursty network profile and increases the bandwidth requirements for validators and builders.

Pipelining changes the shape of the same work. A node periodically broadcasts the latest proof for the covered blob set and only the new column-sample diff since the previous broadcast. Peers that already saw earlier updates do not need the old samples again.

For this note, assume two primitives:

  • a proof that binds the covered blob cells to a valid data-availability statement;
  • a column sample that is committed to with a commitment that is incrementally updatable, such as a Merkle tree.

The question is therefore a bandwidth-shaping question:

Do we prefer one low-overhead burst at the end,
or repeated proof overhead in exchange for smaller bursts during the slot?

End-of-slot burst versus pipelined updates


2. Bandwidth Analysis

Use a concrete example, not as a parameter recommendation, but as a way to reason about the tradeoff. The analysis metric is peak interval bandwidth:

interval_bandwidth = bytes_sent_in_interval / interval_duration
peak_bandwidth     = max(interval_bandwidth over the slot)

This is the resource requirement a node must provision for. Total bytes still matter, but a protocol that sends fewer bytes overall can still be harder to run if those bytes all arrive in one short interval.

Assume:

slot_duration             = 12 seconds
update_interval           = 3 seconds
updates_per_slot          = 4
proof_bytes               = 100 kB
sampled_columns_per_update = 32
bytes_per_column_sample   = 6 kB

Each pipelined update carries:

column_diff_bytes = sampled_columns_per_update * bytes_per_column_sample
                  = 32 * 6 kB
                  = 192 kB

pipelined_update_bytes = proof_bytes + column_diff_bytes
                       = 100 kB + 192 kB
                       = 292 kB

The full slot contains:

total_column_sample_bytes = updates_per_slot * column_diff_bytes
                          = 4 * 192 kB
                          = 768 kB

The two dissemination strategies send the same 768 kB of column samples. They differ in when those bytes are sent and how many proofs are repeated.

End-of-slot sends one proof plus all samples in the final interval:

end_of_slot_total_bytes = proof_bytes + total_column_sample_bytes
                         = 100 kB + 768 kB
                         = 868 kB

Pipelining sends one proof plus one column-sample diff in each interval:

pipelined_total_bytes = updates_per_slot * pipelined_update_bytes
                      = 4 * 292 kB
                      = 1,168 kB

So this example pays:

extra_total_bytes = pipelined_total_bytes - end_of_slot_total_bytes
                  = 300 kB

That extra 300 kB is exactly the three additional proof broadcasts:

extra_total_bytes = (updates_per_slot - 1) * proof_bytes
                  = 3 * 100 kB

The total-byte cost is higher for the pipeline, but the bandwidth requirement is lower because the bytes are spread across the slot.

Per-Interval Bandwidth

Divide the slot into four 3 second intervals. The average bandwidth required inside each interval is:

Concrete peak bandwidth example

interval_bandwidth = interval_bytes / 3 seconds
intervalend-of-slot bytesend-of-slot bandwidthpipelined bytespipelined bandwidth
0-3s0 kB0 kB/s292 kB97.3 kB/s
3-6s0 kB0 kB/s292 kB97.3 kB/s
6-9s0 kB0 kB/s292 kB97.3 kB/s
9-12s868 kB289.3 kB/s292 kB97.3 kB/s

So the peak bandwidth requirement is:

end_of_slot_peak_bandwidth = 868 kB / 3 s = 289.3 kB/s
pipelined_peak_bandwidth   = 292 kB / 3 s = 97.3 kB/s

In this example, end-of-slot dissemination uses fewer total bytes:

end_of_slot_total_bytes = 868 kB
pipelined_total_bytes   = 1,168 kB

But end-of-slot dissemination is more resource intensive at the critical moment:

end_of_slot_peak_bandwidth / pipelined_peak_bandwidth
  = 289.3 / 97.3
  ~= 3x

This is the main argument for pipelining. It is not a total-byte optimization; it is a peak-bandwidth optimization. It pays repeated proof bytes to avoid a large final-quarter bandwidth spike.

The rule of thumb is:

Pipelining helps when lower peak bandwidth is worth
the repeated proof-byte overhead.

If blobs arrive evenly and column-sample bytes dominate proof bytes, pipelining smooths the network profile. If proof bytes dominate, or if an adversary withholds most blobs until the last interval, the smoothing benefit shrinks.