11"""API endpoint response conformance fixtures."""
22
3- from collections .abc import Callable
43from typing import Any , ClassVar
54
65from consensus_testing .genesis import build_anchor
76from consensus_testing .test_fixtures .base import BaseConsensusFixture , BaseTestSpec
87from lean_spec .base import StrictBaseModel
8+ from lean_spec .node .metrics .registry import registry as metrics_registry
99from lean_spec .spec .forks import Slot
1010from lean_spec .spec .forks .lstar import Store
1111from lean_spec .spec .forks .lstar .spec import LstarSpec
1212from lean_spec .spec .ssz import Uint64
1313
14+ REQUIRED_METRIC_NAMES = [
15+ "lean_node_info" ,
16+ "lean_node_start_time_seconds" ,
17+ "lean_head_slot" ,
18+ "lean_current_slot" ,
19+ "lean_safe_target_slot" ,
20+ "lean_fork_choice_block_processing_time_seconds" ,
21+ "lean_attestations_valid_total" ,
22+ "lean_attestations_invalid_total" ,
23+ "lean_attestation_validation_time_seconds" ,
24+ "lean_fork_choice_reorgs_total" ,
25+ "lean_fork_choice_reorg_depth" ,
26+ "lean_attestation_aggregate_coverage_validators" ,
27+ "lean_attestation_aggregate_coverage_subnets" ,
28+ "lean_attestation_aggregate_coverage_diff_validators" ,
29+ "lean_latest_justified_slot" ,
30+ "lean_latest_finalized_slot" ,
31+ "lean_state_transition_time_seconds" ,
32+ "lean_validators_count" ,
33+ "lean_connected_peers" ,
34+ ]
35+ """Metric names every client must expose. Changing this list is a cross-client surface change."""
36+
1437
1538class EndpointResponseContract (StrictBaseModel ):
1639 """The status, content type, and body one endpoint handler expects a client to return."""
@@ -25,183 +48,6 @@ class EndpointResponseContract(StrictBaseModel):
2548 """Expected response payload: a JSON object or a hex SSZ string."""
2649
2750
28- EndpointHandler = Callable [[Store , "ApiEndpointTest" ], EndpointResponseContract ]
29- """Uniform signature for endpoint response builders, so the dispatch table has one call site."""
30-
31-
32- def _build_store (num_validators : int , genesis_time : int , anchor_slot : int = 0 ) -> Store :
33- """
34- Build a deterministic store, genesis-only or an empty chain advanced to the anchor slot.
35-
36- No attestations are injected, so justification and finalization stay at genesis.
37- """
38- fork = LstarSpec ()
39- # Walk the chain from genesis using empty blocks; slot 0 returns the genesis pair unchanged.
40- state , block = build_anchor (
41- fork = fork ,
42- num_validators = num_validators ,
43- anchor_slot = Slot (anchor_slot ),
44- genesis_time = Uint64 (genesis_time ),
45- )
46- # No validator identity — fixture only reads store data, never signs.
47- return fork .create_store (state , block , validator_index = None )
48-
49-
50- def _health_response (_store : Store , _fixture : "ApiEndpointTest" ) -> EndpointResponseContract :
51- """Static liveness check, independent of consensus state."""
52- return EndpointResponseContract (
53- status_code = 200 ,
54- content_type = "application/json" ,
55- body = {"status" : "healthy" , "service" : "lean-rpc-api" },
56- )
57-
58-
59- def _justified_response (store : Store , _fixture : "ApiEndpointTest" ) -> EndpointResponseContract :
60- """Latest justified checkpoint as slot and root, with the root varying by validator count."""
61- return EndpointResponseContract (
62- status_code = 200 ,
63- content_type = "application/json" ,
64- body = {
65- "slot" : int (store .latest_justified .slot ),
66- "root" : "0x" + store .latest_justified .root .hex (),
67- },
68- )
69-
70-
71- def _finalized_state_response (
72- store : Store , _fixture : "ApiEndpointTest"
73- ) -> EndpointResponseContract :
74- """Full SSZ-encoded finalized state as hex bytes."""
75- state = store .states [store .latest_finalized .root ]
76- return EndpointResponseContract (
77- status_code = 200 ,
78- content_type = "application/octet-stream" ,
79- body = "0x" + state .encode_bytes ().hex (),
80- )
81-
82-
83- def _fork_choice_response (store : Store , _fixture : "ApiEndpointTest" ) -> EndpointResponseContract :
84- """Fork choice tree: blocks with weights, head, checkpoints, validator count."""
85- weights = LstarSpec ().compute_block_weights (store )
86-
87- # Only post-finalization blocks are relevant to head selection.
88- nodes = [
89- {
90- "root" : "0x" + root .hex (),
91- "slot" : int (block .slot ),
92- "parent_root" : "0x" + block .parent_root .hex (),
93- "proposer_index" : int (block .proposer_index ),
94- "weight" : weights .get (root , 0 ),
95- }
96- for root , block in store .blocks .items ()
97- if block .slot >= store .latest_finalized .slot
98- ]
99-
100- # Validator count from head state (most current view).
101- head_state = store .states .get (store .head )
102- return EndpointResponseContract (
103- status_code = 200 ,
104- content_type = "application/json" ,
105- body = {
106- "nodes" : nodes ,
107- "head" : "0x" + store .head .hex (),
108- "justified" : {
109- "slot" : int (store .latest_justified .slot ),
110- "root" : "0x" + store .latest_justified .root .hex (),
111- },
112- "finalized" : {
113- "slot" : int (store .latest_finalized .slot ),
114- "root" : "0x" + store .latest_finalized .root .hex (),
115- },
116- "safe_target" : "0x" + store .safe_target .hex (),
117- "validator_count" : len (head_state .validators ) if head_state is not None else 0 ,
118- },
119- )
120-
121-
122- def _aggregator_status_response (
123- _store : Store , fixture : "ApiEndpointTest"
124- ) -> EndpointResponseContract :
125- """Current aggregator role as seeded by initial_is_aggregator."""
126- return EndpointResponseContract (
127- status_code = 200 ,
128- content_type = "application/json" ,
129- body = {"is_aggregator" : fixture .initial_is_aggregator },
130- )
131-
132-
133- def _metrics_response (_store : Store , _fixture : "ApiEndpointTest" ) -> EndpointResponseContract :
134- """
135- Prometheus-format metrics scrape.
136-
137- The body is dynamic, so the fixture pins only status, content type, and the metric names.
138- """
139- from lean_spec .node .metrics .registry import registry as metrics_registry
140-
141- # Enumerated from the metrics spec; changing the list is a cross-client surface change.
142- required_metric_names = [
143- "lean_node_info" ,
144- "lean_node_start_time_seconds" ,
145- "lean_head_slot" ,
146- "lean_current_slot" ,
147- "lean_safe_target_slot" ,
148- "lean_fork_choice_block_processing_time_seconds" ,
149- "lean_attestations_valid_total" ,
150- "lean_attestations_invalid_total" ,
151- "lean_attestation_validation_time_seconds" ,
152- "lean_fork_choice_reorgs_total" ,
153- "lean_fork_choice_reorg_depth" ,
154- "lean_attestation_aggregate_coverage_validators" ,
155- "lean_attestation_aggregate_coverage_subnets" ,
156- "lean_attestation_aggregate_coverage_diff_validators" ,
157- "lean_latest_justified_slot" ,
158- "lean_latest_finalized_slot" ,
159- "lean_state_transition_time_seconds" ,
160- "lean_validators_count" ,
161- "lean_connected_peers" ,
162- ]
163- # Touch the registry so its removal trips this fixture instead of failing silently.
164- assert metrics_registry is not None
165- return EndpointResponseContract (
166- status_code = 200 ,
167- content_type = "text/plain; version=0.0.4; charset=utf-8" ,
168- body = {"required_metric_names" : required_metric_names },
169- )
170-
171-
172- def _aggregator_toggle_response (
173- _store : Store , fixture : "ApiEndpointTest"
174- ) -> EndpointResponseContract :
175- """Expected response after toggling the aggregator role, with the new and previous values."""
176- body = fixture .request_body
177- if not isinstance (body , dict ) or not isinstance (body .get ("enabled" ), bool ):
178- raise ValueError (
179- "POST /lean/v0/admin/aggregator fixture requires request_body "
180- "with a boolean 'enabled' field"
181- )
182- new_value = body ["enabled" ]
183- return EndpointResponseContract (
184- status_code = 200 ,
185- content_type = "application/json" ,
186- body = {
187- "is_aggregator" : new_value ,
188- "previous" : fixture .initial_is_aggregator ,
189- },
190- )
191-
192-
193- _ENDPOINT_HANDLERS : dict [tuple [str , str ], EndpointHandler ] = {
194- ("GET" , "/lean/v0/health" ): _health_response ,
195- ("GET" , "/lean/v0/checkpoints/justified" ): _justified_response ,
196- ("GET" , "/lean/v0/states/finalized" ): _finalized_state_response ,
197- ("GET" , "/lean/v0/fork_choice" ): _fork_choice_response ,
198- ("GET" , "/lean/v0/admin/aggregator" ): _aggregator_status_response ,
199- ("POST" , "/lean/v0/admin/aggregator" ): _aggregator_toggle_response ,
200- ("GET" , "/metrics" ): _metrics_response ,
201- }
202- """Maps (method, path) tuples to response builders."""
203-
204-
20551class ApiEndpointFixture (BaseConsensusFixture ):
20652 """Emitted vector for API endpoint response conformance."""
20753
@@ -259,23 +105,134 @@ class ApiEndpointTest(BaseTestSpec):
259105
260106 def generate (self ) -> ApiEndpointFixture :
261107 """Build genesis store, compute expected response, emit the vector."""
262- handler = _ENDPOINT_HANDLERS .get ((self .method , self .endpoint ))
263- if handler is None :
264- raise ValueError (f"Unknown endpoint: { self .method } { self .endpoint } " )
265-
266- store = _build_store (
108+ # Build a deterministic store: genesis-only, or an empty chain advanced to the anchor slot.
109+ # No attestations are injected, so justification and finalization stay at genesis.
110+ fork = LstarSpec ()
111+ # Walk the chain from genesis using empty blocks; slot 0 returns the genesis pair unchanged.
112+ state , block = build_anchor (
113+ fork = fork ,
267114 num_validators = self .genesis_params .get ("numValidators" , 4 ),
268- genesis_time = self .genesis_params .get ("genesisTime " , 0 ),
269- anchor_slot = self .genesis_params .get ("anchorSlot " , 0 ),
115+ anchor_slot = Slot ( self .genesis_params .get ("anchorSlot " , 0 ) ),
116+ genesis_time = Uint64 ( self .genesis_params .get ("genesisTime " , 0 ) ),
270117 )
271- response_contract = handler (store , self )
118+ # No validator identity — fixture only reads store data, never signs.
119+ store = fork .create_store (state , block , validator_index = None )
120+
121+ response = self ._expected_response (store )
272122 return ApiEndpointFixture (
273123 endpoint = self .endpoint ,
274124 method = self .method ,
275125 genesis_params = self .genesis_params ,
276126 request_body = self .request_body ,
277127 initial_is_aggregator = self .initial_is_aggregator ,
278- expected_status_code = response_contract .status_code ,
279- expected_content_type = response_contract .content_type ,
280- expected_body = response_contract .body ,
128+ expected_status_code = response .status_code ,
129+ expected_content_type = response .content_type ,
130+ expected_body = response .body ,
281131 )
132+
133+ def _expected_response (self , store : Store ) -> EndpointResponseContract :
134+ """Compute the response a conforming client must return for the route under test."""
135+ match (self .method , self .endpoint ):
136+ case ("GET" , "/lean/v0/health" ):
137+ # Static liveness check, independent of consensus state.
138+ return EndpointResponseContract (
139+ status_code = 200 ,
140+ content_type = "application/json" ,
141+ body = {"status" : "healthy" , "service" : "lean-rpc-api" },
142+ )
143+
144+ case ("GET" , "/lean/v0/checkpoints/justified" ):
145+ # Latest justified checkpoint; the root varies with validator count.
146+ return EndpointResponseContract (
147+ status_code = 200 ,
148+ content_type = "application/json" ,
149+ body = {
150+ "slot" : int (store .latest_justified .slot ),
151+ "root" : "0x" + store .latest_justified .root .hex (),
152+ },
153+ )
154+
155+ case ("GET" , "/lean/v0/states/finalized" ):
156+ # Full SSZ-encoded finalized state as hex bytes.
157+ finalized_state = store .states [store .latest_finalized .root ]
158+ return EndpointResponseContract (
159+ status_code = 200 ,
160+ content_type = "application/octet-stream" ,
161+ body = "0x" + finalized_state .encode_bytes ().hex (),
162+ )
163+
164+ case ("GET" , "/lean/v0/fork_choice" ):
165+ # Fork choice tree: blocks with weights, head, checkpoints, validator count.
166+ weights = LstarSpec ().compute_block_weights (store )
167+
168+ # Only post-finalization blocks are relevant to head selection.
169+ nodes = [
170+ {
171+ "root" : "0x" + root .hex (),
172+ "slot" : int (block .slot ),
173+ "parent_root" : "0x" + block .parent_root .hex (),
174+ "proposer_index" : int (block .proposer_index ),
175+ "weight" : weights .get (root , 0 ),
176+ }
177+ for root , block in store .blocks .items ()
178+ if block .slot >= store .latest_finalized .slot
179+ ]
180+
181+ # The head always has a stored state, so a missing one is a broken invariant.
182+ head_state = store .states [store .head ]
183+ return EndpointResponseContract (
184+ status_code = 200 ,
185+ content_type = "application/json" ,
186+ body = {
187+ "nodes" : nodes ,
188+ "head" : "0x" + store .head .hex (),
189+ "justified" : {
190+ "slot" : int (store .latest_justified .slot ),
191+ "root" : "0x" + store .latest_justified .root .hex (),
192+ },
193+ "finalized" : {
194+ "slot" : int (store .latest_finalized .slot ),
195+ "root" : "0x" + store .latest_finalized .root .hex (),
196+ },
197+ "safe_target" : "0x" + store .safe_target .hex (),
198+ "validator_count" : len (head_state .validators ),
199+ },
200+ )
201+
202+ case ("GET" , "/lean/v0/admin/aggregator" ):
203+ # Current aggregator role as seeded by the spec.
204+ return EndpointResponseContract (
205+ status_code = 200 ,
206+ content_type = "application/json" ,
207+ body = {"is_aggregator" : self .initial_is_aggregator },
208+ )
209+
210+ case ("POST" , "/lean/v0/admin/aggregator" ):
211+ # Toggling reports the new aggregator value and the previous one.
212+ body = self .request_body
213+ if not isinstance (body , dict ) or not isinstance (body .get ("enabled" ), bool ):
214+ raise ValueError (
215+ "POST /lean/v0/admin/aggregator fixture requires request_body "
216+ "with a boolean 'enabled' field"
217+ )
218+ return EndpointResponseContract (
219+ status_code = 200 ,
220+ content_type = "application/json" ,
221+ body = {
222+ "is_aggregator" : body ["enabled" ],
223+ "previous" : self .initial_is_aggregator ,
224+ },
225+ )
226+
227+ case ("GET" , "/metrics" ):
228+ # The body is dynamic, so pin only status, content type, and the metric names.
229+ # Touch the registry so its removal trips this fixture instead of failing silently.
230+ assert metrics_registry is not None
231+ return EndpointResponseContract (
232+ status_code = 200 ,
233+ content_type = "text/plain; version=0.0.4; charset=utf-8" ,
234+ body = {"required_metric_names" : REQUIRED_METRIC_NAMES },
235+ )
236+
237+ case _:
238+ raise ValueError (f"Unknown endpoint: { self .method } { self .endpoint } " )
0 commit comments