|
1 | 1 | use std::net::{IpAddr, SocketAddr}; |
2 | 2 |
|
3 | 3 | use axum::{ |
4 | | - Extension, Json, Router, http::HeaderValue, http::header, response::IntoResponse, routing::get, |
| 4 | + Extension, Json, Router, |
| 5 | + http::{HeaderValue, StatusCode, header}, |
| 6 | + response::IntoResponse, |
| 7 | + routing::get, |
5 | 8 | }; |
6 | 9 | use ethlambda_storage::Store; |
7 | 10 | use ethlambda_types::aggregator::AggregatorController; |
@@ -75,6 +78,7 @@ fn build_api_router(store: Store) -> Router { |
75 | 78 | Router::new() |
76 | 79 | .route("/lean/v0/health", get(metrics::get_health)) |
77 | 80 | .route("/lean/v0/states/finalized", get(get_latest_finalized_state)) |
| 81 | + .route("/lean/v0/blocks/finalized", get(get_latest_finalized_block)) |
78 | 82 | .route( |
79 | 83 | "/lean/v0/checkpoints/justified", |
80 | 84 | get(get_latest_justified_state), |
@@ -118,6 +122,17 @@ async fn get_latest_finalized_state( |
118 | 122 | ssz_response(state.to_ssz()) |
119 | 123 | } |
120 | 124 |
|
| 125 | +async fn get_latest_finalized_block( |
| 126 | + axum::extract::State(store): axum::extract::State<Store>, |
| 127 | +) -> impl IntoResponse { |
| 128 | + let finalized = store.latest_finalized(); |
| 129 | + // Returns 404 for genesis since it doesn't have a valid signature |
| 130 | + match store.get_signed_block(&finalized.root) { |
| 131 | + Some(block) => ssz_response(block.to_ssz()), |
| 132 | + None => StatusCode::NOT_FOUND.into_response(), |
| 133 | + } |
| 134 | +} |
| 135 | + |
121 | 136 | async fn get_latest_justified_state( |
122 | 137 | axum::extract::State(store): axum::extract::State<Store>, |
123 | 138 | ) -> impl IntoResponse { |
@@ -185,11 +200,8 @@ pub(crate) mod test_utils { |
185 | 200 | #[cfg(test)] |
186 | 201 | mod tests { |
187 | 202 | use super::*; |
188 | | - use axum::{ |
189 | | - body::Body, |
190 | | - http::{Request, StatusCode}, |
191 | | - }; |
192 | | - use ethlambda_storage::{Store, backend::InMemoryBackend}; |
| 203 | + use axum::{body::Body, http::Request}; |
| 204 | + use ethlambda_storage::{ForkCheckpoints, Store, backend::InMemoryBackend}; |
193 | 205 | use http_body_util::BodyExt; |
194 | 206 | use serde_json::json; |
195 | 207 | use std::sync::Arc; |
@@ -266,4 +278,95 @@ mod tests { |
266 | 278 | let body = response.into_body().collect().await.unwrap().to_bytes(); |
267 | 279 | assert_eq!(body.as_ref(), expected_ssz.as_slice()); |
268 | 280 | } |
| 281 | + |
| 282 | + #[tokio::test] |
| 283 | + async fn test_get_latest_finalized_block() { |
| 284 | + use ethlambda_types::{ |
| 285 | + attestation::XmssSignature, |
| 286 | + block::{Block, BlockBody, BlockSignatures, SignedBlock}, |
| 287 | + checkpoint::Checkpoint, |
| 288 | + primitives::{H256, HashTreeRoot as _}, |
| 289 | + signature::SIGNATURE_SIZE, |
| 290 | + }; |
| 291 | + use libssz::SszEncode; |
| 292 | + |
| 293 | + let state = create_test_state(); |
| 294 | + let backend = Arc::new(InMemoryBackend::new()); |
| 295 | + let mut store = Store::from_anchor_state(backend, state); |
| 296 | + |
| 297 | + // Build a non-genesis signed block with empty body and zero proposer signature. |
| 298 | + let block = Block { |
| 299 | + slot: 1, |
| 300 | + proposer_index: 0, |
| 301 | + parent_root: store.latest_finalized().root, |
| 302 | + state_root: H256::ZERO, |
| 303 | + body: BlockBody::default(), |
| 304 | + }; |
| 305 | + let block_root = block.header().hash_tree_root(); |
| 306 | + let signed_block = SignedBlock { |
| 307 | + message: block, |
| 308 | + signature: BlockSignatures { |
| 309 | + attestation_signatures: Default::default(), |
| 310 | + proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), |
| 311 | + }, |
| 312 | + }; |
| 313 | + |
| 314 | + // Persist the signed block and mark it as the latest finalized checkpoint. |
| 315 | + store.insert_signed_block(block_root, signed_block.clone()); |
| 316 | + store.update_checkpoints(ForkCheckpoints::new( |
| 317 | + block_root, |
| 318 | + None, |
| 319 | + Some(Checkpoint { |
| 320 | + root: block_root, |
| 321 | + slot: 1, |
| 322 | + }), |
| 323 | + )); |
| 324 | + |
| 325 | + let expected_ssz = signed_block.to_ssz(); |
| 326 | + |
| 327 | + let app = build_api_router(store); |
| 328 | + |
| 329 | + let response = app |
| 330 | + .oneshot( |
| 331 | + Request::builder() |
| 332 | + .uri("/lean/v0/blocks/finalized") |
| 333 | + .body(Body::empty()) |
| 334 | + .unwrap(), |
| 335 | + ) |
| 336 | + .await |
| 337 | + .unwrap(); |
| 338 | + |
| 339 | + assert_eq!(response.status(), StatusCode::OK); |
| 340 | + assert_eq!( |
| 341 | + response.headers().get(header::CONTENT_TYPE).unwrap(), |
| 342 | + SSZ_CONTENT_TYPE |
| 343 | + ); |
| 344 | + |
| 345 | + let body = response.into_body().collect().await.unwrap().to_bytes(); |
| 346 | + assert_eq!(body.as_ref(), expected_ssz.as_slice()); |
| 347 | + } |
| 348 | + |
| 349 | + #[tokio::test] |
| 350 | + async fn test_get_latest_finalized_block_returns_404_when_absent() { |
| 351 | + // Genesis-anchored store: init_store writes header + state but no |
| 352 | + // BlockSignatures entry, so get_signed_block(genesis_root) returns None |
| 353 | + // and the endpoint must report 404 rather than panic. |
| 354 | + let state = create_test_state(); |
| 355 | + let backend = Arc::new(InMemoryBackend::new()); |
| 356 | + let store = Store::from_anchor_state(backend, state); |
| 357 | + |
| 358 | + let app = build_api_router(store); |
| 359 | + |
| 360 | + let response = app |
| 361 | + .oneshot( |
| 362 | + Request::builder() |
| 363 | + .uri("/lean/v0/blocks/finalized") |
| 364 | + .body(Body::empty()) |
| 365 | + .unwrap(), |
| 366 | + ) |
| 367 | + .await |
| 368 | + .unwrap(); |
| 369 | + |
| 370 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); |
| 371 | + } |
269 | 372 | } |
0 commit comments