Skip to content

feat(api): restore /lean/v0/blocks/finalized for checkpoint-sync anchor block#974

Open
MegaRedHand wants to merge 5 commits into
leanEthereum:mainfrom
MegaRedHand:restore-blocks-finalized-endpoint
Open

feat(api): restore /lean/v0/blocks/finalized for checkpoint-sync anchor block#974
MegaRedHand wants to merge 5 commits into
leanEthereum:mainfrom
MegaRedHand:restore-blocks-finalized-endpoint

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores the /lean/v0/blocks/finalized endpoint that #713 introduced and #751 removed as dead code, makes the node's checkpoint-sync client consume it, and wires the live node to serve it.

The endpoint's consumers were external, which is why the dead-code sweep missed them:

  • The hive lean simulator gates every checkpoint-sync scenario on it: its helper runner feature-detects the signed_block_getter parameter on ApiServer (lean_spec_client_runner.py) and its readiness probe polls /lean/v0/blocks/finalized (helper.rs). Since refactor: dead-code sweep in sync / api / storage subspecs #751 shipped, the probe 404s forever and all checkpoint-sync-based reqresp tests fail for every client on hive.leanroadmap.org: 17 of 21 tests per client in the latest reqresp run. These failures are easy to miss in the UI, since they happen during setup, before the client-under-test starts, so they disappear from per-client filtered views.
  • Client implementations serve the endpoint for checkpoint-sync interop, mirroring this spec (e.g. ethlambda).

Restore the endpoint (server side)

This branch was rebased onto the node/api refactor that landed in main (handlers.py / context.py / responses.py, replacing the old routes.py + endpoints/ modules). The endpoint is integrated into that new structure rather than the original file layout:

  • node/api/handlers.py: a finalized_block handler method, alongside the other endpoint handlers.
  • node/api/context.py: an optional signed_block_getter on ApiContext, with a require_signed_block_getter accessor that raises 503 when unset. The fork-choice store retains only unsigned blocks, so the embedding node injects the signed-block source.
  • node/api/server.py: the signed_block_getter field on ApiServer, threaded into the context, and the route registration. The injection interface matches feat(api,sync): add /lean/v0/blocks/finalized for checkpoint-sync anchor block #713 and hive's feature detection.

Consume it during checkpoint sync (client side)

Anchor.from_checkpoint previously rebuilt the anchor block from state.latest_block_header with an empty body. create_store keys the head, the checkpoints, and the block map by hash_tree_root(anchor_block), so whenever the finalized block carried attestations the rebuilt anchor root diverged from the finalized root the rest of the network agrees on. This is the gap #712 originally described.

The new flow, replacing the header reconstruction entirely:

  1. Fetch /lean/v0/blocks/finalized. The block is fetched first because it is small: a source that cannot serve it (503/404) aborts checkpoint sync before the multi-megabyte state download starts.
  2. Fetch /lean/v0/states/finalized and run the existing structural and genesis-time checks.
  3. Verify the pairing: block.state_root == hash_tree_root(state). A mismatch raises, since it means the source advanced finalization between the two requests and a retry is the fix.
  4. create_store(state, block), now keyed by the network's true finalized root.

Wire the live node to serve it

The server accepted a signed_block_getter but the node never supplied one, so on a real node the endpoint always returned 503 (only the test/conformance servers wired a source). Node.from_genesis now injects a source that looks up the block by root in the live store and wraps it in an empty proof.

The empty proof is intentional, not a placeholder:

  • The store and database retain only unsigned blocks; the original full-block proof is deconstructed into per-attestation proofs at import and is not reconstructable.
  • The consuming side (Anchor.from_checkpoint) uses only signed_block.block and never verifies the proof, so an empty one is sufficient for the anchor pair. This mirrors the genesis anchor that no proposer ever signed.
  • No current consumer (this spec, the hive simulator, other clients' checkpoint-sync paths) verifies the anchor proof. Serving a real proof would require a short-term signed-block cache near the network layer; that is a best-effort optimization with no functional consumer today, so it is deliberately out of scope.

This is a deliberate departure from #713, which served a mock proposer signature (in the older BlockSignatures envelope, since replaced by the merged MultiMessageAggregate) and whose comment said real nodes should retain the actual signed block for the finalized root. With no consumer verifying the proof, the empty proof avoids that retention machinery until a verifier exists.

Tests

  • tests/node/api/endpoints/test_blocks.py: contract tests (200, content type, SSZ round-trip, block state-root matches the finalized state's hash tree root).
  • tests/node/api/test_server.py: 503 without store, 503 without signed-block source, 404 when the source has no block, 200 + anchor-root match.
  • tests/node/test_node.py: the node wires the signed-block source (returns the finalized block in an empty proof, returns None for an unknown root).
  • tests/node/sync/test_checkpoint_sync.py: transport/HTTP/corrupt-SSZ error wrapping for the block fetch, plus a live-server round-trip and the 503 path.
  • tests/node/test_anchor.py: anchor keyed by the fetched block's root, abort on block-fetch failure, abort on state-fetch failure, raise on state/block pairing mismatch.
  • tests/node/api/endpoints/conftest.py: the conformance server wires a signed-block source that wraps the store's anchor block with an empty proof.

just check and the node/api test trees are green.

…or 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.
@MegaRedHand MegaRedHand marked this pull request as draft June 12, 2026 14:35
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.
…zed-endpoint

# Conflicts:
#	src/lean_spec/node/api/routes.py
#	tests/node/api/endpoints/test_blocks.py
…zed-endpoint

# Conflicts:
#	src/lean_spec/node/api/routes.py
#	tests/node/api/endpoints/conftest.py
#	tests/node/api/test_server.py
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.
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.

1 participant