Skip to content

Commit cce7955

Browse files
authored
fix(node): wire inbound ReqResp block serving into the running node (#1193)
The block lookup callbacks on the inbound request handler were never assigned outside tests, so a running node answered every BlocksByRoot and BlocksByRange request with SERVER_ERROR while its own client side kept issuing range requests no leanSpec peer could answer. The forkchoice store keeps unsigned blocks only, and a requesting peer verifies each served block's proof on import, so serving store blocks rewrapped with an empty proof would be rejected at the receiver. The sync service now retains processed signed blocks inside the sliding serving-history window and exposes root and canonical-slot lookups. The boot sequence wires those lookups into the event source before it starts serving. Closes #1192
1 parent 0f5b8e5 commit cce7955

5 files changed

Lines changed: 217 additions & 5 deletions

File tree

src/lean_spec/cli/run.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
from lean_spec.node.networking.client import LiveNetworkEventSource
2323
from lean_spec.node.networking.gossipsub import GossipTopic
2424
from lean_spec.node.node import Node, NodeConfig
25-
from lean_spec.spec.forks import SubnetId
25+
from lean_spec.spec.forks import SignedBlock, Slot, SubnetId
2626
from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT
2727
from lean_spec.spec.observability import set_observer
28+
from lean_spec.spec.ssz import Bytes32
2829

2930
logger = logging.getLogger(__name__)
3031

@@ -105,10 +106,23 @@ async def run_node(boot: NodeBootstrap) -> None:
105106

106107
logger.info("Node initialized, peer_id=%s", event_source.connection_manager.peer_id)
107108

109+
# Inbound block serving reads the sync service's retained signed blocks.
110+
#
111+
# The requesting peer verifies each served block's proof on import,
112+
# so the lookups return the retained signed blocks, never store blocks
113+
# rewrapped with an empty proof.
114+
async def signed_block_for_root(block_root: Bytes32) -> SignedBlock | None:
115+
return node.sync_service.signed_block_for_root(block_root)
116+
117+
async def signed_block_by_slot(slot: Slot) -> SignedBlock | None:
118+
return node.sync_service.signed_block_by_slot(slot)
119+
108120
# Bring the listener and outbound dialer online in the spec-required order.
109121
await event_source.start_serving(
110122
status=anchor.initial_status,
111123
current_slot_lookup=node.clock.current_slot,
124+
block_lookup=signed_block_for_root,
125+
block_by_slot_lookup=signed_block_by_slot,
112126
listen_address=boot.listen_address,
113127
bootnode_multiaddrs=boot.bootnode_multiaddrs,
114128
)

src/lean_spec/node/networking/client/event_source/live.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
from lean_spec.node.networking.gossipsub.types import TopicId
8282
from lean_spec.node.networking.reqresp.handler import (
8383
REQRESP_PROTOCOL_IDS,
84+
AsyncBlockBySlotLookup,
8485
AsyncBlockLookup,
8586
CurrentSlotLookup,
8687
ReqRespServer,
@@ -320,6 +321,18 @@ def set_block_lookup(self, lookup: AsyncBlockLookup) -> None:
320321
"""
321322
self._reqresp_handler.block_lookup = lookup
322323

324+
def set_block_by_slot_lookup(self, lookup: AsyncBlockBySlotLookup) -> None:
325+
"""
326+
Set the callback for looking up canonical blocks by slot.
327+
328+
Used by the inbound ReqResp handler to serve BlocksByRange requests.
329+
330+
Args:
331+
lookup: Async function that takes a Slot and returns the
332+
canonical SignedBlock if available, None otherwise.
333+
"""
334+
self._reqresp_handler.block_by_slot_lookup = lookup
335+
323336
def set_current_slot_lookup(self, lookup: CurrentSlotLookup) -> None:
324337
"""
325338
Set the callback returning the node's current slot.
@@ -367,6 +380,8 @@ async def start_serving(
367380
*,
368381
status: Status,
369382
current_slot_lookup: CurrentSlotLookup,
383+
block_lookup: AsyncBlockLookup,
384+
block_by_slot_lookup: AsyncBlockBySlotLookup,
370385
listen_address: str | None,
371386
bootnode_multiaddrs: Sequence[str],
372387
) -> None:
@@ -376,24 +391,28 @@ async def start_serving(
376391
Five steps, each a precondition for the next:
377392
378393
1. Set the Status the responder serves.
379-
2. Wire the current-slot lookup the range queries depend on.
394+
2. Wire the block and current-slot lookups the responder depends on.
380395
3. Dial bootnodes best-effort, since a peerless honest node remains valid.
381396
4. Bind the listener with a short bind-error probe window.
382397
5. Start gossipsub last so the heartbeat reaches reachable peers only.
383398
384399
Args:
385400
status: Initial finalized and head checkpoints the responder serves.
386401
current_slot_lookup: Wall-clock-to-slot callback for range bounds.
402+
block_lookup: Callback serving signed blocks by root.
403+
block_by_slot_lookup: Callback serving canonical signed blocks by slot.
387404
listen_address: Multiaddr to bind for inbound connections, or None for dial-only.
388405
bootnode_multiaddrs: Pre-resolved outbound peers.
389406
390407
Raises:
391408
OSError: If the listener fails to bind within the probe window.
392409
"""
393-
# Status and current-slot lookup must be set before the responder serves.
394-
# Without them, range queries return SERVER_ERROR.
410+
# Status and lookups must be set before the responder serves.
411+
# Without them, block and range queries return SERVER_ERROR.
395412
self.set_status(status)
396413
self.set_current_slot_lookup(current_slot_lookup)
414+
self.set_block_lookup(block_lookup)
415+
self.set_block_by_slot_lookup(block_by_slot_lookup)
397416

398417
# Dial and listen each clear the stop event internally.
399418
# Clearing it here covers the no-bootnodes, no-listen case.

src/lean_spec/node/sync/service.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from lean_spec.node.chain.clock import SlotClock
1515
from lean_spec.node.metrics import registry as metrics
16+
from lean_spec.node.networking.config import MIN_SLOTS_FOR_BLOCK_REQUESTS
1617
from lean_spec.node.networking.reqresp.message import Status
1718
from lean_spec.node.networking.transport.peer_id import PeerId
1819
from lean_spec.node.storage import Database
@@ -133,6 +134,21 @@ class SyncService:
133134
_pending_block_aggregates: list[SignedAggregatedAttestation] = field(default_factory=list)
134135
"""Aggregates recovered from processed blocks, queued for the aggregator to publish."""
135136

137+
_signed_blocks_for_serving: dict[Bytes32, SignedBlock] = field(default_factory=dict)
138+
"""
139+
Signed blocks retained to serve inbound block requests, keyed by block root.
140+
141+
The forkchoice store keeps unsigned blocks only.
142+
A served block must carry its original proof.
143+
The requesting peer verifies that proof on import.
144+
145+
Bounded by the serving history window.
146+
Blocks below the window can never be served again, so they are pruned.
147+
148+
In-memory only: after a restart the node serves nothing
149+
until new blocks arrive.
150+
"""
151+
136152
def __post_init__(self) -> None:
137153
"""Wire sub-components and apply the genesis-start state hint."""
138154
# Backfill reads the store through self, so it sees each post-block reassignment.
@@ -205,6 +221,13 @@ def ancestors(start: Bytes32) -> set[Bytes32]:
205221
# We only count blocks that pass validation and update the store.
206222
self._blocks_processed += 1
207223

224+
# Retain the signed block so peers can request it back.
225+
#
226+
# The store keeps only the unsigned block, which cannot be served:
227+
# the requesting peer verifies the proof when importing the block.
228+
self._signed_blocks_for_serving[hash_tree_root(block.block)] = block
229+
self._prune_signed_blocks_below_serving_window()
230+
208231
# Aggregators recover per-attestation proofs from each processed block.
209232
# They queue the recovered proofs for re-broadcast.
210233
# Non-aggregators rely on the gossip path instead.
@@ -284,6 +307,42 @@ def _persist_block(self, store: Store, block: Block) -> None:
284307
keep_roots=frozenset({store.latest_finalized.root}),
285308
)
286309

310+
def _prune_signed_blocks_below_serving_window(self) -> None:
311+
"""Drop retained signed blocks that fell out of the serving history window."""
312+
# The responder refuses range requests below the sliding window floor.
313+
# A block below the floor can never be served again, so retaining it is waste.
314+
current_slot = self.clock.current_slot()
315+
if current_slot < Slot(MIN_SLOTS_FOR_BLOCK_REQUESTS):
316+
return
317+
window_floor = current_slot - Slot(MIN_SLOTS_FOR_BLOCK_REQUESTS)
318+
self._signed_blocks_for_serving = {
319+
block_root: signed_block
320+
for block_root, signed_block in self._signed_blocks_for_serving.items()
321+
if signed_block.block.slot >= window_floor
322+
}
323+
324+
def signed_block_for_root(self, block_root: Bytes32) -> SignedBlock | None:
325+
"""Return the retained signed block for a root, or None when not retained."""
326+
return self._signed_blocks_for_serving.get(block_root)
327+
328+
def signed_block_by_slot(self, slot: Slot) -> SignedBlock | None:
329+
"""
330+
Return the retained signed block on the canonical chain at a slot.
331+
332+
Walks parent links from the head until the slot is reached.
333+
334+
Returns None for an empty slot, a slot above the head, and a canonical
335+
block whose signed form was never retained.
336+
"""
337+
block_root = self.store.head
338+
block = self.store.blocks.get(block_root)
339+
while block is not None and block.slot > slot:
340+
block_root = block.parent_root
341+
block = self.store.blocks.get(block_root)
342+
if block is None or block.slot != slot:
343+
return None
344+
return self._signed_blocks_for_serving.get(block_root)
345+
287346
def has_root(self, root: Bytes32) -> bool:
288347
"""Return True if the block root is present in the current store."""
289348
return root in self.store.blocks

tests/node/networking/client/event_source/test_live.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ async def mock_lookup(root: Bytes32) -> SignedBlock | None:
146146
assert es._reqresp_handler.block_lookup is mock_lookup
147147

148148

149+
class TestLiveNetworkEventSourceSetBlockBySlotLookup:
150+
"""
151+
Canonical block-by-slot lookup callback registration.
152+
153+
The req/resp handler needs a callback to retrieve canonical blocks
154+
by slot when peers send BlocksByRange requests.
155+
"""
156+
157+
def test_set_block_by_slot_lookup_propagates_to_handler(self) -> None:
158+
"""Sets the block-by-slot lookup callback on the reqresp handler."""
159+
es = _make_event_source()
160+
161+
async def mock_lookup(slot: Slot) -> SignedBlock | None:
162+
return None
163+
164+
es.set_block_by_slot_lookup(mock_lookup)
165+
166+
assert es._reqresp_handler.block_by_slot_lookup is mock_lookup
167+
168+
149169
class TestLiveNetworkEventSourceSubscribeGossipTopic:
150170
"""
151171
Gossip topic subscription delegation to the gossipsub behavior.

tests/node/sync/test_service.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
make_signed_block,
1919
)
2020
from consensus_testing.keys import XmssKeyManager
21+
from lean_spec.node.chain.clock import SlotClock
2122
from lean_spec.node.networking import PeerId
23+
from lean_spec.node.networking.config import MIN_SLOTS_FOR_BLOCK_REQUESTS
2224
from lean_spec.node.networking.reqresp.message import Status
2325
from lean_spec.node.storage.database import Database
2426
from lean_spec.node.sync.config import MAX_PENDING_ATTESTATIONS
@@ -34,6 +36,7 @@
3436
ValidatorIndex,
3537
)
3638
from lean_spec.spec.forks.lstar import Store
39+
from lean_spec.spec.forks.lstar.config import SECONDS_PER_SLOT
3740
from lean_spec.spec.forks.lstar.containers import (
3841
AttestationData,
3942
MultiMessageAggregate,
@@ -43,7 +46,7 @@
4346
SingleMessageAggregate,
4447
)
4548
from lean_spec.spec.forks.lstar.spec import LstarSpec
46-
from lean_spec.spec.ssz import Bytes32
49+
from lean_spec.spec.ssz import Bytes32, Uint64
4750

4851

4952
def make_store_with_attestation_data(
@@ -665,6 +668,103 @@ def test_persist_writes_state_and_prunes_when_finalized_advanced(
665668
]
666669

667670

671+
class TestSignedBlockServing:
672+
"""Tests for signed-block retention and the inbound serving lookups."""
673+
674+
def test_process_block_retains_signed_block_for_root(self, peer_id: PeerId) -> None:
675+
"""A processed block is retrievable by its root with the proof intact."""
676+
service = create_mock_sync_service(peer_id)
677+
genesis_root = service.store.head
678+
block = make_signed_block(
679+
slot=Slot(1),
680+
proposer_index=ValidatorIndex(0),
681+
parent_root=genesis_root,
682+
state_root=Bytes32.zero(),
683+
)
684+
service.store = service.process_block(service.store, block)
685+
686+
block_root = hash_tree_root(block.block)
687+
assert service.signed_block_for_root(block_root) == block
688+
689+
def test_signed_block_for_root_returns_none_for_unknown_root(self, peer_id: PeerId) -> None:
690+
"""An unknown root yields no block."""
691+
service = create_mock_sync_service(peer_id)
692+
693+
assert service.signed_block_for_root(Bytes32(b"\x2a" * 32)) is None
694+
695+
def test_signed_block_by_slot_returns_canonical_block(self, peer_id: PeerId) -> None:
696+
"""The canonical block at a filled slot is served with the proof intact."""
697+
service = create_mock_sync_service(peer_id)
698+
genesis_root = service.store.head
699+
block = make_signed_block(
700+
slot=Slot(1),
701+
proposer_index=ValidatorIndex(0),
702+
parent_root=genesis_root,
703+
state_root=Bytes32.zero(),
704+
)
705+
service.store = service.process_block(service.store, block)
706+
707+
assert service.signed_block_by_slot(Slot(1)) == block
708+
709+
def test_signed_block_by_slot_returns_none_for_empty_slot(self, peer_id: PeerId) -> None:
710+
"""A slot the canonical chain skipped yields no block."""
711+
service = create_mock_sync_service(peer_id)
712+
genesis_root = service.store.head
713+
block = make_signed_block(
714+
slot=Slot(2),
715+
proposer_index=ValidatorIndex(0),
716+
parent_root=genesis_root,
717+
state_root=Bytes32.zero(),
718+
)
719+
service.store = service.process_block(service.store, block)
720+
721+
assert service.signed_block_by_slot(Slot(1)) is None
722+
723+
def test_signed_block_by_slot_returns_none_above_head(self, peer_id: PeerId) -> None:
724+
"""A slot above the head yields no block."""
725+
service = create_mock_sync_service(peer_id)
726+
genesis_root = service.store.head
727+
block = make_signed_block(
728+
slot=Slot(1),
729+
proposer_index=ValidatorIndex(0),
730+
parent_root=genesis_root,
731+
state_root=Bytes32.zero(),
732+
)
733+
service.store = service.process_block(service.store, block)
734+
735+
assert service.signed_block_by_slot(Slot(5)) is None
736+
737+
def test_retained_blocks_pruned_below_serving_window(self, peer_id: PeerId) -> None:
738+
"""A retained block below the sliding history window is dropped."""
739+
service = create_mock_sync_service(peer_id)
740+
# Clock far past genesis: current slot 3610 puts the window floor at slot 10.
741+
current_slot_past_window = MIN_SLOTS_FOR_BLOCK_REQUESTS + 10
742+
service.clock = SlotClock(
743+
genesis_time=Uint64(0),
744+
time_fn=lambda: float(current_slot_past_window * int(SECONDS_PER_SLOT)),
745+
)
746+
genesis_root = service.store.head
747+
block_below_window = make_signed_block(
748+
slot=Slot(1),
749+
proposer_index=ValidatorIndex(0),
750+
parent_root=genesis_root,
751+
state_root=Bytes32.zero(),
752+
)
753+
service.store = service.process_block(service.store, block_below_window)
754+
block_inside_window = make_signed_block(
755+
slot=Slot(current_slot_past_window),
756+
proposer_index=ValidatorIndex(0),
757+
parent_root=hash_tree_root(block_below_window.block),
758+
state_root=Bytes32.zero(),
759+
)
760+
service.store = service.process_block(service.store, block_inside_window)
761+
762+
below_window_root = hash_tree_root(block_below_window.block)
763+
inside_window_root = hash_tree_root(block_inside_window.block)
764+
assert service.signed_block_for_root(below_window_root) is None
765+
assert service.signed_block_for_root(inside_window_root) == block_inside_window
766+
767+
668768
class TestPublishAggregatedAttestation:
669769
"""Tests for aggregated attestation publish wiring."""
670770

0 commit comments

Comments
 (0)