From 54ea884dbf2d4f3ba21b35acb9af4c74f5b0b235 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 22:58:07 +0200 Subject: [PATCH 1/9] refactor(cli): split __main__ into cli package with clean seams Replaces the 803-line __main__.py with a focused cli/ package and introduces five reusable seams in surrounding subspecs: - Anchor value type unifies genesis-sync and checkpoint-sync boot under one return shape with two classmethod builders. - NodeConfig.anchor_store eliminates the post-hoc node.store assignment the CLI used to perform on the checkpoint path. - LiveNetworkEventSource.start_serving encodes the spec-required order for status, current-slot lookup, dial, listen, and gossipsub start, and internalises the stop-event clear. - ValidatorRegistry.from_keys_directory owns the ream/zeam layout convention and raises on missing manifest. - compute_subscription_subnets is a pure helper for attestation subnet planning, replacing the aggregator-no-validators subnet-zero fallback. The new cli/ package has five files: args.py (argparse boundary), bootstrap.py (validation and resolution), main.py (process entry), run.py (linear boot sequence), __init__.py (re-exports). Drops two CLI-level conveniences that were spec-unsafe: - --genesis-time-now, which rewrote chain identity at runtime. - aggregator + no validators routed to subnet zero, operationally incoherent and not derived from the spec. Tests mirror the source layout under tests/lean_spec/cli/, with non-CLI tests relocated to the appropriate subspec test folders. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/__main__.py | 801 +----------------- src/lean_spec/cli/__init__.py | 15 + src/lean_spec/cli/args.py | 163 ++++ src/lean_spec/cli/bootstrap.py | 225 +++++ src/lean_spec/cli/main.py | 54 ++ src/lean_spec/cli/run.py | 118 +++ src/lean_spec/log.py | 72 ++ .../networking/client/event_source/live.py | 73 ++ .../networking/gossipsub/subscription.py | 53 ++ src/lean_spec/subspecs/node/anchor.py | 141 +++ src/lean_spec/subspecs/node/node.py | 24 + .../subspecs/sync/checkpoint_sync.py | 50 ++ src/lean_spec/subspecs/validator/registry.py | 37 + tests/lean_spec/cli/__init__.py | 1 + tests/lean_spec/cli/test_args.py | 131 +++ tests/lean_spec/cli/test_bootstrap.py | 313 +++++++ tests/lean_spec/cli/test_main.py | 33 + tests/lean_spec/cli/test_run.py | 116 +++ .../networking/gossipsub/test_subscription.py | 65 ++ tests/lean_spec/subspecs/node/__init__.py | 1 + tests/lean_spec/subspecs/node/test_anchor.py | 128 +++ tests/lean_spec/subspecs/node/test_node.py | 31 +- .../subspecs/sync/test_checkpoint_sync.py | 66 +- .../subspecs/validator/test_registry.py | 9 + tests/lean_spec/test_cli.py | 446 ---------- 25 files changed, 1919 insertions(+), 1247 deletions(-) create mode 100644 src/lean_spec/cli/__init__.py create mode 100644 src/lean_spec/cli/args.py create mode 100644 src/lean_spec/cli/bootstrap.py create mode 100644 src/lean_spec/cli/main.py create mode 100644 src/lean_spec/cli/run.py create mode 100644 src/lean_spec/log.py create mode 100644 src/lean_spec/subspecs/networking/gossipsub/subscription.py create mode 100644 src/lean_spec/subspecs/node/anchor.py create mode 100644 tests/lean_spec/cli/__init__.py create mode 100644 tests/lean_spec/cli/test_args.py create mode 100644 tests/lean_spec/cli/test_bootstrap.py create mode 100644 tests/lean_spec/cli/test_main.py create mode 100644 tests/lean_spec/cli/test_run.py create mode 100644 tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py create mode 100644 tests/lean_spec/subspecs/node/__init__.py create mode 100644 tests/lean_spec/subspecs/node/test_anchor.py delete mode 100644 tests/lean_spec/test_cli.py diff --git a/src/lean_spec/__main__.py b/src/lean_spec/__main__.py index ce03cdd5f..40ad1375a 100644 --- a/src/lean_spec/__main__.py +++ b/src/lean_spec/__main__.py @@ -1,803 +1,6 @@ -""" -Lean consensus node CLI entry point. - -Run a minimal lean consensus client that can sync with other lean consensus nodes. - -Usage:: - - python -m lean_spec --genesis config.yaml --bootnode /ip4/127.0.0.1/udp/9000/quic-v1 - python -m lean_spec --genesis config.yaml --bootnode enr:-IS4QHCYrYZbAKW... - python -m lean_spec --genesis config.yaml --checkpoint-sync-url http://localhost:5052 - python -m lean_spec --genesis config.yaml --validator-keys ./keys --node-id lean_spec_0 - python -m lean_spec --genesis config.yaml --validator-keys ./keys --is-aggregator - -Options: - --genesis Path to genesis YAML file (required) - --bootnode Bootnode address (multiaddr or ENR string, can be repeated) - --listen Address to listen on (default: /ip4/0.0.0.0/udp/9001/quic-v1) - --checkpoint-sync-url URL to fetch finalized checkpoint state for fast sync - --validator-keys Path to validator keys directory - --node-id Node identifier for validator assignment (default: lean_spec_0) - --is-aggregator Enable aggregator mode for attestation aggregation (default: false) - --aggregate-subnet-ids Comma-separated extra subnet IDs to subscribe/aggregate (e.g. "0,1,2") - --api-port Port for API server and Prometheus /metrics (default: 5052, 0 to disable) -""" - -from __future__ import annotations - -import argparse -import asyncio -import logging -import os -import sys -import time -from pathlib import Path -from typing import cast - -from lean_spec.forks import DEFAULT_REGISTRY, ForkProtocol, State, Store -from lean_spec.forks.lstar.containers import ( - Block, - BlockBody, -) -from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations -from lean_spec.subspecs.api import ApiServerConfig -from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT -from lean_spec.subspecs.genesis import GenesisConfig -from lean_spec.subspecs.metrics import PrometheusObserver, registry as metrics -from lean_spec.subspecs.networking.client import LiveNetworkEventSource -from lean_spec.subspecs.networking.enr import ENR -from lean_spec.subspecs.networking.gossipsub import GossipTopic -from lean_spec.subspecs.networking.reqresp.message import Status -from lean_spec.subspecs.node import Node, NodeConfig -from lean_spec.subspecs.observability import set_observer -from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.sync.checkpoint_sync import ( - CheckpointSyncError, - fetch_finalized_state, - verify_checkpoint_state, -) -from lean_spec.subspecs.validator import ValidatorRegistry -from lean_spec.types import Bytes32, Checkpoint, Slot, SubnetId, Uint64 - -logger = logging.getLogger(__name__) - - -def resolve_bootnode(bootnode: str) -> str: - """ - Resolve a bootnode string to a multiaddr. - - Supports both ENR and multiaddr formats for interoperability. - Different tools emit different formats: - - - Lighthouse, Prysm: Often provide ENR strings - - libp2p tools: Usually provide multiaddrs directly - - Args: - bootnode: Either an ENR string (enr:-IS4Q...) or multiaddr (/ip4/.../udp/.../quic-v1). - - Returns: - Multiaddr string suitable for dialing. - - Raises: - ValueError: If ENR is malformed or has no UDP connection info. - """ - if bootnode.startswith("enr:"): - enr = ENR.from_string(bootnode) - - # Verify structural validity (correct scheme, public key present). - if not enr.is_valid(): - raise ValueError(f"ENR structurally invalid: {enr}") - - # Cryptographically verify signature to ensure authenticity. - # - # This prevents attackers from forging ENRs to redirect connections. - if not enr.verify_signature(): - raise ValueError(f"ENR signature verification failed: {enr}") - - # ENR.multiaddr() returns None when the record lacks IP or UDP port. - # - # We require UDP for QUIC connections. - multiaddr = enr.multiaddr() - if multiaddr is None: - raise ValueError(f"ENR has no UDP connection info: {enr}") - return multiaddr - - # Already a multiaddr string. Pass through without validation. - # - # Validation happens when dialing; early validation here would - # duplicate logic and reduce flexibility for multiaddr extensions. - return bootnode - - -def create_anchor_block(state: State) -> Block: - """ - Create an anchor block from a checkpoint state. - - The forkchoice store requires a block to establish the starting point. - We reconstruct this "anchor block" from the header embedded in the state. - - The body content does not matter for fork choice initialization. - Only header fields (slot, parent, state root) establish the anchor. - - Args: - state: The checkpoint state containing the latest block header. - - Returns: - A Block suitable for initializing the forkchoice store. - """ - header = state.latest_block_header - - # The state root in the header may be zero. - # - # Why? Block processing stores the header BEFORE computing post-state root. - # This prevents circular dependency: state root depends on header, header - # would depend on state root. The spec breaks this cycle by storing zero - # initially, then filling it in when the next slot processes. - # - # For checkpoint sync, we may receive state at exactly the block's slot. - # In this case, the state root was never filled in. We compute it now. - state_root = header.state_root - if state_root == Bytes32.zero(): - state_root = hash_tree_root(state) - - # Build a minimal body. - # - # Fork choice only cares about the block's identity (its hash) and - # lineage (parent_root). The body content is irrelevant for anchoring. - # We use an empty body because we lack the original block data. - body = BlockBody(attestations=AggregatedAttestations(data=[])) - - return Block( - slot=header.slot, - proposer_index=header.proposer_index, - parent_root=header.parent_root, - state_root=state_root, - body=body, - ) - - -def _init_from_genesis( - genesis: GenesisConfig, - event_source: LiveNetworkEventSource, - fork: ForkProtocol, - validator_registry: ValidatorRegistry | None = None, - is_aggregator: bool = False, - api_port: int | None = None, -) -> Node: - """ - Initialize a node from genesis configuration. - - Args: - genesis: Genesis configuration with time and validators. - event_source: Network transport for the node. - fork: Fork specification for state/store construction. - validator_registry: Optional registry with validator secret keys. - is_aggregator: Enable aggregator mode for attestation aggregation. - api_port: Port for API server and /metrics. None disables the API. - - Returns: - A fully initialized Node starting from genesis. - """ - # Set initial status for handshakes. - # - # At genesis, our finalized and head are both the genesis block (unknown root). - genesis_status = Status( - finalized=Checkpoint(root=Bytes32.zero(), slot=Slot(0)), - head=Checkpoint(root=Bytes32.zero(), slot=Slot(0)), - ) - event_source.set_status(genesis_status) - - # Create node configuration. - config = NodeConfig( - genesis_time=genesis.genesis_time, - validators=genesis.to_validators(), - event_source=event_source, - network=event_source.reqresp_client, - fork=fork, - validator_registry=validator_registry, - network_name=fork.GOSSIP_DIGEST, - is_aggregator=is_aggregator, - api_config=ApiServerConfig(port=api_port) if api_port is not None else None, - ) - - # Create and return the node. - return Node.from_genesis(config) - - -async def _init_from_checkpoint( - checkpoint_sync_url: str, - genesis: GenesisConfig, - event_source: LiveNetworkEventSource, - fork: ForkProtocol, - validator_registry: ValidatorRegistry | None = None, - is_aggregator: bool = False, - api_port: int | None = None, -) -> Node | None: - """ - Initialize a node from a checkpoint state fetched from a remote node. - - Checkpoint sync trades trustlessness for speed. The node trusts the - checkpoint source to provide a valid finalized state. This is acceptable - because: - - - The state is finalized (2/3 of validators attested to it) - - Users explicitly opt in via the CLI flag - - The alternative (syncing from genesis) takes hours or days - - Processing steps: - - 1. Fetch finalized state from checkpoint URL - 2. Verify structural validity - 3. Validate genesis time matches - 4. Create anchor block - 5. Initialize forkchoice store - 6. Return configured Node - - Args: - checkpoint_sync_url: URL of the node to fetch checkpoint state from. - genesis: Local genesis configuration for validation. - event_source: Network transport for the node. - fork: Fork specification for state/store construction. - validator_registry: Optional registry with validator secret keys. - is_aggregator: Enable aggregator mode for attestation aggregation. - api_port: Port for API server and /metrics. None disables the API. - - Returns: - A fully initialized Node if successful, None if checkpoint sync failed. - """ - try: - logger.info("Fetching checkpoint state from %s", checkpoint_sync_url) - state = await fetch_finalized_state(checkpoint_sync_url, fork.state_class) - - # Structural validation catches corrupted or malformed states. - # - # This is defense in depth. We trust the source, but still verify - # basic invariants before using the state. - if not verify_checkpoint_state(state): - logger.error("Checkpoint state verification failed") - return None - - # Genesis time MUST match. - # - # This is our only protection against syncing to a different chain. - # If genesis times differ, the checkpoint belongs to another network. - # We reject rather than risk corrupting our view of the chain. - # - # We do NOT fall back to genesis sync on failure. That would silently - # mask configuration errors and leave operators unaware their node - # started from scratch instead of the checkpoint. - if state.config.genesis_time != genesis.genesis_time: - logger.error( - "Genesis time mismatch: checkpoint=%d, local=%d", - state.config.genesis_time, - genesis.genesis_time, - ) - return None - - # Create anchor block from checkpoint state. - anchor_block = create_anchor_block(state) - - # Initialize forkchoice store from checkpoint. - # - # The store treats this as the new "genesis" for fork choice purposes. - # All blocks before the checkpoint are effectively pruned. - validator_id = validator_registry.primary_index() if validator_registry else None - store = cast(Store, fork.create_store(state, anchor_block, validator_id)) - logger.info( - "Initialized from checkpoint at slot %d (finalized=%s)", - state.slot, - store.latest_finalized.root.hex()[:16], - ) - - # Set initial status for handshakes based on checkpoint. - checkpoint_status = Status( - finalized=store.latest_finalized, - head=Checkpoint(root=store.head, slot=store.blocks[store.head].slot), - ) - event_source.set_status(checkpoint_status) - - # Use validators from checkpoint state, not genesis. - # - # The validator set evolves over time. Deposits add validators, - # exits remove them. The checkpoint state reflects the current set. - config = NodeConfig( - genesis_time=genesis.genesis_time, - validators=state.validators, - event_source=event_source, - network=event_source.reqresp_client, - fork=fork, - validator_registry=validator_registry, - network_name=fork.GOSSIP_DIGEST, - is_aggregator=is_aggregator, - api_config=ApiServerConfig(port=api_port) if api_port is not None else None, - ) - - # Create node and inject checkpoint store. - # - # TODO: Add a dedicated factory method for cleaner API. - node = Node.from_genesis(config) - node.store = store - node.sync_service.store = store - - return node - - except CheckpointSyncError as e: - logger.error("Checkpoint sync failed: %s", e) - return None - - -class ColoredFormatter(logging.Formatter): - """Logging formatter with ANSI colors for better readability.""" - - # ANSI color codes - GREY = "\x1b[38;5;244m" - BLUE = "\x1b[38;5;39m" - GREEN = "\x1b[38;5;40m" - YELLOW = "\x1b[38;5;220m" - RED = "\x1b[38;5;196m" - BOLD_RED = "\x1b[38;5;196;1m" - CYAN = "\x1b[38;5;51m" - RESET = "\x1b[0m" - - LEVEL_COLORS = { - logging.DEBUG: GREY, - logging.INFO: GREEN, - logging.WARNING: YELLOW, - logging.ERROR: RED, - logging.CRITICAL: BOLD_RED, - } - - def format(self, record: logging.LogRecord) -> str: - """Format log record with colors.""" - # Get color for this level - color = self.LEVEL_COLORS.get(record.levelno, self.RESET) - - # Format timestamp in cyan - timestamp = self.formatTime(record, self.datefmt) - colored_time = f"{self.CYAN}{timestamp}{self.RESET}" - - # Format level name with color - levelname = f"{color}{record.levelname:8}{self.RESET}" - - # Format logger name in blue - name = f"{self.BLUE}{record.name}{self.RESET}" - - # Format message - message = record.getMessage() - - return f"{colored_time} {levelname} {name}: {message}" - - -def setup_logging(verbose: bool = False, no_color: bool = False) -> None: - """Configure logging for the node with optional colors.""" - level = logging.DEBUG if verbose else logging.INFO - - # Create handler - handler = logging.StreamHandler() - handler.setLevel(level) - - # Use colored formatter unless disabled - if no_color: - formatter = logging.Formatter( - "%(asctime)s %(levelname)-8s %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - else: - formatter = ColoredFormatter(datefmt="%Y-%m-%d %H:%M:%S") - - handler.setFormatter(formatter) - - # Configure root logger - root = logging.getLogger() - root.setLevel(level) - root.addHandler(handler) - - -async def run_node( - genesis_path: Path, - bootnodes: list[str], - listen_addr: str, - checkpoint_sync_url: str | None = None, - validator_keys_path: Path | None = None, - node_id: str = "lean_spec_0", - genesis_time_now: bool = False, - is_aggregator: bool = False, - aggregate_subnet_ids: tuple[SubnetId, ...] = (), - api_port: int | None = 5052, -) -> None: - """ - Run the lean consensus node. - - Args: - genesis_path: Path to genesis YAML file (config.yaml). - bootnodes: List of bootnode multiaddrs to connect to. - listen_addr: Address to listen on. - checkpoint_sync_url: Optional URL to fetch checkpoint state for fast sync. - validator_keys_path: Optional path to validator keys directory. - node_id: Node identifier for validator assignment. - genesis_time_now: Override genesis time to current time for testing. - is_aggregator: Enable aggregator mode for attestation aggregation. - aggregate_subnet_ids: Additional subnets to subscribe and aggregate from. - Only effective when is_aggregator is also True. - api_port: Port for API server (health, fork_choice, /metrics). None or 0 disables. - """ - fork = DEFAULT_REGISTRY.current - - metrics.init(name="leanspec-node", version="0.0.1") - set_observer(PrometheusObserver()) - logger.info("Loading genesis from %s", genesis_path) - genesis = GenesisConfig.from_yaml_file(genesis_path) - - # Override genesis time for testing if requested - if genesis_time_now: - original_time = genesis.genesis_time - new_time = Uint64(int(time.time())) - # Create new config with updated genesis time. - # - # GenesisConfig is frozen, so we use model_copy to create - # a new instance with the updated field. - genesis = genesis.model_copy(update={"genesis_time": new_time}) - logger.warning( - "Overriding genesis time: %d -> %d (now)", - original_time, - new_time, - ) - - logger.info( - "Genesis loaded: time=%d, validators=%d", - genesis.genesis_time, - len(genesis.genesis_validators), - ) - - # Log aggregator mode if enabled - if is_aggregator: - logger.info("Aggregator mode enabled - node will perform attestation aggregation") - if aggregate_subnet_ids: - logger.info("Aggregate subnet IDs configured: %s", list(aggregate_subnet_ids)) - - # Load validator keys if path provided. - # - # The registry holds secret keys for validators assigned to this node. - # Without a registry, the node runs in passive mode (sync only). - # - # Expected directory structure (ream/zeam compatible): - # validators.yaml - node to validator index mapping - # hash-sig-keys/validator-keys-manifest.yaml - key metadata and file paths - validator_registry: ValidatorRegistry | None = None - if validator_keys_path is not None: - validators_yaml = validator_keys_path / "validators.yaml" - manifest_path = validator_keys_path / "hash-sig-keys/validator-keys-manifest.yaml" - - if manifest_path.exists(): - validator_registry = ValidatorRegistry.from_yaml( - node_id=node_id, - validators_path=validators_yaml, - manifest_path=manifest_path, - ) - else: - logger.error( - "Validator keys manifest not found: %s", - manifest_path, - ) - - if validator_registry is not None and len(validator_registry) > 0: - logger.info( - "Loaded %d validators for node %s: indices=%s", - len(validator_registry), - node_id, - validator_registry.indices(), - ) - elif validator_registry is not None: - logger.warning("No validators assigned to node %s", node_id) - - event_source = await LiveNetworkEventSource.create() - - # Set the network name for incoming message validation. - # - # Without this, the event source defaults to "0x00000000" and rejects - # all messages from other clients that use "12345678". - event_source.set_network_name(fork.GOSSIP_DIGEST) - - # Subscribe to gossip topics. - # - # We subscribe before connecting to bootnodes so that when - # we establish connections, we can immediately announce our - # subscriptions to peers. - block_topic = GossipTopic.block(fork.GOSSIP_DIGEST).to_topic_id() - event_source.subscribe_gossip_topic(block_topic) - - # Determine attestation subnets to subscribe to. - # - # Subscribing to a subnet causes the p2p layer to receive all messages on it. - # Not subscribing saves bandwidth — messages arrive only if the node publishes - # to that subnet (via gossipsub fanout), not as a mesh member. - # - # Subscription rules: - # - All nodes with registered validators subscribe to their validator-derived subnets. - # This forms the mesh network for attestation propagation. - # - Aggregator nodes additionally subscribe to any explicit aggregate-subnet-ids. - # - Non-aggregator nodes with no validators skip attestation subscriptions entirely. - subscription_subnets: set[SubnetId] = set() - - # Always subscribe to validator-derived subnets regardless of aggregator flag. - # Loop over all registered validators so each one's subnet is covered. - if validator_registry is not None: - for validator_id in validator_registry.indices(): - subscription_subnets.add(validator_id.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT)) - - # Explicit aggregate subnets are additive but only meaningful for aggregators. - if is_aggregator: - if not subscription_subnets: - # Aggregator with no registered validators — fall back to subnet 0. - subscription_subnets.add(SubnetId(0)) - subscription_subnets.update(aggregate_subnet_ids) - - for subnet_id in subscription_subnets: - attestation_subnet_topic = GossipTopic.attestation_subnet( - fork.GOSSIP_DIGEST, subnet_id - ).to_topic_id() - event_source.subscribe_gossip_topic(attestation_subnet_topic) - logger.info("Subscribed to attestation subnet %d", subnet_id) - - if not subscription_subnets: - logger.info("Not subscribing to any attestation subnet") - - logger.info("Subscribed to block gossip topic: %s", block_topic) - - # Two initialization paths: checkpoint sync or genesis sync. - # - # Checkpoint sync (preferred for mainnet/testnets): - # - Downloads finalized state from trusted node - # - Skips weeks/months of historical block processing - # - Ready to participate in consensus within minutes - # - # Genesis sync (required for new networks): - # - Starts from block 0 with initial validator set - # - Must process every block to reach current head - # - Only practical for new or small networks - api_port_int: int | None = api_port if api_port and api_port > 0 else None - - node: Node | None - if checkpoint_sync_url is not None: - node = await _init_from_checkpoint( - checkpoint_sync_url=checkpoint_sync_url, - genesis=genesis, - event_source=event_source, - fork=fork, - validator_registry=validator_registry, - is_aggregator=is_aggregator, - api_port=api_port_int, - ) - if node is None: - # Checkpoint sync failed. Exit rather than falling back. - # - # Silent fallback to genesis would surprise operators. - # They explicitly requested checkpoint sync for a reason. - return - else: - node = _init_from_genesis( - genesis=genesis, - event_source=event_source, - fork=fork, - validator_registry=validator_registry, - is_aggregator=is_aggregator, - api_port=api_port_int, - ) - - logger.info("Node initialized, peer_id=%s", event_source.connection_manager.peer_id) - - # Update status with actual head and finalized checkpoints. - updated_status = Status( - finalized=node.store.latest_finalized, - head=Checkpoint(root=node.store.head, slot=node.store.blocks[node.store.head].slot), - ) - event_source.set_status(updated_status) - - # Wire the responder's view of wall-clock time. - # - # Without this, the responder cannot bound the sliding history window and - # rejects every range request. The block-by-slot and block-by-root lookups - # still need SignedBlock storage; they share the same gap and are owned by - # a follow-up storage refactor. - event_source.set_current_slot_lookup(node.clock.current_slot) - - # Connect to bootnodes. - # - # Best-effort connection: failures don't abort the loop. - # The node can still function if at least one bootnode connects. - for bootnode in bootnodes: - try: - multiaddr = resolve_bootnode(bootnode) - logger.info("Connecting to bootnode %s", multiaddr) - peer_id = await event_source.dial(multiaddr) - if peer_id: - logger.info("Connected to bootnode, peer_id=%s", peer_id) - else: - logger.warning("Failed to connect to bootnode %s", multiaddr) - except ValueError as e: - # Truncate bootnode string in error logs. - # - # ENR strings can exceed 200 characters, making logs unreadable. - # First 40 chars include the "enr:" prefix and enough to identify. - logger.warning("Invalid bootnode %s: %s", bootnode[:40], e) - - # Start listening (in background). - # - # We start the listener as a background task, but give it a moment - # to bind the port. If binding fails (e.g., port already in use), - # we want to fail fast with a clear error rather than continue - # running without the ability to accept incoming connections. - listener_task = None - if listen_addr: - logger.info("Starting listener on %s", listen_addr) - listener_task = asyncio.create_task(event_source.listen(listen_addr)) - - # Give the listener a moment to bind the port. - # If it fails immediately (e.g., "Address already in use"), - # the task will complete with an exception. - await asyncio.sleep(0.1) - - if listener_task.done(): - # Listener failed early - propagate the error. - try: - listener_task.result() - except OSError as e: - logger.error("Failed to start listener: %s", e) - logger.error( - "Port may be in use. Run './scripts/run_leanspec.sh clean' to free ports." - ) - return - - # Start gossipsub behavior. - # - # This starts the heartbeat loop and enables message forwarding. - # Must be called after subscribing to topics and connecting to peers. - logger.info("Starting gossipsub behavior...") - await event_source.start_gossipsub() - - # Run the node. - logger.info("Starting consensus node...") - event_source._stop_event.clear() - await node.run() - - -def main() -> None: - """CLI entry point.""" - parser = argparse.ArgumentParser( - description="Lean consensus node", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - parser.add_argument( - "--genesis", - required=True, - type=Path, - help="Path to genesis YAML file (config.yaml)", - ) - parser.add_argument( - "--bootnode", - action="append", - default=[], - dest="bootnodes", - help="Bootnode address (multiaddr or ENR string, can be repeated)", - ) - parser.add_argument( - "--listen", - default="/ip4/0.0.0.0/udp/9001/quic-v1", - help="Address to listen on (default: /ip4/0.0.0.0/udp/9001/quic-v1)", - ) - parser.add_argument( - "--checkpoint-sync-url", - type=str, - default=None, - help="URL to fetch finalized checkpoint state for fast sync (e.g., http://localhost:5052)", - ) - parser.add_argument( - "--validator-keys", - type=Path, - default=None, - help="Path to validator keys directory", - ) - parser.add_argument( - "--node-id", - type=str, - default="lean_spec_0", - help="Node identifier for validator assignment (default: lean_spec_0)", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable debug logging", - ) - parser.add_argument( - "--no-color", - action="store_true", - help="Disable colored logging output", - ) - parser.add_argument( - "--genesis-time-now", - action="store_true", - help="Override genesis time to current time (for testing)", - ) - parser.add_argument( - "--is-aggregator", - action="store_true", - help="Enable aggregator mode (node performs attestation aggregation)", - ) - parser.add_argument( - "--aggregate-subnet-ids", - type=str, - default=None, - metavar="SUBNETS", - help=( - "Comma-separated attestation subnet IDs to additionally subscribe and aggregate " - "(e.g. '0,1,2'). Requires --is-aggregator. " - "Adds to the validator-derived subnet." - ), - ) - parser.add_argument( - "--api-port", - type=int, - default=5052, - metavar="PORT", - help="Port for API server and /metrics (default: 5052). Set 0 to disable.", - ) - - args = parser.parse_args() - - setup_logging(args.verbose, args.no_color) - - # Parse --aggregate-subnet-ids from comma-separated string to tuple of SubnetId. - # Reject the flag upfront if --is-aggregator is not set. - aggregate_subnet_ids: tuple[SubnetId, ...] = () - if args.aggregate_subnet_ids: - if not args.is_aggregator: - logger.error("--aggregate-subnet-ids requires --is-aggregator to be set") - sys.exit(1) - try: - aggregate_subnet_ids = tuple( - SubnetId(int(s.strip())) for s in args.aggregate_subnet_ids.split(",") if s.strip() - ) - except ValueError: - logger.error( - "Invalid --aggregate-subnet-ids value: %r (expected comma-separated integers)", - args.aggregate_subnet_ids, - ) - sys.exit(1) - - # Use asyncio.run with proper task cancellation on interrupt. - # This ensures all tasks are cancelled and resources are released. - try: - asyncio.run( - run_node( - genesis_path=args.genesis, - bootnodes=args.bootnodes, - listen_addr=args.listen, - checkpoint_sync_url=args.checkpoint_sync_url, - validator_keys_path=args.validator_keys, - node_id=args.node_id, - genesis_time_now=args.genesis_time_now, - is_aggregator=args.is_aggregator, - aggregate_subnet_ids=aggregate_subnet_ids, - api_port=args.api_port, - ) - ) - except KeyboardInterrupt: - # asyncio.run() handles task cancellation, but we log for clarity. - logger.info("Shutting down...") - except Exception: - logger.exception("Node failed to start") - sys.stdout.flush() - sys.stderr.flush() - os._exit(1) - finally: - # Force exit to ensure all threads/sockets are released. - # This is important for QUIC which may have background threads. - sys.stdout.flush() - sys.stderr.flush() - os._exit(0) +"""Run the lean consensus node CLI.""" +from lean_spec.cli import main if __name__ == "__main__": main() diff --git a/src/lean_spec/cli/__init__.py b/src/lean_spec/cli/__init__.py new file mode 100644 index 000000000..581302206 --- /dev/null +++ b/src/lean_spec/cli/__init__.py @@ -0,0 +1,15 @@ +"""Lean consensus node CLI package.""" + +from .args import CliArgs, parse_args +from .bootstrap import CliValidationError, NodeBootstrap +from .main import main +from .run import run_node + +__all__ = [ + "CliArgs", + "CliValidationError", + "NodeBootstrap", + "main", + "parse_args", + "run_node", +] diff --git a/src/lean_spec/cli/args.py b/src/lean_spec/cli/args.py new file mode 100644 index 000000000..fcf5231e6 --- /dev/null +++ b/src/lean_spec/cli/args.py @@ -0,0 +1,163 @@ +"""Argument-vector boundary for the lean consensus node.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from pydantic import StrictBool + +from lean_spec.types import StrictBaseModel + + +class CliArgs(StrictBaseModel): + """ + Typed view of the parsed command line. + + Cross-field validation lives one layer up. + The parser stays a pure transport between the OS and the rest of the node. + """ + + genesis_path: Path + """Path to the genesis YAML file.""" + + bootnodes: tuple[str, ...] + """Raw bootnode strings, each either a multiaddr or an ENR.""" + + listen_addr: str + """Multiaddr the node binds for inbound QUIC connections.""" + + checkpoint_sync_url: str | None + """URL of a peer serving a finalized state for checkpoint sync.""" + + validator_keys_path: Path | None + """Directory containing the ream/zeam validator key layout, if any.""" + + node_id: str + """Identifier looked up in validators.yaml to find this node's indices.""" + + verbose: StrictBool + """When true, log at DEBUG instead of INFO.""" + + no_color: StrictBool + """When true, drop ANSI colors from log output.""" + + is_aggregator: StrictBool + """When true, the node performs attestation aggregation.""" + + aggregate_subnet_ids_raw: str | None + """Comma-separated extra subnet ids, resolved one layer up.""" + + api_port: int + """Port for the API server and Prometheus scrape endpoint. + + A value of zero disables both endpoints.""" + + +def parse_args(argv: list[str] | None = None) -> CliArgs: + """ + Parse an argument vector into the typed view. + + Args: + argv: Argument vector to parse. + Passing None defers to the standard parser's default of sys.argv. + + Returns: + The parsed arguments as a frozen value type. + """ + parser = argparse.ArgumentParser( + description="Lean consensus node", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--genesis", + required=True, + type=Path, + dest="genesis_path", + help="Path to genesis YAML file (config.yaml)", + ) + parser.add_argument( + "--bootnode", + action="append", + default=[], + dest="bootnodes", + help="Bootnode address (multiaddr or ENR string, can be repeated)", + ) + parser.add_argument( + "--listen", + default="/ip4/0.0.0.0/udp/9001/quic-v1", + dest="listen_addr", + help="Address to listen on (default: /ip4/0.0.0.0/udp/9001/quic-v1)", + ) + parser.add_argument( + "--checkpoint-sync-url", + type=str, + default=None, + dest="checkpoint_sync_url", + help="URL to fetch finalized checkpoint state for fast sync", + ) + parser.add_argument( + "--validator-keys", + type=Path, + default=None, + dest="validator_keys_path", + help="Path to validator keys directory", + ) + parser.add_argument( + "--node-id", + type=str, + default="lean_spec_0", + dest="node_id", + help="Node identifier for validator assignment (default: lean_spec_0)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debug logging", + ) + parser.add_argument( + "--no-color", + action="store_true", + dest="no_color", + help="Disable colored logging output", + ) + parser.add_argument( + "--is-aggregator", + action="store_true", + dest="is_aggregator", + help="Enable aggregator mode (node performs attestation aggregation)", + ) + parser.add_argument( + "--aggregate-subnet-ids", + type=str, + default=None, + dest="aggregate_subnet_ids_raw", + metavar="SUBNETS", + help=( + "Comma-separated attestation subnet IDs to additionally subscribe and aggregate " + "(e.g. '0,1,2'). Requires --is-aggregator." + ), + ) + parser.add_argument( + "--api-port", + type=int, + default=5052, + dest="api_port", + metavar="PORT", + help="Port for API server and /metrics (default: 5052). Set 0 to disable.", + ) + namespace = parser.parse_args(argv) + return CliArgs( + genesis_path=namespace.genesis_path, + bootnodes=tuple(namespace.bootnodes), + listen_addr=namespace.listen_addr, + checkpoint_sync_url=namespace.checkpoint_sync_url, + validator_keys_path=namespace.validator_keys_path, + node_id=namespace.node_id, + verbose=namespace.verbose, + no_color=namespace.no_color, + is_aggregator=namespace.is_aggregator, + aggregate_subnet_ids_raw=namespace.aggregate_subnet_ids_raw, + api_port=namespace.api_port, + ) diff --git a/src/lean_spec/cli/bootstrap.py b/src/lean_spec/cli/bootstrap.py new file mode 100644 index 000000000..2b60d87fa --- /dev/null +++ b/src/lean_spec/cli/bootstrap.py @@ -0,0 +1,225 @@ +""" +Validated, resolved configuration for a node boot. + +The boundary between argument parsing and the run sequence. +The run sequence consumes the validated value without further guards. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from lean_spec.forks import DEFAULT_REGISTRY, ForkProtocol +from lean_spec.subspecs.api import ApiServerConfig +from lean_spec.subspecs.genesis import GenesisConfig +from lean_spec.subspecs.networking.enr import ENR +from lean_spec.subspecs.node.anchor import Anchor +from lean_spec.subspecs.validator import ValidatorRegistry +from lean_spec.types import SubnetId + +from .args import CliArgs + +logger = logging.getLogger(__name__) + + +class CliValidationError(ValueError): + """Raised when CLI arguments fail cross-field validation.""" + + +@dataclass(frozen=True, slots=True) +class NodeBootstrap: + """ + Validated, file-loaded view of what a node needs to start. + + Every field is fully resolved: + + - bootnode strings are concrete multiaddrs, + - aggregate subnet ids are parsed integers, + - the genesis file is already on disk and loaded. + """ + + genesis: GenesisConfig + """Loaded genesis configuration.""" + + registry: ValidatorRegistry + """Loaded validator registry. + + The registry is empty when no key directory was supplied.""" + + fork: ForkProtocol + """Active fork specification driving state and store construction.""" + + bootnode_multiaddrs: tuple[str, ...] + """Multiaddrs ready to dial. + + Any ENR inputs are already resolved to plain multiaddrs.""" + + listen_addr: str | None + """Inbound listen address. + + A value of None means a dial-only configuration.""" + + checkpoint_sync_url: str | None + """Peer URL for fetching a finalized state. + + A value of None means genesis sync.""" + + node_id: str + """Node identifier used during validator-key loading.""" + + is_aggregator: bool + """Whether this node performs attestation aggregation.""" + + aggregate_subnet_ids: tuple[SubnetId, ...] = field(default=()) + """Additional subnets the aggregator subscribes to. + + Sits on top of the subnets derived from owned validators.""" + + api_config: ApiServerConfig | None = field(default=None) + """API server configuration. + + A value of None disables the API and the metrics endpoint.""" + + @classmethod + def from_cli_args(cls, args: CliArgs) -> NodeBootstrap: + """ + Resolve and validate CLI arguments into a boot configuration. + + Args: + args: Parsed CLI arguments. + + Returns: + A boot configuration with every field resolved. + + Raises: + CliValidationError: If any cross-field invariant is violated. + FileNotFoundError: If the validator key manifest is missing. + """ + # Aggregator role guard. + # + # An aggregator with no owned validators has no role in the network. + if args.is_aggregator and args.validator_keys_path is None: + raise CliValidationError( + "--is-aggregator requires --validator-keys to be set; " + "an aggregator with no validators has no role in the network" + ) + + # Extra subnets. + # + # Only valid in aggregator mode. + # Absent input maps to an empty tuple. + aggregate_subnet_ids: tuple[SubnetId, ...] = () + if args.aggregate_subnet_ids_raw: + if not args.is_aggregator: + raise CliValidationError("--aggregate-subnet-ids requires --is-aggregator") + try: + aggregate_subnet_ids = tuple( + SubnetId(int(s.strip())) + for s in args.aggregate_subnet_ids_raw.split(",") + if s.strip() + ) + except ValueError as exc: + raise CliValidationError( + "--aggregate-subnet-ids expects comma-separated integers, " + f"got {args.aggregate_subnet_ids_raw!r}" + ) from exc + + # Genesis load. + logger.info("Loading genesis from %s", args.genesis_path) + genesis = GenesisConfig.from_yaml_file(args.genesis_path) + logger.info( + "Genesis loaded: time=%d, validators=%d", + genesis.genesis_time, + len(genesis.genesis_validators), + ) + + # Validator registry load. + # - An empty registry covers two cases. + # - No key path was given, or the path mapped to no validators. + # + # Both produce a passive node. + registry = ( + ValidatorRegistry() + if args.validator_keys_path is None + else ValidatorRegistry.from_keys_directory( + node_id=args.node_id, base_dir=args.validator_keys_path + ) + ) + if len(registry) > 0: + logger.info( + "Loaded %d validators for node %s: indices=%s", + len(registry), + args.node_id, + registry.indices(), + ) + elif args.validator_keys_path is not None: + logger.warning("No validators assigned to node %s", args.node_id) + + # An empty registry leaves the aggregator role with nothing to do. + if args.is_aggregator and len(registry) == 0: + raise CliValidationError( + f"--is-aggregator set but no validators are assigned to node {args.node_id}; " + "check validators.yaml mapping or --node-id" + ) + + # Bootnode resolution. + # + # Each input is either a bare multiaddr or an ENR record. + # Failing fast at boot beats reporting an opaque dial-time error. + bootnode_multiaddrs: list[str] = [] + for bootnode in args.bootnodes: + # Bare multiaddrs pass through; the dial path validates them later. + if not bootnode.startswith("enr:"): + bootnode_multiaddrs.append(bootnode) + continue + + # Decode the signed ENR envelope from its base64 text form. + enr = ENR.from_string(bootnode) + + # Reject records whose RLP layout breaks the ENR schema. + if not enr.is_valid(): + raise CliValidationError(f"ENR structurally invalid: {enr}") + + # Reject forged records whose signature does not match the public key. + if not enr.verify_signature(): + raise CliValidationError(f"ENR signature verification failed: {enr}") + + # Reject records that omit the UDP endpoint needed for QUIC dialing. + multiaddr = enr.multiaddr() + if multiaddr is None: + raise CliValidationError(f"ENR has no UDP connection info: {enr}") + + bootnode_multiaddrs.append(multiaddr) + + return cls( + genesis=genesis, + registry=registry, + fork=DEFAULT_REGISTRY.current, + bootnode_multiaddrs=tuple(bootnode_multiaddrs), + listen_addr=args.listen_addr or None, + checkpoint_sync_url=args.checkpoint_sync_url, + node_id=args.node_id, + is_aggregator=args.is_aggregator, + aggregate_subnet_ids=aggregate_subnet_ids, + api_config=ApiServerConfig(port=args.api_port) if args.api_port > 0 else None, + ) + + async def build_anchor(self) -> Anchor: + """ + Build the boot anchor for this configuration. + + Without a checkpoint URL the node syncs from genesis. + With one, the anchor is fetched from a peer. + Both paths return the same shape. + """ + if self.checkpoint_sync_url is None: + return Anchor.from_genesis(self.genesis) + + logger.info("Fetching checkpoint state from %s", self.checkpoint_sync_url) + return await Anchor.from_checkpoint( + url=self.checkpoint_sync_url, + genesis=self.genesis, + fork=self.fork, + validator_id=self.registry.primary_index(), + ) diff --git a/src/lean_spec/cli/main.py b/src/lean_spec/cli/main.py new file mode 100644 index 000000000..1b690d62e --- /dev/null +++ b/src/lean_spec/cli/main.py @@ -0,0 +1,54 @@ +"""Process entry point for the lean consensus node.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys + +from lean_spec.log import setup_logging + +from .args import parse_args +from .bootstrap import CliValidationError, NodeBootstrap +from .run import run_node + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Parse CLI arguments and run the node to completion.""" + # Translate the OS argument vector into the typed view. + args = parse_args() + + # Wire console logging before anything else so early errors are visible. + setup_logging(args.verbose, args.no_color) + + # Validate cross-field rules and load referenced files. + try: + boot = NodeBootstrap.from_cli_args(args) + except (CliValidationError, FileNotFoundError) as exc: + logger.error("%s", exc) + sys.exit(1) + + # Run the node under an event loop until shutdown or a fatal error. + try: + asyncio.run(run_node(boot)) + except KeyboardInterrupt: + # The async runtime cancels tasks on interrupt by itself. + # + # The log line only surfaces a graceful-shutdown notice to the operator. + logger.info("Shutting down...") + except Exception: + # Crash path: log the traceback before forcing a non-zero exit. + logger.exception("Node failed to start") + sys.stdout.flush() + sys.stderr.flush() + os._exit(1) + finally: + # QUIC keeps background threads holding UDP sockets after loop exit. + # + # A hard exit is the only way to release those sockets on shutdown. + sys.stdout.flush() + sys.stderr.flush() + os._exit(0) diff --git a/src/lean_spec/cli/run.py b/src/lean_spec/cli/run.py new file mode 100644 index 000000000..f262e2fe8 --- /dev/null +++ b/src/lean_spec/cli/run.py @@ -0,0 +1,118 @@ +""" +Run sequence for the lean consensus node. + +A linearised boot in five steps: + +1. Bring observability up. +2. Materialise the boot anchor. +3. Wire the event source and its subscriptions. +4. Construct the node from the validated configuration. +5. Start serving inbound traffic, then run to shutdown. + +Each step has a single purpose. +A reader can trace the spec invariants top to bottom. +""" + +from __future__ import annotations + +import logging + +from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT +from lean_spec.subspecs.metrics import PrometheusObserver, registry as metrics +from lean_spec.subspecs.networking.client import LiveNetworkEventSource +from lean_spec.subspecs.networking.gossipsub import GossipTopic +from lean_spec.subspecs.networking.gossipsub.subscription import ( + compute_subscription_subnets, +) +from lean_spec.subspecs.node import Node, NodeConfig +from lean_spec.subspecs.observability import set_observer + +from .bootstrap import NodeBootstrap + +logger = logging.getLogger(__name__) + + +async def _build_event_source(boot: NodeBootstrap) -> LiveNetworkEventSource: + """Construct the event source and apply its pre-serving wiring.""" + # Spin up the QUIC transport and gossipsub plumbing. + event_source = await LiveNetworkEventSource.create() + + # Pin the network identity carried by every topic id and Status message. + event_source.set_network_name(boot.fork.GOSSIP_DIGEST) + + # A peer that meshes with us before the topic exists drops our heartbeat. + block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + event_source.subscribe_gossip_topic(block_topic) + logger.info("Subscribed to block gossip topic: %s", block_topic) + + # Derive the attestation subnets from owned validators and aggregator extras. + subnets = compute_subscription_subnets( + boot.registry.indices(), + committee_count=ATTESTATION_COMMITTEE_COUNT, + is_aggregator=boot.is_aggregator, + extra_subnets=boot.aggregate_subnet_ids, + ) + + # Subscribe to every owned subnet under its fork-scoped topic id. + for subnet_id in subnets: + topic = GossipTopic.attestation_subnet(boot.fork.GOSSIP_DIGEST, subnet_id).to_topic_id() + event_source.subscribe_gossip_topic(topic) + logger.info("Subscribed to attestation subnet %d", subnet_id) + + # A passive non-aggregator node owns no subnets. + # + # That is a valid configuration; we just log it. + if not subnets: + logger.info("Not subscribing to any attestation subnet") + + return event_source + + +async def run_node(boot: NodeBootstrap) -> None: + """ + Run the consensus node to shutdown. + + Args: + boot: Validated, fully resolved boot configuration. + """ + # Observability comes up first. + # + # Later construction paths emit metrics and register meters as they wire up. + metrics.init(name="leanspec-node", version="0.0.1") + set_observer(PrometheusObserver()) + + # Materialise the starting point: genesis or checkpoint-synced anchor. + anchor = await boot.build_anchor() + + # Wire transport and topic subscriptions before any peer can reach us. + event_source = await _build_event_source(boot) + + # Construct the node with the anchor store and event source attached. + node = Node.from_genesis( + NodeConfig( + genesis_time=boot.genesis.genesis_time, + validators=anchor.validators, + event_source=event_source, + network=event_source.reqresp_client, + fork=boot.fork, + validator_registry=boot.registry, + network_name=boot.fork.GOSSIP_DIGEST, + is_aggregator=boot.is_aggregator, + api_config=boot.api_config, + anchor_store=anchor.store, + ) + ) + + logger.info("Node initialized, peer_id=%s", event_source.connection_manager.peer_id) + + # Bring the listener and outbound dialer online in the spec-required order. + await event_source.start_serving( + status=anchor.initial_status, + current_slot_lookup=node.clock.current_slot, + listen_addr=boot.listen_addr, + bootnode_multiaddrs=boot.bootnode_multiaddrs, + ) + + # Run all services concurrently until shutdown is signalled. + logger.info("Starting consensus node...") + await node.run() diff --git a/src/lean_spec/log.py b/src/lean_spec/log.py new file mode 100644 index 000000000..1a7ed9a7d --- /dev/null +++ b/src/lean_spec/log.py @@ -0,0 +1,72 @@ +"""Console logging setup for the lean consensus node CLI.""" + +from __future__ import annotations + +import logging + + +class ColoredFormatter(logging.Formatter): + """Logging formatter with ANSI colors for better readability.""" + + # ANSI color codes + GREY = "\x1b[38;5;244m" + BLUE = "\x1b[38;5;39m" + GREEN = "\x1b[38;5;40m" + YELLOW = "\x1b[38;5;220m" + RED = "\x1b[38;5;196m" + BOLD_RED = "\x1b[38;5;196;1m" + CYAN = "\x1b[38;5;51m" + RESET = "\x1b[0m" + + LEVEL_COLORS = { + logging.DEBUG: GREY, + logging.INFO: GREEN, + logging.WARNING: YELLOW, + logging.ERROR: RED, + logging.CRITICAL: BOLD_RED, + } + + def format(self, record: logging.LogRecord) -> str: + """Format log record with colors.""" + # Get color for this level + color = self.LEVEL_COLORS.get(record.levelno, self.RESET) + + # Format timestamp in cyan + timestamp = self.formatTime(record, self.datefmt) + colored_time = f"{self.CYAN}{timestamp}{self.RESET}" + + # Format level name with color + levelname = f"{color}{record.levelname:8}{self.RESET}" + + # Format logger name in blue + name = f"{self.BLUE}{record.name}{self.RESET}" + + # Format message + message = record.getMessage() + + return f"{colored_time} {levelname} {name}: {message}" + + +def setup_logging(verbose: bool = False, no_color: bool = False) -> None: + """Configure logging for the node with optional colors.""" + level = logging.DEBUG if verbose else logging.INFO + + # Create handler + handler = logging.StreamHandler() + handler.setLevel(level) + + # Use colored formatter unless disabled + if no_color: + formatter: logging.Formatter = logging.Formatter( + "%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + else: + formatter = ColoredFormatter(datefmt="%Y-%m-%d %H:%M:%S") + + handler.setFormatter(formatter) + + # Configure root logger + root = logging.getLogger() + root.setLevel(level) + root.addHandler(handler) diff --git a/src/lean_spec/subspecs/networking/client/event_source/live.py b/src/lean_spec/subspecs/networking/client/event_source/live.py index 6b7ad7538..f1eafdea8 100644 --- a/src/lean_spec/subspecs/networking/client/event_source/live.py +++ b/src/lean_spec/subspecs/networking/client/event_source/live.py @@ -58,6 +58,7 @@ import asyncio import logging +from collections.abc import Sequence from dataclasses import dataclass, field from lean_spec.forks import SignedAggregatedAttestation, SignedAttestation, SignedBlock @@ -373,6 +374,78 @@ async def start_gossipsub(self) -> None: logger.info("GossipSub behavior started") + async def start_serving( + self, + *, + status: Status, + current_slot_lookup: CurrentSlotLookup, + listen_addr: str | None, + bootnode_multiaddrs: Sequence[str], + ) -> None: + """ + Bring the event source online in the spec-required order. + + The order matters for ReqResp correctness: + + 1. Status must be set before any peer can hit the responder, or + BlocksByRoot / BlocksByRange queries return SERVER_ERROR. + 2. The current-slot lookup must be wired before BlocksByRange + serves, for the same reason. + 3. Bootnodes are dialed best-effort; failures log but do not abort. + A peerless node is still a valid honest participant. + 4. The listener binds the local port. We give it a 100 ms head start + so a bind error (port in use) surfaces here rather than silently + in the background. + 5. Gossipsub starts last so the heartbeat does not run before peers + are reachable. + + Args: + status: Initial Status (finalized, head) the responder serves. + current_slot_lookup: Wall-clock-to-slot callback for ReqResp range bounds. + listen_addr: Multiaddr to bind for inbound connections, or None to + run dial-only. + bootnode_multiaddrs: Pre-resolved outbound peers. Caller is + expected to have parsed ENR strings into multiaddrs already. + + Raises: + OSError: If the listener fails to bind within the probe window. + """ + # The set-state-before-serving invariant: every other setter is + # idempotent, these two are load-bearing for correctness. + self.set_status(status) + self.set_current_slot_lookup(current_slot_lookup) + + # Best-effort outbound connections. Both dial() and listen() clear + # the stop event internally; we also clear it here so a no-bootnodes, + # no-listen configuration still yields events. + self._stop_event.clear() + + for multiaddr in bootnode_multiaddrs: + logger.info("Connecting to bootnode %s", multiaddr) + try: + peer_id = await self.dial(multiaddr) + except Exception as exc: + logger.warning("Failed to connect to bootnode %s: %s", multiaddr, exc) + continue + if peer_id is not None: + logger.info("Connected to bootnode, peer_id=%s", peer_id) + else: + logger.warning("Failed to connect to bootnode %s", multiaddr) + + if listen_addr: + logger.info("Starting listener on %s", listen_addr) + listener_task = asyncio.create_task(self.listen(listen_addr)) + + # Surface immediate bind failures (port already in use, etc.) + # synchronously instead of leaving them as a silent background + # task crash. + await asyncio.sleep(0.1) + if listener_task.done(): + listener_task.result() + + logger.info("Starting gossipsub behavior...") + await self.start_gossipsub() + async def _forward_gossipsub_events(self) -> None: """Forward events from GossipsubBehavior to our event queue.""" try: diff --git a/src/lean_spec/subspecs/networking/gossipsub/subscription.py b/src/lean_spec/subspecs/networking/gossipsub/subscription.py new file mode 100644 index 000000000..2c9cdfed7 --- /dev/null +++ b/src/lean_spec/subspecs/networking/gossipsub/subscription.py @@ -0,0 +1,53 @@ +""" +Subnet subscription planning for the node. + +Maps validator identity and aggregator role to the set of attestation +subnets the node must subscribe to at boot. Separated from networking +lifecycle so it can be tested as a pure function and so the spec rule +("aggregators advertise extra subnets, validators always sit on their +own subnet") lives in one readable place. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from lean_spec.types import SubnetId, Uint64, ValidatorIndex + + +def compute_subscription_subnets( + validator_indices: Iterable[ValidatorIndex], + *, + committee_count: Uint64, + is_aggregator: bool, + extra_subnets: Iterable[SubnetId] = (), +) -> frozenset[SubnetId]: + """ + Compute the set of attestation subnets the node must subscribe to. + + Validator-derived subnets are the load-bearing ones: missing them + breaks the attestation mesh for the owned validators. The aggregator + extras are advisory subnets the operator wants this node to also see. + + Args: + validator_indices: Validator indices this node owns secret keys for. + committee_count: ATTESTATION_COMMITTEE_COUNT for the active fork; + decides which subnet a given validator hashes to. + is_aggregator: True if the node is configured to aggregate. When + False, extra_subnets is ignored even if non-empty. + extra_subnets: Additional subnets requested by the operator (only + consulted when is_aggregator is True). + + Returns: + Immutable set of subnet ids; empty when there are no validators and + the node is not configured as an aggregator. + + Notes: + The spec is silent on the aggregator-without-validators corner; + callers are expected to reject that combination upstream rather + than expecting a magic fallback here. + """ + derived = {idx.compute_subnet_id(committee_count) for idx in validator_indices} + if is_aggregator: + derived.update(extra_subnets) + return frozenset(derived) diff --git a/src/lean_spec/subspecs/node/anchor.py b/src/lean_spec/subspecs/node/anchor.py new file mode 100644 index 000000000..6a07822b2 --- /dev/null +++ b/src/lean_spec/subspecs/node/anchor.py @@ -0,0 +1,141 @@ +""" +Boot anchor for the lean consensus node. + +The forkchoice store starts at a trusted block plus the matching state. +Two sources are supported: + +- Genesis: build the store from the genesis validator set the first time + the node starts. No network round-trip; no state to verify. +- Checkpoint: fetch a finalized state from a peer, verify it, and build + the forkchoice store from that state. The validators inside the state + replace the genesis validator set as the source of truth. + +Both paths land on the same shape so the rest of the boot sequence does +not branch on the anchor source. The protocol does not distinguish +"started from genesis" from "started from a finalized checkpoint" once +the store exists. +""" + +from __future__ import annotations + +from typing import cast + +from lean_spec.forks import ForkProtocol, Store, Validators +from lean_spec.subspecs.genesis import GenesisConfig +from lean_spec.subspecs.networking.reqresp.message import Status +from lean_spec.subspecs.sync.checkpoint_sync import ( + CheckpointSyncError, + create_anchor_block, + fetch_finalized_state, + verify_checkpoint_state, +) +from lean_spec.types import Bytes32, Checkpoint, Slot, StrictBaseModel, ValidatorIndex + + +class Anchor(StrictBaseModel): + """ + Starting state for the node's forkchoice store. + + Carries the three values the boot sequence needs to construct a Node + and announce itself to peers: + + - validators: the validator set the store will see at slot zero of + this run (either genesis or the checkpointed state's validators). + - store: a pre-built forkchoice store on the checkpoint path, or None + to ask Node.from_genesis to synthesize one from validators. + - initial_status: the Status broadcast to peers before the listener + starts serving inbound ReqResp queries. + """ + + validators: Validators + """Validator set to wire into NodeConfig.validators.""" + + store: Store | None + """Pre-built forkchoice store, or None to synthesize from validators.""" + + initial_status: Status + """Status to publish on the event source before serving inbound traffic.""" + + @classmethod + def from_genesis(cls, genesis: GenesisConfig) -> Anchor: + """ + Build an anchor from a fresh genesis configuration. + + No store is constructed here: Node.from_genesis synthesizes one + from the validator set. The initial status carries zero roots and + slot zero because the genesis block's identity is not yet computed + at this point in the boot sequence. + + Args: + genesis: Genesis YAML loaded from disk. + + Returns: + An anchor that asks the node to synthesize its own store. + """ + zero_checkpoint = Checkpoint(root=Bytes32.zero(), slot=Slot(0)) + return cls( + validators=genesis.to_validators(), + store=None, + initial_status=Status(finalized=zero_checkpoint, head=zero_checkpoint), + ) + + @classmethod + async def from_checkpoint( + cls, + url: str, + genesis: GenesisConfig, + fork: ForkProtocol, + validator_id: ValidatorIndex | None, + ) -> Anchor: + """ + Build an anchor by fetching a finalized state from a peer. + + The fetched state replaces the genesis validator set: deposits and + exits since genesis are baked into state.validators, so we use + that as the source of truth. + + Args: + url: HTTP endpoint of the node serving the checkpoint state. + genesis: Local genesis. Only its genesis_time is consulted, as + a chain-identity guard against syncing to the wrong network. + fork: Fork specification driving state/store construction. + validator_id: Local validator index used as a forkchoice + tiebreaker hint. Same value passed on the genesis path. + + Raises: + CheckpointSyncError: For every failure mode (HTTP transport, + structural verification, genesis-time mismatch). Callers + see one typed exception instead of three implicit branches. + """ + state = await fetch_finalized_state(url, fork.state_class) + + # Defense in depth even though we trust the source: catches a + # corrupted download or a misconfigured server before the bad state + # contaminates the forkchoice store. + if not verify_checkpoint_state(state): + raise CheckpointSyncError("checkpoint state failed structural verification") + + # Genesis time is the only chain-identity guard we can apply at + # this layer. A mismatch means the checkpoint belongs to a different + # network; refusing to start is safer than silently corrupting the + # node's view of history. + if state.config.genesis_time != genesis.genesis_time: + raise CheckpointSyncError( + f"genesis time mismatch: checkpoint={state.config.genesis_time}, " + f"local={genesis.genesis_time}" + ) + + anchor_block = create_anchor_block(state) + # The fork protocol returns the structural Store contract; the + # concrete Store is the only one wired into NodeConfig today. + store = cast(Store, fork.create_store(state, anchor_block, validator_id)) + head_slot = store.blocks[store.head].slot + + return cls( + validators=state.validators, + store=store, + initial_status=Status( + finalized=store.latest_finalized, + head=Checkpoint(root=store.head, slot=head_slot), + ), + ) diff --git a/src/lean_spec/subspecs/node/node.py b/src/lean_spec/subspecs/node/node.py index f1197f145..4741c292d 100644 --- a/src/lean_spec/subspecs/node/node.py +++ b/src/lean_spec/subspecs/node/node.py @@ -132,6 +132,24 @@ class NodeConfig: restarts and do not update the local ENR or subnet subscriptions. """ + anchor_store: Store | None = field(default=None) + """ + Pre-built forkchoice store to anchor the node on. + + When set, the node skips genesis-store synthesis and uses this store + directly. Used for checkpoint sync, where the store is built from a + fetched finalized state rather than from the genesis validator set. + + The store-load order in from_genesis is: + + 1. Database (if database_path is set and contains valid state). + 2. anchor_store (this field), if provided. + 3. Fresh synthesis from the genesis validator set. + + The validators field MUST match the validator set inside this store + (state.validators for checkpoint, genesis.to_validators() for genesis). + """ + @dataclass(slots=True) class Node: @@ -212,6 +230,12 @@ def from_genesis(cls, config: NodeConfig) -> Node: database, validator_id, config.genesis_time, config.time_fn, fork ) + # An explicit anchor store wins over genesis synthesis but loses to a + # populated database, so a restart with persisted state still recovers + # from disk even when the caller passes a checkpoint anchor. + if store is None and config.anchor_store is not None: + store = config.anchor_store + if store is None: # Generate genesis state from validators. # diff --git a/src/lean_spec/subspecs/sync/checkpoint_sync.py b/src/lean_spec/subspecs/sync/checkpoint_sync.py index 1a6668b6a..3bda0d5ea 100644 --- a/src/lean_spec/subspecs/sync/checkpoint_sync.py +++ b/src/lean_spec/subspecs/sync/checkpoint_sync.py @@ -24,8 +24,11 @@ import httpx from lean_spec.forks import State +from lean_spec.forks.lstar.containers import Block, BlockBody +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.subspecs.chain.config import VALIDATOR_REGISTRY_LIMIT from lean_spec.subspecs.ssz.hash import hash_tree_root +from lean_spec.types import Bytes32 logger = logging.getLogger(__name__) @@ -153,3 +156,50 @@ def verify_checkpoint_state(state: State) -> bool: except Exception as e: logger.error("State verification failed: %s", e) return False + + +def create_anchor_block(state: State) -> Block: + """ + Create an anchor block from a checkpoint state. + + The forkchoice store requires a block to establish the starting point. + We reconstruct this "anchor block" from the header embedded in the state. + + The body content does not matter for fork choice initialization. + Only header fields (slot, parent, state root) establish the anchor. + + Args: + state: The checkpoint state containing the latest block header. + + Returns: + A Block suitable for initializing the forkchoice store. + """ + header = state.latest_block_header + + # The state root in the header may be zero. + # + # Why? Block processing stores the header BEFORE computing post-state root. + # This prevents circular dependency: state root depends on header, header + # would depend on state root. The spec breaks this cycle by storing zero + # initially, then filling it in when the next slot processes. + # + # For checkpoint sync, we may receive state at exactly the block's slot. + # In this case, the state root was never filled in. We compute it now. + state_root = header.state_root + if state_root == Bytes32.zero(): + state_root = hash_tree_root(state) + + # Build a minimal body. + # + # Fork choice only cares about the block's identity (its hash) and + # lineage (parent_root). The body content is irrelevant for anchoring. + # We use an empty body because we lack the original block data. + body = BlockBody(attestations=AggregatedAttestations(data=[])) + + return Block( + slot=header.slot, + proposer_index=header.proposer_index, + parent_root=header.parent_root, + state_root=state_root, + body=body, + ) diff --git a/src/lean_spec/subspecs/validator/registry.py b/src/lean_spec/subspecs/validator/registry.py index 93861d67d..b200ddfe2 100644 --- a/src/lean_spec/subspecs/validator/registry.py +++ b/src/lean_spec/subspecs/validator/registry.py @@ -229,6 +229,43 @@ def __len__(self) -> int: """Number of validators in the registry.""" return len(self._validators) + @classmethod + def from_keys_directory( + cls, + node_id: str, + base_dir: Path | str, + ) -> ValidatorRegistry: + """ + Load a validator registry from the ream/zeam keystore layout. + + Convention: + + - base_dir / "validators.yaml" maps nodes to validator indices. + - base_dir / "hash-sig-keys/validator-keys-manifest.yaml" carries + each validator's key metadata and SSZ file path. + + Args: + node_id: Identifier for this node in validators.yaml. + base_dir: Directory containing the two layout files above. + + Returns: + Registry populated with the keys assigned to node_id. + + Raises: + FileNotFoundError: If the manifest file is missing. validators.yaml + may be missing only when the node has no assigned validators, + in which case the registry is empty. + """ + base = Path(base_dir) + manifest_path = base / "hash-sig-keys" / "validator-keys-manifest.yaml" + if not manifest_path.exists(): + raise FileNotFoundError(f"Validator keys manifest not found: {manifest_path}") + return cls.from_yaml( + node_id=node_id, + validators_path=base / "validators.yaml", + manifest_path=manifest_path, + ) + @classmethod def from_yaml( cls, diff --git a/tests/lean_spec/cli/__init__.py b/tests/lean_spec/cli/__init__.py new file mode 100644 index 000000000..5e7697431 --- /dev/null +++ b/tests/lean_spec/cli/__init__.py @@ -0,0 +1 @@ +"""Tests for the lean consensus node CLI package.""" diff --git a/tests/lean_spec/cli/test_args.py b/tests/lean_spec/cli/test_args.py new file mode 100644 index 000000000..5bc89435c --- /dev/null +++ b/tests/lean_spec/cli/test_args.py @@ -0,0 +1,131 @@ +"""Tests for the argument-vector parser.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from lean_spec.cli import CliArgs, parse_args + + +class TestParseArgsDefaults: + """Defaults for every optional flag.""" + + def test_only_genesis_populates_defaults(self) -> None: + """A minimal invocation fills every other field with its documented default.""" + assert parse_args(["--genesis", "config.yaml"]) == CliArgs( + genesis_path=Path("config.yaml"), + bootnodes=(), + listen_addr="/ip4/0.0.0.0/udp/9001/quic-v1", + checkpoint_sync_url=None, + validator_keys_path=None, + node_id="lean_spec_0", + verbose=False, + no_color=False, + is_aggregator=False, + aggregate_subnet_ids_raw=None, + api_port=5052, + ) + + +class TestParseArgsFullFlagSet: + """Every supported flag together produces the matching typed view.""" + + def test_full_flag_set_round_trip(self, tmp_path: Path) -> None: + """Every flag set on the command line shows up in the parsed value.""" + keys_dir = tmp_path / "keys" + assert parse_args( + [ + "--genesis", + "g.yaml", + "--bootnode", + "/ip4/1.1.1.1/udp/9000/quic-v1", + "--listen", + "/ip4/0.0.0.0/udp/9100/quic-v1", + "--checkpoint-sync-url", + "http://localhost:5052", + "--validator-keys", + str(keys_dir), + "--node-id", + "node_7", + "--verbose", + "--no-color", + "--is-aggregator", + "--aggregate-subnet-ids", + "0,1", + "--api-port", + "8080", + ] + ) == CliArgs( + genesis_path=Path("g.yaml"), + bootnodes=("/ip4/1.1.1.1/udp/9000/quic-v1",), + listen_addr="/ip4/0.0.0.0/udp/9100/quic-v1", + checkpoint_sync_url="http://localhost:5052", + validator_keys_path=keys_dir, + node_id="node_7", + verbose=True, + no_color=True, + is_aggregator=True, + aggregate_subnet_ids_raw="0,1", + api_port=8080, + ) + + +class TestBootnodeRepetition: + """Repeated bootnode flags accumulate in order.""" + + def test_repeated_bootnode_preserves_order(self) -> None: + """Three repeats produce a tuple of three strings in input order.""" + args = parse_args( + [ + "--genesis", + "g.yaml", + "--bootnode", + "a", + "--bootnode", + "b", + "--bootnode", + "c", + ] + ) + assert args.bootnodes == ("a", "b", "c") + + +class TestApiPort: + """Round-trip parsing for the API port flag.""" + + @pytest.mark.parametrize("port", [0, 8080]) + def test_api_port_round_trip(self, port: int) -> None: + """The parsed port equals the integer passed on the command line.""" + args = parse_args(["--genesis", "g.yaml", "--api-port", str(port)]) + assert args.api_port == port + + +class TestRequiredArguments: + """Missing required flags exit via the standard parser.""" + + def test_genesis_is_required(self) -> None: + """Omitting the genesis flag causes argparse to exit the process.""" + with pytest.raises(SystemExit): + parse_args([]) + + +class TestBooleanFlags: + """Each boolean flag flips its dedicated field.""" + + def test_is_aggregator_flag(self) -> None: + """The aggregator flag sets its field to true.""" + assert parse_args(["--genesis", "g.yaml", "--is-aggregator"]).is_aggregator is True + + def test_no_color_flag(self) -> None: + """The no-color flag sets its field to true.""" + assert parse_args(["--genesis", "g.yaml", "--no-color"]).no_color is True + + def test_verbose_long_form(self) -> None: + """The long verbose flag sets its field to true.""" + assert parse_args(["--genesis", "g.yaml", "--verbose"]).verbose is True + + def test_verbose_short_form(self) -> None: + """The short verbose flag sets the same field as the long form.""" + assert parse_args(["--genesis", "g.yaml", "-v"]).verbose is True diff --git a/tests/lean_spec/cli/test_bootstrap.py b/tests/lean_spec/cli/test_bootstrap.py new file mode 100644 index 000000000..188d9216a --- /dev/null +++ b/tests/lean_spec/cli/test_bootstrap.py @@ -0,0 +1,313 @@ +"""Tests for the validated, file-loaded boot configuration.""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from consensus_testing.keys import XmssKeyManager +from Crypto.Hash import keccak +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature + +from lean_spec.cli import CliValidationError, NodeBootstrap, parse_args +from lean_spec.subspecs.api import ApiServerConfig +from lean_spec.types import Slot, SubnetId, ValidatorIndex +from lean_spec.types.rlp import RLPItem, encode_rlp + +# Generate a test keypair once for all ENR tests. +_TEST_PRIVATE_KEY = ec.generate_private_key(ec.SECP256K1()) +_TEST_PUBLIC_KEY = _TEST_PRIVATE_KEY.public_key() +_TEST_COMPRESSED_PUBKEY = _TEST_PUBLIC_KEY.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.CompressedPoint, +) + + +def _sign_enr_content(content_items: list[RLPItem]) -> bytes: + """Sign ENR content and return 64-byte r||s signature.""" + content_rlp = encode_rlp(content_items) + + k = keccak.new(digest_bits=256) + k.update(content_rlp) + digest = k.digest() + + signature_der = _TEST_PRIVATE_KEY.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256()))) + r, s = decode_dss_signature(signature_der) + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + + +def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: + """Create a properly signed ENR string with IPv4 and UDP port.""" + # Content items (keys must be sorted). + content_items: list[RLPItem] = [ + b"\x01", # seq = 1 + b"id", + b"v4", + b"ip", + ip_bytes, + b"secp256k1", + _TEST_COMPRESSED_PUBKEY, + b"udp", + udp_port.to_bytes(2, "big"), + ] + signature = _sign_enr_content(content_items) + + rlp_data = encode_rlp([signature] + content_items) + b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") + return f"enr:{b64_content}" + + +def _make_enr_without_udp(ip_bytes: bytes) -> str: + """Create a properly signed ENR string with IPv4 but no UDP port.""" + content_items: list[RLPItem] = [ + b"\x01", # seq = 1 + b"id", + b"v4", + b"ip", + ip_bytes, + b"secp256k1", + _TEST_COMPRESSED_PUBKEY, + ] + signature = _sign_enr_content(content_items) + + rlp_data = encode_rlp([signature] + content_items) + b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") + return f"enr:{b64_content}" + + +# Pre-built test ENRs +ENR_WITH_UDP = _make_enr_with_udp(b"\xc0\xa8\x01\x01", 9000) # 192.168.1.1:9000 +ENR_WITHOUT_UDP = _make_enr_without_udp(b"\xc0\xa8\x01\x01") # 192.168.1.1, no UDP + +# Valid multiaddr strings (QUIC format) +MULTIADDR_IPV4 = "/ip4/127.0.0.1/udp/9000/quic-v1" + + +@pytest.fixture +def genesis_yaml(tmp_path: Path) -> Path: + """Write a minimal genesis YAML to a temporary path.""" + path = tmp_path / "genesis.yaml" + path.write_text("GENESIS_TIME: 1000\nGENESIS_VALIDATORS: []\n") + return path + + +def _write_aggregator_key_layout(tmp_path: Path) -> Path: + """Materialise a one-validator key directory the registry loader accepts.""" + keys_root = tmp_path / "keys" + hash_sig_dir = keys_root / "hash-sig-keys" + hash_sig_dir.mkdir(parents=True) + + km = XmssKeyManager.shared(max_slot=Slot(10)) + kp = km[ValidatorIndex(0)] + + (hash_sig_dir / "att_key_0.ssz").write_bytes(kp.attestation_keypair.secret_key.encode_bytes()) + (hash_sig_dir / "prop_key_0.ssz").write_bytes(kp.proposal_keypair.secret_key.encode_bytes()) + + manifest = hash_sig_dir / "validator-keys-manifest.yaml" + manifest.write_text( + "key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8\n" + "hash_function: Poseidon2\n" + "encoding: TargetSum\n" + "lifetime: 32\n" + "log_num_active_epochs: 5\n" + "num_active_epochs: 32\n" + "num_validators: 1\n" + "validators:\n" + " - index: 0\n" + f" attestation_pubkey_hex: '0x{'00' * 52}'\n" + f" proposal_pubkey_hex: '0x{'00' * 52}'\n" + " attestation_privkey_file: att_key_0.ssz\n" + " proposal_privkey_file: prop_key_0.ssz\n" + ) + + (keys_root / "validators.yaml").write_text("lean_spec_0: [0]\n") + return keys_root + + +def _bootstrap_from_argv(genesis_yaml: Path, *extra: str) -> NodeBootstrap: + """Construct a bootstrap from a genesis path and extra CLI tokens.""" + argv = ["--genesis", str(genesis_yaml), *extra] + return NodeBootstrap.from_cli_args(parse_args(argv)) + + +def _bootstrap_with_bootnodes(genesis_yaml: Path, *bootnodes: str) -> NodeBootstrap: + """Construct a bootstrap from a genesis path and bootnode strings.""" + extra: list[str] = [] + for b in bootnodes: + extra.extend(["--bootnode", b]) + return _bootstrap_from_argv(genesis_yaml, *extra) + + +class TestBootnodeResolution: + """Tests for bootnode resolution at the CLI boundary.""" + + def test_multiaddr_passes_through(self, genesis_yaml: Path) -> None: + """A bare multiaddr is kept as-is.""" + boot = _bootstrap_with_bootnodes(genesis_yaml, MULTIADDR_IPV4) + assert boot.bootnode_multiaddrs == (MULTIADDR_IPV4,) + + def test_enr_resolves_to_multiaddr(self, genesis_yaml: Path) -> None: + """An ENR carrying UDP info expands to its dialable multiaddr view.""" + boot = _bootstrap_with_bootnodes(genesis_yaml, ENR_WITH_UDP) + assert boot.bootnode_multiaddrs == ("/ip4/192.168.1.1/udp/9000/quic-v1",) + + def test_enr_without_udp_rejected(self, genesis_yaml: Path) -> None: + """An ENR lacking UDP info is rejected before any dial attempt.""" + with pytest.raises(CliValidationError, match=r"no UDP connection info"): + _bootstrap_with_bootnodes(genesis_yaml, ENR_WITHOUT_UDP) + + def test_malformed_enr_rejected(self, genesis_yaml: Path) -> None: + """A malformed ENR fails at RLP decoding.""" + with pytest.raises(ValueError, match=r"Invalid RLP"): + _bootstrap_with_bootnodes(genesis_yaml, "enr:YWJj") + + def test_mixed_inputs_preserve_order(self, genesis_yaml: Path) -> None: + """Mixed multiaddr and ENR inputs resolve into a single ordered tuple.""" + boot = _bootstrap_with_bootnodes( + genesis_yaml, + MULTIADDR_IPV4, + ENR_WITH_UDP, + "/ip4/10.0.0.1/udp/8000/quic-v1", + ) + assert boot.bootnode_multiaddrs == ( + MULTIADDR_IPV4, + "/ip4/192.168.1.1/udp/9000/quic-v1", + "/ip4/10.0.0.1/udp/8000/quic-v1", + ) + + def test_no_bootnodes_resolves_empty_tuple(self, genesis_yaml: Path) -> None: + """No bootnode flags resolve to an empty tuple.""" + assert _bootstrap_from_argv(genesis_yaml).bootnode_multiaddrs == () + + +class TestNodeBootstrapValidation: + """Tests for the CLI argument validator.""" + + def test_aggregator_without_validator_keys_rejected(self, tmp_path: Path) -> None: + """The aggregator flag requires a validator keys path.""" + genesis_path = tmp_path / "genesis.yaml" + genesis_path.write_text("GENESIS_TIME: 1000\nGENESIS_VALIDATORS: []\n") + + args = parse_args( + [ + "--genesis", + str(genesis_path), + "--is-aggregator", + ] + ) + + with pytest.raises(CliValidationError, match="--is-aggregator requires --validator-keys"): + NodeBootstrap.from_cli_args(args) + + +class TestAggregateSubnetIds: + """Tests for parsing the extra-subnets flag.""" + + def test_empty_string_parses_to_empty_tuple(self, genesis_yaml: Path) -> None: + """An empty extras string resolves to an empty subnet tuple.""" + boot = _bootstrap_from_argv(genesis_yaml, "--aggregate-subnet-ids", "") + assert boot.aggregate_subnet_ids == () + + def test_valid_extras_parse_into_tuple(self, genesis_yaml: Path, tmp_path: Path) -> None: + """A comma list with aggregator mode and a populated registry resolves in order.""" + keys_root = _write_aggregator_key_layout(tmp_path) + boot = _bootstrap_from_argv( + genesis_yaml, + "--validator-keys", + str(keys_root), + "--is-aggregator", + "--aggregate-subnet-ids", + "1,2,3", + ) + assert boot.aggregate_subnet_ids == (SubnetId(1), SubnetId(2), SubnetId(3)) + + def test_extras_without_aggregator_rejected(self, genesis_yaml: Path) -> None: + """Subnet extras without aggregator mode raise a typed validation error.""" + with pytest.raises(CliValidationError, match="requires --is-aggregator"): + _bootstrap_from_argv(genesis_yaml, "--aggregate-subnet-ids", "1,2,3") + + def test_malformed_extras_rejected(self, genesis_yaml: Path, tmp_path: Path) -> None: + """A non-integer token in the extras list raises a typed validation error.""" + # Why: + # The integer parser runs before any registry load, so any non-empty + # validator-keys path is enough to reach the parse branch. + with pytest.raises(CliValidationError, match="comma-separated integers"): + _bootstrap_from_argv( + genesis_yaml, + "--validator-keys", + str(tmp_path / "keys"), + "--is-aggregator", + "--aggregate-subnet-ids", + "1,abc,3", + ) + + +class TestApiConfigResolution: + """Tests for the api_config field on the boot configuration.""" + + def test_zero_port_disables_api(self, genesis_yaml: Path) -> None: + """A port of zero leaves the API configuration unset.""" + boot = _bootstrap_from_argv(genesis_yaml, "--api-port", "0") + assert boot.api_config is None + + def test_non_zero_port_enables_api(self, genesis_yaml: Path) -> None: + """A non-zero port produces an API configuration carrying that port.""" + boot = _bootstrap_from_argv(genesis_yaml, "--api-port", "5052") + assert boot.api_config == ApiServerConfig(port=5052) + + +class TestListenAddressResolution: + """Tests for the listen-address field on the boot configuration.""" + + def test_empty_listen_address_becomes_none(self, genesis_yaml: Path) -> None: + """An empty listen string resolves to no listener.""" + boot = _bootstrap_from_argv(genesis_yaml, "--listen", "") + assert boot.listen_addr is None + + +class TestBuildAnchor: + """Tests for the async anchor builder method.""" + + async def test_no_checkpoint_calls_from_genesis(self, genesis_yaml: Path) -> None: + """Without a checkpoint URL the boot delegates to the synchronous genesis builder.""" + boot = _bootstrap_from_argv(genesis_yaml) + + sentinel = object() + with patch( + "lean_spec.cli.bootstrap.Anchor.from_genesis", + return_value=sentinel, + ) as from_genesis: + anchor = await boot.build_anchor() + + assert anchor is sentinel + assert from_genesis.call_args.args == (boot.genesis,) + + async def test_checkpoint_url_calls_from_checkpoint(self, genesis_yaml: Path) -> None: + """With a checkpoint URL the boot delegates to the asynchronous checkpoint builder.""" + boot = _bootstrap_from_argv( + genesis_yaml, + "--checkpoint-sync-url", + "http://localhost:5052", + ) + + sentinel = object() + with patch( + "lean_spec.cli.bootstrap.Anchor.from_checkpoint", + new_callable=AsyncMock, + return_value=sentinel, + ) as from_checkpoint: + anchor = await boot.build_anchor() + + assert anchor is sentinel + await_args = from_checkpoint.await_args + assert await_args is not None + assert await_args.kwargs == { + "url": "http://localhost:5052", + "genesis": boot.genesis, + "fork": boot.fork, + "validator_id": boot.registry.primary_index(), + } diff --git a/tests/lean_spec/cli/test_main.py b/tests/lean_spec/cli/test_main.py new file mode 100644 index 000000000..a52d06294 --- /dev/null +++ b/tests/lean_spec/cli/test_main.py @@ -0,0 +1,33 @@ +"""Tests for the process entry point.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from lean_spec.cli import main + + +class TestMainEntry: + """Smoke tests for the process entry point.""" + + def test_help_exits_cleanly(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The standard help flag exits the process with status zero.""" + monkeypatch.setattr(sys, "argv", ["leanspec", "--help"]) + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 0 + + def test_missing_genesis_file_exits_non_zero( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """A non-existent genesis path triggers a non-zero exit before reaching the run loop.""" + missing = tmp_path / "does-not-exist.yaml" + monkeypatch.setattr(sys, "argv", ["leanspec", "--genesis", str(missing)]) + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 1 diff --git a/tests/lean_spec/cli/test_run.py b/tests/lean_spec/cli/test_run.py new file mode 100644 index 000000000..abfe3a2f6 --- /dev/null +++ b/tests/lean_spec/cli/test_run.py @@ -0,0 +1,116 @@ +"""Tests for the consensus node run sequence.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from consensus_testing.keys import XmssKeyManager + +from lean_spec.cli import NodeBootstrap, parse_args +from lean_spec.cli.run import _build_event_source +from lean_spec.forks import DEFAULT_REGISTRY +from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT +from lean_spec.subspecs.genesis import GenesisConfig +from lean_spec.subspecs.networking.gossipsub import GossipTopic +from lean_spec.subspecs.validator import ValidatorRegistry +from lean_spec.subspecs.validator.registry import ValidatorEntry +from lean_spec.types import Slot, ValidatorIndex + + +@pytest.fixture +def genesis_yaml(tmp_path: Path) -> Path: + """Write a minimal genesis YAML to a temporary path.""" + path = tmp_path / "genesis.yaml" + path.write_text("GENESIS_TIME: 1000\nGENESIS_VALIDATORS: []\n") + return path + + +def _make_event_source_mock() -> MagicMock: + """Construct a mock event source that records gossip subscriptions.""" + event_source = MagicMock() + event_source.set_network_name = MagicMock() + event_source.subscribe_gossip_topic = MagicMock() + return event_source + + +async def _run_build(boot: NodeBootstrap) -> tuple[MagicMock, list[str]]: + """Run the event-source builder against a mocked transport and capture subscriptions.""" + event_source = _make_event_source_mock() + with patch( + "lean_spec.cli.run.LiveNetworkEventSource.create", + new_callable=AsyncMock, + return_value=event_source, + ): + await _build_event_source(boot) + topics = [call.args[0] for call in event_source.subscribe_gossip_topic.call_args_list] + return event_source, topics + + +class TestBuildEventSourceBlockTopic: + """The block topic is always subscribed, regardless of validator state.""" + + async def test_block_topic_subscribed_exactly_once(self, genesis_yaml: Path) -> None: + """A bare boot configuration subscribes to the fork's block topic exactly once.""" + boot = NodeBootstrap.from_cli_args(parse_args(["--genesis", str(genesis_yaml)])) + block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + + _, topics = await _run_build(boot) + + assert topics == [block_topic] + + +class TestBuildEventSourcePassive: + """A passive (non-aggregator, no validators) node subscribes to no subnets.""" + + async def test_no_subnet_subscription_for_empty_registry(self, genesis_yaml: Path) -> None: + """A non-aggregator with no owned validators subscribes only to the block topic.""" + boot = NodeBootstrap.from_cli_args(parse_args(["--genesis", str(genesis_yaml)])) + block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + + _, topics = await _run_build(boot) + + assert topics == [block_topic] + + +class TestBuildEventSourceOwnedValidator: + """A node with one owned validator subscribes to its derived subnet.""" + + async def test_single_validator_subscribes_to_block_and_subnet( + self, genesis_yaml: Path + ) -> None: + """One owned validator adds exactly one attestation subnet topic.""" + km = XmssKeyManager.shared(max_slot=Slot(10)) + kp = km[ValidatorIndex(0)] + registry = ValidatorRegistry() + registry.add( + ValidatorEntry( + index=ValidatorIndex(0), + attestation_secret_key=kp.attestation_keypair.secret_key, + proposal_secret_key=kp.proposal_keypair.secret_key, + ) + ) + + # Skip the file-loading bootstrap path: it has its own tests. + # Build the boot configuration directly so this test exercises only the run-time wiring. + boot = NodeBootstrap( + genesis=GenesisConfig.model_validate({"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []}), + registry=registry, + fork=DEFAULT_REGISTRY.current, + bootnode_multiaddrs=(), + listen_addr=None, + checkpoint_sync_url=None, + node_id="lean_spec_0", + is_aggregator=False, + ) + + subnet_id = ValidatorIndex(0).compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) + block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + subnet_topic = GossipTopic.attestation_subnet( + boot.fork.GOSSIP_DIGEST, subnet_id + ).to_topic_id() + + _, topics = await _run_build(boot) + + assert topics == [block_topic, subnet_topic] diff --git a/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py b/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py new file mode 100644 index 000000000..6385ffa22 --- /dev/null +++ b/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py @@ -0,0 +1,65 @@ +"""Tests for the pure subnet subscription planner.""" + +from __future__ import annotations + +from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT +from lean_spec.subspecs.networking.gossipsub.subscription import ( + compute_subscription_subnets, +) +from lean_spec.types import SubnetId, ValidatorIndex + + +class TestComputeSubscriptionSubnets: + """Tests for the pure subnet planner.""" + + def test_no_validators_no_aggregator(self) -> None: + """A passive node subscribes to no attestation subnets.""" + assert ( + compute_subscription_subnets( + [], + committee_count=ATTESTATION_COMMITTEE_COUNT, + is_aggregator=False, + extra_subnets=(), + ) + == frozenset() + ) + + def test_validators_drive_subnets(self) -> None: + """Validator indices contribute their derived subnet ids.""" + indices = [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)] + expected = frozenset(i.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) for i in indices) + + assert ( + compute_subscription_subnets( + indices, + committee_count=ATTESTATION_COMMITTEE_COUNT, + is_aggregator=False, + extra_subnets=(), + ) + == expected + ) + + def test_extras_ignored_when_not_aggregator(self) -> None: + """Extra subnets do nothing on a non-aggregator node.""" + assert ( + compute_subscription_subnets( + [], + committee_count=ATTESTATION_COMMITTEE_COUNT, + is_aggregator=False, + extra_subnets=(SubnetId(7), SubnetId(8)), + ) + == frozenset() + ) + + def test_aggregator_unions_extras(self) -> None: + """Aggregator mode unions validator-derived and extra subnets.""" + indices = [ValidatorIndex(0)] + extras = (SubnetId(5), SubnetId(9)) + derived = {i.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) for i in indices} + + assert compute_subscription_subnets( + indices, + committee_count=ATTESTATION_COMMITTEE_COUNT, + is_aggregator=True, + extra_subnets=extras, + ) == frozenset(derived | set(extras)) diff --git a/tests/lean_spec/subspecs/node/__init__.py b/tests/lean_spec/subspecs/node/__init__.py new file mode 100644 index 000000000..487270697 --- /dev/null +++ b/tests/lean_spec/subspecs/node/__init__.py @@ -0,0 +1 @@ +"""Tests for node module.""" diff --git a/tests/lean_spec/subspecs/node/test_anchor.py b/tests/lean_spec/subspecs/node/test_anchor.py new file mode 100644 index 000000000..0659ca2fd --- /dev/null +++ b/tests/lean_spec/subspecs/node/test_anchor.py @@ -0,0 +1,128 @@ +"""Tests for the boot anchor builder.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from lean_spec.forks.lstar.spec import LstarSpec +from lean_spec.subspecs.genesis import GenesisConfig +from lean_spec.subspecs.node.anchor import Anchor +from lean_spec.subspecs.sync.checkpoint_sync import CheckpointSyncError +from lean_spec.types import Bytes32, Slot +from tests.lean_spec.helpers import make_genesis_state + + +class TestAnchorFromGenesis: + """Tests for the synchronous genesis anchor builder.""" + + def test_anchor_from_genesis_has_zero_status_and_no_store(self) -> None: + """Genesis anchors carry zero checkpoints and no pre-built store.""" + genesis = GenesisConfig.model_validate({"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []}) + + anchor = Anchor.from_genesis(genesis) + + assert anchor.store is None + assert anchor.initial_status.finalized.root == Bytes32.zero() + assert anchor.initial_status.finalized.slot == Slot(0) + assert anchor.initial_status.head.root == Bytes32.zero() + assert anchor.initial_status.head.slot == Slot(0) + assert anchor.validators == genesis.to_validators() + + +class TestAnchorFromCheckpoint: + """Tests for the asynchronous checkpoint anchor builder.""" + + async def test_genesis_time_mismatch_raises(self) -> None: + """Mismatched genesis time raises a typed CheckpointSyncError.""" + checkpoint_state = make_genesis_state(num_validators=3, genesis_time=1000) + local_genesis = GenesisConfig.model_validate( + {"GENESIS_TIME": 2000, "GENESIS_VALIDATORS": []} + ) + + with ( + patch( + "lean_spec.subspecs.node.anchor.fetch_finalized_state", + new_callable=AsyncMock, + return_value=checkpoint_state, + ), + pytest.raises(CheckpointSyncError, match="genesis time mismatch"), + ): + await Anchor.from_checkpoint( + url="http://localhost:5052", + genesis=local_genesis, + fork=LstarSpec(), + validator_id=None, + ) + + async def test_verification_failure_raises(self) -> None: + """Structural verification failure raises CheckpointSyncError.""" + checkpoint_state = make_genesis_state(num_validators=3, genesis_time=1000) + local_genesis = GenesisConfig.model_validate( + {"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []} + ) + + with ( + patch( + "lean_spec.subspecs.node.anchor.fetch_finalized_state", + new_callable=AsyncMock, + return_value=checkpoint_state, + ), + patch( + "lean_spec.subspecs.node.anchor.verify_checkpoint_state", + return_value=False, + ), + pytest.raises(CheckpointSyncError, match="structural verification"), + ): + await Anchor.from_checkpoint( + url="http://localhost:5052", + genesis=local_genesis, + fork=LstarSpec(), + validator_id=None, + ) + + async def test_network_error_propagates(self) -> None: + """Network errors surface as CheckpointSyncError.""" + local_genesis = GenesisConfig.model_validate( + {"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []} + ) + + with ( + patch( + "lean_spec.subspecs.node.anchor.fetch_finalized_state", + new_callable=AsyncMock, + side_effect=CheckpointSyncError("connection refused"), + ), + pytest.raises(CheckpointSyncError, match="connection refused"), + ): + await Anchor.from_checkpoint( + url="http://localhost:5052", + genesis=local_genesis, + fork=LstarSpec(), + validator_id=None, + ) + + async def test_success_builds_store_and_status(self) -> None: + """Successful checkpoint sync produces a populated anchor.""" + genesis_time = 1000 + checkpoint_state = make_genesis_state(num_validators=3, genesis_time=genesis_time) + local_genesis = GenesisConfig.model_validate( + {"GENESIS_TIME": genesis_time, "GENESIS_VALIDATORS": []} + ) + + with patch( + "lean_spec.subspecs.node.anchor.fetch_finalized_state", + new_callable=AsyncMock, + return_value=checkpoint_state, + ): + anchor = await Anchor.from_checkpoint( + url="http://localhost:5052", + genesis=local_genesis, + fork=LstarSpec(), + validator_id=None, + ) + + assert anchor.store is not None + assert anchor.validators == checkpoint_state.validators + assert anchor.initial_status.finalized == anchor.store.latest_finalized diff --git a/tests/lean_spec/subspecs/node/test_node.py b/tests/lean_spec/subspecs/node/test_node.py index c1967e654..b75700791 100644 --- a/tests/lean_spec/subspecs/node/test_node.py +++ b/tests/lean_spec/subspecs/node/test_node.py @@ -35,10 +35,16 @@ ) from lean_spec.subspecs.node import Node, NodeConfig from lean_spec.subspecs.storage.sqlite import SQLiteDatabase +from lean_spec.subspecs.sync.checkpoint_sync import create_anchor_block from lean_spec.subspecs.validator import ValidatorRegistry from lean_spec.subspecs.validator.registry import ValidatorEntry from lean_spec.types import Bytes32, Checkpoint, Slot, Uint64, ValidatorIndex -from tests.lean_spec.helpers import MockEventSource, MockNetworkRequester, make_validators +from tests.lean_spec.helpers import ( + MockEventSource, + MockNetworkRequester, + make_genesis_state, + make_validators, +) GENESIS_TIME = Uint64(1704067200) @@ -730,3 +736,26 @@ def test_sync_service_receives_store_from_genesis(self, node_config: NodeConfig) head_block = node.sync_service.store.blocks[node.sync_service.store.head] assert head_block.slot == Slot(0) + + +class TestNodeFromGenesisAnchorStore: + """Regression guard for the anchor_store wiring on NodeConfig.""" + + def test_anchor_store_is_used_when_provided(self, spec: LstarSpec) -> None: + """The node adopts the provided anchor store rather than synthesising one.""" + state = make_genesis_state(num_validators=3, genesis_time=1000) + anchor_block = create_anchor_block(state) + anchor_store = spec.create_store(state, anchor_block, None) + + config = NodeConfig( + genesis_time=Uint64(1000), + validators=state.validators, + event_source=MockEventSource(), + network=MockNetworkRequester(), + fork=spec, + anchor_store=anchor_store, + ) + + node = Node.from_genesis(config) + + assert node.store is anchor_store diff --git a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py index 4903e5589..5e7863f8d 100644 --- a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py +++ b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py @@ -8,16 +8,22 @@ import pytest from lean_spec.forks.lstar import State, Store +from lean_spec.forks.lstar.containers import Block, BlockBody +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import Validators +from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.api import ApiServer, ApiServerConfig from lean_spec.subspecs.chain.config import VALIDATOR_REGISTRY_LIMIT +from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.sync.checkpoint_sync import ( FINALIZED_STATE_ENDPOINT, CheckpointSyncError, + create_anchor_block, fetch_finalized_state, verify_checkpoint_state, ) -from lean_spec.types import Slot +from lean_spec.types import Bytes32, Slot, Uint64 +from tests.lean_spec.helpers import make_genesis_state class _MockTransport(httpx.AsyncBaseTransport): @@ -220,3 +226,61 @@ async def test_client_fetches_and_deserializes_state(self, base_store: Store) -> finally: await server.aclose() + + +class TestCreateAnchorBlock: + """Tests for the anchor-block reconstruction helper.""" + + def test_computes_state_root_when_zero(self) -> None: + """State root is computed when header has zero state root.""" + state = make_genesis_state(num_validators=3, genesis_time=1000) + assert state.latest_block_header.state_root == Bytes32.zero() + + anchor_block = create_anchor_block(state) + + expected_state_root = hash_tree_root(state) + assert anchor_block.state_root == expected_state_root + assert anchor_block.state_root != Bytes32.zero() + + def test_preserves_non_zero_state_root(self, spec: LstarSpec) -> None: + """Non-zero state root in header is preserved.""" + state = make_genesis_state(num_validators=3, genesis_time=1000) + state_with_root = spec.process_slots(state, Slot(1)) + assert state_with_root.latest_block_header.state_root != Bytes32.zero() + + anchor_block = create_anchor_block(state_with_root) + + assert anchor_block.state_root == state_with_root.latest_block_header.state_root + + def test_preserves_header_fields(self) -> None: + """Slot, proposer_index, and parent_root are preserved from header.""" + state = make_genesis_state(num_validators=3, genesis_time=1000) + header = state.latest_block_header + + anchor_block = create_anchor_block(state) + + assert anchor_block.slot == header.slot + assert anchor_block.proposer_index == header.proposer_index + assert anchor_block.parent_root == header.parent_root + + def test_creates_empty_body(self) -> None: + """Block body contains empty attestations list.""" + state = make_genesis_state(num_validators=3, genesis_time=1000) + + anchor_block = create_anchor_block(state) + + assert len(anchor_block.body.attestations) == 0 + + def test_anchor_block_structure_is_valid(self) -> None: + """Anchor block has all required fields populated.""" + state = make_genesis_state(num_validators=5, genesis_time=2000) + + anchor_block = create_anchor_block(state) + + assert isinstance(anchor_block, Block) + assert isinstance(anchor_block.slot, Slot) + assert isinstance(anchor_block.proposer_index, Uint64) + assert isinstance(anchor_block.parent_root, Bytes32) + assert isinstance(anchor_block.state_root, Bytes32) + assert isinstance(anchor_block.body, BlockBody) + assert isinstance(anchor_block.body.attestations, AggregatedAttestations) diff --git a/tests/lean_spec/subspecs/validator/test_registry.py b/tests/lean_spec/subspecs/validator/test_registry.py index 86ca06807..ba600c83e 100644 --- a/tests/lean_spec/subspecs/validator/test_registry.py +++ b/tests/lean_spec/subspecs/validator/test_registry.py @@ -539,3 +539,12 @@ def test_only_assigned_node_keys_are_loaded(self, tmp_path: Path, km: XmssKeyMan assert set(registry.indices()) == {ValidatorIndex(0)} assert ValidatorIndex(1) not in registry assert ValidatorIndex(2) not in registry + + +class TestValidatorRegistryFromKeysDirectory: + """Tests for the ream/zeam-layout registry loader.""" + + def test_missing_manifest_raises(self, tmp_path: Path) -> None: + """The loader raises when the manifest file is absent.""" + with pytest.raises(FileNotFoundError, match="Validator keys manifest not found"): + ValidatorRegistry.from_keys_directory(node_id="lean_spec_0", base_dir=tmp_path) diff --git a/tests/lean_spec/test_cli.py b/tests/lean_spec/test_cli.py deleted file mode 100644 index 4cd76fba2..000000000 --- a/tests/lean_spec/test_cli.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Tests for CLI functions. - -Tests the ENR detection, bootnode resolution, and checkpoint sync functionality -used by the CLI. -""" - -from __future__ import annotations - -import base64 -from unittest.mock import AsyncMock, patch - -import pytest -from Crypto.Hash import keccak -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature - -from lean_spec.__main__ import ( - _init_from_checkpoint, - create_anchor_block, - resolve_bootnode, -) -from lean_spec.forks.lstar.containers import ( - Block, - BlockBody, -) -from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations -from lean_spec.forks.lstar.spec import LstarSpec -from lean_spec.subspecs.genesis import GenesisConfig -from lean_spec.subspecs.node import Node -from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.sync.checkpoint_sync import CheckpointSyncError -from lean_spec.types import Bytes32, Slot, Uint64 -from lean_spec.types.rlp import RLPItem, encode_rlp -from tests.lean_spec.helpers import make_genesis_state - -# Generate a test keypair once for all ENR tests. -_TEST_PRIVATE_KEY = ec.generate_private_key(ec.SECP256K1()) -_TEST_PUBLIC_KEY = _TEST_PRIVATE_KEY.public_key() -_TEST_COMPRESSED_PUBKEY = _TEST_PUBLIC_KEY.public_bytes( - encoding=serialization.Encoding.X962, - format=serialization.PublicFormat.CompressedPoint, -) - - -def _sign_enr_content(content_items: list[RLPItem]) -> bytes: - """Sign ENR content and return 64-byte r||s signature.""" - content_rlp = encode_rlp(content_items) - - k = keccak.new(digest_bits=256) - k.update(content_rlp) - digest = k.digest() - - signature_der = _TEST_PRIVATE_KEY.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256()))) - r, s = decode_dss_signature(signature_der) - return r.to_bytes(32, "big") + s.to_bytes(32, "big") - - -def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: - """Create a properly signed ENR string with IPv4 and UDP port.""" - # Content items (keys must be sorted). - content_items: list[RLPItem] = [ - b"\x01", # seq = 1 - b"id", - b"v4", - b"ip", - ip_bytes, - b"secp256k1", - _TEST_COMPRESSED_PUBKEY, - b"udp", - udp_port.to_bytes(2, "big"), - ] - signature = _sign_enr_content(content_items) - - rlp_data = encode_rlp([signature] + content_items) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - return f"enr:{b64_content}" - - -def _make_enr_without_udp(ip_bytes: bytes) -> str: - """Create a properly signed ENR string with IPv4 but no UDP port.""" - content_items: list[RLPItem] = [ - b"\x01", # seq = 1 - b"id", - b"v4", - b"ip", - ip_bytes, - b"secp256k1", - _TEST_COMPRESSED_PUBKEY, - ] - signature = _sign_enr_content(content_items) - - rlp_data = encode_rlp([signature] + content_items) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - return f"enr:{b64_content}" - - -# Pre-built test ENRs -ENR_WITH_UDP = _make_enr_with_udp(b"\xc0\xa8\x01\x01", 9000) # 192.168.1.1:9000 -ENR_WITHOUT_UDP = _make_enr_without_udp(b"\xc0\xa8\x01\x01") # 192.168.1.1, no UDP - -# Valid multiaddr strings (QUIC format) -MULTIADDR_IPV4 = "/ip4/127.0.0.1/udp/9000/quic-v1" - - -class TestResolveBootnode: - """Tests for resolve_bootnode() resolution function.""" - - def test_resolve_multiaddr_unchanged(self) -> None: - """Multiaddr strings are returned unchanged.""" - assert resolve_bootnode(MULTIADDR_IPV4) == MULTIADDR_IPV4 - - def test_resolve_arbitrary_multiaddr_unchanged(self) -> None: - """Any non-ENR string passes through unchanged.""" - # The function does not validate multiaddr format - arbitrary = "/some/arbitrary/path" - assert resolve_bootnode(arbitrary) == arbitrary - - def test_resolve_valid_enr_with_udp(self) -> None: - """ENR with IPv4+UDP extracts QUIC multiaddr correctly.""" - result = resolve_bootnode(ENR_WITH_UDP) - assert result == "/ip4/192.168.1.1/udp/9000/quic-v1" - - def test_resolve_enr_without_udp_raises(self) -> None: - """ENR without UDP port raises ValueError.""" - with pytest.raises(ValueError, match=r"no UDP connection info"): - resolve_bootnode(ENR_WITHOUT_UDP) - - def test_resolve_invalid_enr_raises(self) -> None: - """Malformed ENR raises ValueError.""" - # Valid base64 but invalid RLP structure - with pytest.raises(ValueError, match=r"Invalid RLP"): - resolve_bootnode("enr:YWJj") # "abc" in base64, not valid RLP structure - - # Another invalid RLP - too short for ENR - with pytest.raises(ValueError, match=r"(Invalid RLP|at least signature)"): - resolve_bootnode("enr:wA") # Single byte 0xc0 = empty list - - def test_resolve_enr_prefix_only_raises(self) -> None: - """ENR with prefix only (no content) raises ValueError.""" - with pytest.raises(ValueError): - resolve_bootnode("enr:") - - def test_resolve_enr_with_different_ports(self) -> None: - """ENR resolution handles various port numbers.""" - # Port 30303 - enr_30303 = _make_enr_with_udp(b"\x7f\x00\x00\x01", 30303) - result = resolve_bootnode(enr_30303) - assert result == "/ip4/127.0.0.1/udp/30303/quic-v1" - - # Port 1 (minimum valid) - enr_1 = _make_enr_with_udp(b"\x7f\x00\x00\x01", 1) - result = resolve_bootnode(enr_1) - assert result == "/ip4/127.0.0.1/udp/1/quic-v1" - - # Port 65535 (maximum) - enr_max = _make_enr_with_udp(b"\x7f\x00\x00\x01", 65535) - result = resolve_bootnode(enr_max) - assert result == "/ip4/127.0.0.1/udp/65535/quic-v1" - - def test_resolve_enr_with_different_ips(self) -> None: - """ENR resolution handles various IPv4 addresses.""" - test_cases = [ - (b"\x00\x00\x00\x00", "0.0.0.0"), - (b"\xff\xff\xff\xff", "255.255.255.255"), - (b"\x0a\x00\x00\x01", "10.0.0.1"), - ] - for ip_bytes, expected_ip in test_cases: - enr = _make_enr_with_udp(ip_bytes, 9000) - result = resolve_bootnode(enr) - assert result == f"/ip4/{expected_ip}/udp/9000/quic-v1" - - -class TestMixedBootnodes: - """Integration tests for mixed bootnode types.""" - - def test_mixed_bootnodes_list(self) -> None: - """Process a list containing both ENR and multiaddr.""" - bootnodes = [ - MULTIADDR_IPV4, - ENR_WITH_UDP, - "/ip4/10.0.0.1/udp/8000/quic-v1", - ] - - resolved = [resolve_bootnode(b) for b in bootnodes] - - assert resolved[0] == MULTIADDR_IPV4 - assert resolved[1] == "/ip4/192.168.1.1/udp/9000/quic-v1" - assert resolved[2] == "/ip4/10.0.0.1/udp/8000/quic-v1" - - def test_filter_invalid_enrs(self) -> None: - """Demonstrate filtering out invalid ENRs from a bootnode list.""" - bootnodes = [ - MULTIADDR_IPV4, - ENR_WITHOUT_UDP, # Invalid - no UDP - ENR_WITH_UDP, - ] - - resolved = [] - for bootnode in bootnodes: - try: - resolved.append(resolve_bootnode(bootnode)) - except ValueError: - continue # Skip invalid - - assert len(resolved) == 2 - assert resolved[0] == MULTIADDR_IPV4 - assert resolved[1] == "/ip4/192.168.1.1/udp/9000/quic-v1" - - -class TestCreateAnchorBlock: - """Tests for create_anchor_block() function.""" - - def test_computes_state_root_when_zero(self) -> None: - """State root is computed when header has zero state root.""" - # Arrange: Create a genesis state (header has zero state root) - state = make_genesis_state(num_validators=3, genesis_time=1000) - - # Verify the header has zero state root - assert state.latest_block_header.state_root == Bytes32.zero() - - # Act - anchor_block = create_anchor_block(state) - - # Assert: State root should be computed from the state - expected_state_root = hash_tree_root(state) - assert anchor_block.state_root == expected_state_root - assert anchor_block.state_root != Bytes32.zero() - - def test_preserves_non_zero_state_root(self, spec: LstarSpec) -> None: - """Non-zero state root in header is preserved.""" - # Arrange: Create a state and process a slot to fill in state root - state = make_genesis_state(num_validators=3, genesis_time=1000) - # Process slot advances and fills in the state root - state_with_root = spec.process_slots(state, Slot(1)) - - # The state root should now be non-zero in the header - assert state_with_root.latest_block_header.state_root != Bytes32.zero() - - # Act - anchor_block = create_anchor_block(state_with_root) - - # Assert: State root is preserved from the header - assert anchor_block.state_root == state_with_root.latest_block_header.state_root - - def test_preserves_header_fields(self) -> None: - """Slot, proposer_index, and parent_root are preserved from header.""" - # Arrange - state = make_genesis_state(num_validators=3, genesis_time=1000) - header = state.latest_block_header - - # Act - anchor_block = create_anchor_block(state) - - # Assert: Core header fields are preserved - assert anchor_block.slot == header.slot - assert anchor_block.proposer_index == header.proposer_index - assert anchor_block.parent_root == header.parent_root - - def test_creates_empty_body(self) -> None: - """Block body contains empty attestations list.""" - # Arrange - state = make_genesis_state(num_validators=3, genesis_time=1000) - - # Act - anchor_block = create_anchor_block(state) - - # Assert: Body has empty attestations - assert len(anchor_block.body.attestations) == 0 - - def test_anchor_block_structure_is_valid(self) -> None: - """Anchor block has all required fields populated.""" - # Arrange - state = make_genesis_state(num_validators=5, genesis_time=2000) - - # Act - anchor_block = create_anchor_block(state) - - # Assert: Block has proper structure - assert isinstance(anchor_block, Block) - assert isinstance(anchor_block.slot, Slot) - assert isinstance(anchor_block.proposer_index, Uint64) - assert isinstance(anchor_block.parent_root, Bytes32) - assert isinstance(anchor_block.state_root, Bytes32) - assert isinstance(anchor_block.body, BlockBody) - assert isinstance(anchor_block.body.attestations, AggregatedAttestations) - - -class TestInitFromCheckpoint: - """Tests for _init_from_checkpoint() async function.""" - - async def test_checkpoint_sync_genesis_time_mismatch_returns_none(self) -> None: - """Returns None when checkpoint state genesis time differs from local config.""" - # Arrange: Create checkpoint state with genesis_time=1000 - checkpoint_state = make_genesis_state(num_validators=3, genesis_time=1000) - - # Local genesis config with different genesis_time=2000 - local_genesis = GenesisConfig.model_validate( - { - "GENESIS_TIME": 2000, - "GENESIS_VALIDATORS": [], - } - ) - - mock_event_source = AsyncMock() - - with patch( - "lean_spec.__main__.fetch_finalized_state", - new_callable=AsyncMock, - return_value=checkpoint_state, - ): - result = await _init_from_checkpoint( - checkpoint_sync_url="http://localhost:5052", - genesis=local_genesis, - event_source=mock_event_source, - fork=LstarSpec(), - ) - - # Returns None due to genesis time mismatch - assert result is None - - async def test_checkpoint_sync_verification_failure_returns_none(self) -> None: - """Returns None when checkpoint state verification fails.""" - # Arrange - checkpoint_state = make_genesis_state(num_validators=3, genesis_time=1000) - local_genesis = GenesisConfig.model_validate( - { - "GENESIS_TIME": 1000, - "GENESIS_VALIDATORS": [], - } - ) - - mock_event_source = AsyncMock() - - with ( - patch( - "lean_spec.__main__.fetch_finalized_state", - new_callable=AsyncMock, - return_value=checkpoint_state, - ), - patch( - "lean_spec.__main__.verify_checkpoint_state", - return_value=False, # Verification fails - ), - ): - # Act - result = await _init_from_checkpoint( - checkpoint_sync_url="http://localhost:5052", - genesis=local_genesis, - event_source=mock_event_source, - fork=LstarSpec(), - ) - - # Assert - assert result is None - - async def test_checkpoint_sync_network_error_returns_none(self) -> None: - """Returns None when network error occurs during fetch.""" - # Arrange - local_genesis = GenesisConfig.model_validate( - { - "GENESIS_TIME": 1000, - "GENESIS_VALIDATORS": [], - } - ) - - mock_event_source = AsyncMock() - - with patch( - "lean_spec.__main__.fetch_finalized_state", - new_callable=AsyncMock, - side_effect=CheckpointSyncError("Network error: connection refused"), - ): - # Act - result = await _init_from_checkpoint( - checkpoint_sync_url="http://localhost:5052", - genesis=local_genesis, - event_source=mock_event_source, - fork=LstarSpec(), - ) - - # Assert - assert result is None - - async def test_checkpoint_sync_success_returns_node(self) -> None: - """Successful checkpoint sync returns initialized Node.""" - # Arrange: Create matching genesis times - genesis_time = 1000 - checkpoint_state = make_genesis_state(num_validators=3, genesis_time=genesis_time) - - local_genesis = GenesisConfig.model_validate( - { - "GENESIS_TIME": genesis_time, - "GENESIS_VALIDATORS": [], - } - ) - - # Create a mock event source with required attributes - mock_event_source = AsyncMock() - mock_event_source.reqresp_client = AsyncMock() - - with patch( - "lean_spec.__main__.fetch_finalized_state", - new_callable=AsyncMock, - return_value=checkpoint_state, - ): - result = await _init_from_checkpoint( - checkpoint_sync_url="http://localhost:5052", - genesis=local_genesis, - event_source=mock_event_source, - fork=LstarSpec(), - ) - - assert result is not None - assert isinstance(result, Node) - - # Verify the node's store was initialized - assert result.store is not None - - async def test_checkpoint_sync_http_status_error_returns_none(self) -> None: - """Returns None when HTTP status error occurs.""" - # Arrange - local_genesis = GenesisConfig.model_validate( - { - "GENESIS_TIME": 1000, - "GENESIS_VALIDATORS": [], - } - ) - - mock_event_source = AsyncMock() - - with patch( - "lean_spec.__main__.fetch_finalized_state", - new_callable=AsyncMock, - side_effect=CheckpointSyncError("HTTP error 404: Not Found"), - ): - # Act - result = await _init_from_checkpoint( - checkpoint_sync_url="http://localhost:5052", - genesis=local_genesis, - event_source=mock_event_source, - fork=LstarSpec(), - ) - - # Assert - assert result is None From c1a798c33c07399a493298468327ac9615de1355 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:01:57 +0200 Subject: [PATCH 2/9] refactor(cli): inline compute_subscription_subnets into run.py The helper had one call site, a four-line body, and a dedicated test file. Inlining it into the event-source builder removes a file, a function, and a test module without losing any behavioural coverage, since the surviving event-source tests already exercise the empty-registry, validator-owned, and aggregator-extra paths through the surrounding call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/cli/run.py | 21 +++--- .../networking/gossipsub/subscription.py | 53 --------------- .../networking/gossipsub/test_subscription.py | 65 ------------------- 3 files changed, 11 insertions(+), 128 deletions(-) delete mode 100644 src/lean_spec/subspecs/networking/gossipsub/subscription.py delete mode 100644 tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py diff --git a/src/lean_spec/cli/run.py b/src/lean_spec/cli/run.py index f262e2fe8..8334bf03f 100644 --- a/src/lean_spec/cli/run.py +++ b/src/lean_spec/cli/run.py @@ -21,11 +21,9 @@ from lean_spec.subspecs.metrics import PrometheusObserver, registry as metrics from lean_spec.subspecs.networking.client import LiveNetworkEventSource from lean_spec.subspecs.networking.gossipsub import GossipTopic -from lean_spec.subspecs.networking.gossipsub.subscription import ( - compute_subscription_subnets, -) from lean_spec.subspecs.node import Node, NodeConfig from lean_spec.subspecs.observability import set_observer +from lean_spec.types import SubnetId from .bootstrap import NodeBootstrap @@ -45,13 +43,16 @@ async def _build_event_source(boot: NodeBootstrap) -> LiveNetworkEventSource: event_source.subscribe_gossip_topic(block_topic) logger.info("Subscribed to block gossip topic: %s", block_topic) - # Derive the attestation subnets from owned validators and aggregator extras. - subnets = compute_subscription_subnets( - boot.registry.indices(), - committee_count=ATTESTATION_COMMITTEE_COUNT, - is_aggregator=boot.is_aggregator, - extra_subnets=boot.aggregate_subnet_ids, - ) + # Validator-owned subnets carry the attestations the mesh must propagate. + # Missing one of them breaks the attestation flow for that validator. + subnets: set[SubnetId] = { + idx.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) for idx in boot.registry.indices() + } + + # Aggregator extras are advisory subnets the operator wants this node to see. + # They are ignored on non-aggregator nodes, which the bootstrap layer enforces. + if boot.is_aggregator: + subnets.update(boot.aggregate_subnet_ids) # Subscribe to every owned subnet under its fork-scoped topic id. for subnet_id in subnets: diff --git a/src/lean_spec/subspecs/networking/gossipsub/subscription.py b/src/lean_spec/subspecs/networking/gossipsub/subscription.py deleted file mode 100644 index 2c9cdfed7..000000000 --- a/src/lean_spec/subspecs/networking/gossipsub/subscription.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Subnet subscription planning for the node. - -Maps validator identity and aggregator role to the set of attestation -subnets the node must subscribe to at boot. Separated from networking -lifecycle so it can be tested as a pure function and so the spec rule -("aggregators advertise extra subnets, validators always sit on their -own subnet") lives in one readable place. -""" - -from __future__ import annotations - -from collections.abc import Iterable - -from lean_spec.types import SubnetId, Uint64, ValidatorIndex - - -def compute_subscription_subnets( - validator_indices: Iterable[ValidatorIndex], - *, - committee_count: Uint64, - is_aggregator: bool, - extra_subnets: Iterable[SubnetId] = (), -) -> frozenset[SubnetId]: - """ - Compute the set of attestation subnets the node must subscribe to. - - Validator-derived subnets are the load-bearing ones: missing them - breaks the attestation mesh for the owned validators. The aggregator - extras are advisory subnets the operator wants this node to also see. - - Args: - validator_indices: Validator indices this node owns secret keys for. - committee_count: ATTESTATION_COMMITTEE_COUNT for the active fork; - decides which subnet a given validator hashes to. - is_aggregator: True if the node is configured to aggregate. When - False, extra_subnets is ignored even if non-empty. - extra_subnets: Additional subnets requested by the operator (only - consulted when is_aggregator is True). - - Returns: - Immutable set of subnet ids; empty when there are no validators and - the node is not configured as an aggregator. - - Notes: - The spec is silent on the aggregator-without-validators corner; - callers are expected to reject that combination upstream rather - than expecting a magic fallback here. - """ - derived = {idx.compute_subnet_id(committee_count) for idx in validator_indices} - if is_aggregator: - derived.update(extra_subnets) - return frozenset(derived) diff --git a/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py b/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py deleted file mode 100644 index 6385ffa22..000000000 --- a/tests/lean_spec/subspecs/networking/gossipsub/test_subscription.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for the pure subnet subscription planner.""" - -from __future__ import annotations - -from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT -from lean_spec.subspecs.networking.gossipsub.subscription import ( - compute_subscription_subnets, -) -from lean_spec.types import SubnetId, ValidatorIndex - - -class TestComputeSubscriptionSubnets: - """Tests for the pure subnet planner.""" - - def test_no_validators_no_aggregator(self) -> None: - """A passive node subscribes to no attestation subnets.""" - assert ( - compute_subscription_subnets( - [], - committee_count=ATTESTATION_COMMITTEE_COUNT, - is_aggregator=False, - extra_subnets=(), - ) - == frozenset() - ) - - def test_validators_drive_subnets(self) -> None: - """Validator indices contribute their derived subnet ids.""" - indices = [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)] - expected = frozenset(i.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) for i in indices) - - assert ( - compute_subscription_subnets( - indices, - committee_count=ATTESTATION_COMMITTEE_COUNT, - is_aggregator=False, - extra_subnets=(), - ) - == expected - ) - - def test_extras_ignored_when_not_aggregator(self) -> None: - """Extra subnets do nothing on a non-aggregator node.""" - assert ( - compute_subscription_subnets( - [], - committee_count=ATTESTATION_COMMITTEE_COUNT, - is_aggregator=False, - extra_subnets=(SubnetId(7), SubnetId(8)), - ) - == frozenset() - ) - - def test_aggregator_unions_extras(self) -> None: - """Aggregator mode unions validator-derived and extra subnets.""" - indices = [ValidatorIndex(0)] - extras = (SubnetId(5), SubnetId(9)) - derived = {i.compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) for i in indices} - - assert compute_subscription_subnets( - indices, - committee_count=ATTESTATION_COMMITTEE_COUNT, - is_aggregator=True, - extra_subnets=extras, - ) == frozenset(derived | set(extras)) From ec5d9f087d104e913437de0d5a8a7e83ad209fdb Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:06:01 +0200 Subject: [PATCH 3/9] refactor(anchor): tighten module docs and comments Collapse the multi-paragraph class docstring and bullet field descriptions into single-line forms, split multi-clause sentences across lines, and trim the three inline rationale comments. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/subspecs/node/anchor.py | 82 +++++++++------------------ 1 file changed, 28 insertions(+), 54 deletions(-) diff --git a/src/lean_spec/subspecs/node/anchor.py b/src/lean_spec/subspecs/node/anchor.py index 6a07822b2..09c01ada6 100644 --- a/src/lean_spec/subspecs/node/anchor.py +++ b/src/lean_spec/subspecs/node/anchor.py @@ -1,19 +1,13 @@ """ Boot anchor for the lean consensus node. -The forkchoice store starts at a trusted block plus the matching state. -Two sources are supported: - -- Genesis: build the store from the genesis validator set the first time - the node starts. No network round-trip; no state to verify. -- Checkpoint: fetch a finalized state from a peer, verify it, and build - the forkchoice store from that state. The validators inside the state - replace the genesis validator set as the source of truth. - -Both paths land on the same shape so the rest of the boot sequence does -not branch on the anchor source. The protocol does not distinguish -"started from genesis" from "started from a finalized checkpoint" once -the store exists. +The forkchoice store starts at a trusted block plus its matching state. +Two sources land on the same return shape: + +- Genesis: synthesise the store from the genesis validator set. +- Checkpoint: fetch a finalized state from a peer and build the store from it. + +Once the store exists the protocol cannot tell the two sources apart. """ from __future__ import annotations @@ -33,44 +27,32 @@ class Anchor(StrictBaseModel): - """ - Starting state for the node's forkchoice store. - - Carries the three values the boot sequence needs to construct a Node - and announce itself to peers: - - - validators: the validator set the store will see at slot zero of - this run (either genesis or the checkpointed state's validators). - - store: a pre-built forkchoice store on the checkpoint path, or None - to ask Node.from_genesis to synthesize one from validators. - - initial_status: the Status broadcast to peers before the listener - starts serving inbound ReqResp queries. - """ + """Starting state passed to the boot sequence regardless of the anchor source.""" validators: Validators - """Validator set to wire into NodeConfig.validators.""" + """Validator set the store sees at slot zero.""" store: Store | None - """Pre-built forkchoice store, or None to synthesize from validators.""" + """Pre-built forkchoice store. + + A value of None asks the node to synthesise one from the validator set.""" initial_status: Status - """Status to publish on the event source before serving inbound traffic.""" + """Status broadcast to peers before the listener starts serving.""" @classmethod def from_genesis(cls, genesis: GenesisConfig) -> Anchor: """ Build an anchor from a fresh genesis configuration. - No store is constructed here: Node.from_genesis synthesizes one - from the validator set. The initial status carries zero roots and - slot zero because the genesis block's identity is not yet computed - at this point in the boot sequence. + The store is left as None so the node can synthesise it from validators. + The status carries zero roots because the genesis block has no id yet. Args: genesis: Genesis YAML loaded from disk. Returns: - An anchor that asks the node to synthesize its own store. + An anchor that asks the node to synthesise its own store. """ zero_checkpoint = Checkpoint(root=Bytes32.zero(), slot=Slot(0)) return cls( @@ -90,35 +72,27 @@ async def from_checkpoint( """ Build an anchor by fetching a finalized state from a peer. - The fetched state replaces the genesis validator set: deposits and - exits since genesis are baked into state.validators, so we use - that as the source of truth. + The fetched state replaces the genesis validator set. + Deposits and exits since genesis are already baked into it. Args: url: HTTP endpoint of the node serving the checkpoint state. - genesis: Local genesis. Only its genesis_time is consulted, as - a chain-identity guard against syncing to the wrong network. - fork: Fork specification driving state/store construction. - validator_id: Local validator index used as a forkchoice - tiebreaker hint. Same value passed on the genesis path. + genesis: Local genesis used as a chain-identity guard via genesis time. + fork: Fork specification driving state and store construction. + validator_id: Local validator index used as a forkchoice tiebreaker hint. Raises: - CheckpointSyncError: For every failure mode (HTTP transport, - structural verification, genesis-time mismatch). Callers - see one typed exception instead of three implicit branches. + CheckpointSyncError: For every failure mode covering transport, + structural verification, and genesis-time mismatch. """ state = await fetch_finalized_state(url, fork.state_class) - # Defense in depth even though we trust the source: catches a - # corrupted download or a misconfigured server before the bad state - # contaminates the forkchoice store. + # Catches a corrupt download before it contaminates the forkchoice store. if not verify_checkpoint_state(state): raise CheckpointSyncError("checkpoint state failed structural verification") - # Genesis time is the only chain-identity guard we can apply at - # this layer. A mismatch means the checkpoint belongs to a different - # network; refusing to start is safer than silently corrupting the - # node's view of history. + # Genesis time is the only chain-identity check available at this layer. + # A mismatch means the checkpoint belongs to a different network. if state.config.genesis_time != genesis.genesis_time: raise CheckpointSyncError( f"genesis time mismatch: checkpoint={state.config.genesis_time}, " @@ -126,8 +100,8 @@ async def from_checkpoint( ) anchor_block = create_anchor_block(state) - # The fork protocol returns the structural Store contract; the - # concrete Store is the only one wired into NodeConfig today. + + # The protocol return type is structural, but only one concrete store ships. store = cast(Store, fork.create_store(state, anchor_block, validator_id)) head_slot = store.blocks[store.head].slot From 43d22702932321874e410447a5ea4d9daaa7293c Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:10:39 +0200 Subject: [PATCH 4/9] refactor: tighten docs on start_serving, anchor_store, and Node.from_genesis Strip multi-clause sentences and dense prose blocks from three places: - LiveNetworkEventSource.start_serving: collapse the five-step rationale into one-line bullets; rewrite the three inline comments as one sentence per line, drop ReqResp-specific protocol citations from the bullet text since they live in the inline comments. - NodeConfig.anchor_store docstring: drop the redundant intro sentence; one sentence per line throughout. - Node.from_genesis anchor-store branch: collapse the three-line rationale into two single-clause lines. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../networking/client/event_source/live.py | 43 +++++++------------ src/lean_spec/subspecs/node/node.py | 22 ++++------ 2 files changed, 25 insertions(+), 40 deletions(-) diff --git a/src/lean_spec/subspecs/networking/client/event_source/live.py b/src/lean_spec/subspecs/networking/client/event_source/live.py index f1eafdea8..a3bc31888 100644 --- a/src/lean_spec/subspecs/networking/client/event_source/live.py +++ b/src/lean_spec/subspecs/networking/client/event_source/live.py @@ -385,39 +385,30 @@ async def start_serving( """ Bring the event source online in the spec-required order. - The order matters for ReqResp correctness: - - 1. Status must be set before any peer can hit the responder, or - BlocksByRoot / BlocksByRange queries return SERVER_ERROR. - 2. The current-slot lookup must be wired before BlocksByRange - serves, for the same reason. - 3. Bootnodes are dialed best-effort; failures log but do not abort. - A peerless node is still a valid honest participant. - 4. The listener binds the local port. We give it a 100 ms head start - so a bind error (port in use) surfaces here rather than silently - in the background. - 5. Gossipsub starts last so the heartbeat does not run before peers - are reachable. + Five steps, each a precondition for the next: + + 1. Set the Status the responder serves. + 2. Wire the current-slot lookup the range queries depend on. + 3. Dial bootnodes best-effort, since a peerless honest node remains valid. + 4. Bind the listener with a short bind-error probe window. + 5. Start gossipsub last so the heartbeat reaches reachable peers only. Args: - status: Initial Status (finalized, head) the responder serves. - current_slot_lookup: Wall-clock-to-slot callback for ReqResp range bounds. - listen_addr: Multiaddr to bind for inbound connections, or None to - run dial-only. - bootnode_multiaddrs: Pre-resolved outbound peers. Caller is - expected to have parsed ENR strings into multiaddrs already. + status: Initial finalized and head checkpoints the responder serves. + current_slot_lookup: Wall-clock-to-slot callback for range bounds. + listen_addr: Multiaddr to bind for inbound connections, or None for dial-only. + bootnode_multiaddrs: Pre-resolved outbound peers. Raises: OSError: If the listener fails to bind within the probe window. """ - # The set-state-before-serving invariant: every other setter is - # idempotent, these two are load-bearing for correctness. + # Status and current-slot lookup must be set before the responder serves. + # Without them, range queries return SERVER_ERROR. self.set_status(status) self.set_current_slot_lookup(current_slot_lookup) - # Best-effort outbound connections. Both dial() and listen() clear - # the stop event internally; we also clear it here so a no-bootnodes, - # no-listen configuration still yields events. + # Dial and listen each clear the stop event internally. + # Clearing it here covers the no-bootnodes, no-listen case. self._stop_event.clear() for multiaddr in bootnode_multiaddrs: @@ -436,9 +427,7 @@ async def start_serving( logger.info("Starting listener on %s", listen_addr) listener_task = asyncio.create_task(self.listen(listen_addr)) - # Surface immediate bind failures (port already in use, etc.) - # synchronously instead of leaving them as a silent background - # task crash. + # Surface bind failures synchronously instead of as silent crashes. await asyncio.sleep(0.1) if listener_task.done(): listener_task.result() diff --git a/src/lean_spec/subspecs/node/node.py b/src/lean_spec/subspecs/node/node.py index 4741c292d..df8954242 100644 --- a/src/lean_spec/subspecs/node/node.py +++ b/src/lean_spec/subspecs/node/node.py @@ -133,21 +133,18 @@ class NodeConfig: """ anchor_store: Store | None = field(default=None) - """ - Pre-built forkchoice store to anchor the node on. + """Pre-built forkchoice store to anchor the node on. - When set, the node skips genesis-store synthesis and uses this store - directly. Used for checkpoint sync, where the store is built from a - fetched finalized state rather than from the genesis validator set. + A non-None value replaces genesis synthesis with the supplied store. + The checkpoint sync path produces one from a peer-fetched finalized state. - The store-load order in from_genesis is: + Store-load order on boot: - 1. Database (if database_path is set and contains valid state). - 2. anchor_store (this field), if provided. + 1. Database, when a path is set and contains valid state. + 2. This field, when provided. 3. Fresh synthesis from the genesis validator set. - The validators field MUST match the validator set inside this store - (state.validators for checkpoint, genesis.to_validators() for genesis). + The validator set must match the validators inside the supplied store. """ @@ -230,9 +227,8 @@ def from_genesis(cls, config: NodeConfig) -> Node: database, validator_id, config.genesis_time, config.time_fn, fork ) - # An explicit anchor store wins over genesis synthesis but loses to a - # populated database, so a restart with persisted state still recovers - # from disk even when the caller passes a checkpoint anchor. + # An explicit anchor wins over genesis synthesis but loses to the database. + # A restart with persisted state recovers from disk even with a checkpoint anchor. if store is None and config.anchor_store is not None: store = config.anchor_store From 4c0cd9bf0c4739e3cfd79c8edbf8715aefbf1c34 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:17:13 +0200 Subject: [PATCH 5/9] refactor(anchor): inline create_anchor_block into Anchor.from_checkpoint The helper had one production call site and a five-test suite for trivial pass-through behaviour. Inlining the block reconstruction into the only caller removes a function, a docstring, and a test class without losing meaningful coverage; the implicit success path through the checkpoint anchor builder still exercises the zero state-root recomputation branch. The single test-setup usage in the node tests inlines the same construction directly, since that test asserts identity on the adopted anchor store and does not depend on a shared helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/subspecs/node/anchor.py | 20 +++++- .../subspecs/sync/checkpoint_sync.py | 50 -------------- tests/lean_spec/subspecs/node/test_node.py | 10 ++- .../subspecs/sync/test_checkpoint_sync.py | 66 +------------------ 4 files changed, 27 insertions(+), 119 deletions(-) diff --git a/src/lean_spec/subspecs/node/anchor.py b/src/lean_spec/subspecs/node/anchor.py index 09c01ada6..80618d9ae 100644 --- a/src/lean_spec/subspecs/node/anchor.py +++ b/src/lean_spec/subspecs/node/anchor.py @@ -15,11 +15,13 @@ from typing import cast from lean_spec.forks import ForkProtocol, Store, Validators +from lean_spec.forks.lstar.containers import Block, BlockBody +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.subspecs.genesis import GenesisConfig from lean_spec.subspecs.networking.reqresp.message import Status +from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.sync.checkpoint_sync import ( CheckpointSyncError, - create_anchor_block, fetch_finalized_state, verify_checkpoint_state, ) @@ -99,7 +101,21 @@ async def from_checkpoint( f"local={genesis.genesis_time}" ) - anchor_block = create_anchor_block(state) + # Reconstruct the anchor block from the header embedded in the state. + # A header stored before its post-state root carries a zero placeholder; + # in that case we recompute the root from the state itself. + # Fork choice only needs identity and lineage, so the body is left empty. + header = state.latest_block_header + state_root = ( + header.state_root if header.state_root != Bytes32.zero() else hash_tree_root(state) + ) + anchor_block = Block( + slot=header.slot, + proposer_index=header.proposer_index, + parent_root=header.parent_root, + state_root=state_root, + body=BlockBody(attestations=AggregatedAttestations(data=[])), + ) # The protocol return type is structural, but only one concrete store ships. store = cast(Store, fork.create_store(state, anchor_block, validator_id)) diff --git a/src/lean_spec/subspecs/sync/checkpoint_sync.py b/src/lean_spec/subspecs/sync/checkpoint_sync.py index 3bda0d5ea..1a6668b6a 100644 --- a/src/lean_spec/subspecs/sync/checkpoint_sync.py +++ b/src/lean_spec/subspecs/sync/checkpoint_sync.py @@ -24,11 +24,8 @@ import httpx from lean_spec.forks import State -from lean_spec.forks.lstar.containers import Block, BlockBody -from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.subspecs.chain.config import VALIDATOR_REGISTRY_LIMIT from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32 logger = logging.getLogger(__name__) @@ -156,50 +153,3 @@ def verify_checkpoint_state(state: State) -> bool: except Exception as e: logger.error("State verification failed: %s", e) return False - - -def create_anchor_block(state: State) -> Block: - """ - Create an anchor block from a checkpoint state. - - The forkchoice store requires a block to establish the starting point. - We reconstruct this "anchor block" from the header embedded in the state. - - The body content does not matter for fork choice initialization. - Only header fields (slot, parent, state root) establish the anchor. - - Args: - state: The checkpoint state containing the latest block header. - - Returns: - A Block suitable for initializing the forkchoice store. - """ - header = state.latest_block_header - - # The state root in the header may be zero. - # - # Why? Block processing stores the header BEFORE computing post-state root. - # This prevents circular dependency: state root depends on header, header - # would depend on state root. The spec breaks this cycle by storing zero - # initially, then filling it in when the next slot processes. - # - # For checkpoint sync, we may receive state at exactly the block's slot. - # In this case, the state root was never filled in. We compute it now. - state_root = header.state_root - if state_root == Bytes32.zero(): - state_root = hash_tree_root(state) - - # Build a minimal body. - # - # Fork choice only cares about the block's identity (its hash) and - # lineage (parent_root). The body content is irrelevant for anchoring. - # We use an empty body because we lack the original block data. - body = BlockBody(attestations=AggregatedAttestations(data=[])) - - return Block( - slot=header.slot, - proposer_index=header.proposer_index, - parent_root=header.parent_root, - state_root=state_root, - body=body, - ) diff --git a/tests/lean_spec/subspecs/node/test_node.py b/tests/lean_spec/subspecs/node/test_node.py index b75700791..4f499b4f8 100644 --- a/tests/lean_spec/subspecs/node/test_node.py +++ b/tests/lean_spec/subspecs/node/test_node.py @@ -34,8 +34,8 @@ SECONDS_PER_SLOT, ) from lean_spec.subspecs.node import Node, NodeConfig +from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.storage.sqlite import SQLiteDatabase -from lean_spec.subspecs.sync.checkpoint_sync import create_anchor_block from lean_spec.subspecs.validator import ValidatorRegistry from lean_spec.subspecs.validator.registry import ValidatorEntry from lean_spec.types import Bytes32, Checkpoint, Slot, Uint64, ValidatorIndex @@ -744,7 +744,13 @@ class TestNodeFromGenesisAnchorStore: def test_anchor_store_is_used_when_provided(self, spec: LstarSpec) -> None: """The node adopts the provided anchor store rather than synthesising one.""" state = make_genesis_state(num_validators=3, genesis_time=1000) - anchor_block = create_anchor_block(state) + anchor_block = Block( + slot=state.latest_block_header.slot, + proposer_index=state.latest_block_header.proposer_index, + parent_root=state.latest_block_header.parent_root, + state_root=hash_tree_root(state), + body=BlockBody(attestations=AggregatedAttestations(data=[])), + ) anchor_store = spec.create_store(state, anchor_block, None) config = NodeConfig( diff --git a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py index 5e7863f8d..4903e5589 100644 --- a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py +++ b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py @@ -8,22 +8,16 @@ import pytest from lean_spec.forks.lstar import State, Store -from lean_spec.forks.lstar.containers import Block, BlockBody -from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import Validators -from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.api import ApiServer, ApiServerConfig from lean_spec.subspecs.chain.config import VALIDATOR_REGISTRY_LIMIT -from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.sync.checkpoint_sync import ( FINALIZED_STATE_ENDPOINT, CheckpointSyncError, - create_anchor_block, fetch_finalized_state, verify_checkpoint_state, ) -from lean_spec.types import Bytes32, Slot, Uint64 -from tests.lean_spec.helpers import make_genesis_state +from lean_spec.types import Slot class _MockTransport(httpx.AsyncBaseTransport): @@ -226,61 +220,3 @@ async def test_client_fetches_and_deserializes_state(self, base_store: Store) -> finally: await server.aclose() - - -class TestCreateAnchorBlock: - """Tests for the anchor-block reconstruction helper.""" - - def test_computes_state_root_when_zero(self) -> None: - """State root is computed when header has zero state root.""" - state = make_genesis_state(num_validators=3, genesis_time=1000) - assert state.latest_block_header.state_root == Bytes32.zero() - - anchor_block = create_anchor_block(state) - - expected_state_root = hash_tree_root(state) - assert anchor_block.state_root == expected_state_root - assert anchor_block.state_root != Bytes32.zero() - - def test_preserves_non_zero_state_root(self, spec: LstarSpec) -> None: - """Non-zero state root in header is preserved.""" - state = make_genesis_state(num_validators=3, genesis_time=1000) - state_with_root = spec.process_slots(state, Slot(1)) - assert state_with_root.latest_block_header.state_root != Bytes32.zero() - - anchor_block = create_anchor_block(state_with_root) - - assert anchor_block.state_root == state_with_root.latest_block_header.state_root - - def test_preserves_header_fields(self) -> None: - """Slot, proposer_index, and parent_root are preserved from header.""" - state = make_genesis_state(num_validators=3, genesis_time=1000) - header = state.latest_block_header - - anchor_block = create_anchor_block(state) - - assert anchor_block.slot == header.slot - assert anchor_block.proposer_index == header.proposer_index - assert anchor_block.parent_root == header.parent_root - - def test_creates_empty_body(self) -> None: - """Block body contains empty attestations list.""" - state = make_genesis_state(num_validators=3, genesis_time=1000) - - anchor_block = create_anchor_block(state) - - assert len(anchor_block.body.attestations) == 0 - - def test_anchor_block_structure_is_valid(self) -> None: - """Anchor block has all required fields populated.""" - state = make_genesis_state(num_validators=5, genesis_time=2000) - - anchor_block = create_anchor_block(state) - - assert isinstance(anchor_block, Block) - assert isinstance(anchor_block.slot, Slot) - assert isinstance(anchor_block.proposer_index, Uint64) - assert isinstance(anchor_block.parent_root, Bytes32) - assert isinstance(anchor_block.state_root, Bytes32) - assert isinstance(anchor_block.body, BlockBody) - assert isinstance(anchor_block.body.attestations, AggregatedAttestations) From a9dab5810fb3e5d1fad39313c7587a14ea948df2 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:17:45 +0200 Subject: [PATCH 6/9] refactor(registry): tighten from_keys_directory signature and docs Collapse the multi-line classmethod signature, drop the "Convention:" header in favour of a one-line lead-in, fold the validators.yaml optional-file caveat into the Raises clause, and use one sentence per line throughout. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/subspecs/validator/registry.py | 28 ++++++++------------ 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/lean_spec/subspecs/validator/registry.py b/src/lean_spec/subspecs/validator/registry.py index b200ddfe2..ab86bc552 100644 --- a/src/lean_spec/subspecs/validator/registry.py +++ b/src/lean_spec/subspecs/validator/registry.py @@ -230,31 +230,25 @@ def __len__(self) -> int: return len(self._validators) @classmethod - def from_keys_directory( - cls, - node_id: str, - base_dir: Path | str, - ) -> ValidatorRegistry: - """ - Load a validator registry from the ream/zeam keystore layout. + def from_keys_directory(cls, node_id: str, base_dir: Path | str) -> ValidatorRegistry: + """Load a validator registry from the ream/zeam keystore layout. - Convention: + Two files relative to the base directory: - - base_dir / "validators.yaml" maps nodes to validator indices. - - base_dir / "hash-sig-keys/validator-keys-manifest.yaml" carries - each validator's key metadata and SSZ file path. + - validators.yaml: maps each node to its validator indices. + - hash-sig-keys/validator-keys-manifest.yaml: lists each validator's + key metadata and SSZ file path. Args: - node_id: Identifier for this node in validators.yaml. - base_dir: Directory containing the two layout files above. + node_id: Identifier looked up in the node-to-validator mapping. + base_dir: Directory containing the two layout files. Returns: - Registry populated with the keys assigned to node_id. + Registry populated with the keys assigned to the node. Raises: - FileNotFoundError: If the manifest file is missing. validators.yaml - may be missing only when the node has no assigned validators, - in which case the registry is empty. + FileNotFoundError: If the manifest file is missing. + A missing validators mapping is allowed and yields an empty registry. """ base = Path(base_dir) manifest_path = base / "hash-sig-keys" / "validator-keys-manifest.yaml" From 2c61d9e7fed4739a6aa5a0ea679f6bdd323ebae6 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:27:21 +0200 Subject: [PATCH 7/9] test(cli): fixture-driven bootstrap tests with byline doc comments Refactor the kitchen-sink module-level helpers into pytest fixtures and tighten inline docs: - Replace module-level keypair constants and ENR construction helpers with a session-scoped enr_keypair fixture plus a make_enr factory. - Add named enr_with_udp / enr_without_udp fixtures for the two shapes used by current tests. - Convert _write_aggregator_key_layout to a validator_keys_dir fixture. - Merge bootstrap construction helpers into a single make_boot fixture that auto-prefixes the genesis flag. - Drop module-level ENR_WITH_UDP / ENR_WITHOUT_UDP constants. - Add byline one-line guides above each step of the two heaviest fixtures (make_enr and validator_keys_dir). - Replace patch on Anchor.from_genesis with a real-call assertion on the resulting Anchor shape; keep the checkpoint patch with a rationale docstring. All 16 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/lean_spec/cli/test_bootstrap.py | 295 +++++++++++++------------- 1 file changed, 152 insertions(+), 143 deletions(-) diff --git a/tests/lean_spec/cli/test_bootstrap.py b/tests/lean_spec/cli/test_bootstrap.py index 188d9216a..8126aae4d 100644 --- a/tests/lean_spec/cli/test_bootstrap.py +++ b/tests/lean_spec/cli/test_bootstrap.py @@ -4,6 +4,7 @@ import base64 from pathlib import Path +from typing import Callable from unittest.mock import AsyncMock, patch import pytest @@ -18,73 +19,73 @@ from lean_spec.types import Slot, SubnetId, ValidatorIndex from lean_spec.types.rlp import RLPItem, encode_rlp -# Generate a test keypair once for all ENR tests. -_TEST_PRIVATE_KEY = ec.generate_private_key(ec.SECP256K1()) -_TEST_PUBLIC_KEY = _TEST_PRIVATE_KEY.public_key() -_TEST_COMPRESSED_PUBKEY = _TEST_PUBLIC_KEY.public_bytes( - encoding=serialization.Encoding.X962, - format=serialization.PublicFormat.CompressedPoint, -) - - -def _sign_enr_content(content_items: list[RLPItem]) -> bytes: - """Sign ENR content and return 64-byte r||s signature.""" - content_rlp = encode_rlp(content_items) - - k = keccak.new(digest_bits=256) - k.update(content_rlp) - digest = k.digest() - - signature_der = _TEST_PRIVATE_KEY.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256()))) - r, s = decode_dss_signature(signature_der) - return r.to_bytes(32, "big") + s.to_bytes(32, "big") - - -def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: - """Create a properly signed ENR string with IPv4 and UDP port.""" - # Content items (keys must be sorted). - content_items: list[RLPItem] = [ - b"\x01", # seq = 1 - b"id", - b"v4", - b"ip", - ip_bytes, - b"secp256k1", - _TEST_COMPRESSED_PUBKEY, - b"udp", - udp_port.to_bytes(2, "big"), - ] - signature = _sign_enr_content(content_items) - - rlp_data = encode_rlp([signature] + content_items) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - return f"enr:{b64_content}" - - -def _make_enr_without_udp(ip_bytes: bytes) -> str: - """Create a properly signed ENR string with IPv4 but no UDP port.""" - content_items: list[RLPItem] = [ - b"\x01", # seq = 1 - b"id", - b"v4", - b"ip", - ip_bytes, - b"secp256k1", - _TEST_COMPRESSED_PUBKEY, - ] - signature = _sign_enr_content(content_items) - - rlp_data = encode_rlp([signature] + content_items) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - return f"enr:{b64_content}" - - -# Pre-built test ENRs -ENR_WITH_UDP = _make_enr_with_udp(b"\xc0\xa8\x01\x01", 9000) # 192.168.1.1:9000 -ENR_WITHOUT_UDP = _make_enr_without_udp(b"\xc0\xa8\x01\x01") # 192.168.1.1, no UDP - -# Valid multiaddr strings (QUIC format) MULTIADDR_IPV4 = "/ip4/127.0.0.1/udp/9000/quic-v1" +"""Valid QUIC multiaddr used to exercise the pass-through resolution path.""" + + +@pytest.fixture(scope="session") +def enr_keypair() -> tuple[ec.EllipticCurvePrivateKey, bytes]: + """One secp256k1 keypair shared across the whole session.""" + private_key = ec.generate_private_key(ec.SECP256K1()) + compressed_pubkey = private_key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.CompressedPoint, + ) + return private_key, compressed_pubkey + + +@pytest.fixture(scope="session") +def make_enr( + enr_keypair: tuple[ec.EllipticCurvePrivateKey, bytes], +) -> Callable[..., str]: + """Build a signed ENR string for the given IP and optional UDP port.""" + private_key, compressed_pubkey = enr_keypair + + def _sign(content_items: list[RLPItem]) -> bytes: + # Hash the RLP-encoded content with keccak-256 and sign the digest. + content_rlp = encode_rlp(content_items) + digest = keccak.new(digest_bits=256, data=content_rlp).digest() + signature_der = private_key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256()))) + + # Convert DER-encoded signature to the 64-byte r-then-s form ENR expects. + r, s = decode_dss_signature(signature_der) + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + + def _build(ip_bytes: bytes, udp_port: int | None = None) -> str: + # Content items must keep keys sorted lexicographically. + content_items: list[RLPItem] = [ + b"\x01", + b"id", + b"v4", + b"ip", + ip_bytes, + b"secp256k1", + compressed_pubkey, + ] + + # UDP port is optional; absent means the record has no dialable endpoint. + if udp_port is not None: + content_items.extend([b"udp", udp_port.to_bytes(2, "big")]) + + # Wrap content with the signature and base64-url-encode for the text form. + signature = _sign(content_items) + rlp_data = encode_rlp([signature, *content_items]) + b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") + return f"enr:{b64_content}" + + return _build + + +@pytest.fixture +def enr_with_udp(make_enr: Callable[..., str]) -> str: + """Signed ENR for 192.168.1.1 with UDP port 9000.""" + return make_enr(b"\xc0\xa8\x01\x01", 9000) + + +@pytest.fixture +def enr_without_udp(make_enr: Callable[..., str]) -> str: + """Signed ENR for 192.168.1.1 with no UDP port.""" + return make_enr(b"\xc0\xa8\x01\x01") @pytest.fixture @@ -95,18 +96,24 @@ def genesis_yaml(tmp_path: Path) -> Path: return path -def _write_aggregator_key_layout(tmp_path: Path) -> Path: +@pytest.fixture +def validator_keys_dir(tmp_path: Path) -> Path: """Materialise a one-validator key directory the registry loader accepts.""" + # The registry loader expects the ream/zeam two-file layout. keys_root = tmp_path / "keys" hash_sig_dir = keys_root / "hash-sig-keys" hash_sig_dir.mkdir(parents=True) + # Borrow a real precomputed XMSS keypair from the shared manager. km = XmssKeyManager.shared(max_slot=Slot(10)) kp = km[ValidatorIndex(0)] + # Drop both secret keys to disk under the names the manifest references. (hash_sig_dir / "att_key_0.ssz").write_bytes(kp.attestation_keypair.secret_key.encode_bytes()) (hash_sig_dir / "prop_key_0.ssz").write_bytes(kp.proposal_keypair.secret_key.encode_bytes()) + # Manifest carries one validator with placeholder public keys. + # The loader does not verify the pubkeys against the secret keys here. manifest = hash_sig_dir / "validator-keys-manifest.yaml" manifest.write_text( "key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8\n" @@ -124,53 +131,58 @@ def _write_aggregator_key_layout(tmp_path: Path) -> Path: " proposal_privkey_file: prop_key_0.ssz\n" ) + # Node-to-validator mapping assigns the only validator to the default node id. (keys_root / "validators.yaml").write_text("lean_spec_0: [0]\n") return keys_root -def _bootstrap_from_argv(genesis_yaml: Path, *extra: str) -> NodeBootstrap: - """Construct a bootstrap from a genesis path and extra CLI tokens.""" - argv = ["--genesis", str(genesis_yaml), *extra] - return NodeBootstrap.from_cli_args(parse_args(argv)) +@pytest.fixture +def make_boot(genesis_yaml: Path) -> Callable[..., NodeBootstrap]: + """Build a boot configuration from extra CLI tokens, auto-prefixing the genesis flag.""" + def _build(*extra: str) -> NodeBootstrap: + return NodeBootstrap.from_cli_args(parse_args(["--genesis", str(genesis_yaml), *extra])) -def _bootstrap_with_bootnodes(genesis_yaml: Path, *bootnodes: str) -> NodeBootstrap: - """Construct a bootstrap from a genesis path and bootnode strings.""" - extra: list[str] = [] - for b in bootnodes: - extra.extend(["--bootnode", b]) - return _bootstrap_from_argv(genesis_yaml, *extra) + return _build class TestBootnodeResolution: """Tests for bootnode resolution at the CLI boundary.""" - def test_multiaddr_passes_through(self, genesis_yaml: Path) -> None: + def test_multiaddr_passes_through(self, make_boot: Callable[..., NodeBootstrap]) -> None: """A bare multiaddr is kept as-is.""" - boot = _bootstrap_with_bootnodes(genesis_yaml, MULTIADDR_IPV4) + boot = make_boot("--bootnode", MULTIADDR_IPV4) assert boot.bootnode_multiaddrs == (MULTIADDR_IPV4,) - def test_enr_resolves_to_multiaddr(self, genesis_yaml: Path) -> None: + def test_enr_resolves_to_multiaddr( + self, make_boot: Callable[..., NodeBootstrap], enr_with_udp: str + ) -> None: """An ENR carrying UDP info expands to its dialable multiaddr view.""" - boot = _bootstrap_with_bootnodes(genesis_yaml, ENR_WITH_UDP) + boot = make_boot("--bootnode", enr_with_udp) assert boot.bootnode_multiaddrs == ("/ip4/192.168.1.1/udp/9000/quic-v1",) - def test_enr_without_udp_rejected(self, genesis_yaml: Path) -> None: + def test_enr_without_udp_rejected( + self, make_boot: Callable[..., NodeBootstrap], enr_without_udp: str + ) -> None: """An ENR lacking UDP info is rejected before any dial attempt.""" with pytest.raises(CliValidationError, match=r"no UDP connection info"): - _bootstrap_with_bootnodes(genesis_yaml, ENR_WITHOUT_UDP) + make_boot("--bootnode", enr_without_udp) - def test_malformed_enr_rejected(self, genesis_yaml: Path) -> None: + def test_malformed_enr_rejected(self, make_boot: Callable[..., NodeBootstrap]) -> None: """A malformed ENR fails at RLP decoding.""" with pytest.raises(ValueError, match=r"Invalid RLP"): - _bootstrap_with_bootnodes(genesis_yaml, "enr:YWJj") + make_boot("--bootnode", "enr:YWJj") - def test_mixed_inputs_preserve_order(self, genesis_yaml: Path) -> None: + def test_mixed_inputs_preserve_order( + self, make_boot: Callable[..., NodeBootstrap], enr_with_udp: str + ) -> None: """Mixed multiaddr and ENR inputs resolve into a single ordered tuple.""" - boot = _bootstrap_with_bootnodes( - genesis_yaml, + boot = make_boot( + "--bootnode", MULTIADDR_IPV4, - ENR_WITH_UDP, + "--bootnode", + enr_with_udp, + "--bootnode", "/ip4/10.0.0.1/udp/8000/quic-v1", ) assert boot.bootnode_multiaddrs == ( @@ -179,65 +191,62 @@ def test_mixed_inputs_preserve_order(self, genesis_yaml: Path) -> None: "/ip4/10.0.0.1/udp/8000/quic-v1", ) - def test_no_bootnodes_resolves_empty_tuple(self, genesis_yaml: Path) -> None: + def test_no_bootnodes_resolves_empty_tuple( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """No bootnode flags resolve to an empty tuple.""" - assert _bootstrap_from_argv(genesis_yaml).bootnode_multiaddrs == () + assert make_boot().bootnode_multiaddrs == () class TestNodeBootstrapValidation: """Tests for the CLI argument validator.""" - def test_aggregator_without_validator_keys_rejected(self, tmp_path: Path) -> None: + def test_aggregator_without_validator_keys_rejected( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """The aggregator flag requires a validator keys path.""" - genesis_path = tmp_path / "genesis.yaml" - genesis_path.write_text("GENESIS_TIME: 1000\nGENESIS_VALIDATORS: []\n") - - args = parse_args( - [ - "--genesis", - str(genesis_path), - "--is-aggregator", - ] - ) - with pytest.raises(CliValidationError, match="--is-aggregator requires --validator-keys"): - NodeBootstrap.from_cli_args(args) + make_boot("--is-aggregator") class TestAggregateSubnetIds: """Tests for parsing the extra-subnets flag.""" - def test_empty_string_parses_to_empty_tuple(self, genesis_yaml: Path) -> None: + def test_empty_string_parses_to_empty_tuple( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """An empty extras string resolves to an empty subnet tuple.""" - boot = _bootstrap_from_argv(genesis_yaml, "--aggregate-subnet-ids", "") + boot = make_boot("--aggregate-subnet-ids", "") assert boot.aggregate_subnet_ids == () - def test_valid_extras_parse_into_tuple(self, genesis_yaml: Path, tmp_path: Path) -> None: + def test_valid_extras_parse_into_tuple( + self, make_boot: Callable[..., NodeBootstrap], validator_keys_dir: Path + ) -> None: """A comma list with aggregator mode and a populated registry resolves in order.""" - keys_root = _write_aggregator_key_layout(tmp_path) - boot = _bootstrap_from_argv( - genesis_yaml, + boot = make_boot( "--validator-keys", - str(keys_root), + str(validator_keys_dir), "--is-aggregator", "--aggregate-subnet-ids", "1,2,3", ) assert boot.aggregate_subnet_ids == (SubnetId(1), SubnetId(2), SubnetId(3)) - def test_extras_without_aggregator_rejected(self, genesis_yaml: Path) -> None: + def test_extras_without_aggregator_rejected( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """Subnet extras without aggregator mode raise a typed validation error.""" with pytest.raises(CliValidationError, match="requires --is-aggregator"): - _bootstrap_from_argv(genesis_yaml, "--aggregate-subnet-ids", "1,2,3") + make_boot("--aggregate-subnet-ids", "1,2,3") - def test_malformed_extras_rejected(self, genesis_yaml: Path, tmp_path: Path) -> None: + def test_malformed_extras_rejected( + self, make_boot: Callable[..., NodeBootstrap], tmp_path: Path + ) -> None: """A non-integer token in the extras list raises a typed validation error.""" - # Why: - # The integer parser runs before any registry load, so any non-empty - # validator-keys path is enough to reach the parse branch. + # The integer parser runs before any registry load. + # Any non-empty validator-keys path is enough to reach the parse branch. with pytest.raises(CliValidationError, match="comma-separated integers"): - _bootstrap_from_argv( - genesis_yaml, + make_boot( "--validator-keys", str(tmp_path / "keys"), "--is-aggregator", @@ -249,50 +258,50 @@ def test_malformed_extras_rejected(self, genesis_yaml: Path, tmp_path: Path) -> class TestApiConfigResolution: """Tests for the api_config field on the boot configuration.""" - def test_zero_port_disables_api(self, genesis_yaml: Path) -> None: + def test_zero_port_disables_api(self, make_boot: Callable[..., NodeBootstrap]) -> None: """A port of zero leaves the API configuration unset.""" - boot = _bootstrap_from_argv(genesis_yaml, "--api-port", "0") + boot = make_boot("--api-port", "0") assert boot.api_config is None - def test_non_zero_port_enables_api(self, genesis_yaml: Path) -> None: + def test_non_zero_port_enables_api(self, make_boot: Callable[..., NodeBootstrap]) -> None: """A non-zero port produces an API configuration carrying that port.""" - boot = _bootstrap_from_argv(genesis_yaml, "--api-port", "5052") + boot = make_boot("--api-port", "5052") assert boot.api_config == ApiServerConfig(port=5052) class TestListenAddressResolution: """Tests for the listen-address field on the boot configuration.""" - def test_empty_listen_address_becomes_none(self, genesis_yaml: Path) -> None: + def test_empty_listen_address_becomes_none( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """An empty listen string resolves to no listener.""" - boot = _bootstrap_from_argv(genesis_yaml, "--listen", "") + boot = make_boot("--listen", "") assert boot.listen_addr is None class TestBuildAnchor: """Tests for the async anchor builder method.""" - async def test_no_checkpoint_calls_from_genesis(self, genesis_yaml: Path) -> None: + async def test_no_checkpoint_calls_from_genesis( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: """Without a checkpoint URL the boot delegates to the synchronous genesis builder.""" - boot = _bootstrap_from_argv(genesis_yaml) + boot = make_boot() + anchor = await boot.build_anchor() - sentinel = object() - with patch( - "lean_spec.cli.bootstrap.Anchor.from_genesis", - return_value=sentinel, - ) as from_genesis: - anchor = await boot.build_anchor() + assert anchor.store is None + assert anchor.validators == boot.genesis.to_validators() - assert anchor is sentinel - assert from_genesis.call_args.args == (boot.genesis,) - - async def test_checkpoint_url_calls_from_checkpoint(self, genesis_yaml: Path) -> None: - """With a checkpoint URL the boot delegates to the asynchronous checkpoint builder.""" - boot = _bootstrap_from_argv( - genesis_yaml, - "--checkpoint-sync-url", - "http://localhost:5052", - ) + async def test_checkpoint_url_calls_from_checkpoint( + self, make_boot: Callable[..., NodeBootstrap] + ) -> None: + """With a checkpoint URL the boot delegates to the asynchronous checkpoint builder. + + The real checkpoint path needs a fetched state plus a constructed store. + A dispatch-via-mock keeps the test focused on the dispatch contract. + """ + boot = make_boot("--checkpoint-sync-url", "http://localhost:5052") sentinel = object() with patch( From 0472d95a1866885e82422b0721419e310c2a0857 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:30:18 +0200 Subject: [PATCH 8/9] test(cli): replace MagicMock plumbing with a small recording fake Refactor the event-source builder tests to be readable top to bottom: - Replace MagicMock event source with a 13-line _RecordingEventSource fake that captures the network name and topic subscriptions. - Add a make_boot fixture that constructs NodeBootstrap with passive defaults overridable by keyword. - Add a one_validator_registry fixture that holds a real precomputed XMSS keypair at index zero. - Add a run_build fixture that patches the live transport factory and drives the builder against the recording fake. - Collapse three single-test classes into one TestBuildEventSource class with three small tests. - Drop the genesis_yaml fixture and CLI-driven boot construction; the bootstrap layer is tested in test_bootstrap.py. - Add a third test for network-identity pinning, previously missing. Three tests pass. No production code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/lean_spec/cli/test_run.py | 190 +++++++++++++++++++------------- 1 file changed, 111 insertions(+), 79 deletions(-) diff --git a/tests/lean_spec/cli/test_run.py b/tests/lean_spec/cli/test_run.py index abfe3a2f6..768eee367 100644 --- a/tests/lean_spec/cli/test_run.py +++ b/tests/lean_spec/cli/test_run.py @@ -1,14 +1,20 @@ -"""Tests for the consensus node run sequence.""" +"""Tests for the consensus node run sequence. + +Only the event-source builder is exercised here. +The full run sequence composes too many side-effecting collaborators to +mock cleanly; that surface is integration-test territory. +""" from __future__ import annotations -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, patch import pytest from consensus_testing.keys import XmssKeyManager -from lean_spec.cli import NodeBootstrap, parse_args +from lean_spec.cli import NodeBootstrap from lean_spec.cli.run import _build_event_source from lean_spec.forks import DEFAULT_REGISTRY from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT @@ -19,98 +25,124 @@ from lean_spec.types import Slot, ValidatorIndex -@pytest.fixture -def genesis_yaml(tmp_path: Path) -> Path: - """Write a minimal genesis YAML to a temporary path.""" - path = tmp_path / "genesis.yaml" - path.write_text("GENESIS_TIME: 1000\nGENESIS_VALIDATORS: []\n") - return path - - -def _make_event_source_mock() -> MagicMock: - """Construct a mock event source that records gossip subscriptions.""" - event_source = MagicMock() - event_source.set_network_name = MagicMock() - event_source.subscribe_gossip_topic = MagicMock() - return event_source - - -async def _run_build(boot: NodeBootstrap) -> tuple[MagicMock, list[str]]: - """Run the event-source builder against a mocked transport and capture subscriptions.""" - event_source = _make_event_source_mock() - with patch( - "lean_spec.cli.run.LiveNetworkEventSource.create", - new_callable=AsyncMock, - return_value=event_source, - ): - await _build_event_source(boot) - topics = [call.args[0] for call in event_source.subscribe_gossip_topic.call_args_list] - return event_source, topics - - -class TestBuildEventSourceBlockTopic: - """The block topic is always subscribed, regardless of validator state.""" - - async def test_block_topic_subscribed_exactly_once(self, genesis_yaml: Path) -> None: - """A bare boot configuration subscribes to the fork's block topic exactly once.""" - boot = NodeBootstrap.from_cli_args(parse_args(["--genesis", str(genesis_yaml)])) - block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() +class _RecordingEventSource: + """In-test fake that records what the builder configures on it. - _, topics = await _run_build(boot) + The builder only calls two methods on the event source during pre-serving + wiring, so a tiny fake is more readable than a mock with attribute setup. + """ - assert topics == [block_topic] + def __init__(self) -> None: + self.network_name: str | None = None + self.topics: list[str] = [] + def set_network_name(self, name: str) -> None: + self.network_name = name -class TestBuildEventSourcePassive: - """A passive (non-aggregator, no validators) node subscribes to no subnets.""" + def subscribe_gossip_topic(self, topic: str) -> None: + self.topics.append(topic) - async def test_no_subnet_subscription_for_empty_registry(self, genesis_yaml: Path) -> None: - """A non-aggregator with no owned validators subscribes only to the block topic.""" - boot = NodeBootstrap.from_cli_args(parse_args(["--genesis", str(genesis_yaml)])) - block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() - _, topics = await _run_build(boot) +@pytest.fixture +def make_boot() -> Callable[..., NodeBootstrap]: + """Construct a NodeBootstrap with passive defaults that any field can override.""" + + def _build(**overrides: Any) -> NodeBootstrap: + # Defaults model a minimal passive node: no validators, no peers, no listener. + defaults: dict[str, Any] = { + "genesis": GenesisConfig.model_validate( + {"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []} + ), + "registry": ValidatorRegistry(), + "fork": DEFAULT_REGISTRY.current, + "bootnode_multiaddrs": (), + "listen_addr": None, + "checkpoint_sync_url": None, + "node_id": "lean_spec_0", + "is_aggregator": False, + } + return NodeBootstrap(**{**defaults, **overrides}) + + return _build - assert topics == [block_topic] +@pytest.fixture +def one_validator_registry() -> ValidatorRegistry: + """Registry holding a single XMSS keypair at validator index zero.""" + # Borrow a real precomputed keypair from the shared XMSS manager. + km = XmssKeyManager.shared(max_slot=Slot(10)) + kp = km[ValidatorIndex(0)] + registry = ValidatorRegistry() + registry.add( + ValidatorEntry( + index=ValidatorIndex(0), + attestation_secret_key=kp.attestation_keypair.secret_key, + proposal_secret_key=kp.proposal_keypair.secret_key, + ) + ) + return registry -class TestBuildEventSourceOwnedValidator: - """A node with one owned validator subscribes to its derived subnet.""" - async def test_single_validator_subscribes_to_block_and_subnet( - self, genesis_yaml: Path +@pytest.fixture +def run_build() -> Callable[[NodeBootstrap], Awaitable[_RecordingEventSource]]: + """Run the event-source builder against a recording fake and return it.""" + + async def _run(boot: NodeBootstrap) -> _RecordingEventSource: + # Intercept the live transport factory so the builder sees the fake. + source = _RecordingEventSource() + with patch( + "lean_spec.cli.run.LiveNetworkEventSource.create", + new_callable=AsyncMock, + return_value=source, + ): + await _build_event_source(boot) + return source + + return _run + + +class TestBuildEventSource: + """Tests for the pre-serving event-source wiring.""" + + async def test_passive_node_subscribes_only_to_block_topic( + self, + make_boot: Callable[..., NodeBootstrap], + run_build: Callable[[NodeBootstrap], Awaitable[_RecordingEventSource]], + ) -> None: + """A non-aggregator with no owned validators subscribes only to the block topic.""" + boot = make_boot() + + source = await run_build(boot) + + block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + assert source.topics == [block_topic] + + async def test_owned_validator_adds_its_subnet( + self, + make_boot: Callable[..., NodeBootstrap], + run_build: Callable[[NodeBootstrap], Awaitable[_RecordingEventSource]], + one_validator_registry: ValidatorRegistry, ) -> None: """One owned validator adds exactly one attestation subnet topic.""" - km = XmssKeyManager.shared(max_slot=Slot(10)) - kp = km[ValidatorIndex(0)] - registry = ValidatorRegistry() - registry.add( - ValidatorEntry( - index=ValidatorIndex(0), - attestation_secret_key=kp.attestation_keypair.secret_key, - proposal_secret_key=kp.proposal_keypair.secret_key, - ) - ) + boot = make_boot(registry=one_validator_registry) - # Skip the file-loading bootstrap path: it has its own tests. - # Build the boot configuration directly so this test exercises only the run-time wiring. - boot = NodeBootstrap( - genesis=GenesisConfig.model_validate({"GENESIS_TIME": 1000, "GENESIS_VALIDATORS": []}), - registry=registry, - fork=DEFAULT_REGISTRY.current, - bootnode_multiaddrs=(), - listen_addr=None, - checkpoint_sync_url=None, - node_id="lean_spec_0", - is_aggregator=False, - ) + source = await run_build(boot) - subnet_id = ValidatorIndex(0).compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) block_topic = GossipTopic.block(boot.fork.GOSSIP_DIGEST).to_topic_id() + subnet_id = ValidatorIndex(0).compute_subnet_id(ATTESTATION_COMMITTEE_COUNT) subnet_topic = GossipTopic.attestation_subnet( boot.fork.GOSSIP_DIGEST, subnet_id ).to_topic_id() + assert source.topics == [block_topic, subnet_topic] + + async def test_network_identity_pinned_on_event_source( + self, + make_boot: Callable[..., NodeBootstrap], + run_build: Callable[[NodeBootstrap], Awaitable[_RecordingEventSource]], + ) -> None: + """The fork's gossip digest is set on the event source before any subscription.""" + boot = make_boot() - _, topics = await _run_build(boot) + source = await run_build(boot) - assert topics == [block_topic, subnet_topic] + assert source.network_name == boot.fork.GOSSIP_DIGEST From c1a275b07249ae2d71760db668e27d782f9894cc Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 23 May 2026 23:31:17 +0200 Subject: [PATCH 9/9] test(cli): trim test_run.py module and class docstrings Drop the multi-paragraph rationale on the module and class docstrings; neither carried information that the surrounding code did not already make obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/lean_spec/cli/test_run.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/lean_spec/cli/test_run.py b/tests/lean_spec/cli/test_run.py index 768eee367..72a0b7da9 100644 --- a/tests/lean_spec/cli/test_run.py +++ b/tests/lean_spec/cli/test_run.py @@ -1,9 +1,4 @@ -"""Tests for the consensus node run sequence. - -Only the event-source builder is exercised here. -The full run sequence composes too many side-effecting collaborators to -mock cleanly; that surface is integration-test territory. -""" +"""Tests for the consensus node run sequence.""" from __future__ import annotations @@ -26,11 +21,7 @@ class _RecordingEventSource: - """In-test fake that records what the builder configures on it. - - The builder only calls two methods on the event source during pre-serving - wiring, so a tiny fake is more readable than a mock with attribute setup. - """ + """In-test fake that records what the builder configures on it.""" def __init__(self) -> None: self.network_name: str | None = None