Skip to content

Commit c7ec04b

Browse files
feat(api): restore /lean/v0/blocks/finalized for checkpoint-sync anchor block (leanEthereum#974)
* feat(api): restore /lean/v0/blocks/finalized for checkpoint-sync anchor block PR leanEthereum#713 added this endpoint so a checkpoint-syncing peer can fetch the (state, signed block) anchor pair. PR leanEthereum#751 removed it as dead code because it has no callers inside this repository. The callers are external: the hive lean simulator gates every checkpoint-sync scenario on this endpoint, and client implementations serve it for interop. Since the removal shipped, all hive checkpoint-sync-based reqresp tests fail for every client with a permanent 404 from the helper node. Restores the endpoint and the injectable signed-block source on the API server, since the fork-choice store only retains unsigned blocks. The handler and field docstrings now name the external consumers so the next dead-code sweep has the missing context. * feat(sync): fetch the finalized block during checkpoint sync The anchor builder previously rebuilt the anchor block from the header embedded in the state with an empty body. The anchor root is the hash of the full block, so whenever the finalized block carried attestations the rebuilt root diverged from the finalized root the rest of the network agrees on. This is the gap issue leanEthereum#712 originally described. Fetch the real signed block from the finalized block endpoint and anchor the store on it. The block is fetched before the state: it is small, so a source that cannot serve it fails fast before the multi-megabyte state download starts. A block that does not pair with the fetched state raises, since that means the source advanced finalization between the two requests and a retry is the fix. This also gives the restored endpoint an in-repo production caller. * feat(node): wire the finalized signed-block source into the API server The API exposes /lean/v0/blocks/finalized so checkpoint-syncing peers can fetch the (state, signed block) anchor pair, but the live node never supplied a signed-block source, so the endpoint always returned 503. The store and database retain only unsigned blocks, and the receiving peer pairs the block with the finalized state without verifying its proof. Wrap the looked-up block in an empty proof, matching the genesis anchor that no proposer ever signed. * docs: simplify doc Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> * docs: apply suggestions Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> * test(api,sync): address review feedback on finalized-block endpoint Reviewer feedback from PR leanEthereum#974: - Document the retryable block/state pairing mismatch in the anchor builder's Raises list, so callers know that case is worth a refetch. - Pin full error messages with equality instead of match=/startswith fragments in the checkpoint-sync and anchor tests, matching the project's full-message assertion rule. - Assert the fetched anchor block actually lands in the store, making the intent of the keyed-by-fetched-block change explicit. - Replace the weak deserialize-and-not-None block test with a full object equality against the block rebuilt from the finalized state. - Hoist the "wrap an unsigned block in an empty proof" helper and the store-backed signed-block getter into consensus_testing, reused across the API, sync, and anchor tests. - Bind test servers to port 0 and read the OS-assigned port via a new ApiServer.bound_port, removing hardcoded ports that could collide under parallel runs. * fix(vulture): whitelist the validator index-position model validator The index/position invariant validator added in leanEthereum#1147 is invoked by Pydantic during validation, so vulture cannot see the call and reports it as dead code. Whitelist it alongside the other model validators. --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com>
1 parent 90c0ffd commit c7ec04b

15 files changed

Lines changed: 645 additions & 83 deletions

File tree

packages/testing/src/consensus_testing/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@
131131
make_signed_block,
132132
make_test_block,
133133
make_test_status,
134+
signed_block_with_empty_proof,
135+
store_backed_signed_block_getter,
134136
)
135137

136138
StateTransitionTestFiller = Callable[..., StateTransitionFixture]
@@ -207,6 +209,8 @@
207209
"make_signed_block",
208210
"make_test_block",
209211
"make_test_status",
212+
"signed_block_with_empty_proof",
213+
"store_backed_signed_block_getter",
210214
# Unit-test fakes
211215
"MockEventSource",
212216
"MockForkchoiceStore",

packages/testing/src/consensus_testing/values.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import annotations
44

5+
from typing import Callable
6+
57
from consensus_testing.keys import create_dummy_signature
68
from lean_spec.node.networking.reqresp.message import Status
79
from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex
10+
from lean_spec.spec.forks.lstar import Store
811
from lean_spec.spec.forks.lstar.containers import (
912
AggregatedAttestations,
1013
AttestationData,
@@ -20,22 +23,52 @@
2023
"""Validator index a node owns by default in unit tests."""
2124

2225

26+
def signed_block_with_empty_proof(block: Block) -> SignedBlock:
27+
"""
28+
Wrap an unsigned block in an empty proof.
29+
30+
The fork-choice store retains only unsigned blocks.
31+
A genesis or anchor block that no proposer ever signed carries an empty proof.
32+
"""
33+
return SignedBlock(
34+
block=block,
35+
proof=MultiMessageAggregate(proof=ByteList512KiB(data=b"")),
36+
)
37+
38+
39+
def store_backed_signed_block_getter(
40+
store: Store,
41+
) -> Callable[[Bytes32], SignedBlock | None]:
42+
"""
43+
Build a signed-block lookup over a store's unsigned blocks.
44+
45+
Returns None for an unknown root, mirroring a node that lacks the block.
46+
"""
47+
48+
def signed_block_for(root: Bytes32) -> SignedBlock | None:
49+
block = store.blocks.get(root)
50+
if block is None:
51+
return None
52+
return signed_block_with_empty_proof(block)
53+
54+
return signed_block_for
55+
56+
2357
def make_signed_block(
2458
slot: Slot,
2559
proposer_index: ValidatorIndex,
2660
parent_root: Bytes32,
2761
state_root: Bytes32,
2862
) -> SignedBlock:
2963
"""Build a signed block with an empty proof for structural tests."""
30-
return SignedBlock(
31-
block=Block(
64+
return signed_block_with_empty_proof(
65+
Block(
3266
slot=slot,
3367
proposer_index=proposer_index,
3468
parent_root=parent_root,
3569
state_root=state_root,
3670
body=BlockBody(attestations=AggregatedAttestations(data=[])),
37-
),
38-
proof=MultiMessageAggregate(proof=ByteList512KiB(data=b"")),
71+
)
3972
)
4073

4174

src/lean_spec/node/anchor.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Two sources land on the same return shape:
66
77
- Genesis: synthesise the store from the genesis validator set.
8-
- Checkpoint: fetch a finalized state from a peer and build the store from it.
8+
- Checkpoint: fetch a finalized block and state from a peer and build the store.
99
1010
Once the store exists the protocol cannot tell the two sources apart.
1111
"""
@@ -19,14 +19,12 @@
1919
from lean_spec.node.networking.reqresp.message import Status
2020
from lean_spec.node.sync.checkpoint_sync import (
2121
CheckpointSyncError,
22+
fetch_finalized_block,
2223
fetch_finalized_state,
2324
verify_checkpoint_state,
2425
)
2526
from lean_spec.spec.crypto.merkleization import hash_tree_root
2627
from lean_spec.spec.forks import (
27-
AggregatedAttestations,
28-
Block,
29-
BlockBody,
3028
Checkpoint,
3129
ForkProtocol,
3230
Slot,
@@ -81,10 +79,12 @@ async def from_checkpoint(
8179
validator_index: ValidatorIndex | None,
8280
) -> Anchor:
8381
"""
84-
Build an anchor by fetching a finalized state from a peer.
82+
Build an anchor by fetching a finalized block and state from a peer.
8583
8684
The fetched state replaces the genesis validator set.
8785
Deposits and exits since genesis are already baked into it.
86+
The fetched block anchors the store at the same finalized root the
87+
network agrees on; a source that cannot serve it cannot be used.
8888
8989
Args:
9090
url: HTTP endpoint of the node serving the checkpoint state.
@@ -95,7 +95,14 @@ async def from_checkpoint(
9595
Raises:
9696
CheckpointSyncError: For every failure mode covering transport,
9797
structural verification, and genesis-time mismatch.
98+
Also raised when the fetched block and state do not pair.
99+
That case is retryable: the source advanced finalization
100+
between the two requests.
98101
"""
102+
# The block comes first: it is small, so an incapable source fails
103+
# fast before the multi-megabyte state download starts.
104+
signed_block = await fetch_finalized_block(url)
105+
99106
state = await fetch_finalized_state(url, fork.state_class)
100107

101108
# Catches a corrupt download before it contaminates the forkchoice store.
@@ -110,24 +117,17 @@ async def from_checkpoint(
110117
f"local={genesis.genesis_time}"
111118
)
112119

113-
# Reconstruct the anchor block from the header embedded in the state.
114-
# A header stored before its post-state root carries a zero placeholder;
115-
# in that case we recompute the root from the state itself.
116-
# Fork choice only needs identity and lineage, so the body is left empty.
117-
header = state.latest_block_header
118-
state_root = (
119-
header.state_root if header.state_root != Bytes32.zero() else hash_tree_root(state)
120-
)
121-
anchor_block = Block(
122-
slot=header.slot,
123-
proposer_index=header.proposer_index,
124-
parent_root=header.parent_root,
125-
state_root=state_root,
126-
body=BlockBody(attestations=AggregatedAttestations(data=[])),
127-
)
120+
# Both fetches read the snapshot at the finalized root.
121+
# A pairing mismatch means finalization advanced between the two
122+
# requests; refetching is the fix.
123+
if signed_block.block.state_root != hash_tree_root(state):
124+
raise CheckpointSyncError(
125+
"anchor block / state mismatch; "
126+
"source advanced finalization between requests, retry"
127+
)
128128

129129
# The protocol return type is structural, but only one concrete store ships.
130-
store = cast(Store, fork.create_store(state, anchor_block, validator_index))
130+
store = cast(Store, fork.create_store(state, signed_block.block, validator_index))
131131

132132
return cls(
133133
validators=state.validators,

src/lean_spec/node/api/context.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from aiohttp import web
1010

11-
from lean_spec.spec.forks import LstarSpec, Store
11+
from lean_spec.spec.forks import LstarSpec, SignedBlock, Store
12+
from lean_spec.spec.ssz import Bytes32
1213

1314

1415
class AggregatorRoleControl(Protocol):
@@ -30,6 +31,9 @@ class ApiContext:
3031
aggregator_role_control: AggregatorRoleControl | None
3132
"""Holder of the aggregator flag, or None when aggregator control is unwired."""
3233

34+
signed_block_getter: Callable[[Bytes32], SignedBlock | None] | None
35+
"""Callable returning the signed block for a block root, or None when unwired."""
36+
3337
def require_store(self) -> Store:
3438
"""
3539
Return the live store, or raise 503 when the node has no store yet.
@@ -46,3 +50,9 @@ def require_aggregator_role_control(self) -> AggregatorRoleControl:
4650
if self.aggregator_role_control is None:
4751
raise web.HTTPServiceUnavailable(reason="Aggregator role control not available")
4852
return self.aggregator_role_control
53+
54+
def require_signed_block_getter(self) -> Callable[[Bytes32], SignedBlock | None]:
55+
"""Return the signed-block source, or raise 503 when it is unwired."""
56+
if self.signed_block_getter is None:
57+
raise web.HTTPServiceUnavailable(reason="Signed block source not configured")
58+
return self.signed_block_getter

src/lean_spec/node/api/handlers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,30 @@ async def finalized_state(self, request: web.Request) -> web.Response:
129129

130130
return web.Response(body=ssz_bytes, content_type="application/octet-stream")
131131

132+
async def finalized_block(self, request: web.Request) -> web.Response:
133+
"""
134+
Return the finalized signed block as SSZ bytes.
135+
136+
Raises:
137+
HTTPNotFound: The source has no block for the finalized root.
138+
HTTPInternalServerError: Encoding the signed block failed.
139+
"""
140+
store = self.context.require_store()
141+
signed_block_getter = self.context.require_signed_block_getter()
142+
143+
signed_block = signed_block_getter(store.latest_finalized.root)
144+
if signed_block is None:
145+
raise web.HTTPNotFound(reason="Finalized signed block not available")
146+
147+
# Encoding a full block is CPU-heavy, so run it off the event loop.
148+
try:
149+
ssz_bytes = await asyncio.to_thread(signed_block.encode_bytes)
150+
except Exception as exception:
151+
logger.error("Failed to encode signed block: %s", exception)
152+
raise web.HTTPInternalServerError(reason="Encoding failed") from exception
153+
154+
return web.Response(body=ssz_bytes, content_type="application/octet-stream")
155+
132156
async def aggregator_status(self, request: web.Request) -> web.Response:
133157
"""Report whether the node is acting as an aggregator."""
134158
aggregator_role_control = self.context.require_aggregator_role_control()

src/lean_spec/node/api/server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
from lean_spec.node.api.context import AggregatorRoleControl, ApiContext
1313
from lean_spec.node.api.handlers import ApiHandlers
14-
from lean_spec.spec.forks import LstarSpec, Store
14+
from lean_spec.spec.forks import LstarSpec, SignedBlock, Store
15+
from lean_spec.spec.ssz import Bytes32
1516

1617
logger = logging.getLogger(__name__)
1718

@@ -40,6 +41,9 @@ class ApiServer:
4041
store_getter: Callable[[], Store | None] | None = None
4142
"""Callable that returns the current Store instance."""
4243

44+
signed_block_getter: Callable[[Bytes32], SignedBlock | None] | None = None
45+
"""Optional callable returning the signed block for a block root."""
46+
4347
aggregator_role_control: AggregatorRoleControl | None = None
4448
"""Optional runtime accessor for the node's aggregator role."""
4549

@@ -57,6 +61,19 @@ def store(self) -> Store | None:
5761
"""Get the current Store instance."""
5862
return self.store_getter() if self.store_getter else None
5963

64+
@property
65+
def bound_port(self) -> int:
66+
"""
67+
TCP port the running server actually listens on.
68+
69+
Resolves the OS-assigned port when the configuration requested port 0.
70+
"""
71+
if self._runner is None:
72+
raise RuntimeError("API server is not running")
73+
# The runner exposes one socket address per listening site.
74+
# The port is the second element of the first address.
75+
return int(self._runner.addresses[0][1])
76+
6077
async def start(self) -> None:
6178
"""Start the API server in the background."""
6279
app = web.Application()
@@ -67,6 +84,7 @@ async def start(self) -> None:
6784
spec=self.spec,
6885
store_getter=self.store_getter,
6986
aggregator_role_control=self.aggregator_role_control,
87+
signed_block_getter=self.signed_block_getter,
7088
)
7189
handlers = ApiHandlers(context)
7290

@@ -76,6 +94,7 @@ async def start(self) -> None:
7694
[
7795
web.get("/lean/v0/health", handlers.health),
7896
web.get("/lean/v0/states/finalized", handlers.finalized_state),
97+
web.get("/lean/v0/blocks/finalized", handlers.finalized_block),
7998
web.get("/lean/v0/checkpoints/justified", handlers.justified_checkpoint),
8099
web.get("/lean/v0/fork_choice", handlers.fork_choice),
81100
web.get("/metrics", handlers.metrics),
@@ -90,7 +109,7 @@ async def start(self) -> None:
90109
self._site = web.TCPSite(self._runner, self.config.host, self.config.port)
91110
await self._site.start()
92111

93-
logger.info("API server listening on %s:%d", self.config.host, self.config.port)
112+
logger.info("API server listening on %s:%d", self.config.host, self.bound_port)
94113

95114
async def run(self) -> None:
96115
"""Run the API server until it is asked to stop."""

src/lean_spec/node/node.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
Validators,
4545
)
4646
from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT
47-
from lean_spec.spec.ssz import Bytes32, Uint64
47+
from lean_spec.spec.forks.lstar.containers import MultiMessageAggregate
48+
from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Uint64
4849

4950
logger = logging.getLogger(__name__)
5051

@@ -310,13 +311,29 @@ def from_genesis(cls, config: NodeConfig) -> Node:
310311
# Create API server if configured
311312
api_server: ApiServer | None = None
312313
if config.api_config is not None:
314+
315+
def signed_block_for_root(block_root: Bytes32) -> SignedBlock | None:
316+
# The store and database retain only unsigned blocks.
317+
# Wrap the looked-up block in an empty proof to serve the
318+
# checkpoint-sync anchor pair.
319+
# The receiving peer pairs the block with the finalized state
320+
# and never verifies this proof, so an empty one suffices.
321+
block = sync_service.store.blocks.get(block_root)
322+
if block is None:
323+
return None
324+
return SignedBlock(
325+
block=block,
326+
proof=MultiMessageAggregate(proof=ByteList512KiB(data=b"")),
327+
)
328+
313329
# The admin API reads and mutates the sync service aggregator flag,
314330
# letting operators rotate the role at runtime without a restart.
315331
# Store getter captures sync_service to get the live store.
316332
api_server = ApiServer(
317333
config=config.api_config,
318334
spec=fork,
319335
store_getter=lambda: sync_service.store,
336+
signed_block_getter=signed_block_for_root,
320337
aggregator_role_control=sync_service,
321338
)
322339

0 commit comments

Comments
 (0)