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
32 changes: 32 additions & 0 deletions src/lean_spec/subspecs/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from lean_spec.subspecs.networking import NetworkEventSource, NetworkService
from lean_spec.subspecs.ssz.hash import hash_tree_root
from lean_spec.subspecs.sync import BlockCache, NetworkRequester, PeerManager, SyncService
from lean_spec.subspecs.validator import ValidatorRegistry, ValidatorService
from lean_spec.types import Bytes32, Uint64

if TYPE_CHECKING:
Expand Down Expand Up @@ -71,6 +72,17 @@ class NodeConfig:
Use \":memory:\" for in-memory database (testing only).
"""

validator_registry: ValidatorRegistry | None = field(default=None)
"""
Optional validator registry with secret keys.

If provided, the node will participate in consensus by:
- Proposing blocks when scheduled
- Creating attestations every slot

If None, the node runs in passive mode (sync only).
"""


@dataclass(slots=True)
class Node:
Expand Down Expand Up @@ -99,6 +111,9 @@ class Node:
api_server: ApiServer | None = field(default=None)
"""Optional API server for checkpoint sync and status endpoints."""

validator_service: ValidatorService | None = field(default=None)
"""Optional validator service for block/attestation production."""

_shutdown: asyncio.Event = field(default_factory=asyncio.Event)
"""Event signaling shutdown request."""

Expand Down Expand Up @@ -195,13 +210,26 @@ def from_genesis(cls, config: NodeConfig) -> Node:
store_getter=lambda: sync_service.store,
)

# Create validator service if registry provided.
#
# Validators need keys to sign blocks and attestations.
# Without a registry, the node runs in passive mode.
validator_service: ValidatorService | None = None
if config.validator_registry is not None:
validator_service = ValidatorService(
sync_service=sync_service,
clock=clock,
registry=config.validator_registry,
)

return cls(
store=store,
clock=clock,
sync_service=sync_service,
chain_service=chain_service,
network_service=network_service,
api_server=api_server,
validator_service=validator_service,
)

@staticmethod
Expand Down Expand Up @@ -297,6 +325,8 @@ async def run(self, *, install_signal_handlers: bool = True) -> None:
tg.create_task(self.network_service.run())
if self.api_server is not None:
tg.create_task(self.api_server.run())
if self.validator_service is not None:
tg.create_task(self.validator_service.run())
tg.create_task(self._wait_shutdown())

def _install_signal_handlers(self) -> None:
Expand Down Expand Up @@ -332,6 +362,8 @@ async def _wait_shutdown(self) -> None:
self.network_service.stop()
if self.api_server is not None:
self.api_server.stop()
if self.validator_service is not None:
self.validator_service.stop()

def stop(self) -> None:
"""
Expand Down
9 changes: 9 additions & 0 deletions src/lean_spec/subspecs/validator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Validator service module for producing blocks and attestations."""

from .registry import ValidatorRegistry
from .service import ValidatorService

__all__ = [
"ValidatorService",
"ValidatorRegistry",
]
197 changes: 197 additions & 0 deletions src/lean_spec/subspecs/validator/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""
Validator registry for managing validator keys.

Loads validator keys from JSON configuration files.

The registry supports two configuration files:

1. **validators.json** - Maps node IDs to validator indices:
```json
{
"node_0": [0, 1],
"node_1": [2]
}
```

2. **validator-keys-manifest.json** - Contains key metadata and paths:
```json
{
"num_validators": 3,
"validators": [
{"index": 0, "pubkey_hex": "0xe2a03c...", "privkey_file": "validator_0_sk.ssz"}
]
}
```
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path

from lean_spec.subspecs.xmss import SecretKey
from lean_spec.types import Uint64


@dataclass(frozen=True, slots=True)
class ValidatorEntry:
"""
A single validator's key material.

Holds both the index and the secret key needed for signing.
"""

index: Uint64
"""Validator index in the registry."""

secret_key: SecretKey
"""XMSS secret key for signing."""


@dataclass(slots=True)
class ValidatorRegistry:
"""
Registry of validator keys controlled by this node.

The registry holds secret keys for validators assigned to this node.
It provides lookup by validator index for signing operations.
"""

_validators: dict[Uint64, ValidatorEntry] = field(default_factory=dict)
"""Map from validator index to entry."""

def add(self, entry: ValidatorEntry) -> None:
"""
Add a validator entry to the registry.

Args:
entry: Validator entry to add.
"""
self._validators[entry.index] = entry

def get(self, index: Uint64) -> ValidatorEntry | None:
"""
Get validator entry by index.

Args:
index: Validator index to look up.

Returns:
Validator entry if found, None otherwise.
"""
return self._validators.get(index)

def has(self, index: Uint64) -> bool:
"""
Check if we control this validator.

Args:
index: Validator index to check.

Returns:
True if we have keys for this validator.
"""
return index in self._validators

def indices(self) -> list[Uint64]:
"""
Get all validator indices we control.

Returns:
List of validator indices.
"""
return list(self._validators.keys())

def __len__(self) -> int:
"""Number of validators in the registry."""
return len(self._validators)

@classmethod
def from_json(
cls,
node_id: str,
validators_path: Path | str,
manifest_path: Path | str,
) -> ValidatorRegistry:
"""
Load validator registry from JSON configuration files.

The loading process:
1. Read validators.json to find indices assigned to this node
2. Read manifest to get key file paths
3. Load secret keys from SSZ files

Args:
node_id: Identifier for this node in validators.json.
validators_path: Path to validators.json.
manifest_path: Path to validator-keys-manifest.json.

Returns:
Registry populated with validator keys for this node.
"""
validators_path = Path(validators_path)
manifest_path = Path(manifest_path)

# Load node-to-validator mapping.
with validators_path.open() as f:
validators_config = json.load(f)

# Get indices assigned to this node.
#
# If node not in config, return empty registry.
assigned_indices = validators_config.get(node_id, [])
if not assigned_indices:
return cls()

# Load manifest with key metadata.
with manifest_path.open() as f:
manifest = json.load(f)

# Build index-to-entry lookup from manifest.
manifest_entries = {v["index"]: v for v in manifest.get("validators", [])}

# Load keys for assigned validators.
registry = cls()
manifest_dir = manifest_path.parent

for index in assigned_indices:
entry = manifest_entries.get(index)
if entry is None:
continue

# Load secret key from SSZ file.
privkey_file = manifest_dir / entry["privkey_file"]
secret_key = SecretKey.decode_bytes(privkey_file.read_bytes())

registry.add(
ValidatorEntry(
index=Uint64(index),
secret_key=secret_key,
)
)

return registry

@classmethod
def from_secret_keys(cls, keys: dict[int, SecretKey]) -> ValidatorRegistry:
"""
Create registry from a dictionary of secret keys.

Convenience method for testing or programmatic key loading.

Args:
keys: Mapping from validator index to secret key.

Returns:
Registry populated with provided keys.
"""
registry = cls()
for index, secret_key in keys.items():
registry.add(
ValidatorEntry(
index=Uint64(index),
secret_key=secret_key,
)
)
return registry
Loading
Loading