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
144 changes: 127 additions & 17 deletions src/lean_spec/subspecs/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

from lean_spec.subspecs.api import ApiServer, ApiServerConfig
from lean_spec.subspecs.chain import ChainService, SlotClock
from lean_spec.subspecs.chain.config import SECONDS_PER_SLOT
from lean_spec.subspecs.containers import Block, BlockBody, State
from lean_spec.subspecs.containers.block.types import AggregatedAttestations
from lean_spec.subspecs.containers.slot import Slot
Expand All @@ -28,6 +31,9 @@
from lean_spec.subspecs.sync import BlockCache, NetworkRequester, PeerManager, SyncService
from lean_spec.types import Bytes32, Uint64

if TYPE_CHECKING:
from lean_spec.subspecs.storage import Database


@dataclass(frozen=True, slots=True)
class NodeConfig:
Expand Down Expand Up @@ -55,6 +61,16 @@ class NodeConfig:
api_config: ApiServerConfig | None = field(default=None)
"""Optional API server configuration. If None, API server is disabled."""

database_path: Path | str | None = field(default=None)
"""
Optional path to SQLite database file for persistence.

If provided, the node will persist blocks and states to disk.
On restart, existing state is loaded from the database.

Use \":memory:\" for in-memory database (testing only).
"""


@dataclass(slots=True)
class Node:
Expand Down Expand Up @@ -91,33 +107,60 @@ def from_genesis(cls, config: NodeConfig) -> Node:
"""
Create a fully-wired node from genesis configuration.

If a database path is provided and contains existing state, the node
resumes from the persisted state. Otherwise, it starts fresh from genesis.

Args:
config: Node configuration with genesis parameters.

Returns:
A Node ready to run.
"""
# Generate genesis state from validators.
# Initialize database if path provided.
#
# Includes initial checkpoints, validator registry, and config.
state = State.generate_genesis(config.genesis_time, config.validators)
# The database is optional - nodes can run without persistence.
database: Database | None = None
if config.database_path is not None:
database = cls._create_database(config.database_path)

# Create genesis block.
# Try to load existing state from database.
#
# Slot 0, no parent, empty body.
# State root is the hash of the genesis state.
block = Block(
slot=Slot(0),
proposer_index=Uint64(0),
parent_root=Bytes32.zero(),
state_root=hash_tree_root(state),
body=BlockBody(attestations=AggregatedAttestations(data=[])),
)
# If database contains valid state, resume from there.
# Otherwise, fall through to genesis initialization.
store = cls._try_load_from_database(database)

if store is None:
# Generate genesis state from validators.
#
# Includes initial checkpoints, validator registry, and config.
state = State.generate_genesis(config.genesis_time, config.validators)

# Create genesis block.
#
# Slot 0, no parent, empty body.
# State root is the hash of the genesis state.
block = Block(
slot=Slot(0),
proposer_index=Uint64(0),
parent_root=Bytes32.zero(),
state_root=hash_tree_root(state),
body=BlockBody(attestations=AggregatedAttestations(data=[])),
)

# Initialize forkchoice store.
#
# Genesis block is both justified and finalized.
store = Store.get_forkchoice_store(state, block)
# Initialize forkchoice store.
#
# Genesis block is both justified and finalized.
store = Store.get_forkchoice_store(state, block)

# Persist genesis to database if available.
if database is not None:
block_root = hash_tree_root(block)
database.put_block(block, block_root)
database.put_state(state, block_root)
database.put_head_root(block_root)
database.put_justified_checkpoint(store.latest_justified)
database.put_finalized_checkpoint(store.latest_finalized)
database.put_block_root_by_slot(block.slot, block_root)

# Create shared dependencies.
clock = SlotClock(genesis_time=config.genesis_time, _time_fn=config.time_fn)
Expand All @@ -134,6 +177,7 @@ def from_genesis(cls, config: NodeConfig) -> Node:
block_cache=block_cache,
clock=clock,
network=config.network,
database=database,
)

chain_service = ChainService(sync_service=sync_service, clock=clock)
Expand All @@ -160,6 +204,72 @@ def from_genesis(cls, config: NodeConfig) -> Node:
api_server=api_server,
)

@staticmethod
def _create_database(path: Path | str) -> Database:
"""
Create database instance from path.

Args:
path: Path to SQLite database file.

Returns:
Database instance ready for use.
"""
from lean_spec.subspecs.storage import SQLiteDatabase

# SQLite handles its own caching at the filesystem level.
return SQLiteDatabase(path)

@staticmethod
def _try_load_from_database(database: Database | None) -> Store | None:
"""
Try to load forkchoice store from existing database state.

Returns None if database is empty or unavailable.

Args:
database: Database to load from.

Returns:
Loaded Store or None if no valid state exists.
"""
if database is None:
return None

# Check if database has existing state.
head_root = database.get_head_root()
if head_root is None:
return None

# Load head block and state.
head_block = database.get_block(head_root)
head_state = database.get_state(head_root)

if head_block is None or head_state is None:
return None

# Load checkpoints.
justified = database.get_justified_checkpoint()
finalized = database.get_finalized_checkpoint()

if justified is None or finalized is None:
return None

# Reconstruct minimal store from persisted data.
#
# The store starts with just the head block and state.
# Additional blocks can be loaded on demand or via sync.
return Store(
time=Uint64(head_block.slot * SECONDS_PER_SLOT),
config=head_state.config,
head=head_root,
safe_target=head_root,
latest_justified=justified,
latest_finalized=finalized,
blocks={head_root: head_block},
states={head_root: head_state},
)

async def run(self, *, install_signal_handlers: bool = True) -> None:
"""
Run all services until shutdown.
Expand Down
18 changes: 18 additions & 0 deletions src/lean_spec/subspecs/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
Storage module for persistent block and state storage.

Provides database abstraction for consensus data persistence.
Uses SQLite for simplicity and correctness.
"""

from .database import Database
from .namespaces import BlockNamespace, CheckpointNamespace, StateNamespace
from .sqlite import SQLiteDatabase

__all__ = [
"Database",
"SQLiteDatabase",
"BlockNamespace",
"StateNamespace",
"CheckpointNamespace",
]
Loading
Loading