Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lean_spec/subspecs/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Subspecifications for the Lean Ethereum Python specifications."""

from .genesis import GenesisConfig

__all__ = ["GenesisConfig"]
5 changes: 5 additions & 0 deletions src/lean_spec/subspecs/genesis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Genesis configuration and state initialization."""

from .config import GenesisConfig

__all__ = ["GenesisConfig"]
156 changes: 156 additions & 0 deletions src/lean_spec/subspecs/genesis/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Genesis configuration loader."""

from __future__ import annotations

import json
from pathlib import Path

from pydantic import Field, field_validator

from lean_spec.subspecs.containers import State, Validator
from lean_spec.subspecs.containers.state import Validators
from lean_spec.types import Bytes52, StrictBaseModel, Uint64


class GenesisConfig(StrictBaseModel):
"""
Configuration that establishes the birth of an Ethereum consensus chain.

Genesis is the shared starting point for all participants in the network.
Without a common genesis, nodes cannot agree on the chain's history.
Every block traces its ancestry back to this origin.

The genesis configuration solves two fundamental coordination problems:

1. **Time Synchronization**: All nodes must agree on when slots begin.
The genesis time anchors the chain's internal clock to real-world time.
From this moment, slots tick forward at fixed intervals. A node can
compute "what slot is it now?" by measuring seconds since genesis.

2. **Initial Trust**: Proof-of-stake requires an initial set of validators.
These validators form the first committee that can produce and attest
to blocks. Without them, no blocks could ever be finalized.

The genesis block (slot 0) is implicit. It has no parent, no proposer,
and no attestations. The first real block builds on top of this implicit
origin, establishing the chain's cryptographic lineage.

Example JSON configuration:

{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": [
"0xe2a03c1689769ae5f5762222b170b4a925f3f8e89340ed1cd31d31c134b0abc2...",
"0x0767e659c1b61d30f65eadb7a309c4183d5d4c0f99e935737b89ce95dd1c4568..."
]
}

Field names use UPPERCASE to match the cross-client JSON convention.
Pydantic aliases map them to snake_case Python attributes.
"""

genesis_time: Uint64 = Field(alias="GENESIS_TIME")
"""
Unix timestamp (seconds since 1970-01-01 UTC) when slot 0 begins.

Anchors the chain's clock to real-world time.

Nodes compute the current slot as: (now - genesis_time) / slot_duration.

Immutable once the chain launches.
"""

genesis_validators: list[Bytes52] = Field(alias="GENESIS_VALIDATORS")
"""
Public keys of validators trusted to secure the chain from slot 0.

Bootstrap the proof-of-stake mechanism.

These validators can:

- Propose the first blocks
- Cast attestations for justification/finalization
- Form the supermajority needed for consensus

Each key is 52 bytes (XMSS format).

Security note: 2/3+ collusion controls the chain until new validators join.
"""

@field_validator("genesis_validators", mode="before")
@classmethod
def parse_hex_pubkeys(cls, v: list[str]) -> list[Bytes52]:
"""
Convert hex strings to validated Bytes52 pubkeys.

The JSON contains string representations.
We parse them into typed Bytes52 objects for validation and use.

Args:
v: List of hex-encoded pubkey strings from JSON.

Returns:
List of validated Bytes52 pubkey objects.
"""
return [Bytes52(pk) for pk in v]

def to_validators(self) -> Validators:
"""
Build the genesis validator set with assigned indices.

Each validator needs an index for the registry.
Indices are assigned sequentially starting from 0.

Returns:
Validators container ready for State creation.
"""
return Validators(
data=[
Validator(pubkey=pk, index=Uint64(i))
for i, pk in enumerate(self.genesis_validators)
]
)

def create_state(self) -> State:
"""
Generate the complete genesis state from this configuration.

Combines genesis time and validator set to create the initial
consensus state. This state becomes slot 0 for the chain.

Returns:
Fully initialized genesis State object.
"""
return State.generate_genesis(self.genesis_time, self.to_validators())

@classmethod
def from_json_file(cls, path: Path | str) -> GenesisConfig:
"""
Load configuration from a JSON file on disk.

Use this to load shared genesis files distributed to all clients.

Args:
path: Path to genesis JSON file.

Returns:
Validated GenesisConfig instance.
"""
with open(path) as f:
data = json.load(f)
return cls.model_validate(data)

@classmethod
def from_json(cls, content: str) -> GenesisConfig:
"""
Load configuration from a JSON string.

Use this for testing or programmatic config generation.

Args:
content: JSON content as a string.

Returns:
Validated GenesisConfig instance.
"""
return cls.model_validate_json(content)
1 change: 1 addition & 0 deletions tests/lean_spec/subspecs/genesis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the genesis configuration module."""
197 changes: 197 additions & 0 deletions tests/lean_spec/subspecs/genesis/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Tests for the GenesisConfig class."""

from __future__ import annotations

import json
import tempfile

import pytest
from pydantic import ValidationError

from lean_spec.subspecs.containers.slot import Slot
from lean_spec.subspecs.genesis import GenesisConfig
from lean_spec.types import Bytes52, SSZValueError, Uint64

# Sample pubkeys (52 bytes each, hex-encoded)
SAMPLE_PUBKEY_1 = "0x" + "00" * 52
SAMPLE_PUBKEY_2 = "0x" + "01" * 52
SAMPLE_PUBKEY_3 = "0x" + "02" * 52

SAMPLE_JSON = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": [SAMPLE_PUBKEY_1, SAMPLE_PUBKEY_2, SAMPLE_PUBKEY_3],
}
)

# Real pubkeys from ream config (split for line length)
REAM_PUBKEY_1 = (
"0xe2a03c16122c7e0f940e2301aa460c54a2e1e8343968bb2782f26636f051e65e"
"c589c858b9c7980b276ebe550056b23f0bdc3b5a"
)
REAM_PUBKEY_2 = (
"0x0767e65924063f79ae92ee1953685f06718b1756cc665a299bd61b4b82055e37"
"7237595d9a27887421b5233d09a50832db2f303d"
)
REAM_PUBKEY_3 = (
"0xd4355005bc37f76f390dcd2bcc51677d8c6ab44e0cc64913fb84ad459789a311"
"05bd9a69afd2690ffd737d22ec6e3b31d47a642f"
)


class TestGenesisConfigJsonLoading:
"""Tests for JSON loading functionality."""

def test_load_from_json_string(self) -> None:
"""Parses JSON with UPPERCASE keys."""
config = GenesisConfig.from_json(SAMPLE_JSON)

assert config.genesis_time == Uint64(1704085200)
assert len(config.genesis_validators) == 3

def test_load_from_json_file(self) -> None:
"""Loads config from file path."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write(SAMPLE_JSON)
f.flush()

config = GenesisConfig.from_json_file(f.name)

assert config.genesis_time == Uint64(1704085200)
assert len(config.genesis_validators) == 3

def test_pubkeys_parsed_correctly(self) -> None:
"""Pubkeys are converted to Bytes52 instances."""
config = GenesisConfig.from_json(SAMPLE_JSON)

for pk in config.genesis_validators:
assert isinstance(pk, Bytes52)
assert len(pk) == 52

def test_pubkey_without_0x_prefix(self) -> None:
"""Handles pubkeys without 0x prefix (zeam format)."""
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": ["00" * 52, "01" * 52],
}
)
config = GenesisConfig.from_json(json_content)

assert len(config.genesis_validators) == 2
assert config.genesis_validators[0] == Bytes52(b"\x00" * 52)


class TestGenesisConfigValidators:
"""Tests for validator conversion."""

def test_to_validators_creates_indexed_list(self) -> None:
"""Validators have correct indices."""
config = GenesisConfig.from_json(SAMPLE_JSON)
validators = config.to_validators()

assert len(validators.data) == 3

for i, validator in enumerate(validators.data):
assert validator.index == Uint64(i)
assert validator.pubkey == config.genesis_validators[i]

def test_empty_validators_list(self) -> None:
"""Handles empty validator list."""
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": [],
}
)
config = GenesisConfig.from_json(json_content)
validators = config.to_validators()

assert len(validators.data) == 0


class TestGenesisConfigState:
"""Tests for state creation."""

def test_create_state_returns_valid_genesis(self) -> None:
"""State has correct genesis time and validators."""
config = GenesisConfig.from_json(SAMPLE_JSON)
state = config.create_state()

# Genesis time is stored in the state's config.
assert state.config.genesis_time == config.genesis_time
assert state.slot == Slot(0)
assert len(state.validators.data) == 3


class TestGenesisConfigValidation:
"""Tests for validation errors."""

def test_invalid_pubkey_raises_validation_error(self) -> None:
"""Rejects malformed hex."""
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": ["not_valid_hex"],
}
)
with pytest.raises(ValidationError):
GenesisConfig.from_json(json_content)

def test_wrong_length_pubkey_raises_error(self) -> None:
"""Rejects pubkeys with wrong length."""
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": ["0x0011223344"],
}
)
with pytest.raises(SSZValueError):
GenesisConfig.from_json(json_content)

def test_missing_genesis_time_raises_error(self) -> None:
"""Requires GENESIS_TIME field."""
json_content = json.dumps(
{
"GENESIS_VALIDATORS": [SAMPLE_PUBKEY_1],
}
)
with pytest.raises(ValidationError):
GenesisConfig.from_json(json_content)

def test_missing_validators_raises_error(self) -> None:
"""Requires GENESIS_VALIDATORS field."""
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
}
)
with pytest.raises(ValidationError):
GenesisConfig.from_json(json_content)


class TestReamCompatibility:
"""Tests for compatibility with ream config format."""

def test_ream_format_config(self) -> None:
"""Loads config in ream format with 0x-prefixed pubkeys."""
# This matches the format used in ream/bin/ream/assets/lean/config.yaml
json_content = json.dumps(
{
"GENESIS_TIME": 1704085200,
"GENESIS_VALIDATORS": [REAM_PUBKEY_1, REAM_PUBKEY_2, REAM_PUBKEY_3],
}
)
config = GenesisConfig.from_json(json_content)

assert config.genesis_time == Uint64(1704085200)
assert len(config.genesis_validators) == 3

# Verify first pubkey matches.
expected_first = Bytes52(
bytes.fromhex(
"e2a03c16122c7e0f940e2301aa460c54a2e1e8343968bb2782f26636f051e65e"
"c589c858b9c7980b276ebe550056b23f0bdc3b5a"
)
)
assert config.genesis_validators[0] == expected_first
Loading