ethereum-optimism / ethereum-optimism/optimism

kona-node: derive L2BlockInfo and SystemConfig without fetching full L2 blocks

Open
#22,432 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
6.5k
Forks
4k
Avg merge
2d 15h
Merged PRs (30d)
145

Description

## Summary

Deriving an `L2BlockInfo` (op-node: `eth.L2BlockRef`) or a `SystemConfig` from an L2 block needs only a handful of header fields plus the block's **first** transaction — the L1-info deposit. Both kona and op-node instead fetch the entire block (`eth_getBlockByX(id, fullTx=true)`) and discard every transaction but the first. On busy chains that wastes the overwhelming majority of the bytes and decode time, on hot paths.

kona already has the minimal constructor — [`L2BlockInfo::from_header_and_first_tx`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/protocol/protocol/src/block.rs#L233), added in #21737 — but only the fault-proof driver uses it. Every RPC-backed path calls `.full()`.

This issue covers kona; op-node is a follow-up (see below).

## What is actually needed

- `L2BlockInfo`: `hash`, `number`, `parent_hash`, `timestamp` from the header, plus the L1-info deposit's calldata ([`from_block_info_and_first_tx`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/protocol/protocol/src/block.rs#L185)).
- `SystemConfig`: additionally `gas_limit` and `extra_data` from the header — still nothing beyond the first transaction ([`to_system_config`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/protocol/protocol/src/utils.rs#L16)).

## kona sites

| Site | Today | Needs |
| --- | --- | --- |
| [`AlloyL2ChainProvider::block_info_by_id`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/l2_chain_provider.rs#L104) | `get_block_by_{number,hash}(..).full()` | header + first tx (also bypasses the LRU entirely) |
| [`AlloyL2ChainProvider::block_by_number`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/l2_chain_provider.rs#L230) | `.full()`, LRU of 1024 whole `OpBlock`s | full block only for the span-batch overlap check |
| ↳ [`l2_block_info_by_number`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/l2_chain_provider.rs#L221) | via `block_by_number` | header + first tx |
| ↳ [`system_config_by_number`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/l2_chain_provider.rs#L259) | via `block_by_number` | header + first tx |
| [`OpEngineClient::l2_block_info_by_label`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/client.rs#L237) | `.full()` | **dead code** — no non-test callers; delete |
| Sync start walk-back: [`sync/mod.rs#L65`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/sync/mod.rs#L65), [`#L105`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/sync/mod.rs#L105), [`forkchoice.rs#L55`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/sync/forkchoice.rs#L55), [`#L91`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/sync/forkchoice.rs#L91) | one `.full()` per block walked back — can span a sequencing window | header + first tx |
| [`ConsolidateTask::consolidate`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/task_queue/tasks/consolidate/task.rs#L169) | `.full()` for both input variants | full block only for `ConsolidateInput::Attributes` (`AttributesMatch::check`); the `BlockInfo` variant only compares the header hash |
| [`FinalizeTask`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/task_queue/tasks/finalize/task.rs#L53) | `.full()` | header + first tx |
| [`EngineQueries::OutputAtBlock`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/query.rs#L87) | `.full()` | legitimate — the full block *is* the RPC response. Its [comment](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/engine/src/query.rs#L90-L92) already flags the problem |

**The hot one** is `system_config_by_number`: [`StatefulAttributesBuilder::prepare_payload_attributes`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/protocol/derive/src/attributes/stateful.rs#L96) calls it for the parent of **every** derived L2 block. Consecutive calls target consecutive parents, so the LRU misses at the leading edge — one full-block fetch per derived block, in both the derivation and sequencer paths.

Related: [`create_attributes_builder`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/service/src/service/node.rs#L171) and [`create_pipeline`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/node/service/src/service/node.rs#L194) each construct their own `AlloyL2ChainProvider`, so the caches are disjoint and the same block can be fetched twice.

## Proposed fix

> **Superseded.** The plan below cannot work over the Engine API port, which is the only L2
> endpoint either client has. See [the constraint write-up](https://github.com/ethereum-optimism/optimism/issues/22432#issuecomment-5297533301)
> and the revised plan under [Status](#status). Kept for context, since the same plan becomes
> viable if the EL-side follow-up lands.

1. A minimal-fetch helper issuing `eth_getBlockByX(id, false)` + `eth_getTransactionByBlockXAndIndex(id, 0)` in one batched JSON-RPC round trip (alloy `RpcClient::new_batch()`), returning the sealed header and the raw first tx — feeding the existing `L2BlockInfo::from_header_and_first_tx`.
- By-hash is consistent by construction.
- By-number/by-label need an optimistic consistency check (`header.transactions[0] == tx.hash`) with a tx-only refetch by resolved block hash on mismatch (rare: reorg / new-head race).
2. A `SystemConfig` equivalent of `from_header_and_first_tx` — `to_system_config` currently takes `&OpBlock`.
3. Route the sites above through it. Keep `.full()` only where the transaction list is genuinely consumed: span-batch overlap validation, `AttributesMatch::check`, `OutputAtBlock`.
4. Delete `EngineClient::l2_block_info_by_label`.

## Status

`eth_getTransactionByBlock{Number,Hash}AndIndex` is not exposed on the authenticated Engine API
port, which is the only L2 endpoint kona-node (`--l2-engine-rpc`) and op-node (`EngineClient`
embeds `L2Client`) talk to. This is very likely why both clients fetch full blocks in the first
place.

So the hot path was fixed by removing the fetch rather than shrinking it: the engine already
decodes every block it imports, so it hands them to a local buffer that derivation and the
sequencer read before falling back to RPC.

**Landed** — the kona side is complete.

- [x] kona: drop the dead `EngineClient::l2_block_info_by_label` and `AlloyL2ChainProvider::block_info_by_id`
- [x] kona: key the system config lookup by L2 block hash, matching op-node — #22465
- [x] kona: serve that lookup from imported blocks; take the block buffer off async locks — #22466
- [x] kona: hand out span-batch overlap blocks behind an `Arc` — #22466
- [x] kona: hash-keyed `L2BlockInfo` lookup for the reset walk-back — #22467 (with #22475)

The per-block `SystemConfig` lookup no longer reaches the execution layer at all while the buffer
is warm — on the sequencer that was the only full-block fetch on the block-production path. What
remains below is gated on the EL exposing a per-index transaction getter.

**Still open**

- [x] ~~EL side: expose a per-index transaction getter on the auth port.~~ **Done upstream** —
[paradigmxyz/reth#26760](https://github.com/paradigmxyz/reth/pull/26760) adds
`eth_getRawTransactionByBlockHashAndIndex` and `eth_getRawTransactionByBlockNumberAndIndex`
to `EngineEthApi`, so no op-reth-local `merge_auth_methods` shim is needed. The *raw*
variants are the better fit: they return EIP-2718 bytes, which feed
`L2BlockInfo::from_header_and_first_tx` and `to_system_config_from_header_and_first_tx`
directly with no RPC-type decode.
- [ ] Pick it up here: wait for `op-rs/reth` to carry the release, then bump the pin in
`rust/Cargo.toml`.
- [ ] Then implement the minimal fetch: `eth_getBlockByX(id, false)` +
`eth_getRawTransactionByBlockXAndIndex(id, 0)` batched. By-hash is race-free; by-number and
by-label need the consistency check (compare against the header's first tx hash, refetch by
resolved block hash on mismatch). Needs probe-and-fallback to the full-block path, since
op-geth serves the whole `eth` namespace on authrpc but older ELs have neither.
- [ ] kona: the paths still fetching whole blocks — sync-start walk-back
(`sync/mod.rs`, `forkchoice.rs`), `FinalizeTask`, and the buffer-miss fallbacks. These need
the EL capability above.
- [ ] op-node: revisit #20532 only once that capability exists.
- [ ] Latency instrumentation. Fetch counts and cache hit rates are measurable today
(`kona_providers_l2_chain_requests`, `kona_providers_local_cache_hits`/`_misses`, both
labelled by method), but there are no histograms on these paths, so wall-clock improvement
can only be inferred.

## Deferred / non-goals

- **L1 side.** [`AlloyChainProvider::header_by_hash`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/chain_provider.rs#L155) and [`block_info_by_number`](https://github.com/ethereum-optimism/optimism/blob/ab4abcb7dde289aa11563cfb4f9b7b491f6cd205/rust/kona/crates/providers/providers-alloy/src/chain_provider.rs#L185) already fetch with `fullTx=false`, but still transfer the whole tx-hash list (~6–13 KB for a mainnet L1 block) to read a ~600-byte header. `eth_getHeaderBy*` would fix it but is non-standard across RPC providers, and op-node does the same thing today.
- Multi-block batching for the sync-start walk-back.
- `ConsolidateTask`'s full-block fetch stays: `AttributesMatch::check` genuinely compares the
transaction list.

🤖 *Co-created with Claude Opus 5*

Contributor guide

Open the contributing guide

Research direction

Start by checking the op-rs/reth release and the pin in rust/Cargo.toml, then read the existing constructors in the protocol crate and the provider and engine paths listed in the issue. Implement the raw first-transaction fetch with capability fallback, then cover the remaining whole-block paths in sync/mod.rs, forkchoice.rs, and FinalizeTask. Done means supported Engine API clients avoid unnecessary full-block fetches without breaking older clients.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api, backend, infrastructure
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.