Skip to content

feat(p2p): use BlocksByRange for long-range sync#351

Open
dicethedev wants to merge 17 commits into
lambdaclass:mainfrom
dicethedev:feat/blocks-by-range-long-range-sync
Open

feat(p2p): use BlocksByRange for long-range sync#351
dicethedev wants to merge 17 commits into
lambdaclass:mainfrom
dicethedev:feat/blocks-by-range-long-range-sync

Conversation

@dicethedev
Copy link
Copy Markdown
Contributor

🗒️ Description / Motivation

This PR closes #347 by wiring the BlocksByRange protocol added in #348 into the status-response sync path.

Previously, when a peer's Status response revealed it was ahead of our local head, we had no mechanism to backfill the gap. Now, when the gap exceeds a configurable threshold (LONG_RANGE_SYNC_THRESHOLD = 2 slots), we request the missing range using BlocksByRange instead of relying on gossip or individual BlocksByRoot fetches.

For small gaps (1–2 slots), we defer to the existing FetchBlock path since roots are typically already available from gossip and BlocksByRoot is more precise for that case.


What Changed

lib.rs

  • Added LONG_RANGE_SYNC_THRESHOLD: u64 = 2 constant

req_resp/handlers.rs

  • Updated handle_status_response to branch on gap size:
    • gap > LONG_RANGE_SYNC_THRESHOLDrequest_blocks_by_range_from_peer
    • gap ≤ threshold → defers to gossip / FetchBlock

Correctness / Behavior Guarantees

  • request_blocks_by_range_from_peer already batches internally at MAX_REQUEST_BLOCKS (1024), so nodes thousands of slots behind are handled correctly across multiple requests with no additional changes
  • handle_blocks_by_range_response (added in feat(p2p): add inbound BlocksByRange req/resp support #348) already forwards each block to the blockchain layer — the response path is complete
  • BlocksByRoot behavior for individual missing blocks (FetchBlock, retry/backoff logic) is unchanged

Tests Added / Run

No new tests required. The range response handling and canonical block selection are covered by the test added in #348 (blocks_by_range_returns_canonical_blocks_in_requested_order).

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran cargo test --workspace --release — all passing

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR wires the previously-added BlocksByRange protocol into the status-response sync path: when a peer's Status reveals it is more than LONG_RANGE_SYNC_THRESHOLD (2) slots ahead, the node now requests the missing range in batches of up to 1024 blocks instead of relying solely on gossip.

  • handle_status_response now computes the slot gap and dispatches request_blocks_by_range_from_peer, which loops ⌈gap / 1024⌉ times sending batched requests; the gap is taken directly from the peer's untrusted Status message with no upper bound.
  • canonical_blocks_by_range serves inbound requests by walking the canonical chain backwards from store.head() until reaching start_slot; traversal cost is O(head_slot − start_slot), not O(count), making it vulnerable to cheap deep-history requests.
  • handle_blocks_by_range_response forwards received blocks to the blockchain layer with no slot-range validation and no retry path wired into the OutboundFailure event handler.

Confidence Score: 3/5

The codec, messages, and module wiring changes are safe, but handlers.rs has three defects on the changed sync path that should be addressed before merging.

The gap passed to the batch-request loop is taken directly from a peer's unauthenticated Status message with no ceiling, so a malicious peer can send an astronomically large head slot and pin the node in a near-infinite async loop. Separately, BlocksByRange request IDs are never registered in request_id_map, so every outbound-failure event silently drops the failure with no retry, leaving the node permanently behind if any batch times out. Additionally, the inbound handler walks the canonical chain from head to the requested start_slot, making its cost proportional to chain depth rather than the requested block count — a cheap way to induce heavy work on the responding node.

crates/net/p2p/src/req_resp/handlers.rs warrants close attention across handle_status_response, request_blocks_by_range_from_peer, canonical_blocks_by_range, and the OutboundFailure branch of handle_req_resp_message.

Important Files Changed

Filename Overview
crates/net/p2p/src/req_resp/handlers.rs Core file with the most significant changes: adds handle_status_response gap-based branching, canonical_blocks_by_range chain traversal, and BlocksByRange request/response handlers — has uncapped gap loop, no OutboundFailure recovery, and O(head_slot) traversal vulnerability
crates/net/p2p/src/req_resp/codec.rs Cleanly refactors decode_blocks_by_root_response into shared decode_blocks_response helper and wires BlocksByRange into the codec; no issues found
crates/net/p2p/src/req_resp/messages.rs Adds BlocksByRangeRequest struct, BlocksByRange response payload variant, MAX_REQUEST_BLOCKS constant, and removes the dead_code attribute from error_message; straightforward and correct
crates/net/p2p/src/lib.rs Adds LONG_RANGE_SYNC_THRESHOLD constant and registers BlocksByRange protocol with ProtocolSupport::Full in the swarm builder; no issues found
crates/net/p2p/src/req_resp/mod.rs Re-exports new BlocksByRange symbols; trivial change with no issues

Sequence Diagram

sequenceDiagram
    participant Local as Local Node
    participant Peer as Remote Peer
    participant Blockchain as Blockchain Layer

    Local->>Peer: Status Request
    Peer-->>Local: Status Response (head_slot, finalized)
    
    Local->>Local: "gap = peer.head_slot - our_head_slot"

    alt "gap <= LONG_RANGE_SYNC_THRESHOLD (2)"
        Local->>Local: rely on gossip / FetchBlock (BlocksByRoot)
    else "gap > LONG_RANGE_SYNC_THRESHOLD"
        loop ceil(gap / MAX_REQUEST_BLOCKS) batches
            Local->>Peer: "BlocksByRange Request (start_slot, count<=1024, step=1)"
            Peer-->>Local: BlocksByRange Response [blocks...]
            Local->>Blockchain: new_block() for each block
        end
    end

    note over Local,Peer: Inbound path (serving requests)
    Peer->>Local: BlocksByRange Request
    Local->>Local: canonical_blocks_by_range() walk chain from head to start_slot
    Local-->>Peer: BlocksByRange Response [canonical blocks]
Loading

Comments Outside Diff (1)

  1. crates/net/p2p/src/req_resp/handlers.rs, line 93-105 (link)

    P1 BlocksByRange outbound failures are silently discarded with no recovery

    request_id_map is only populated for BlocksByRoot requests (see fetch_block_from_peer). When an OutboundFailure fires for a BlocksByRange request, the if let Some(root) = server.request_id_map.remove(&request_id) branch is never taken, so the failure is logged but nothing else happens. If any batch in a long-range sync fails (network error, peer disconnect, timeout), the sync silently stops with no retry or fallback. The node remains stuck behind its peers with no automatic recovery until the next Status message happens to arrive.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/net/p2p/src/req_resp/handlers.rs
    Line: 93-105
    
    Comment:
    **BlocksByRange outbound failures are silently discarded with no recovery**
    
    `request_id_map` is only populated for `BlocksByRoot` requests (see `fetch_block_from_peer`). When an `OutboundFailure` fires for a `BlocksByRange` request, the `if let Some(root) = server.request_id_map.remove(&request_id)` branch is never taken, so the failure is logged but nothing else happens. If any batch in a long-range sync fails (network error, peer disconnect, timeout), the sync silently stops with no retry or fallback. The node remains stuck behind its peers with no automatic recovery until the next Status message happens to arrive.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/net/p2p/src/req_resp/handlers.rs:141-151
**Uncapped gap triggers a near-infinite request loop**

`gap` is directly used as the `count` passed to `request_blocks_by_range_from_peer`, which loops `⌈gap / MAX_REQUEST_BLOCKS⌉` times sending requests. A malicious peer sending `Status { head.slot = u64::MAX }` would cause the loop to iterate ~1.8 × 10¹⁶ times — effectively hanging the node until the swarm channel closes (whose capacity determines how long that takes). Even a "legitimate" peer claiming to be 10 million slots ahead would immediately queue ~9,766 requests. There is no upper bound on how many batches are dispatched in a single call.

### Issue 2 of 4
crates/net/p2p/src/req_resp/handlers.rs:93-105
**BlocksByRange outbound failures are silently discarded with no recovery**

`request_id_map` is only populated for `BlocksByRoot` requests (see `fetch_block_from_peer`). When an `OutboundFailure` fires for a `BlocksByRange` request, the `if let Some(root) = server.request_id_map.remove(&request_id)` branch is never taken, so the failure is logged but nothing else happens. If any batch in a long-range sync fails (network error, peer disconnect, timeout), the sync silently stops with no retry or fallback. The node remains stuck behind its peers with no automatic recovery until the next Status message happens to arrive.

### Issue 3 of 4
crates/net/p2p/src/req_resp/handlers.rs:221-267
**O(head\_slot) chain traversal for old `start_slot` requests**

`canonical_blocks_by_range` always starts walking from `store.head()` and traverses backwards one header at a time until `header.slot < start_slot`. If a peer requests `start_slot = 0, count = 1024` on a chain whose head is at slot 1,000,000, the loop performs 1,000,000 `store.get_block_header` calls before collecting any of the 1024 requested blocks. Since `count` is bounded by `MAX_REQUEST_BLOCKS` but `start_slot` is not validated against the local chain, this becomes an unbounded-work request handler. A peer can exploit this to perform a cheap DoS by repeatedly requesting from `start_slot = 0`.

### Issue 4 of 4
crates/net/p2p/src/req_resp/handlers.rs:328-342
`handle_blocks_by_range_response` does not verify that the returned blocks' slots fall within the requested range. A misbehaving peer can inject arbitrary blocks (from a different range or a different chain) and they will be forwarded unconditionally to the blockchain layer. At minimum, the slot of each block should be cross-checked against the requested `[start_slot, start_slot + count)` window, which is available from the request context.

```suggestion
    if let Some(ref blockchain) = server.blockchain {
        for block in blocks {
            let block_root = block.message.hash_tree_root();
            let slot = block.message.slot;
            // TODO: validate block.message.slot is within the originally requested range.
            let _ = blockchain.new_block(block).inspect_err(|err| {
                error!(
                    %peer,
                    %slot,
                    block_root = %ethlambda_types::ShortRoot(&block_root.0),
                    %err,
                    "Failed to forward range-fetched block to blockchain"
                )
            });
        }
    }
```

Reviews (1): Last reviewed commit: "fix(clippy): use is_multiple_of for slot..." | Re-trigger Greptile

Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
@dicethedev
Copy link
Copy Markdown
Contributor Author

@MegaRedHand do we still need this PR now that #355 is planned? Since #355 adds a slot/block-number index for efficient BlocksByRange lookups, I’m wondering if this PR should wait for that, or if it’s still useful to keep this implementation for now and let #355 optimize the lookup path later.

@MegaRedHand
Copy link
Copy Markdown
Collaborator

Let's keep this PR and #355 separate. It's easier to review this way

Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

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

Left some comments.

Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
- Remove redundant MAX_SLOT_LOOKBACK guard
- Always trigger long-range sync on status response
- Deduplicate range requests across peers
@dicethedev
Copy link
Copy Markdown
Contributor Author

Left some comments.

You can review again.

Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

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

LGTM. We'll run some tests before merging

@MegaRedHand
Copy link
Copy Markdown
Collaborator

@dicethedev the CI is failing. The step field was removed in #365

@dicethedev dicethedev requested a review from MegaRedHand May 19, 2026 23:52
Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

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

Left some more comments, but they can be addressed in another PR.

Also, there's an issue with decode_payload (#384), which makes response decoding fail for BlocksByRange. We need to fix that before merging this. Feel free to look at that if you have time.

Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/req_resp/handlers.rs Outdated
Comment thread crates/net/p2p/src/lib.rs Outdated
@dicethedev
Copy link
Copy Markdown
Contributor Author

dicethedev commented May 20, 2026

Also, there's an issue with decode_payload (#384), which makes response decoding fail for BlocksByRange. We need to fix that before merging this. Feel free to look at that if you have time.

@MegaRedHand Thanks! So regarding the decode_payload issue (#384), would you prefer I fix that on this branch as part of the current PR, or should I open a separate PR for it?

@MegaRedHand
Copy link
Copy Markdown
Collaborator

A separate PR would be best. We need to add tests for it anyway, so this PR isn't actually needed for testing.

Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

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

Blocked until #384 is fixed

@dicethedev dicethedev requested a review from MegaRedHand May 28, 2026 15:55
@dicethedev
Copy link
Copy Markdown
Contributor Author

@MegaRedHand still waiting for reviews

@MegaRedHand
Copy link
Copy Markdown
Collaborator

PR looks good, but it requires #384 to be fixed first

@dicethedev
Copy link
Copy Markdown
Contributor Author

PR looks good, but it requires #384 to be fixed first

I will push a fix soon for #384

MegaRedHand pushed a commit that referenced this pull request Jun 2, 2026
…403)

When `--checkpoint-sync-url` is provided, the node currently always
downloads a fresh finalized state from the peer, even if a recent state
is already on disk. Skip the network round-trip when the persisted state
is fresh enough to resume from.

This PR introduces a state data freshness threshold -
`MAX_RESUMABLE_DB_STATE_AGE = 450` slots (~30 min at 4s/slot) - picked
conservatively considering the per-block backfill cost. I couldn't
identify in the specs a formal weak-subjectivity period or a method of
calculating it, so this is a judgement call; happy to take any
suggestions on the value or a better approach for it.

## What Changed

- `crates/storage/src/store.rs` — added `Store::from_db_state(backend,
expected_genesis_time)`, a no-write constructor that wraps an
already-initialized backend. Returns `None` if the backend is empty or
its persisted `genesis_time` doesn't match. Added the
`MAX_RESUMABLE_DB_STATE_AGE = 450` constant (~30 min at 4s/slot).
- `crates/storage/src/lib.rs` — re-exported
`MAX_RESUMABLE_DB_STATE_AGE`.
- `bin/ethlambda/src/main.rs` — in the checkpoint-sync branch of
`fetch_initial_state`, try `Store::from_db_state` first. If the
persisted finalized slot is within `MAX_RESUMABLE_DB_STATE_AGE` of
wall-clock, return the resumed store and skip checkpoint sync. Otherwise
warn and fall through to the existing sync path.

## Correctness / Behavior Guarantees

- New short-circuit fires only when: checkpoint URL provided AND DB
populated AND persisted `genesis_time` matches AND `current_slot -
latest_finalized.slot <= MAX_RESUMABLE_DB_STATE_AGE`. Everything else
remains unchanged.
- 30 min threshold is conservative for the current `BlocksByRoot`-only
backfill cost; can be increased once `BlocksByRange` long-range sync
(#351) is added.

## Tests Added / Run

Three unit tests in `crates/storage/src/store.rs` covering the
`from_db_state` contract:
- `from_db_state_returns_none_on_empty_backend`
- `from_db_state_returns_some_on_matching_genesis_time`
- `from_db_state_returns_none_on_genesis_time_mismatch`


## Related Issues / PRs

- Closes #121
- Related to #351 (BlocksByRange long-range sync — once landed,
`MAX_RESUMABLE_DB_STATE_AGE` can probably be raised toward
`STATES_TO_KEEP`)

## ✅ Verification Checklist

- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [x] Ran `cargo test --workspace --release` — all passing
- [x] Local devnet test

---------

Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
MegaRedHand added a commit that referenced this pull request Jun 3, 2026
## 🗒️ Description / Motivation
- Fixes req-resp payload decoding so multi-chunk responses are handled
correctly.
- `decode_payload` previously read the entire remaining stream, causing
BlocksByRange / multi-block responses to fail when the first chunk
decoder consumed bytes belonging to following chunks.
- This solves snappy decode failures for responses containing multiple
payload chunks.

## What Changed
- Updated `crates/net/p2p/src/req_resp/encoding.rs`.
- Reworked `decode_payload` to read one varint-prefixed snappy payload
at a time.
- Added async helpers for reading the varint prefix and one snappy
frame.
- Added tests covering adjacent payloads, large snappy-framed payloads,
and empty payload boundaries.

## Correctness / Behavior Guarantees
- Each response chunk is decoded independently.
- The next response code / payload remains unread for the outer response
loop.
- Existing maximum payload and compressed payload size checks are
preserved.
- Response size metrics now report the compressed size for the current
payload only.

## Tests Added / Run
- Added req-resp encoding tests.
- Ran:
  - `cargo fmt --check`
  - `cargo test -p ethlambda-p2p`

## Related Issues / PRs
- Closes #384
- Related to #351

## ✅ Verification Checklist

- [ x] Ran `make fmt` — clean
- [ x] Ran `make lint` (clippy with `-D warnings`) — clean
- [ x] Ran `cargo test --workspace --release` — all passing

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

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

Retry functionality is not working, currently. Let's remove it before merging this PR.

We can add the retry functionality in another PR. I left some comments regarding that.

Comment on lines +126 to +137
server
.pending_range_requests
.remove(&(start_slot, end_slot));
send_after(
Duration::from_millis(500),
ctx.clone(),
p2p_protocol::RetryRangeSync {
peer_id: peer,
start_slot,
end_slot: total_end_slot, // retry the full remaining range
},
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's make this similar to handle_fetch_failure. In fact, I think we can remove this match altogether, something like before:

if let Some(pending_request) = server.outbound_requests.remove(&request_id) {
    handle_fetch_failure(server, pending_request, peer, ctx).await;
}

And then have the match inside handle_fetch_failure. Also, we should add retry tracking to the range requests too.

Comment thread crates/net/p2p/src/lib.rs
Comment on lines +436 to +444
// safety check: if already synced, skip retry
let still_needed = !self
.pending_range_requests
.contains(&(start_slot, end_slot));

if still_needed {
tracing::trace!(%peer, start_slot, end_slot, "Skipping retry, range already resolved");
return;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We remove the entry from pending_range_requests right before sending the RetryRangeSync message. That would make this always land on this skip on error.

Comment on lines +494 to +513
loop {
let covered = server
.pending_range_requests
.iter()
.find(|&&(s, e)| s <= effective_start && effective_start <= e)
.copied();
match covered {
Some((_, covered_end)) => effective_start = covered_end + 1,
None => break,
}
if effective_start > total_end_slot {
info!(
%peer,
start_slot,
total_end_slot,
"BlocksByRange fully covered by in-flight requests, skipping"
);
return true;
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should look for a better way to handle deduplication. I'm thinking we can simplify this by having a single BlocksByRange request in progress, then we can just merge all seen ranges.

When we receive the first new range, we initialize our total range to the start and end of that range. Since the start can't possibly change unless we advance our head, we can add to this range by bumping the end slot whenever we find a newer range from peer status responses. Then, whenever we receive a response for a BlocksByRange message, we'd increase the start slot to the end of the received range and request the next chunk. Once we reach the end slot, we can stop requesting blocks, and represent that by setting our "queue" to None.

For peer tracking, we can have a list of peers with the latest slot we saw in their status message. We can then request ranges from peers that we know have them, while dropping peers with an old head.

// We can also use std::range::Range<u64> instead
range_sync_state: Option<RangeSyncState>

struct RangeSyncState {
    current_range: Range<u64>,
    peer_set: HashMap<PeerId, u64>,
}

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.

Use BlocksByRange for long range syncing

3 participants