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 packages/testing/src/consensus_testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import forks
from .genesis import generate_pre_state
from .test_fixtures import (
ApiEndpointTest,
BaseConsensusFixture,
ForkChoiceTest,
GossipsubHandlerTest,
Expand Down Expand Up @@ -36,6 +37,7 @@
SSZTestFiller = Type[SSZTest]
NetworkingCodecTestFiller = Type[NetworkingCodecTest]
GossipsubHandlerTestFiller = Type[GossipsubHandlerTest]
ApiEndpointTestFiller = Type[ApiEndpointTest]

__all__ = [
# Public API
Expand All @@ -54,6 +56,7 @@
"SSZTest",
"NetworkingCodecTest",
"GossipsubHandlerTest",
"ApiEndpointTest",
# Test types
"BaseForkChoiceStep",
"TickStep",
Expand All @@ -72,4 +75,5 @@
"SSZTestFiller",
"NetworkingCodecTestFiller",
"GossipsubHandlerTestFiller",
"ApiEndpointTestFiller",
]
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Consensus test fixture format definitions (Pydantic models)."""

from .api_endpoint import ApiEndpointTest
from .base import BaseConsensusFixture
from .fork_choice import ForkChoiceTest
from .gossipsub_handler import GossipsubHandlerTest
Expand All @@ -16,4 +17,5 @@
"SSZTest",
"NetworkingCodecTest",
"GossipsubHandlerTest",
"ApiEndpointTest",
]
152 changes: 152 additions & 0 deletions packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""API endpoint response conformance fixtures."""

from collections.abc import Callable
from typing import Any, ClassVar

from lean_spec.subspecs.containers import BlockBody, Slot, ValidatorIndex
from lean_spec.subspecs.containers.block import Block
from lean_spec.subspecs.containers.block.types import AggregatedAttestations
from lean_spec.subspecs.containers.state import State
from lean_spec.subspecs.forkchoice.store import Store
from lean_spec.subspecs.ssz.hash import hash_tree_root
from lean_spec.types import Bytes32, Uint64

from ..genesis import generate_pre_state
from .base import BaseConsensusFixture


def _make_genesis_block(state: State) -> Block:
"""Build a slot-0 block anchored to the given genesis state."""
body = BlockBody(attestations=AggregatedAttestations(data=[]))
return Block(
slot=Slot(0),
proposer_index=ValidatorIndex(0),
parent_root=Bytes32.zero(),
state_root=Bytes32(hash_tree_root(state)),
body=body,
)


def _build_store(num_validators: int, genesis_time: int) -> Store:
"""Build a deterministic genesis-only store. Same params always produce same roots."""
state = generate_pre_state(genesis_time=Uint64(genesis_time), num_validators=num_validators)
block = _make_genesis_block(state)
# No validator identity — fixture only reads store data, never signs.
return Store.from_anchor(state, block, validator_id=None)


def _health_response(_store: Store) -> dict[str, Any]:
"""Static liveness check. Independent of consensus state."""
return {
"expected_status_code": 200,
"expected_content_type": "application/json",
"expected_body": {"status": "healthy", "service": "lean-rpc-api"},
}


def _justified_response(store: Store) -> dict[str, Any]:
"""Latest justified checkpoint: slot + root. Root varies with validator count."""
return {
"expected_status_code": 200,
"expected_content_type": "application/json",
"expected_body": {
"slot": int(store.latest_justified.slot),
"root": "0x" + store.latest_justified.root.hex(),
},
}


def _finalized_state_response(store: Store) -> dict[str, Any]:
"""Full SSZ-encoded finalized state as hex bytes."""
state = store.states[store.latest_finalized.root]
return {
"expected_status_code": 200,
"expected_content_type": "application/octet-stream",
"expected_body": "0x" + state.encode_bytes().hex(),
}


def _fork_choice_response(store: Store) -> dict[str, Any]:
"""Fork choice tree: blocks with weights, head, checkpoints, validator count."""
weights = store.compute_block_weights()

# Only post-finalization blocks are relevant to head selection.
nodes = [
{
"root": "0x" + root.hex(),
"slot": int(block.slot),
"parent_root": "0x" + block.parent_root.hex(),
"proposer_index": int(block.proposer_index),
"weight": weights.get(root, 0),
}
for root, block in store.blocks.items()
if block.slot >= store.latest_finalized.slot
]

# Validator count from head state (most current view).
head_state = store.states.get(store.head)
return {
"expected_status_code": 200,
"expected_content_type": "application/json",
"expected_body": {
"nodes": nodes,
"head": "0x" + store.head.hex(),
"justified": {
"slot": int(store.latest_justified.slot),
"root": "0x" + store.latest_justified.root.hex(),
},
"finalized": {
"slot": int(store.latest_finalized.slot),
"root": "0x" + store.latest_finalized.root.hex(),
},
"safe_target": "0x" + store.safe_target.hex(),
"validator_count": len(head_state.validators) if head_state is not None else 0,
},
}


_ENDPOINT_HANDLERS: dict[str, Callable[[Store], dict[str, Any]]] = {
"/lean/v0/health": _health_response,
"/lean/v0/checkpoints/justified": _justified_response,
"/lean/v0/states/finalized": _finalized_state_response,
"/lean/v0/fork_choice": _fork_choice_response,
}
"""Maps endpoint paths to response builders."""


class ApiEndpointTest(BaseConsensusFixture):
"""Fixture for API endpoint response conformance.

JSON output: endpoint, genesisParams, expectedStatusCode,
expectedContentType, expectedBody.
"""

format_name: ClassVar[str] = "api_endpoint"
description: ClassVar[str] = "Tests API endpoint responses against known state"

endpoint: str
"""API path under test, e.g. /lean/v0/health."""

genesis_params: dict[str, int]
"""Genesis store inputs: numValidators and genesisTime."""

expected_status_code: int = 0
"""HTTP status code. Filled by make_fixture."""

expected_content_type: str = ""
"""Response MIME type. Filled by make_fixture."""

expected_body: Any = None
"""Response payload. JSON dict or hex SSZ string. Filled by make_fixture."""

def make_fixture(self) -> "ApiEndpointTest":
"""Build genesis store, compute expected response, populate output fields."""
handler = _ENDPOINT_HANDLERS.get(self.endpoint)
if handler is None:
raise ValueError(f"Unknown endpoint: {self.endpoint}")

store = _build_store(
num_validators=self.genesis_params.get("numValidators", 4),
genesis_time=self.genesis_params.get("genesisTime", 0),
)
return self.model_copy(update=handler(store))
47 changes: 47 additions & 0 deletions tests/consensus/devnet/api/test_api_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
Test vectors for API endpoint responses.

Each test generates a JSON fixture that client teams use to validate their
API server returns the exact same response given the same genesis parameters.
"""

import pytest
from consensus_testing import ApiEndpointTestFiller

pytestmark = pytest.mark.valid_until("Devnet")

GENESIS_4V = {"numValidators": 4, "genesisTime": 0}
"""Minimal genesis: 4 validators at epoch 0."""

GENESIS_12V = {"numValidators": 12, "genesisTime": 0}
"""Larger genesis: 12 validators produce a different state root than 4."""


def test_health(api_endpoint: ApiEndpointTestFiller) -> None:
"""Health returns a fixed payload independent of consensus state."""
api_endpoint(endpoint="/lean/v0/health", genesis_params=GENESIS_4V)


def test_justified_checkpoint_4v(api_endpoint: ApiEndpointTestFiller) -> None:
"""Justified checkpoint at genesis with 4 validators."""
api_endpoint(endpoint="/lean/v0/checkpoints/justified", genesis_params=GENESIS_4V)


def test_justified_checkpoint_12v(api_endpoint: ApiEndpointTestFiller) -> None:
"""Justified checkpoint at genesis with 12 validators. Root differs from 4v."""
api_endpoint(endpoint="/lean/v0/checkpoints/justified", genesis_params=GENESIS_12V)


def test_finalized_state_4v(api_endpoint: ApiEndpointTestFiller) -> None:
"""Full SSZ-encoded finalized state for a 4-validator genesis."""
api_endpoint(endpoint="/lean/v0/states/finalized", genesis_params=GENESIS_4V)


def test_fork_choice_4v(api_endpoint: ApiEndpointTestFiller) -> None:
"""Fork choice tree at genesis: single node, zero attestation weights."""
api_endpoint(endpoint="/lean/v0/fork_choice", genesis_params=GENESIS_4V)


def test_fork_choice_12v(api_endpoint: ApiEndpointTestFiller) -> None:
"""Fork choice tree at genesis with 12 validators. Same shape, higher count."""
api_endpoint(endpoint="/lean/v0/fork_choice", genesis_params=GENESIS_12V)
Loading