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
16 changes: 5 additions & 11 deletions src/lean_spec/subspecs/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import httpx

from lean_spec.subspecs.chain.config import DEVNET_CONFIG
from lean_spec.subspecs.containers import Slot
from lean_spec.subspecs.ssz.hash import hash_tree_root

if TYPE_CHECKING:
Expand All @@ -28,8 +29,6 @@
class CheckpointSyncError(Exception):
"""Error during checkpoint sync."""

pass


async def fetch_finalized_state(url: str, state_class: type[Any]) -> "State":
"""
Expand All @@ -52,9 +51,7 @@ async def fetch_finalized_state(url: str, state_class: type[Any]) -> "State":

logger.info(f"Fetching finalized state from {full_url}")

headers = {
"Accept": "application/octet-stream",
}
headers = {"Accept": "application/octet-stream"}

try:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
Expand All @@ -77,8 +74,6 @@ async def fetch_finalized_state(url: str, state_class: type[Any]) -> "State":
raise CheckpointSyncError(
f"HTTP error {exc.response.status_code}: {exc.response.text[:200]}"
) from exc
except CheckpointSyncError:
raise
except Exception as e:
raise CheckpointSyncError(f"Failed to fetch state: {e}") from e

Expand All @@ -96,9 +91,7 @@ async def verify_checkpoint_state(state: "State") -> bool:
True if valid, False otherwise
"""
try:
computed_root = hash_tree_root(state)

if int(state.slot) < 0:
if state.slot < Slot(0):
logger.error("Invalid state: negative slot")
return False

Expand All @@ -114,7 +107,8 @@ async def verify_checkpoint_state(state: "State") -> bool:
)
return False

root_preview = computed_root.hex()[:16]
state_root = hash_tree_root(state)
root_preview = state_root.hex()[:16]
logger.info(f"Checkpoint state verified: slot={state.slot}, root={root_preview}...")
return True

Expand Down
53 changes: 23 additions & 30 deletions src/lean_spec/subspecs/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from aiohttp import web
Expand All @@ -24,6 +24,16 @@
logger = logging.getLogger(__name__)


def _no_store() -> Store | None:
"""Default store getter that returns None."""
return None


async def _handle_health(_request: web.Request) -> web.Response:
"""Handle health check endpoint."""
return web.json_response({"status": "healthy", "service": "lean-spec-api"})


@dataclass(frozen=True, slots=True)
class ApiServerConfig:
"""Configuration for the API server."""
Expand All @@ -38,6 +48,7 @@ class ApiServerConfig:
"""Whether the API server is enabled."""


@dataclass(slots=True)
class ApiServer:
"""
HTTP API server for checkpoint sync and node status.
Expand All @@ -49,36 +60,22 @@ class ApiServer:
Uses aiohttp to handle HTTP protocol details efficiently.
"""

def __init__(
self,
config: ApiServerConfig,
store_getter: Callable[[], Store | None] = lambda: None,
):
"""
Initialize the API server.
config: ApiServerConfig
"""Server configuration."""

Args:
config: Server configuration.
store_getter: Callable that returns the current Store instance.
"""
self.config = config
self._store_getter = store_getter
self._runner: web.AppRunner | None = None
self._site: web.TCPSite | None = None
store_getter: Callable[[], Store | None] = _no_store
"""Callable that returns the current Store instance."""

def set_store_getter(self, getter: Callable[[], Store | None]) -> None:
"""
Set the store getter function.
_runner: web.AppRunner | None = field(default=None, init=False)
"""The aiohttp application runner."""

Args:
getter: Callable that returns the current Store instance.
"""
self._store_getter = getter
_site: web.TCPSite | None = field(default=None, init=False)
"""The TCP site for the server."""

@property
def store(self) -> Store | None:
"""Get the current Store instance."""
return self._store_getter()
return self.store_getter()

async def start(self) -> None:
"""Start the API server in the background."""
Expand All @@ -89,7 +86,7 @@ async def start(self) -> None:
app = web.Application()
app.add_routes(
[
web.get("/health", self._handle_health),
web.get("/health", _handle_health),
web.get("/lean/states/finalized", self._handle_finalized_state),
]
)
Expand Down Expand Up @@ -127,11 +124,7 @@ async def _async_stop(self) -> None:
self._site = None
logger.info("API server stopped")

async def _handle_health(self, request: web.Request) -> web.Response:
"""Handle health check endpoint."""
return web.json_response({"status": "healthy", "service": "lean-spec-api"})

async def _handle_finalized_state(self, request: web.Request) -> web.Response:
async def _handle_finalized_state(self, _request: web.Request) -> web.Response:
"""
Handle finalized checkpoint state endpoint.

Expand Down
9 changes: 5 additions & 4 deletions src/lean_spec/subspecs/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ def from_genesis(cls, config: NodeConfig) -> Node:
# Create API server if configured
api_server: ApiServer | None = None
if config.api_config is not None:
api_server = ApiServer(config=config.api_config)
# Set up store getter so API server can access current state
# We use a lambda that captures sync_service to get the live store
api_server.set_store_getter(lambda: sync_service.store)
# Store getter captures sync_service to get the live store
api_server = ApiServer(
config=config.api_config,
store_getter=lambda: sync_service.store,
)

return cls(
store=store,
Expand Down
4 changes: 1 addition & 3 deletions tests/lean_spec/subspecs/api/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ def test_server_created_without_store(self) -> None:
def test_store_getter_provides_access_to_store(self, store: Store) -> None:
"""Store getter callable provides access to the forkchoice store."""
config = ApiServerConfig()
server = ApiServer(config=config)

server.set_store_getter(lambda: store)
server = ApiServer(config=config, store_getter=lambda: store)

assert server.store is store

Expand Down
Loading