Skip to content

feat(l1): add meta entries + muiltiget for faster receipt retrieval#6507

Open
Arkenan wants to merge 2 commits intomainfrom
improve-receipts-rpc
Open

feat(l1): add meta entries + muiltiget for faster receipt retrieval#6507
Arkenan wants to merge 2 commits intomainfrom
improve-receipts-rpc

Conversation

@Arkenan
Copy link
Copy Markdown
Collaborator

@Arkenan Arkenan commented Apr 20, 2026

Motivation

We saw that the method to retrieve a single receipt was taking too much time (seconds). We traced this to the sequential get of all the receipts of a block to calculate the gas used and the log index.

Description

This PR adds two mechanisms to solve this:

  • Cache the values (non cumulative gas used and log index) in a separate receipt metadata CF. This is written on block execution.
  • If receipts for older transactions are requested the receipts are pulled in multiget instead of one by one, and then the meta is calculated and written so future requests will benefit from this as well, specially if requesting from the same block, which some clients might do.

@Arkenan Arkenan changed the title Add meta entries + muiltiget for faster receipt Bretrieval feat(l1): add meta entries + muiltiget for faster receipt retrieval Apr 20, 2026
@github-actions github-actions Bot added the L1 Ethereum client label Apr 20, 2026
@github-actions
Copy link
Copy Markdown

Lines of code report

Total lines added: 278
Total lines removed: 5
Total lines changed: 283

Detailed view
+-------------------------------------------------+-------+------+
| File                                            | Lines | Diff |
+-------------------------------------------------+-------+------+
| ethrex/crates/common/types/receipt.rs           | 407   | +20  |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/block.rs       | 360   | -5   |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/transaction.rs | 649   | +72  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/api/mod.rs                | 48    | +11  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/api/tables.rs             | 24    | +1   |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/backend/rocksdb.rs        | 353   | +30  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/store.rs                  | 2736  | +144 |
+-------------------------------------------------+-------+------+

@Arkenan Arkenan marked this pull request as ready for review April 21, 2026 14:18
@Arkenan Arkenan requested a review from a team as a code owner April 21, 2026 14:18
@ethrex-project-sync ethrex-project-sync Bot moved this to In Review in ethrex_l1 Apr 21, 2026
@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have all the information needed to write a comprehensive review.


PR Review: feat(l1): add meta entries + multiget for faster receipt retrieval

Overview

This PR addresses a legitimate performance problem: eth_getTransactionReceipt was O(N) because it had to walk all receipts in a block to derive cumulative gas and log-index offsets. The fix is two-pronged:

  • A new BLOCK_RECEIPT_META column family stores pre-computed per-tx (gas_used, log_index_offset) pairs, written at block commit time. Single-receipt queries become 2 DB reads.
  • Bulk receipt queries (eth_getBlockReceipts) use RocksDB batched_multi_get_cf to read all receipts in one SST-block pass.

The approach is sound. The lazy-backfill for pre-existing blocks is a nice touch.


Correctness

Documentation / encoding mismatch (tables.rs)

The column family doc comment says:

/// - Value: packed `[u32 gas_used_le | u32 log_index_offset_le]` * N_transactions

But the encode function writes two u64 fields (8 bytes each), for 16 bytes per entry — matching BLOCK_RECEIPT_META_ENTRY_LEN = 16. The u32 in the comment is wrong and will mislead anyone trying to read or port the storage format. Fix to [u64 gas_used_le | u64 log_index_offset_le].

Unchecked subtraction in get_all_block_rpc_receipts (block.rs:369)

let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;

This panics in debug mode and wraps in release if receipts are misordered or corrupt. The old code had this same bug, but since the area was touched it's worth fixing with saturating_sub (consistent with build_block_meta) or — better — returning an error.

sorted_input: false in batched_multi_get_cf (rocksdb.rs)

The keys passed to get_receipts_by_block_hash are generated as (block_hash, 0..count), which are already in ascending order. Passing sorted_input: true to batched_multi_get_cf tells RocksDB not to re-sort them, enabling a small but free performance win.

Backfill error silently swallowed (transaction.rs:336)

let _ = storage.put_block_receipt_meta(block_hash, &meta).await;

The comment calls this "non-fatal", which is reasonable, but a transient write error here means every subsequent request for the same block re-pays the full backfill cost. At minimum, log the error at warn! level so operators can diagnose persistent storage issues. Silently discarding errors in storage code is a pattern that can hide real problems.

Missing schema version bump

A new column family is added to TABLES. There's a StoreMetadata::schema_version field in the store. The PR doesn't appear to bump that version or add a migration note. If the node is upgraded against an existing RocksDB (which already has the 19 prior CFs), RocksDB will create the new CF on open automatically, so there's no hard breakage — but if the project uses schema_version to detect incompatible upgrades, this should be acknowledged.


Performance

multi_read_async spawns a new blocking task per call (store.rs:1354)

Each call to get_receipts_by_block_hash spawns a spawn_blocking task. For the backfill path (one call, many keys) this is fine. For the get_all_block_rpc_receipts path (one call per RPC request), the overhead is negligible. No action needed, just confirming intentionality.

Backfill re-reads the entire block on every miss

For a block with 300 transactions that was committed before this CF was introduced, the first receipt request reads all 300 receipts, builds the meta, and writes it. Subsequent requests are O(1). This is acceptable given that the miss path disappears after first access. Worth noting in the PR description for ops awareness.


Style / Idiomatic Rust

Internal use statements inside build_rpc_receipt_single (transaction.rs:348–352)

fn build_rpc_receipt_single(...) {
    use crate::types::receipt::{RpcReceipt, RpcReceiptBlockInfo, RpcReceiptTxInfo};
    use ethrex_common::types::calculate_base_fee_per_blob_gas;
    ...
}

Function-scoped use is valid Rust but unusual for module-level types. Move these to the top of the file with the existing imports for consistency with the rest of the codebase.

_block_number (transaction.rs:293)

Renaming to _block_number is correct to suppress the warning, but given that block_hash is now derived from the block body, it's worth confirming block_number is genuinely no longer needed anywhere in this handler (it appears so).


Tests

The unit tests in block_receipt_meta_tests are well-structured and cover the important cases:

  • Encode/decode roundtrip with boundary values (u64::MAX - 1)
  • Corrupt-length rejection
  • build_block_meta gas and log-offset derivation

What's missing:

  • A test for get_receipts_by_block_hash returning an error when a receipt is absent mid-block (ensures the ok_or_else path is exercised).
  • A test for the backfill path in GetTransactionReceiptRequest: request on a block with no BLOCK_RECEIPT_META entry, verify the meta is written and the correct receipt is returned.

Summary

The design is correct and the performance improvement is real. The primary issues to address before merging:

  1. Fix the u32u64 documentation error in tables.rs.
  2. Add at least a warn! log on the silent backfill-write error in transaction.rs.
  3. Pass sorted_input: true to batched_multi_get_cf since keys are already ordered.
  4. Move the use imports in build_rpc_receipt_single to module level.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment:
This PR introduces a well-designed optimization for RPC receipt queries, reducing complexity from O(N) to O(1) for eth_getTransactionReceipt via a metadata column family. The implementation is generally correct, memory-safe, and follows Rust best practices. However, there are two correctness issues regarding unchecked arithmetic and missing validation that should be addressed.


Critical Issues

1. Potential panic on corrupted receipt data
crates/networking/rpc/eth/block.rs:361

let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;

This subtraction can panic if cumulative_gas_used is less than last_cumulative_gas_used (data corruption). While ReceiptMeta::build_block_meta correctly uses saturating_sub, this code path still uses unchecked subtraction.

Fix: Use saturating_sub or checked_sub with error handling:

let gas_used = receipt.cumulative_gas_used.saturating_sub(last_cumulative_gas_used);

2. Missing validation between transaction and receipt counts
crates/networking/rpc/eth/block.rs:344-350

let raw_receipts = storage.get_receipts_by_block_hash(block_hash, tx_count).await?;
// ...
for (index, (tx, receipt)) in body.transactions.iter().zip(raw_receipts.into_iter()).enumerate()

If storage corruption causes raw_receipts.len() != body.transactions.len(), the zip will silently truncate to the shorter length, potentially returning malformed RPC responses where transactions are paired with wrong receipts.

Fix: Validate lengths match before zipping:

if raw_receipts.len() != body.transactions.len() {
    return Err(RpcErr::Internal("Receipt/transaction count mismatch".to_owned()));
}

Minor Issues

3. Race condition on metadata backfill
crates/networking/rpc/eth/transaction.rs:335-338

let _ = storage.put_block_receipt_meta(block_hash, &meta).await;

Concurrent requests for the same legacy block may trigger redundant backfill writes. While harmless, this wastes I/O.

Suggestion: Consider logging backfill occurrences for monitoring, or document that idempotent writes are acceptable here.

4. Unnecessary block hash recalculation
crates/networking/rpc/eth/transaction.rs:311 and block.rs:341

let block_hash = header.hash();

If header.hash() is not cached, this recomputes the hash (keccak256). Since the storage likely already has the block hash from the query, passing it through would be more efficient, though this is a minor optimization.


Positive Observations

  1. Defensive arithmetic in build_block_meta: Correct use of saturating_sub and saturating_add prevents overflow/underflow when computing metadata.
  2. Proper async blocking: multi_read_async correctly uses spawn_blocking for RocksDB operations.
  3. Data integrity checks: decode_block_receipt_meta validates byte length is a multiple of 16 before parsing.
  4. Clean fallback strategy: The lazy backfill approach for pre-existing blocks is elegant and maintains backward compatibility.
  5. RocksDB optimization: The multi_get implementation leverages batched_multi_get_cf for efficient SST block reads.

Summary

The PR is architecturally sound and improves performance significantly. Please address items 1 and 2 (unchecked subtraction and missing length validation) before merging to prevent potential panics or data corruption bugs in edge cases.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 21, 2026

Greptile Summary

This PR adds a BLOCK_RECEIPT_META column family that caches per-transaction gas_used and log_index_offset values derived at block commit time, turning eth_getTransactionReceipt from an O(N) sequential scan into an O(1) pair of DB reads. A lazy-backfill path handles blocks committed before this CF existed by issuing a single batched MultiGet across all receipts in the block, deriving and persisting the metadata, then serving subsequent requests from the cache.

Confidence Score: 5/5

Safe to merge; only P2 style findings remain.

All remaining findings are P2: one is a stale doc comment (u32 vs u64 in tables.rs) and the other is a minor arithmetic inconsistency (plain subtraction vs saturating_sub in block.rs). Neither affects runtime correctness on valid block data. The core logic — MultiGet batching, meta encoding/decoding, lazy backfill — is correct and well-tested.

crates/storage/api/tables.rs (doc comment), crates/networking/rpc/eth/block.rs (subtraction style)

Important Files Changed

Filename Overview
crates/common/types/receipt.rs Adds ReceiptMeta struct and build_block_meta helper using saturating_sub/saturating_add; clean implementation with unit tests.
crates/storage/api/tables.rs Adds BLOCK_RECEIPT_META column family constant; doc comment incorrectly says u32 when the actual encoding uses u64 (16 bytes/entry).
crates/storage/api/mod.rs Adds multi_get to StorageReadView with a correct serial fallback default implementation.
crates/storage/backend/rocksdb.rs Implements multi_get via batched_multi_get_cf with sorted_input=false; correct and idiomatic.
crates/storage/store.rs Adds get_receipts_by_block_hash, get_block_receipt_meta, put_block_receipt_meta, and multi_read_async; encodes/decodes ReceiptMeta correctly; unit tests cover roundtrip and edge cases.
crates/networking/rpc/eth/block.rs Refactors get_all_block_rpc_receipts to use get_receipts_by_block_hash; uses plain u64 subtraction where saturating_sub is used in the shared helper, creating a minor inconsistency.
crates/networking/rpc/eth/transaction.rs Implements the O(1) fast path for eth_getTransactionReceipt with lazy backfill of the metadata CF on miss; non-fatal backfill errors are correctly swallowed.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RPC as eth_getTransactionReceipt
    participant Store
    participant RocksDB

    Client->>RPC: tx_hash
    RPC->>Store: get_transaction_location(tx_hash)
    Store-->>RPC: block_hash, index
    RPC->>Store: get_block_receipt_meta(block_hash)

    alt Meta CF hit - new blocks
        Store->>RocksDB: GET block_receipt_meta[block_hash]
        RocksDB-->>Store: packed gas_used + log_offset x N
        Store-->>RPC: Vec of ReceiptMeta
        RPC->>Store: get_receipt_by_block_hash(block_hash, index)
        RocksDB-->>RPC: single Receipt
    else Meta CF miss - old blocks lazy backfill
        Store-->>RPC: None
        RPC->>Store: get_receipts_by_block_hash(block_hash, tx_count)
        Store->>RocksDB: batched_multi_get_cf receipts keys 0 to N
        RocksDB-->>Store: Vec of Receipt
        Store-->>RPC: Vec of Receipt
        RPC->>RPC: Receipt::build_block_meta
        RPC->>Store: put_block_receipt_meta fire and forget
    end

    RPC->>RPC: build_rpc_receipt_single
    RPC-->>Client: RpcReceipt
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/storage/api/tables.rs
Line: 41

Comment:
**Incorrect field width in doc comment**

The comment says `u32` for both fields, but the implementation in `store.rs` packs two `u64` values (8 bytes each), giving `BLOCK_RECEIPT_META_ENTRY_LEN = 16`. The comment should read `u64` to match the actual encoding.

```suggestion
/// - Value: packed `[u64 gas_used_le | u64 log_index_offset_le]` * N_transactions
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/networking/rpc/eth/block.rs
Line: 376

Comment:
**Arithmetic inconsistency with `build_block_meta`**

`get_all_block_rpc_receipts` uses plain subtraction here, which will panic in debug builds (and produce wrapping arithmetic in release) if `cumulative_gas_used` ever decreases across receipts. The shared helper `Receipt::build_block_meta` uses `saturating_sub` for exactly this reason — it's worth staying consistent here.

```suggestion
        let gas_used = receipt.cumulative_gas_used.saturating_sub(last_cumulative_gas_used);
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Merge branch 'main' into improve-receipt..." | Re-trigger Greptile

/// Per-block receipt metadata column family: [`Vec<u8>`] => [`Vec<u8>`]
/// - Key: `block_hash.as_bytes()` (32 bytes)
/// - Value: packed `[u32 gas_used_le | u32 log_index_offset_le]` * N_transactions
///
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Incorrect field width in doc comment

The comment says u32 for both fields, but the implementation in store.rs packs two u64 values (8 bytes each), giving BLOCK_RECEIPT_META_ENTRY_LEN = 16. The comment should read u64 to match the actual encoding.

Suggested change
///
/// - Value: packed `[u64 gas_used_le | u64 log_index_offset_le]` * N_transactions
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/api/tables.rs
Line: 41

Comment:
**Incorrect field width in doc comment**

The comment says `u32` for both fields, but the implementation in `store.rs` packs two `u64` values (8 bytes each), giving `BLOCK_RECEIPT_META_ENTRY_LEN = 16`. The comment should read `u64` to match the actual encoding.

```suggestion
/// - Value: packed `[u64 gas_used_le | u64 log_index_offset_le]` * N_transactions
```

How can I resolve this? If you propose a fix, please make it concise.

Some(receipt) => receipt,
_ => return Err(RpcErr::Internal("Could not get receipt".to_owned())),
};
let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Arithmetic inconsistency with build_block_meta

get_all_block_rpc_receipts uses plain subtraction here, which will panic in debug builds (and produce wrapping arithmetic in release) if cumulative_gas_used ever decreases across receipts. The shared helper Receipt::build_block_meta uses saturating_sub for exactly this reason — it's worth staying consistent here.

Suggested change
let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;
let gas_used = receipt.cumulative_gas_used.saturating_sub(last_cumulative_gas_used);
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/eth/block.rs
Line: 376

Comment:
**Arithmetic inconsistency with `build_block_meta`**

`get_all_block_rpc_receipts` uses plain subtraction here, which will panic in debug builds (and produce wrapping arithmetic in release) if `cumulative_gas_used` ever decreases across receipts. The shared helper `Receipt::build_block_meta` uses `saturating_sub` for exactly this reason — it's worth staying consistent here.

```suggestion
        let gas_used = receipt.cumulative_gas_used.saturating_sub(last_cumulative_gas_used);
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. eth_getTransactionReceipt now returns null for internal storage inconsistencies that should still be surfaced as errors. In transaction.rs:319, transaction.rs:322, transaction.rs:338, and transaction.rs:341, a missing meta entry or missing receipt now maps to Ok(Value::Null). At that point the tx location and block body have already proven the transaction exists, so this is DB corruption/partial data, not “receipt not found”. The old path returned an internal error on missing receipts; this change can make a mined transaction appear nonexistent to RPC callers. I’d keep this as RpcErr::Internal(...) or rebuild from the full receipt list before giving up.

  2. Receipt::build_block_meta masks invalid receipt sequences with saturating arithmetic. In receipt.rs:59 and receipt.rs:65, saturating_sub / saturating_add silently turn non-monotonic cumulative_gas_used or overflowing log offsets into plausible values. Because the helper is used both on commit in store.rs:1506 and on lazy backfill in transaction.rs:334, bad data gets persisted as incorrect gasUsed / logIndex instead of being detected. For blockchain data, that should be a hard error via checked arithmetic.

No other blocking correctness or EVM-consensus issues stood out in the diff. I could not run the targeted tests locally because rustup failed to create temp files under the read-only /home/runner/.rustup, so this review is static-only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

Copy link
Copy Markdown
Contributor

@ElFantasma ElFantasma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up worth filing: eth_getBlockReceipts (get_all_block_rpc_receipts / get_all_block_receipts) doesn't use the meta CF — both still iterate from scratch. Could short-circuit via the meta CF for blocks that have it.


/// Per-block receipt metadata column family: [`Vec<u8>`] => [`Vec<u8>`]
/// - Key: `block_hash.as_bytes()` (32 bytes)
/// - Value: packed `[u32 gas_used_le | u32 log_index_offset_le]` * N_transactions
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc/impl mismatch: this says Value: packed [u32 gas_used_le | u32 log_index_offset_le], but the implementation uses u64::to_le_bytes() for both fields and BLOCK_RECEIPT_META_ENTRY_LEN = 16 (store.rs:148). Should read [u64 gas_used_le | u64 log_index_offset_le]. (Greptile flagged this.)

Some(receipt) => receipt,
_ => return Err(RpcErr::Internal("Could not get receipt".to_owned())),
};
let gas_used = receipt.cumulative_gas_used - last_cumulative_gas_used;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saturating_sub vs plain - inconsistency: Receipt::build_block_meta uses saturating_sub for gas_used; this path uses plain -. Same inputs flow through both paths post-backfill, so a hypothetical pathological cumulative-gas sequence saturates in one and panics in the other. Pre-existing rather than introduced here, but worth aligning while you're touching this code.

let meta = Receipt::build_block_meta(&all);
// Backfill the CF so the next request takes the fast path.
// Errors here are non-fatal.
let _ = storage.put_block_receipt_meta(block_hash, &meta).await;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent error drop: if RocksDB returns a transient error here, we lose observability and the next request re-derives. tracing::warn! on Err is cheap and non-fatal.

let all = storage
.get_receipts_by_block_hash(block_hash, tx_count)
.await?;
let meta = Receipt::build_block_meta(&all);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent-request race on lazy backfill: two concurrent GetTransactionReceiptRequests on a pre-meta block both reach this None arm, both compute the same vec, both put_block_receipt_meta. Idempotent so no correctness issue, but flagging for potential thundering-herd patterns on archival queries.


let results = self
.db
.batched_multi_get_cf(&cf, keys.iter().map(|k| k.as_slice()), false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorted=false is correct for general callers, but in get_receipts_by_block_hash keys are constructed from (block_hash, 0..count) — already sorted by RLP encoding. Passing true lets RocksDB skip the internal sort. Tiny win; not blocking.

Comment thread crates/storage/store.rs
/// Keys share a `block_hash` prefix so RocksDB's MultiGet coalesces them
/// into ~one SST-block read rather than the `n` independent reads a
/// `join_all` over `get_receipt_by_block_hash` would do.
pub async fn get_receipts_by_block_hash(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns Err if any receipt is missing — a partially-pruned block fails outright in GetTransactionReceiptRequest's None arm and never gets meta backfilled. Probably fine since partial-prune is a separate problem, but worth a thought if pruning lands on the L1 path.

@github-project-automation github-project-automation Bot moved this from In Review to In Progress in ethrex_l1 Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants