Skip to content

feat(rpc): add GET /lean/v0/blocks range query for backfill#457

Open
MegaRedHand wants to merge 3 commits into
mainfrom
feat/lean-api-blocks-range
Open

feat(rpc): add GET /lean/v0/blocks range query for backfill#457
MegaRedHand wants to merge 3 commits into
mainfrom
feat/lean-api-blocks-range

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Adds a range query variant of the blocks endpoint (GET /lean/v0/blocks?from=<slot>&to=<slot>), allowing explorers and sync helpers to fetch a contiguous sequence of block headers without issuing one request per slot.

Intended primarily for backfill and checkpoint-sync helper flows. Has unit tests and passed clippy.

Stacked on #454.

Split the monolithic lib.rs API router into focused modules: core.rs
holds the finalized-state/finalized-block/justified-checkpoint handlers
and shared response helpers; blocks.rs, fork_choice.rs, and admin.rs
each expose pub(crate) routes() -> Router<Store>. build_api_router
merges them with .with_state(store). No behavior change.
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/net/rpc/src/blocks.rs silently drops a slot when historical_block_hashes contains a non-zero canonical root but store.get_block(root) returns None (lines 114-118). That is inconsistent with the existing single-slot path, which returns 404 for the same condition (blocks.rs). In practice this hides storage corruption or partial persistence behind a 200 OK with an incomplete array, which is a correctness problem for downstream sync/debug tooling. I’d surface this as an error instead of treating it like an empty slot.

  2. crates/net/rpc/src/blocks.rs does up to 1024 independent store.get_block calls per request, and each call opens a fresh backend read view (crates/storage/src/store.rs). That means one RPC can trigger 1024 DB transactions and build 1024 full blocks in memory before JSON serialization. For a public endpoint, that is a fairly cheap CPU/IO and memory-pressure vector. I’d either lower the cap materially, add a batched/read-view-based store API, or stream/paginate the response.

No consensus-critical fork-choice / attestation / STF / SSZ logic changed in this diff beyond the RPC surface.

I couldn’t run cargo test in this environment because cargo/rustup need writable ~/.cargo / ~/.rustup, which are read-only here.


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

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Consensus Correctness

  • Circular buffer indexing (Critical): Lines 113–114 access head_state.historical_block_hashes.get(slot as usize) using the slot as a direct index. In standard Ethereum consensus specs, state.block_roots (here historical_block_hashes) is a circular buffer of length SLOTS_PER_HISTORICAL_ROOT (8192). The correct index is slot % SLOTS_PER_HISTORICAL_ROOT, and this only works for slots in [state.slot - 8192, state.slot). If ethlambda uses a growable vector instead, this creates an O(n) memory scalability issue. If it follows the standard spec, this returns incorrect roots for slots > 8192 or causes out-of-bounds access when the buffer wraps.
  • Historical block availability: For slots older than SLOTS_PER_HISTORICAL_ROOT, blocks must be retrieved via historical_roots/historical_summaries. The current implementation will silently skip these, potentially misleading clients expecting canonical blocks.

Security & Safety

  • Integer truncation on 32-bit platforms: Line 113 casts slot: u64 to usize with as usize. On 32-bit architectures, this truncates high slots (e.g., slot 5,000,000,000 mod 2^32), causing the wrong index to be accessed and potentially returning incorrect block data instead of None. Use usize::try_from(slot).ok()? or ensure the client only targets 64-bit systems.
  • Memory pressure: Line 119 collects all blocks into a Vec before serialization. With MAX_RANGE_COUNT = 1024 and full blocks (including blobs), this could temporarily allocate significant memory. Consider streaming the JSON response (e.g., axum::Json with a stream or paginating) to reduce peak memory usage.

Performance

  • Sequential store lookups: The loop at lines 112–118 performs O(count) sequential database lookups (store.get_block). While bounded by 1024, consider if the storage backend supports batch retrieval (get_blocks(Vec<H256>)) to reduce I/O overhead.

Code Quality & Testing

  • Silent skips: The endpoint silently skips slots with zero roots or missing data. This is documented, but consider returning a 404 or partial error indicator if the range extends beyond available history to help clients distinguish "no blocks" from "node pruned".
  • Test coverage: The test at line 158 only validates the happy path. Add tests for:
    1. start_slot in the future (should return empty array).
    2. count = 0 (should return empty array).
    3. Slots with zero roots explicitly placed between valid blocks (ensure filtering works).
    4. start_slot very large (near u64::MAX) to test saturating_add behavior.

Minor

  • Line 115: The let Some(block) = store.get_block(root) chain is clean use of Rust 1.65+ let-chains.
  • Line 102: Good use of saturating_add to prevent overflow when calculating

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

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a GET /lean/v0/blocks?start_slot=&count= range query endpoint to the lean RPC, returning an array of canonical blocks for up to 1024 contiguous slots — intended for backfill and checkpoint-sync flows.

  • The new get_blocks_by_range handler uses Axum's Query<BlockRangeParams> extractor, which on invalid/missing params returns a plain-text 422 rather than the {"error": "..."} JSON used by every other error path in the file; callers cannot parse these errors uniformly.
  • The PR description documents ?from=<slot>&to=<slot> semantics, but the implementation uses ?start_slot=<slot>&count=<count> — these are different interfaces and the discrepancy should be resolved before integrators rely on the description.
  • Test coverage exercises only the happy path; the MAX_RANGE_COUNT cap, missing-param rejection, and out-of-range start_slot behavior are untested.

Confidence Score: 3/5

The endpoint logic itself is sound — reads are consistent, the count cap prevents runaway loops — but the error-handling gap means any caller sending a malformed request gets a plain-text 422 instead of the JSON envelope the rest of the API returns, and the PR description documents a different query interface than was actually built.

The range handler silently bypasses the JSON error wrapper used everywhere else when Axum's Query extractor rejects bad or missing params; this is a real behavioral difference that would break API consumers trying to parse error responses uniformly. The interface mismatch between the PR description and the implemented query params adds integration risk before the description is corrected.

crates/net/rpc/src/blocks.rs — specifically the Query extractor error path and the missing test cases for the cap and bad-input scenarios.

Important Files Changed

Filename Overview
crates/net/rpc/src/blocks.rs Adds GET /lean/v0/blocks?start_slot=&count= range endpoint; Query extractor bypasses the JSON error wrapper used by all other handlers, causing inconsistent error responses for malformed/missing params.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Axum Router
    participant get_blocks_by_range
    participant Store

    Client->>Axum Router: GET /lean/v0/blocks?start_slot=N&count=K

    alt Query deserialization fails (missing/bad param)
        Axum Router-->>Client: 422 Unprocessable Entity (plain text)
    else Query OK
        Axum Router->>get_blocks_by_range: Query(params), State(store)
        get_blocks_by_range->>get_blocks_by_range: "cap count = min(count, 1024)"
        get_blocks_by_range->>Store: head_state()
        Store-->>get_blocks_by_range: head_state snapshot
        loop for slot in start_slot..start_slot+count
            get_blocks_by_range->>Store: historical_block_hashes.get(slot)
            alt root exists and non-zero
                get_blocks_by_range->>Store: get_block(root)
                Store-->>get_blocks_by_range: Block
                get_blocks_by_range->>get_blocks_by_range: push to blocks vec
            else slot empty or beyond tip
                get_blocks_by_range->>get_blocks_by_range: skip silently
            end
        end
        get_blocks_by_range-->>Client: 200 OK, JSON array of blocks
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Axum Router
    participant get_blocks_by_range
    participant Store

    Client->>Axum Router: GET /lean/v0/blocks?start_slot=N&count=K

    alt Query deserialization fails (missing/bad param)
        Axum Router-->>Client: 422 Unprocessable Entity (plain text)
    else Query OK
        Axum Router->>get_blocks_by_range: Query(params), State(store)
        get_blocks_by_range->>get_blocks_by_range: "cap count = min(count, 1024)"
        get_blocks_by_range->>Store: head_state()
        Store-->>get_blocks_by_range: head_state snapshot
        loop for slot in start_slot..start_slot+count
            get_blocks_by_range->>Store: historical_block_hashes.get(slot)
            alt root exists and non-zero
                get_blocks_by_range->>Store: get_block(root)
                Store-->>get_blocks_by_range: Block
                get_blocks_by_range->>get_blocks_by_range: push to blocks vec
            else slot empty or beyond tip
                get_blocks_by_range->>get_blocks_by_range: skip silently
            end
        end
        get_blocks_by_range-->>Client: 200 OK, JSON array of blocks
    end
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/net/rpc/src/blocks.rs:106-122
**Malformed/missing query params return non-JSON error**

When `start_slot` or `count` is absent or cannot be parsed as `u64`, Axum's `Query` extractor short-circuits and returns a `422 Unprocessable Entity` with a plain-text body like `Failed to deserialize query string: missing field 'start_slot'`. Every other error path in this file returns `{"error": "…"}` JSON, so callers of this endpoint will receive an inconsistently formatted error they cannot parse the same way. The existing `Path<String>` handlers dodge this by accepting raw strings and self-validating; the `Query<BlockRangeParams>` approach delegates validation to serde at extraction time, bypassing the JSON error wrapper entirely.

### Issue 2 of 3
crates/net/rpc/src/blocks.rs:96-100
**PR description documents different query parameters than implemented**

The PR description advertises `GET /lean/v0/blocks?from=<slot>&to=<slot>` (a `from`/`to` inclusive range), but `BlockRangeParams` uses `start_slot` + `count` (a start-point + length). The in-code doc comment on the handler is correct, but the discrepancy in the PR description may mislead integrators before they read the source. Worth updating the description — or deciding which interface is actually intended.

### Issue 3 of 3
crates/net/rpc/src/blocks.rs:142-184
**`MAX_RANGE_COUNT` cap and bad-input paths are not exercised by the test**

The single test sends `count=2`, which is far below the 1024 cap, so the capping logic on line 110 is never exercised. There are also no tests for: (a) `count` exceeding `MAX_RANGE_COUNT` (expect the response to contain ≤ 1024 entries), (b) a request missing `start_slot` or `count` entirely (to document and assert the current response shape), and (c) `start_slot` pointing entirely beyond `historical_block_hashes` (to verify the 200-empty-array behavior is intentional). Adding these would harden the endpoint against regressions when the cap or error format is changed.

Reviews (1): Last reviewed commit: "feat(rpc): add GET /lean/v0/blocks range..." | Re-trigger Greptile

Comment on lines +106 to +122
pub(crate) async fn get_blocks_by_range(
Query(params): Query<BlockRangeParams>,
State(store): State<Store>,
) -> impl IntoResponse {
let count = params.count.min(MAX_RANGE_COUNT);
let head_state = store.head_state();
let mut blocks = Vec::new();
for slot in params.start_slot..params.start_slot.saturating_add(count) {
if let Some(root) = head_state.historical_block_hashes.get(slot as usize)
&& !root.is_zero()
&& let Some(block) = store.get_block(root)
{
blocks.push(block);
}
}
json_response(blocks)
}

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.

P1 Malformed/missing query params return non-JSON error

When start_slot or count is absent or cannot be parsed as u64, Axum's Query extractor short-circuits and returns a 422 Unprocessable Entity with a plain-text body like Failed to deserialize query string: missing field 'start_slot'. Every other error path in this file returns {"error": "…"} JSON, so callers of this endpoint will receive an inconsistently formatted error they cannot parse the same way. The existing Path<String> handlers dodge this by accepting raw strings and self-validating; the Query<BlockRangeParams> approach delegates validation to serde at extraction time, bypassing the JSON error wrapper entirely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/blocks.rs
Line: 106-122

Comment:
**Malformed/missing query params return non-JSON error**

When `start_slot` or `count` is absent or cannot be parsed as `u64`, Axum's `Query` extractor short-circuits and returns a `422 Unprocessable Entity` with a plain-text body like `Failed to deserialize query string: missing field 'start_slot'`. Every other error path in this file returns `{"error": "…"}` JSON, so callers of this endpoint will receive an inconsistently formatted error they cannot parse the same way. The existing `Path<String>` handlers dodge this by accepting raw strings and self-validating; the `Query<BlockRangeParams>` approach delegates validation to serde at extraction time, bypassing the JSON error wrapper entirely.

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

Comment on lines +96 to +100
#[derive(Deserialize)]
pub(crate) struct BlockRangeParams {
start_slot: u64,
count: u64,
}

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.

P2 PR description documents different query parameters than implemented

The PR description advertises GET /lean/v0/blocks?from=<slot>&to=<slot> (a from/to inclusive range), but BlockRangeParams uses start_slot + count (a start-point + length). The in-code doc comment on the handler is correct, but the discrepancy in the PR description may mislead integrators before they read the source. Worth updating the description — or deciding which interface is actually intended.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/blocks.rs
Line: 96-100

Comment:
**PR description documents different query parameters than implemented**

The PR description advertises `GET /lean/v0/blocks?from=<slot>&to=<slot>` (a `from`/`to` inclusive range), but `BlockRangeParams` uses `start_slot` + `count` (a start-point + length). The in-code doc comment on the handler is correct, but the discrepancy in the PR description may mislead integrators before they read the source. Worth updating the description — or deciding which interface is actually intended.

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +142 to +184
#[cfg(test)]
mod range_tests {
use crate::test_utils::{create_test_state, insert_block_raw, make_block};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use ethlambda_storage::{Store, backend::InMemoryBackend};
use ethlambda_types::{primitives::H256, state::JustifiedSlots};
use http_body_util::BodyExt;
use std::sync::Arc;
use tower::ServiceExt;

fn store_with_block_at_slot_1() -> Store {
let backend = Arc::new(InMemoryBackend::new());
let target = make_block(1, H256::ZERO);
let root = insert_block_raw(backend.as_ref(), &target);
let mut anchor = create_test_state();
anchor.slot = 2;
anchor.historical_block_hashes = vec![H256::ZERO, root].try_into().unwrap();
anchor.justified_slots = JustifiedSlots::with_length(2).unwrap();
Store::from_anchor_state(backend, anchor)
}

#[tokio::test]
async fn blocks_range_returns_canonical_blocks() {
let app = crate::build_api_router(store_with_block_at_slot_1());
let resp = app
.oneshot(
Request::builder()
.uri("/lean/v0/blocks?start_slot=1&count=2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json.as_array().unwrap().len(), 1);
assert_eq!(json[0]["slot"], 1);
}
}

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.

P2 MAX_RANGE_COUNT cap and bad-input paths are not exercised by the test

The single test sends count=2, which is far below the 1024 cap, so the capping logic on line 110 is never exercised. There are also no tests for: (a) count exceeding MAX_RANGE_COUNT (expect the response to contain ≤ 1024 entries), (b) a request missing start_slot or count entirely (to document and assert the current response shape), and (c) start_slot pointing entirely beyond historical_block_hashes (to verify the 200-empty-array behavior is intentional). Adding these would harden the endpoint against regressions when the cap or error format is changed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/blocks.rs
Line: 142-184

Comment:
**`MAX_RANGE_COUNT` cap and bad-input paths are not exercised by the test**

The single test sends `count=2`, which is far below the 1024 cap, so the capping logic on line 110 is never exercised. There are also no tests for: (a) `count` exceeding `MAX_RANGE_COUNT` (expect the response to contain ≤ 1024 entries), (b) a request missing `start_slot` or `count` entirely (to document and assert the current response shape), and (c) `start_slot` pointing entirely beyond `historical_block_hashes` (to verify the 200-empty-array behavior is intentional). Adding these would harden the endpoint against regressions when the cap or error format is changed.

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have verified findings. Let me compose the final review.


PR Review: feat(rpc): add GET /lean/v0/blocks range query for backfill

This PR adds a GET /lean/v0/blocks?start_slot=&count= endpoint backed by historical_block_hashes lookup, intended for backfill and checkpoint-sync helpers. The implementation is straightforward and the unit test is well-structured. There are a few issues worth addressing before merge.


Finding 1 — TOCTOU: reorg between head_state() and get_block() silently returns non-canonical blocks

crates/net/rpc/src/blocks.rs:111–119

head_state() deserializes a point-in-time copy of the state, then get_block(root) is called in a loop with up to 1024 separate backend reads. These are not covered by a single consistent read transaction. If the chain reorgs between the two, historical_block_hashes reflects the old canonical chain. Because block headers are never pruned (only signatures are), get_block() always succeeds — but it silently returns now-non-canonical blocks with HTTP 200. A caller cannot detect this. The right fix is a Store::blocks_by_slot_range(start: u64, count: u64) -> Vec<Block> method that opens one consistent read view, matching the pattern used by get_live_chain() and other bulk Store helpers.


Finding 2 — Missing query parameters return 422 with a plain-text body, inconsistent with the rest of the API

crates/net/rpc/src/blocks.rs:96–100

BlockRangeParams has no #[serde(default)] on either field. Omitting start_slot or count causes Axum 0.8's Query<T> extractor to reject with HTTP 422 and a raw string like Failed to deserialize query string: missing field 'count'. Every other error path in this file returns a JSON {"error": "..."} body. There is no custom rejection handler in lib.rs. Fix: either wrap fields as Option<u64> with explicit defaults and return a custom 400 JSON response, or add a global QueryRejection error layer.


Finding 3 — Full State deserialized per request; only historical_block_hashes is used

crates/net/rpc/src/blocks.rs:111

store.head_state() SSZ-deserializes the entire State from RocksDB (validators, justifications, roots, etc.) and returns it by value. The handler then touches only head_state.historical_block_hashes. At slot 1,000 this wastes ~31 KB; at the HISTORICAL_ROOTS_LIMIT ceiling it wastes ~8 MB. A Store::block_root_at_slot(slot: u64) -> Option<H256> accessor — or the same Store::blocks_by_slot_range method from Finding 1 — would eliminate this entirely. The existing resolve_slot function has the same issue; the new handler replicates it.


Finding 4 — Range iteration algorithm belongs in Store, not the HTTP handler

crates/net/rpc/src/blocks.rs:108–122

Every other handler in this file is a thin shim: parse the HTTP input → one or two Store calls → serialize the response. get_blocks_by_range is 14 lines of algorithm: cap the count, call head_state(), walk historical_block_hashes, filter zero roots, fetch each block. This logic is reusable (e.g., P2P backfill serving, sync helpers) and the TOCTOU fix from Finding 1 naturally lands in a Store method anyway. Moving the range logic to Store keeps the handler at the right altitude and is consistent with the existing get_live_chain() pattern.


Finding 5 — && let chain doesn't match the file's idiom for conditional guard logic

crates/net/rpc/src/blocks.rs:114–119

if let Some(root) = head_state.historical_block_hashes.get(slot as usize)
    && !root.is_zero()
    && let Some(block) = store.get_block(root)
{
    blocks.push(block);
}

The rest of the file uses match … { Err => return, Ok(x) => x } or early-return guards. The let chain mixes two Option unwraps with a boolean guard in a way the formatter can't fully linearize. The CLAUDE.md formatting rule says to "extract long arguments into variables so formatter can join lines." The cleaner equivalent:

let Some(&root) = head_state.historical_block_hashes.get(slot as usize) else { continue; };
if root.is_zero() { continue; }
let Some(block) = store.get_block(&root) else { continue; };
blocks.push(block);

Finding 6 — PR description documents different query parameters than the implementation

crates/net/rpc/src/blocks.rs (informational)

The PR body says the endpoint signature is GET /lean/v0/blocks?from=<slot>&to=<slot> (open-ended slot range, two slot bounds). The implementation uses ?start_slot=<slot>&count=<n> (start + count). These are semantically different — ?from=0&to=1024 would return 1024 slots while ?start_slot=0&count=1024 returns up to 1024. If a peer client (Zeam, gean, qlean) reads the PR description to implement a caller, it will send parameters that Axum rejects with 422. The CLAUDE.md API table should be updated with the correct parameter names before the endpoint is treated as stable.


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

…on storage gap, tests)

- Return JSON 400 (not Axum plain-text 422) for missing/non-numeric query params
  by accepting Result<Query<_>, QueryRejection> and converting manually.
- Emit tracing::warn! when a non-zero root in historical_block_hashes has no
  corresponding block in the store, making storage gaps observable in logs.
- Add three new range_tests: count clamped to MAX_RANGE_COUNT, missing params
  return JSON 400, and start_slot beyond history length returns empty array.
Base automatically changed from feat/lean-api-router-scaffold to main June 24, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant