Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lean_spec/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,14 @@ async def run_node(
)
event_source.set_status(updated_status)

# Wire the responder's view of wall-clock time.
#
# Without this, the responder cannot bound the sliding history window and
# rejects every range request. The block-by-slot and block-by-root lookups
# still need SignedBlock storage; they share the same gap and are owned by
# a follow-up storage refactor.
event_source.set_current_slot_lookup(node.clock.current_slot)

# Connect to bootnodes.
#
# Best-effort connection: failures don't abort the loop.
Expand Down
29 changes: 29 additions & 0 deletions src/lean_spec/subspecs/networking/client/event_source/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@
from lean_spec.subspecs.networking.gossipsub.types import TopicId
from lean_spec.subspecs.networking.reqresp.handler import (
REQRESP_PROTOCOL_IDS,
AsyncBlockBySlotLookup,
AsyncBlockLookup,
CurrentSlotLookup,
ReqRespServer,
RequestHandler,
)
Expand Down Expand Up @@ -313,6 +315,33 @@ def set_block_lookup(self, lookup: AsyncBlockLookup) -> None:
"""
self._reqresp_handler.block_lookup = lookup

def set_block_by_slot_lookup(self, lookup: AsyncBlockBySlotLookup) -> None:
"""
Set the callback for looking up canonical blocks by slot.

Used by the inbound ReqResp handler to serve BlocksByRange requests.

The callback MUST consult fork choice.
It returns the canonical block at that slot, or None for empty slots.

Args:
lookup: Async function from Slot to SignedBlock or None.
"""
self._reqresp_handler.block_by_slot_lookup = lookup

def set_current_slot_lookup(self, lookup: CurrentSlotLookup) -> None:
"""
Set the callback returning the node's current slot.

Used to compute the BlocksByRange sliding history window.

Without this callback, the responder rejects every range request with SERVER_ERROR.

Args:
lookup: Function returning the current Slot.
"""
self._reqresp_handler.current_slot_lookup = lookup

def subscribe_gossip_topic(self, topic: TopicId) -> None:
"""
Subscribe to a gossip topic.
Expand Down
189 changes: 187 additions & 2 deletions src/lean_spec/subspecs/networking/client/reqresp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@
import logging
from dataclasses import dataclass, field

from lean_spec.forks.lstar.containers import SignedBlock
from lean_spec.forks.lstar.containers import SignedBlock, Slot
from lean_spec.subspecs.networking.config import MAX_REQUEST_BLOCKS
from lean_spec.subspecs.networking.reqresp.codec import (
CodecError,
ResponseCode,
encode_request,
)
from lean_spec.subspecs.networking.reqresp.message import (
BLOCKS_BY_RANGE_PROTOCOL_V1,
BLOCKS_BY_ROOT_PROTOCOL_V1,
STATUS_PROTOCOL_V1,
BlocksByRangeRequest,
BlocksByRootRequest,
RequestedBlockRoots,
Status,
Expand All @@ -50,7 +53,8 @@
QuicConnection,
QuicConnectionManager,
)
from lean_spec.types import Bytes32
from lean_spec.subspecs.ssz.hash import hash_tree_root
from lean_spec.types import Bytes32, Uint64

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -205,6 +209,187 @@ async def _do_blocks_by_root_request(
finally:
await stream.close()

async def request_blocks_by_range(
self,
peer_id: PeerId,
start_slot: Slot,
count: Uint64,
) -> list[SignedBlock]:
"""
Request blocks by range from a peer.

Implements the NetworkRequester protocol method.

Returns an empty list on transport-level errors (no connection,
timeout, network failure).

Raises CodecError on protocol violations by the peer (non-monotonic
slots, out-of-range slots, parent-root mismatch, more than count
chunks). Callers may use this signal for peer downscoring.

Args:
peer_id: Peer to request from.
start_slot: Start slot of the range.
count: Number of blocks to request.

Returns:
List of blocks received. May be fewer than requested if peer
does not have all blocks.
"""
# Local validation: matches the responder's bounds so a malformed call
# is rejected before opening a stream.
if count == Uint64(0):
return []
if count > Uint64(MAX_REQUEST_BLOCKS):
return []
if int(start_slot) + int(count) > int(Uint64.max_value()):
# Range would overflow Uint64; cannot be satisfied.
return []

conn = self._connections.get(peer_id)
if conn is None:
logger.debug("No connection to peer %s for blocks_by_range", peer_id)
return []

try:
return await asyncio.wait_for(
self._do_blocks_by_range_request(conn, start_slot, count),
timeout=self.timeout,
)
except CodecError:
# Protocol violation: propagate so callers can downscore the peer.
raise
except asyncio.TimeoutError:
logger.warning("Timeout requesting blocks from %s", peer_id)
return []
except Exception as e:
logger.warning("Error requesting blocks from %s: %s", peer_id, e)
return []

async def _do_blocks_by_range_request(
self,
conn: QuicConnection,
start_slot: Slot,
count: Uint64,
) -> list[SignedBlock]:
"""
Execute a BlocksByRange request.

Opens a stream, negotiates the protocol, sends the request,
and reads all response chunks.

Args:
conn: QuicConnection to use.
start_slot: Start slot of the range.
count: Number of blocks to request.

Returns:
List of blocks received.
"""
# Open a new stream and negotiate the protocol.
stream = await conn.open_stream(BLOCKS_BY_RANGE_PROTOCOL_V1)
end_slot_exclusive = int(start_slot) + int(count)

try:
# Build and send the request.
request = BlocksByRangeRequest(start_slot=start_slot, count=count)
request_bytes = encode_request(request.encode_bytes())
await stream.write(request_bytes)

# Half-close to signal we're done sending.
finish_write = getattr(stream, "finish_write", None)
if finish_write is not None:
await finish_write()

# Read response chunks.
#
# Each block is sent as a separate response chunk.
# We read until the stream closes or we get all blocks.
blocks: list[SignedBlock] = []
prev_slot: Slot | None = None
prev_root: Bytes32 | None = None
stream_ended = False

for _ in range(int(count)):
try:
response_data = await stream.read()
if not response_data:
# Stream closed; no more blocks.
stream_ended = True
break

code, ssz_bytes = ResponseCode.decode(response_data)

if code == ResponseCode.SUCCESS:
inner = SignedBlock.decode_bytes(ssz_bytes).block
block_slot = inner.slot

# Slots MUST be strictly increasing across the stream.
if prev_slot is not None and block_slot <= prev_slot:
raise CodecError(f"Non-monotonic slot: {block_slot} <= {prev_slot}")

# Block MUST fall inside the requested half-open range.
# Use int math so an end_slot near 2**64 cannot wrap.
if int(block_slot) < int(start_slot) or (
int(block_slot) >= end_slot_exclusive
):
raise CodecError(f"Block slot {block_slot} outside requested range")

# Parent-root continuity.
#
# The responder serves canonical blocks only and skips
# empty slots. The next non-empty block's parent_root
# therefore equals the last received block's root,
# regardless of how many empty slots lie between.
if prev_root is not None and inner.parent_root != prev_root:
raise CodecError(
f"Parent root mismatch at slot {block_slot}: "
f"expected {prev_root.hex()}, "
f"got {inner.parent_root.hex()}"
)

blocks.append(SignedBlock.decode_bytes(ssz_bytes))
prev_slot = block_slot
prev_root = hash_tree_root(inner)

elif code == ResponseCode.RESOURCE_UNAVAILABLE:
# Peer doesn't have this block, continue.
continue
else:
# Other error, stop reading.
logger.debug("BlocksByRange error response: %s", code)
stream_ended = True
break

except CodecError as e:
# Protocol violation: log with peer id and re-raise.
logger.warning("Protocol violation from %s: %s", conn.peer_id, e)
raise

# No-more-than-count enforcement.
#
# After we have read count chunks the peer MUST have finished
# writing. Any extra response chunk is a protocol violation.
# Skip when the stream already ended inside the loop.
if not stream_ended:
try:
extra = await stream.read()
except Exception:
extra = b""
if extra:
msg = "Peer sent more than count BlocksByRange chunks"
logger.warning("Protocol violation from %s: %s", conn.peer_id, msg)
raise CodecError(msg)

return blocks

finally:
# Always close the stream.
try:
await stream.close()
except Exception as e:
logger.debug("Error closing stream: %s", e)

async def send_status(
self,
peer_id: PeerId,
Expand Down
11 changes: 11 additions & 0 deletions src/lean_spec/subspecs/networking/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,14 @@
"libp2p" is the Application-Layer Protocol Negotiation (ALPN) value used
during the TLS 1.3 handshake to identify libp2p connections.
"""

MIN_SLOTS_FOR_BLOCK_REQUESTS: Final[int] = 3600
"""History window for BlocksByRange responders, in slots.

Responders MUST keep this many recent slots available.

The window slides with the node's current slot. It is never an absolute slot number.

A request whose start slot falls below the window receives RESOURCE_UNAVAILABLE.
This lets nodes prune state below the window.
"""
6 changes: 6 additions & 0 deletions src/lean_spec/subspecs/networking/reqresp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,30 @@
)
from .handler import (
REQRESP_PROTOCOL_IDS,
AsyncBlockBySlotLookup,
AsyncBlockLookup,
ReqRespServer,
RequestHandler,
StreamResponseAdapter,
)
from .message import (
BLOCKS_BY_RANGE_PROTOCOL_V1,
BLOCKS_BY_ROOT_PROTOCOL_V1,
STATUS_PROTOCOL_V1,
BlocksByRangeRequest,
BlocksByRootRequest,
RequestedBlockRoots,
Status,
)

__all__ = [
# Protocol IDs
"BLOCKS_BY_RANGE_PROTOCOL_V1",
"BLOCKS_BY_ROOT_PROTOCOL_V1",
"STATUS_PROTOCOL_V1",
"REQRESP_PROTOCOL_IDS",
# Message types
"BlocksByRangeRequest",
"BlocksByRootRequest",
"RequestedBlockRoots",
"Status",
Expand All @@ -36,6 +41,7 @@
"encode_request",
"decode_request",
# Inbound handlers
"AsyncBlockBySlotLookup",
"AsyncBlockLookup",
"RequestHandler",
"ReqRespServer",
Expand Down
Loading
Loading