From 29bdf27c2cc77975dc9f754c70474520898fed18 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 1/2] 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 09268765..948ebc0f 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; mod admin; mod base; +mod attestations; mod blocks; mod fork_choice; mod heap_profiling; @@ -97,6 +98,7 @@ pub async fn start_rpc_server( fn build_api_router(store: Store) -> Router { Router::new() .merge(base::routes()) + .merge(attestations::routes()) .merge(blocks::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) From 25dde47b56a75754ab29ad1d4d18886f2f044cc3 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 2/2] 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 ++++++++++++++++++++++++++++- crates/net/rpc/src/lib.rs | 2 +- 2 files changed, 214 insertions(+), 5 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"); } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 948ebc0f..5bff1e95 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -9,8 +9,8 @@ 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 base; mod attestations; +mod base; mod blocks; mod fork_choice; mod heap_profiling;