diff --git a/Cargo.lock b/Cargo.lock index c93c457..faaa6e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1992,9 +1992,12 @@ dependencies = [ "axum", "ethlambda-metrics", "ethlambda-storage", + "ethlambda-types", + "http-body-util", "serde", "serde_json", "tokio", + "tower", "tracing", ] diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index a40c721..c1b8e1f 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -17,3 +17,8 @@ tracing.workspace = true ethlambda-storage.workspace = true serde.workspace = true serde_json.workspace = true + +[dev-dependencies] +ethlambda-types.workspace = true +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index d1bb391..9c42a70 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -7,26 +7,27 @@ pub mod metrics; pub async fn start_rpc_server(address: SocketAddr, store: Store) -> Result<(), std::io::Error> { let metrics_router = metrics::start_prometheus_metrics_api(); + let api_router = build_api_router(store); - // Create stateful routes first, then convert to stateless by applying state - let api_routes = Router::new() - .route("/lean/v0/states/finalized", get(get_latest_finalized_state)) - .route( - "/lean/v0/checkpoints/justified", - get(get_latest_justified_state), - ) - .with_state(store); - - // Merge stateless routers - let app = Router::new().merge(metrics_router).merge(api_routes); + let app = Router::new().merge(metrics_router).merge(api_router); - // Start the axum app let listener = tokio::net::TcpListener::bind(address).await?; axum::serve(listener, app).await?; Ok(()) } +/// Build the API router with the given store. +fn build_api_router(store: Store) -> Router { + Router::new() + .route("/lean/v0/states/finalized", get(get_latest_finalized_state)) + .route( + "/lean/v0/checkpoints/justified", + get(get_latest_justified_state), + ) + .with_state(store) +} + async fn get_latest_finalized_state( axum::extract::State(store): axum::extract::State, ) -> impl IntoResponse { @@ -43,3 +44,141 @@ async fn get_latest_justified_state( let checkpoint = store.latest_justified(); Json(checkpoint) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use ethlambda_storage::Store; + use ethlambda_types::{ + block::{BlockBody, BlockHeader}, + primitives::TreeHash, + state::{ChainConfig, Checkpoint, JustificationValidators, JustifiedSlots, State}, + }; + use http_body_util::BodyExt; + use serde_json::json; + use tower::ServiceExt; + + /// Create a minimal test state for testing. + fn create_test_state() -> State { + let genesis_header = BlockHeader { + slot: 0, + proposer_index: 0, + parent_root: ethlambda_types::primitives::H256::ZERO, + state_root: ethlambda_types::primitives::H256::ZERO, + body_root: BlockBody::default().tree_hash_root(), + }; + + let genesis_checkpoint = Checkpoint { + root: ethlambda_types::primitives::H256::ZERO, + slot: 0, + }; + + State { + config: ChainConfig { genesis_time: 1000 }, + slot: 0, + latest_block_header: genesis_header, + latest_justified: genesis_checkpoint, + latest_finalized: genesis_checkpoint, + historical_block_hashes: Default::default(), + justified_slots: JustifiedSlots::with_capacity(0).unwrap(), + validators: Default::default(), + justifications_roots: Default::default(), + justifications_validators: JustificationValidators::with_capacity(0).unwrap(), + } + } + + #[tokio::test] + async fn test_get_latest_justified_checkpoint() { + let state = create_test_state(); + let store = Store::from_genesis(state); + + let app = build_api_router(store.clone()); + + let response = app + .oneshot( + Request::builder() + .uri("/lean/v0/checkpoints/justified") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let checkpoint: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + // The justified checkpoint should match the store's latest justified + let expected = store.latest_justified(); + assert_eq!( + checkpoint, + json!({ + "slot": expected.slot, + "root": format!("{:#x}", expected.root) + }) + ); + } + + #[tokio::test] + async fn test_get_latest_finalized_state() { + let state = create_test_state(); + let store = Store::from_genesis(state); + + // Get the expected state from the store to build expected JSON + let finalized = store.latest_finalized(); + let expected_state = store.get_state(&finalized.root).unwrap(); + + let app = build_api_router(store); + + let response = app + .oneshot( + Request::builder() + .uri("/lean/v0/states/finalized") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let returned_state: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + let header = &expected_state.latest_block_header; + assert_eq!( + returned_state, + json!({ + "config": { + "genesis_time": expected_state.config.genesis_time + }, + "slot": expected_state.slot, + "latest_block_header": { + "slot": header.slot, + "proposer_index": header.proposer_index, + "parent_root": format!("{:#x}", header.parent_root), + "state_root": format!("{:#x}", header.state_root), + "body_root": format!("{:#x}", header.body_root) + }, + "latest_justified": { + "slot": expected_state.latest_justified.slot, + "root": format!("{:#x}", expected_state.latest_justified.root) + }, + "latest_finalized": { + "slot": expected_state.latest_finalized.slot, + "root": format!("{:#x}", expected_state.latest_finalized.root) + }, + "historical_block_hashes": [], + "justified_slots": "0x01", + "validators": [], + "justifications_roots": [], + "justifications_validators": "0x01" + }) + ); + } +}