Skip to content

Commit c5705af

Browse files
dicethedevgreptile-apps[bot]MegaRedHand
authored
feat(p2p): add inbound BlocksByRange req/resp support (#348)
## 🗒️ Description / Motivation This PR adds inbound `BlocksByRange` request-response support to the P2P req/resp protocol implementation. The change follows the recently merged spec update: - leanEthereum/leanSpec#691 This is needed so peers can request canonical blocks by slot range, similar to the existing `BlocksByRoot` protocol. The implementation: - registers the new protocol - adds SSZ request/response handling - supports serving canonical blocks from local storage - validates malformed requests This improves interoperability with other clients implementing the updated spec. --- ## What Changed ### Req/Resp Protocol - Added `BlocksByRangeRequest` - Added `BlocksByRange` response payload variant - Added protocol ID: - `/leanconsensus/req/blocks_by_range/1/ssz_snappy` ### Codec - Updated request/response codec read paths - Updated request/response codec write paths ### Behaviour Registration - Registered `BlocksByRange` in the libp2p request-response behaviour ### Inbound Request Handling - Added inbound request handler for `BlocksByRange` - Serves canonical blocks by walking backward from the current fork-choice head - Skips: - empty slots - side forks ### Validation Added validation for: - `step == 0` - `count > 1024` Invalid requests return protocol error responses. ### Tests - Added unit test for canonical range selection and ordering --- ## Correctness / Behavior Guarantees ### Preserved Invariants - Only canonical blocks are returned - Returned blocks preserve requested slot ordering - Empty slots are skipped - Non-canonical side forks are ignored ### Behavior Notes - Invalid requests are rejected early with error responses - Maximum request size is capped at `1024` blocks - Implementation mirrors existing `BlocksByRoot` handling patterns for consistency --- ## Tests Added / Run ### Added - `blocks_by_range_returns_canonical_blocks_in_requested_order` ### Verified With ```bash cargo fmt --check cargo check -p ethlambda-p2p cargo test -p ethlambda-p2p blocks_by_range_returns_canonical_blocks_in_requested_order git diff --check ``` --- ## Related Issues / PRs - Closes #346 - Related to leanEthereum/leanSpec#691 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
1 parent 75126cc commit c5705af

5 files changed

Lines changed: 205 additions & 22 deletions

File tree

crates/net/p2p/src/lib.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ use crate::{
4040
publish_attestation, publish_block,
4141
},
4242
req_resp::{
43-
BLOCKS_BY_ROOT_PROTOCOL_V1, Codec, MAX_COMPRESSED_PAYLOAD_SIZE, Request,
44-
STATUS_PROTOCOL_V1, build_status, fetch_block_from_peer,
43+
BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, Codec,
44+
MAX_COMPRESSED_PAYLOAD_SIZE, Request, STATUS_PROTOCOL_V1, build_status,
45+
fetch_block_from_peer,
4546
},
4647
swarm_adapter::SwarmHandle,
4748
};
@@ -154,6 +155,10 @@ pub fn build_swarm(
154155
StreamProtocol::new(BLOCKS_BY_ROOT_PROTOCOL_V1),
155156
request_response::ProtocolSupport::Full,
156157
),
158+
(
159+
StreamProtocol::new(BLOCKS_BY_RANGE_PROTOCOL_V1),
160+
request_response::ProtocolSupport::Full,
161+
),
157162
],
158163
Default::default(),
159164
);

crates/net/p2p/src/req_resp/codec.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use tracing::{debug, trace, warn};
77
use super::{
88
encoding::{MAX_PAYLOAD_SIZE, decode_payload, write_payload},
99
messages::{
10-
BLOCKS_BY_ROOT_PROTOCOL_V1, ErrorMessage, Request, Response, ResponseCode, ResponsePayload,
11-
STATUS_PROTOCOL_V1, Status,
10+
BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, ErrorMessage, Request, Response,
11+
ResponseCode, ResponsePayload, STATUS_PROTOCOL_V1, Status,
1212
},
1313
};
1414

@@ -21,6 +21,7 @@ fn protocol_label(protocol: &str) -> &'static str {
2121
match protocol {
2222
STATUS_PROTOCOL_V1 => "status",
2323
BLOCKS_BY_ROOT_PROTOCOL_V1 => "blocks_by_root",
24+
BLOCKS_BY_RANGE_PROTOCOL_V1 => "blocks_by_range",
2425
_ => "unknown",
2526
}
2627
}
@@ -59,6 +60,12 @@ impl libp2p::request_response::Codec for Codec {
5960
})?;
6061
Ok(Request::BlocksByRoot(request))
6162
}
63+
BLOCKS_BY_RANGE_PROTOCOL_V1 => {
64+
let request = SszDecode::from_ssz_bytes(&payload).map_err(|err| {
65+
io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}"))
66+
})?;
67+
Ok(Request::BlocksByRange(request))
68+
}
6269
_ => Err(io::Error::new(
6370
io::ErrorKind::InvalidData,
6471
format!("unknown protocol: {}", protocol.as_ref()),
@@ -77,7 +84,9 @@ impl libp2p::request_response::Codec for Codec {
7784
let label = protocol_label(protocol.as_ref());
7885
match protocol.as_ref() {
7986
STATUS_PROTOCOL_V1 => decode_status_response(io, label).await,
80-
BLOCKS_BY_ROOT_PROTOCOL_V1 => decode_blocks_by_root_response(io, label).await,
87+
BLOCKS_BY_ROOT_PROTOCOL_V1 | BLOCKS_BY_RANGE_PROTOCOL_V1 => {
88+
decode_blocks_response(io, label).await
89+
}
8190
_ => Err(io::Error::new(
8291
io::ErrorKind::InvalidData,
8392
format!("unknown protocol: {}", protocol.as_ref()),
@@ -99,6 +108,7 @@ impl libp2p::request_response::Codec for Codec {
99108
let encoded = match req {
100109
Request::Status(status) => status.to_ssz(),
101110
Request::BlocksByRoot(request) => request.to_ssz(),
111+
Request::BlocksByRange(request) => request.to_ssz(),
102112
};
103113

104114
let compressed_size = write_payload(io, &encoded).await?;
@@ -132,7 +142,7 @@ impl libp2p::request_response::Codec for Codec {
132142
);
133143
Ok(())
134144
}
135-
ResponsePayload::BlocksByRoot(blocks) => {
145+
ResponsePayload::Blocks(blocks) => {
136146
// Write each block as a separate chunk.
137147
// Encode first, then check size before writing the SUCCESS
138148
// code byte. This avoids corrupting the stream if a block
@@ -143,7 +153,7 @@ impl libp2p::request_response::Codec for Codec {
143153
if encoded.len() > MAX_PAYLOAD_SIZE - 1024 {
144154
warn!(
145155
size = encoded.len(),
146-
"Skipping oversized block in BlocksByRoot response"
156+
"Skipping oversized block in block response"
147157
);
148158
continue;
149159
}
@@ -230,7 +240,7 @@ where
230240
Ok(Response::success(ResponsePayload::Status(status)))
231241
}
232242

233-
/// Decodes a BlocksByRoot protocol response from a multi-chunk response stream.
243+
/// Decodes a block protocol response from a multi-chunk response stream.
234244
///
235245
/// Reads chunks until EOF, collecting successfully decoded blocks. Each chunk has
236246
/// its own response code - chunks with error codes are logged and skipped rather
@@ -253,7 +263,7 @@ where
253263
///
254264
/// Note: Error chunks from the peer (non-SUCCESS response codes) do not cause this
255265
/// function to return `Err` - they are logged and skipped.
256-
async fn decode_blocks_by_root_response<T>(io: &mut T, protocol_label: &str) -> io::Result<Response>
266+
async fn decode_blocks_response<T>(io: &mut T, protocol_label: &str) -> io::Result<Response>
257267
where
258268
T: AsyncRead + Unpin + Send,
259269
{
@@ -291,5 +301,5 @@ where
291301
blocks.push(block);
292302
}
293303

294-
Ok(Response::success(ResponsePayload::BlocksByRoot(blocks)))
304+
Ok(Response::success(ResponsePayload::Blocks(blocks)))
295305
}

crates/net/p2p/src/req_resp/handlers.rs

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashSet;
1+
use std::collections::{HashMap, HashSet};
22

33
use ethlambda_storage::Store;
44
use libp2p::{PeerId, request_response};
@@ -12,7 +12,9 @@ use ethlambda_types::primitives::HashTreeRoot as _;
1212
use ethlambda_types::{block::SignedBlock, primitives::H256};
1313

1414
use super::{
15-
BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRootRequest, Request, Response, ResponsePayload, Status,
15+
BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRangeRequest, BlocksByRootRequest, MAX_REQUEST_BLOCKS,
16+
Request, Response, ResponsePayload, Status,
17+
messages::{ResponseCode, error_message},
1618
};
1719
use crate::{
1820
BACKOFF_MULTIPLIER, INITIAL_BACKOFF_MS, MAX_FETCH_RETRIES, P2PServer, PendingRequest,
@@ -42,6 +44,13 @@ pub async fn handle_req_resp_message(
4244
);
4345
handle_blocks_by_root_request(server, request, channel, peer).await;
4446
}
47+
Request::BlocksByRange(request) => {
48+
info!(
49+
kind = "blocks_by_range_request",
50+
peer_count, "P2P message received"
51+
);
52+
handle_blocks_by_range_request(server, request, channel, peer).await;
53+
}
4554
}
4655
}
4756
request_response::Message::Response {
@@ -55,11 +64,8 @@ pub async fn handle_req_resp_message(
5564
info!(kind = "status_response", peer_count, "P2P message received");
5665
handle_status_response(status, peer).await;
5766
}
58-
ResponsePayload::BlocksByRoot(blocks) => {
59-
info!(
60-
kind = "blocks_by_root_response",
61-
peer_count, "P2P message received"
62-
);
67+
ResponsePayload::Blocks(blocks) => {
68+
info!(kind = "blocks_response", peer_count, "P2P message received");
6369
handle_blocks_by_root_response(server, blocks, peer, request_id, ctx)
6470
.await;
6571
}
@@ -136,10 +142,99 @@ async fn handle_blocks_by_root_request(
136142
let found = blocks.len();
137143
info!(%peer, num_roots, found, "Responding to BlocksByRoot request");
138144

139-
let response = Response::success(ResponsePayload::BlocksByRoot(blocks));
145+
let response = Response::success(ResponsePayload::Blocks(blocks));
146+
server.swarm_handle.send_response(channel, response);
147+
}
148+
149+
async fn handle_blocks_by_range_request(
150+
server: &mut P2PServer,
151+
request: BlocksByRangeRequest,
152+
channel: request_response::ResponseChannel<Response>,
153+
peer: PeerId,
154+
) {
155+
info!(
156+
%peer,
157+
start_slot = request.start_slot,
158+
count = request.count,
159+
step = request.step,
160+
"Received BlocksByRange request"
161+
);
162+
163+
if request.step == 0 || request.count == 0 || request.count > MAX_REQUEST_BLOCKS {
164+
let response = Response::error(
165+
ResponseCode::INVALID_REQUEST,
166+
error_message("invalid BlocksByRange request"),
167+
);
168+
server.swarm_handle.send_response(channel, response);
169+
return;
170+
}
171+
172+
let blocks = canonical_blocks_by_range(
173+
&server.store,
174+
request.start_slot,
175+
request.count,
176+
request.step,
177+
);
178+
179+
info!(
180+
%peer,
181+
start_slot = request.start_slot,
182+
count = request.count,
183+
step = request.step,
184+
found = blocks.len(),
185+
"Responding to BlocksByRange request"
186+
);
187+
188+
let response = Response::success(ResponsePayload::Blocks(blocks));
140189
server.swarm_handle.send_response(channel, response);
141190
}
142191

192+
fn canonical_blocks_by_range(
193+
store: &Store,
194+
start_slot: u64,
195+
count: u64,
196+
step: u64,
197+
) -> Vec<SignedBlock> {
198+
if count == 0 {
199+
return Vec::new();
200+
}
201+
202+
let Some(end_slot) = count
203+
.checked_sub(1)
204+
.and_then(|value| value.checked_mul(step))
205+
.and_then(|last_offset| start_slot.checked_add(last_offset))
206+
else {
207+
return Vec::new();
208+
};
209+
210+
let mut roots_by_slot = HashMap::new();
211+
let mut current_root = store.head();
212+
213+
while !current_root.is_zero() {
214+
let Some(header) = store.get_block_header(&current_root) else {
215+
break;
216+
};
217+
218+
if header.slot < start_slot {
219+
break;
220+
}
221+
222+
if header.slot <= end_slot && (header.slot - start_slot).is_multiple_of(step) {
223+
roots_by_slot.insert(header.slot, current_root);
224+
}
225+
226+
current_root = header.parent_root;
227+
}
228+
229+
(0..count)
230+
.filter_map(|index| {
231+
let slot = start_slot.checked_add(index.checked_mul(step)?)?;
232+
let root = roots_by_slot.get(&slot)?;
233+
store.get_signed_block(root)
234+
})
235+
.collect()
236+
}
237+
143238
async fn handle_blocks_by_root_response(
144239
server: &mut P2PServer,
145240
blocks: Vec<SignedBlock>,
@@ -313,3 +408,67 @@ async fn handle_fetch_failure(
313408

314409
send_after(backoff, ctx.clone(), p2p_protocol::RetryBlockFetch { root });
315410
}
411+
412+
#[cfg(test)]
413+
mod tests {
414+
use super::*;
415+
use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend};
416+
use ethlambda_types::{
417+
attestation::XmssSignature,
418+
block::{Block, BlockBody, BlockSignatures},
419+
signature::SIGNATURE_SIZE,
420+
state::State,
421+
};
422+
use libssz_types::SszList;
423+
use std::sync::Arc;
424+
425+
fn signed_block(slot: u64, parent_root: H256) -> SignedBlock {
426+
SignedBlock {
427+
message: Block {
428+
slot,
429+
proposer_index: 0,
430+
parent_root,
431+
state_root: H256::ZERO,
432+
body: BlockBody::default(),
433+
},
434+
signature: BlockSignatures {
435+
attestation_signatures: SszList::new(),
436+
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
437+
},
438+
}
439+
}
440+
441+
#[test]
442+
fn blocks_by_range_returns_canonical_blocks_in_requested_order() {
443+
let backend = Arc::new(InMemoryBackend::new());
444+
let mut store = Store::from_anchor_state(backend, State::from_genesis(0, vec![]));
445+
446+
let block_1 = signed_block(1, store.head());
447+
let root_1 = block_1.message.hash_tree_root();
448+
store.insert_signed_block(root_1, block_1);
449+
450+
let block_2 = signed_block(2, root_1);
451+
let root_2 = block_2.message.hash_tree_root();
452+
store.insert_signed_block(root_2, block_2);
453+
454+
let side_block_3 = signed_block(3, root_1);
455+
let side_root_3 = side_block_3.message.hash_tree_root();
456+
store.insert_signed_block(side_root_3, side_block_3);
457+
458+
let block_4 = signed_block(4, root_2);
459+
let root_4 = block_4.message.hash_tree_root();
460+
store.insert_signed_block(root_4, block_4);
461+
store.update_checkpoints(ForkCheckpoints::head_only(root_4));
462+
463+
let blocks = canonical_blocks_by_range(&store, 1, 4, 1);
464+
let slots: Vec<_> = blocks.iter().map(|block| block.message.slot).collect();
465+
let roots: Vec<_> = blocks
466+
.iter()
467+
.map(|block| block.message.hash_tree_root())
468+
.collect();
469+
470+
assert_eq!(slots, vec![1, 2, 4]);
471+
assert_eq!(roots, vec![root_1, root_2, root_4]);
472+
assert!(!roots.contains(&side_root_3));
473+
}
474+
}

crates/net/p2p/src/req_resp/messages.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ use libssz_types::SszList;
44

55
pub const STATUS_PROTOCOL_V1: &str = "/leanconsensus/req/status/1/ssz_snappy";
66
pub const BLOCKS_BY_ROOT_PROTOCOL_V1: &str = "/leanconsensus/req/blocks_by_root/1/ssz_snappy";
7+
pub const BLOCKS_BY_RANGE_PROTOCOL_V1: &str = "/leanconsensus/req/blocks_by_range/1/ssz_snappy";
8+
pub const MAX_REQUEST_BLOCKS: u64 = 1024; // Maximum number of blocks in a single request (1024).
79

810
#[derive(Debug, Clone)]
911
pub enum Request {
1012
Status(Status),
1113
BlocksByRoot(BlocksByRootRequest),
14+
BlocksByRange(BlocksByRangeRequest),
1215
}
1316

1417
#[derive(Debug, Clone)]
@@ -88,7 +91,7 @@ impl std::fmt::Debug for ResponseCode {
8891
#[allow(clippy::large_enum_variant)]
8992
pub enum ResponsePayload {
9093
Status(Status),
91-
BlocksByRoot(Vec<SignedBlock>),
94+
Blocks(Vec<SignedBlock>),
9295
}
9396

9497
#[derive(Debug, Clone, SszEncode, SszDecode)]
@@ -106,8 +109,6 @@ pub type ErrorMessage = SszList<u8, 256>;
106109
/// Helper to create an ErrorMessage from a string.
107110
/// Debug builds panic if message exceeds 256 bytes (programming error).
108111
/// Release builds truncate to 256 bytes.
109-
#[expect(dead_code)]
110-
// TODO: map errors to req/resp error messages
111112
pub fn error_message(msg: impl AsRef<str>) -> ErrorMessage {
112113
let bytes = msg.as_ref().as_bytes();
113114
debug_assert!(
@@ -130,3 +131,10 @@ pub fn error_message(msg: impl AsRef<str>) -> ErrorMessage {
130131
pub struct BlocksByRootRequest {
131132
pub roots: RequestedBlockRoots,
132133
}
134+
135+
#[derive(Debug, Clone, SszEncode, SszDecode)]
136+
pub struct BlocksByRangeRequest {
137+
pub start_slot: u64,
138+
pub count: u64,
139+
pub step: u64,
140+
}

crates/net/p2p/src/req_resp/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub use codec::Codec;
77
pub use encoding::{MAX_COMPRESSED_PAYLOAD_SIZE, MAX_PAYLOAD_SIZE};
88
pub use handlers::{build_status, fetch_block_from_peer, handle_req_resp_message};
99
pub use messages::{
10-
BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRootRequest, Request, RequestedBlockRoots, Response,
10+
BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRangeRequest,
11+
BlocksByRootRequest, MAX_REQUEST_BLOCKS, Request, RequestedBlockRoots, Response,
1112
ResponsePayload, STATUS_PROTOCOL_V1, Status,
1213
};

0 commit comments

Comments
 (0)