From 7f7205a13155643c980983b25dec3fcf2a537b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:28:20 -0300 Subject: [PATCH 1/3] refactor(rpc): compose api router from per-feature route modules Split the monolithic lib.rs API router into focused modules: core.rs holds the finalized-state/finalized-block/justified-checkpoint handlers and shared response helpers; blocks.rs, fork_choice.rs, and admin.rs each expose pub(crate) routes() -> Router. build_api_router merges them with .with_state(store). No behavior change. --- crates/net/rpc/src/admin.rs | 17 +++++- crates/net/rpc/src/blocks.rs | 12 +++- crates/net/rpc/src/core.rs | 73 ++++++++++++++++++++++++ crates/net/rpc/src/fork_choice.rs | 19 ++++--- crates/net/rpc/src/lib.rs | 93 ++++--------------------------- 5 files changed, 118 insertions(+), 96 deletions(-) create mode 100644 crates/net/rpc/src/core.rs diff --git a/crates/net/rpc/src/admin.rs b/crates/net/rpc/src/admin.rs index 43804d02..679a636d 100644 --- a/crates/net/rpc/src/admin.rs +++ b/crates/net/rpc/src/admin.rs @@ -12,10 +12,12 @@ //! the role (hot-standby model). See leanSpec PR #636 for the full rationale. use axum::{ - Extension, Json, + Extension, Json, Router, http::StatusCode, response::{IntoResponse, Response}, + routing::get, }; +use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; use serde::Serialize; use serde_json::Value; @@ -23,6 +25,13 @@ use tracing::info; use crate::json_response; +pub(crate) fn routes() -> Router { + Router::new().route( + "/lean/v0/admin/aggregator", + get(get_aggregator).post(post_aggregator), + ) +} + #[derive(Serialize)] struct StatusResponse { is_aggregator: bool, @@ -44,7 +53,9 @@ struct ToggleResponse { /// `Extension` would cause axum to short-circuit with a 500 when the /// extension is missing, whereas `Option` yields `None` and lets us return /// a clean 503 with a useful message. -pub async fn get_aggregator(controller: Option>) -> Response { +pub(crate) async fn get_aggregator( + controller: Option>, +) -> Response { match controller { Some(Extension(controller)) => json_response(StatusResponse { is_aggregator: controller.is_enabled(), @@ -62,7 +73,7 @@ pub async fn get_aggregator(controller: Option>) /// `Extension` would cause axum to short-circuit with a 500 when the /// extension is missing, whereas `Option` yields `None` and lets us return /// a clean 503 with a useful message. -pub async fn post_aggregator( +pub(crate) async fn post_aggregator( controller: Option>, body: Option>, ) -> Response { diff --git a/crates/net/rpc/src/blocks.rs b/crates/net/rpc/src/blocks.rs index edfa1737..8110b99b 100644 --- a/crates/net/rpc/src/blocks.rs +++ b/crates/net/rpc/src/blocks.rs @@ -1,7 +1,9 @@ use axum::{ + Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, + routing::get, }; use ethlambda_storage::Store; use ethlambda_types::primitives::H256; @@ -9,10 +11,16 @@ use serde_json::json; use crate::json_response; +pub(crate) fn routes() -> Router { + Router::new() + .route("/lean/v0/blocks/{block_id}", get(get_block)) + .route("/lean/v0/blocks/{block_id}/header", get(get_block_header)) +} + /// `GET /lean/v0/blocks/:block_id` — returns the block as JSON. /// /// `block_id` can be a `0x`-prefixed 32-byte hex root or a decimal slot. -pub async fn get_block( +pub(crate) async fn get_block( Path(block_id): Path, State(store): State, ) -> impl IntoResponse { @@ -28,7 +36,7 @@ pub async fn get_block( } /// `GET /lean/v0/blocks/:block_id/header` — returns the block header as JSON. -pub async fn get_block_header( +pub(crate) async fn get_block_header( Path(block_id): Path, State(store): State, ) -> impl IntoResponse { diff --git a/crates/net/rpc/src/core.rs b/crates/net/rpc/src/core.rs new file mode 100644 index 00000000..4ccd1f9a --- /dev/null +++ b/crates/net/rpc/src/core.rs @@ -0,0 +1,73 @@ +use axum::{ + Json, Router, + http::{HeaderValue, header}, + response::IntoResponse, + routing::get, +}; +use ethlambda_storage::Store; +use ethlambda_types::primitives::H256; +use libssz::SszEncode; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/lean/v0/health", get(crate::metrics::get_health)) + .route("/lean/v0/states/finalized", get(get_latest_finalized_state)) + .route("/lean/v0/blocks/finalized", get(get_latest_finalized_block)) + .route( + "/lean/v0/checkpoints/justified", + get(get_latest_justified_state), + ) +} + +pub(crate) async fn get_latest_finalized_state( + axum::extract::State(store): axum::extract::State, +) -> impl IntoResponse { + let finalized = store.latest_finalized(); + let mut state = store + .get_state(&finalized.root) + .expect("finalized state exists"); + + // Zero state_root to match the canonical post-state representation. + // The spec's state_transition sets state_root to zero during process_block_header, + // and only fills it in lazily at the next slot's process_slots. + // Serving the canonical form ensures checkpoint sync interoperability. + state.latest_block_header.state_root = H256::ZERO; + + ssz_response(state.to_ssz()) +} + +pub(crate) async fn get_latest_finalized_block( + axum::extract::State(store): axum::extract::State, +) -> impl IntoResponse { + let finalized = store.latest_finalized(); + // Returns 404 for genesis since it doesn't have a valid signature + match store.get_signed_block(&finalized.root) { + Some(block) => ssz_response(block.to_ssz()), + None => axum::http::StatusCode::NOT_FOUND.into_response(), + } +} + +pub(crate) async fn get_latest_justified_state( + axum::extract::State(store): axum::extract::State, +) -> impl IntoResponse { + let checkpoint = store.latest_justified(); + json_response(checkpoint) +} + +pub(crate) fn json_response(value: T) -> axum::response::Response { + let mut response = Json(value).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(crate::JSON_CONTENT_TYPE), + ); + response +} + +fn ssz_response(bytes: Vec) -> axum::response::Response { + let mut response = bytes.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(crate::SSZ_CONTENT_TYPE), + ); + response +} diff --git a/crates/net/rpc/src/fork_choice.rs b/crates/net/rpc/src/fork_choice.rs index 75fb2702..19f368d4 100644 --- a/crates/net/rpc/src/fork_choice.rs +++ b/crates/net/rpc/src/fork_choice.rs @@ -1,10 +1,16 @@ -use axum::{http::HeaderValue, http::header, response::IntoResponse}; +use axum::{Router, http::HeaderValue, http::header, response::IntoResponse, routing::get}; use ethlambda_storage::Store; use ethlambda_types::{checkpoint::Checkpoint, primitives::H256}; use serde::Serialize; use crate::json_response; +pub(crate) fn routes() -> Router { + Router::new() + .route("/lean/v0/fork_choice", get(get_fork_choice)) + .route("/lean/v0/fork_choice/ui", get(get_fork_choice_ui)) +} + const HTML_CONTENT_TYPE: &str = "text/html; charset=utf-8"; const FORK_CHOICE_HTML: &str = include_str!("../static/fork_choice.html"); @@ -27,7 +33,7 @@ pub struct ForkChoiceNode { weight: u64, } -pub async fn get_fork_choice( +pub(crate) async fn get_fork_choice( axum::extract::State(store): axum::extract::State, ) -> impl IntoResponse { let blocks = store.get_live_chain(); @@ -75,7 +81,7 @@ pub async fn get_fork_choice( json_response(response) } -pub async fn get_fork_choice_ui() -> impl IntoResponse { +pub(crate) async fn get_fork_choice_ui() -> impl IntoResponse { let mut response = FORK_CHOICE_HTML.into_response(); response.headers_mut().insert( header::CONTENT_TYPE, @@ -87,7 +93,7 @@ pub async fn get_fork_choice_ui() -> impl IntoResponse { #[cfg(test)] mod tests { use super::*; - use axum::{Router, body::Body, http::Request, http::StatusCode, routing::get}; + use axum::{Router, body::Body, http::Request, http::StatusCode}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use http_body_util::BodyExt; use std::sync::Arc; @@ -96,10 +102,7 @@ mod tests { use crate::test_utils::create_test_state; fn build_test_router(store: Store) -> Router { - Router::new() - .route("/lean/v0/fork_choice", get(get_fork_choice)) - .route("/lean/v0/fork_choice/ui", get(get_fork_choice_ui)) - .with_state(store) + routes().with_state(store) } #[tokio::test] diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 9906bfb9..2886cf16 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -1,15 +1,8 @@ use std::net::{IpAddr, SocketAddr}; -use axum::{ - Extension, Json, Router, - http::{HeaderValue, StatusCode, header}, - response::IntoResponse, - routing::get, -}; +use axum::{Extension, Router}; use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; -use ethlambda_types::primitives::H256; -use libssz::SszEncode; use tokio_util::sync::CancellationToken; pub(crate) const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; @@ -17,11 +10,14 @@ pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; mod admin; mod blocks; +mod core; mod fork_choice; mod heap_profiling; pub mod metrics; pub mod test_driver; +pub(crate) use core::json_response; + #[derive(Debug, Clone)] pub struct RpcConfig { pub http_address: IpAddr, @@ -100,32 +96,16 @@ pub async fn start_rpc_server( /// know about it and admin handlers extract it independently. fn build_api_router(store: Store) -> Router { Router::new() - .route("/lean/v0/health", get(metrics::get_health)) - .route("/lean/v0/states/finalized", get(get_latest_finalized_state)) - .route("/lean/v0/blocks/finalized", get(get_latest_finalized_block)) - .route( - "/lean/v0/checkpoints/justified", - get(get_latest_justified_state), - ) - .route("/lean/v0/fork_choice", get(fork_choice::get_fork_choice)) - .route( - "/lean/v0/fork_choice/ui", - get(fork_choice::get_fork_choice_ui), - ) - .route("/lean/v0/blocks/{block_id}", get(blocks::get_block)) - .route( - "/lean/v0/blocks/{block_id}/header", - get(blocks::get_block_header), - ) - .route( - "/lean/v0/admin/aggregator", - get(admin::get_aggregator).post(admin::post_aggregator), - ) + .merge(core::routes()) + .merge(blocks::routes()) + .merge(fork_choice::routes()) + .merge(admin::routes()) .with_state(store) } /// Build the debug router for profiling endpoints. fn build_debug_router() -> Router { + use axum::routing::get; Router::new() .route("/debug/pprof/allocs", get(heap_profiling::handle_get_heap)) .route( @@ -134,59 +114,6 @@ fn build_debug_router() -> Router { ) } -async fn get_latest_finalized_state( - axum::extract::State(store): axum::extract::State, -) -> impl IntoResponse { - let finalized = store.latest_finalized(); - let mut state = store - .get_state(&finalized.root) - .expect("finalized state exists"); - - // Zero state_root to match the canonical post-state representation. - // The spec's state_transition sets state_root to zero during process_block_header, - // and only fills it in lazily at the next slot's process_slots. - // Serving the canonical form ensures checkpoint sync interoperability. - state.latest_block_header.state_root = H256::ZERO; - - ssz_response(state.to_ssz()) -} - -async fn get_latest_finalized_block( - axum::extract::State(store): axum::extract::State, -) -> impl IntoResponse { - let finalized = store.latest_finalized(); - // Returns 404 for genesis since it doesn't have a valid signature - match store.get_signed_block(&finalized.root) { - Some(block) => ssz_response(block.to_ssz()), - None => StatusCode::NOT_FOUND.into_response(), - } -} - -async fn get_latest_justified_state( - axum::extract::State(store): axum::extract::State, -) -> impl IntoResponse { - let checkpoint = store.latest_justified(); - json_response(checkpoint) -} - -fn json_response(value: T) -> axum::response::Response { - let mut response = Json(value).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static(JSON_CONTENT_TYPE), - ); - response -} - -fn ssz_response(bytes: Vec) -> axum::response::Response { - let mut response = bytes.into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static(SSZ_CONTENT_TYPE), - ); - response -} - #[cfg(test)] pub(crate) mod test_utils { use ethlambda_storage::{StorageBackend, Table}; @@ -267,7 +194,7 @@ pub(crate) mod test_utils { #[cfg(test)] mod tests { use super::*; - use axum::{body::Body, http::Request}; + use axum::{body::Body, http::Request, http::StatusCode, http::header}; use ethlambda_storage::{ForkCheckpoints, Store, backend::InMemoryBackend}; use http_body_util::BodyExt; use serde_json::json; From 2b21320d3b9dc1e49f6d4aae5c172b5926c647b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:51:32 -0300 Subject: [PATCH 2/3] feat(rpc): add GET /lean/v0/attestations for validator participation --- crates/net/rpc/src/attestations.rs | 78 ++++++++++++++++++++++++++++++ crates/net/rpc/src/lib.rs | 2 + 2 files changed, 80 insertions(+) create mode 100644 crates/net/rpc/src/attestations.rs diff --git a/crates/net/rpc/src/attestations.rs b/crates/net/rpc/src/attestations.rs new file mode 100644 index 00000000..e57b25bf --- /dev/null +++ b/crates/net/rpc/src/attestations.rs @@ -0,0 +1,78 @@ +use axum::{ + Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, +}; +use ethlambda_storage::Store; +use serde::{Deserialize, Serialize}; + +use crate::json_response; + +#[derive(Deserialize)] +struct AttQuery { + slot: Option, +} + +#[derive(Serialize)] +struct AttestationEntry { + validator_index: u64, + slot: u64, + source_slot: u64, + target_slot: u64, +} + +async fn get_attestations( + Query(q): Query, + State(store): State, +) -> impl IntoResponse { + let known = store.extract_latest_known_attestations(); + let mut out: Vec = known + .into_iter() + .filter(|(_, data)| q.slot.is_none_or(|s| data.slot == s)) + .map(|(validator_index, data)| AttestationEntry { + validator_index, + slot: data.slot, + source_slot: data.source.slot, + target_slot: data.target.slot, + }) + .collect(); + out.sort_by_key(|e| e.validator_index); + json_response(out) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/lean/v0/attestations", get(get_attestations)) +} + +#[cfg(test)] +mod tests { + use crate::test_utils::create_test_state; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use ethlambda_storage::{Store, backend::InMemoryBackend}; + use http_body_util::BodyExt; + use std::sync::Arc; + use tower::ServiceExt; + + #[tokio::test] + async fn attestations_returns_array() { + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json.is_array()); + } +} diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 2886cf16..0a571d97 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -9,6 +9,7 @@ pub(crate) const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; mod admin; +mod attestations; mod blocks; mod core; mod fork_choice; @@ -97,6 +98,7 @@ pub async fn start_rpc_server( fn build_api_router(store: Store) -> Router { Router::new() .merge(core::routes()) + .merge(attestations::routes()) .merge(blocks::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) From 7050e6e52cc672d055c6469ef265d1e5d0af7734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:40:18 -0300 Subject: [PATCH 3/3] fix(rpc): address review feedback on attestations (validator_index filter, JSON errors, tests) - Add `?validator_index=N` filter to GET /lean/v0/attestations (combinable with `?slot=`) - Document that `?slot=` filters over latest-only attestations, not all historical ones - Bad query params now return JSON {"error":"..."} + 400 instead of Axum's plain-text 422 - Add 5 real tests: seeded entries with field assertions, slot filter, validator_index filter, combined filter, and bad-param JSON 400 contract --- crates/net/rpc/src/attestations.rs | 217 ++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 4 deletions(-) diff --git a/crates/net/rpc/src/attestations.rs b/crates/net/rpc/src/attestations.rs index e57b25bf..f7bdded4 100644 --- a/crates/net/rpc/src/attestations.rs +++ b/crates/net/rpc/src/attestations.rs @@ -1,17 +1,21 @@ use axum::{ Router, + extract::rejection::QueryRejection, extract::{Query, State}, + http::StatusCode, response::IntoResponse, routing::get, }; use ethlambda_storage::Store; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::json_response; #[derive(Deserialize)] struct AttQuery { slot: Option, + validator_index: Option, } #[derive(Serialize)] @@ -22,14 +26,35 @@ struct AttestationEntry { target_slot: u64, } +/// `GET /lean/v0/attestations` — returns per-validator latest attestations. +/// +/// # Query parameters +/// - `slot`: filter to entries whose `slot` matches. Note: the underlying store +/// holds one **latest** attestation per validator (the highest-slot one seen), +/// so `?slot=N` filters *over that latest-only set* — it does NOT return all +/// historical attestations ever cast at slot N. +/// - `validator_index`: filter to a single validator's entry. +/// +/// Both filters may be combined. Results are sorted by `validator_index`. async fn get_attestations( - Query(q): Query, + query: Result, QueryRejection>, State(store): State, ) -> impl IntoResponse { + let Query(q) = match query { + Ok(q) => q, + Err(_) => { + let mut response = json_response(json!({ "error": "invalid query parameter" })); + *response.status_mut() = StatusCode::BAD_REQUEST; + return response; + } + }; + let known = store.extract_latest_known_attestations(); let mut out: Vec = known .into_iter() - .filter(|(_, data)| q.slot.is_none_or(|s| data.slot == s)) + .filter(|(vid, data)| { + q.slot.is_none_or(|s| data.slot == s) && q.validator_index.is_none_or(|v| *vid == v) + }) .map(|(validator_index, data)| AttestationEntry { validator_index, slot: data.slot, @@ -53,12 +78,46 @@ mod tests { http::{Request, StatusCode}, }; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::{ + attestation::AggregationBits, + attestation::{AttestationData, HashedAttestationData}, + block::TypeOneMultiSignature, + checkpoint::Checkpoint, + }; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; + fn make_att_data(slot: u64, source_slot: u64, target_slot: u64) -> AttestationData { + AttestationData { + slot, + head: Checkpoint::default(), + source: Checkpoint { + slot: source_slot, + root: Default::default(), + }, + target: Checkpoint { + slot: target_slot, + root: Default::default(), + }, + } + } + + fn proof_for_validator(vid: usize) -> TypeOneMultiSignature { + let mut bits = AggregationBits::with_length(vid + 1).unwrap(); + bits.set(vid, true).unwrap(); + TypeOneMultiSignature::empty(bits) + } + + fn seed_known_attestation(store: &mut Store, validator_index: usize, data: AttestationData) { + store.insert_known_aggregated_payload( + HashedAttestationData::new(data), + proof_for_validator(validator_index), + ); + } + #[tokio::test] - async fn attestations_returns_array() { + async fn attestations_empty_store_returns_empty_array() { let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); let app = crate::build_api_router(store); let resp = app @@ -73,6 +132,156 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json.is_array()); + assert_eq!(json, serde_json::json!([])); + } + + #[tokio::test] + async fn attestations_returns_seeded_entries_with_correct_fields() { + let mut store = + Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + + seed_known_attestation(&mut store, 0, make_att_data(5, 1, 4)); + seed_known_attestation(&mut store, 2, make_att_data(7, 3, 6)); + + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + + // Sorted by validator_index: 0 first, then 2. + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["validator_index"], 0); + assert_eq!(entries[0]["slot"], 5); + assert_eq!(entries[0]["source_slot"], 1); + assert_eq!(entries[0]["target_slot"], 4); + assert_eq!(entries[1]["validator_index"], 2); + assert_eq!(entries[1]["slot"], 7); + } + + #[tokio::test] + async fn attestations_slot_filter() { + let mut store = + Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + + seed_known_attestation(&mut store, 0, make_att_data(5, 1, 4)); + seed_known_attestation(&mut store, 1, make_att_data(7, 3, 6)); + seed_known_attestation(&mut store, 2, make_att_data(5, 1, 4)); + + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations?slot=5") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + + // Only validators 0 and 2 attested at slot 5. + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["validator_index"], 0); + assert_eq!(entries[1]["validator_index"], 2); + } + + #[tokio::test] + async fn attestations_validator_index_filter() { + let mut store = + Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + + seed_known_attestation(&mut store, 0, make_att_data(5, 1, 4)); + seed_known_attestation(&mut store, 1, make_att_data(7, 3, 6)); + seed_known_attestation(&mut store, 2, make_att_data(5, 1, 4)); + + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations?validator_index=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["validator_index"], 1); + assert_eq!(entries[0]["slot"], 7); + } + + #[tokio::test] + async fn attestations_combined_slot_and_validator_filter() { + let mut store = + Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + + seed_known_attestation(&mut store, 0, make_att_data(5, 1, 4)); + seed_known_attestation(&mut store, 1, make_att_data(5, 1, 4)); + + let app = crate::build_api_router(store); + // validator 0 at slot 5 → match + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/lean/v0/attestations?slot=5&validator_index=0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["validator_index"], 0); + + // validator 0 at slot 9 → no match + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations?slot=9&validator_index=0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(entries.len(), 0); + } + + #[tokio::test] + async fn attestations_bad_query_param_returns_json_400() { + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/attestations?slot=abc") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json.get("error").is_some(), "expected JSON error field"); } }