From 041b34a7784e5de84b6e2f45b74433c0619a85d4 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Thu, 16 Jul 2026 21:01:21 +0800 Subject: [PATCH 01/14] refactor(api): serve espresso-node query API via axum Switch espresso-node's serve modes (full, fs, status-only, bare) to the in-repo axum server. The tide-disco App is still constructed so the module definitions keep compiling; it no longer binds the API port. Restores tide-disco wire parity on the axum server: tide-compatible paths and /v0 URI rewrites (without the unconsumed v0 legacy availability formats), reward-state and namespace proof endpoints, tide 404/400 statuses on reward endpoints, accept-negotiated JSON/vbs healthcheck bodies (AppHealth shape at top level, bare HealthStatus per module), and ws Close frames on stream completion. --- Cargo.lock | 1 + crates/espresso/api/Cargo.toml | 3 +- crates/espresso/api/examples/test_api.rs | 71 +- crates/espresso/api/src/axum.rs | 1986 +++++++++++------ crates/espresso/api/src/axum/routes.rs | 312 ++- crates/espresso/api/src/handlers.rs | 11 +- crates/espresso/api/src/lib.rs | 236 +- crates/espresso/api/src/v1/availability.rs | 2 +- crates/espresso/api/src/v1/database.rs | 2 + crates/espresso/api/src/v1/light_client.rs | 12 + crates/espresso/api/src/v1/reward_state_v2.rs | 53 + crates/espresso/dev-node/src/main.rs | 6 - crates/espresso/node/src/api.rs | 247 +- crates/espresso/node/src/api/light_client.rs | 32 +- crates/espresso/node/src/api/options.rs | 147 +- crates/espresso/node/src/api/state.rs | 422 +++- crates/espresso/node/src/catchup.rs | 28 +- crates/espresso/node/src/options.rs | 2 - crates/espresso/node/src/run.rs | 14 +- ..._tests__config_node_response_postgres.snap | 1 - crates/process-metrics/scripts/soak.py | 6 +- docker-compose.yaml | 6 +- process-compose.yaml | 11 +- scripts/wait-for-header | 8 +- 24 files changed, 2618 insertions(+), 1001 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a6ef9c5d5b..7681ff4d5b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4718,6 +4718,7 @@ dependencies = [ "tower 0.4.13", "tracing", "tracing-subscriber 0.3.23", + "url", "vbs", ] diff --git a/crates/espresso/api/Cargo.toml b/crates/espresso/api/Cargo.toml index 77979858f1f..47868b7743e 100644 --- a/crates/espresso/api/Cargo.toml +++ b/crates/espresso/api/Cargo.toml @@ -21,9 +21,10 @@ tokio = { workspace = true } tonic = { workspace = true } tonic-prost = { workspace = true } tonic-reflection = { workspace = true } -tower = { workspace = true } +tower = { workspace = true, features = ["limit", "load-shed", "util"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } vbs = { workspace = true } [build-dependencies] diff --git a/crates/espresso/api/examples/test_api.rs b/crates/espresso/api/examples/test_api.rs index 9dca770524e..ae7a1ca63ad 100644 --- a/crates/espresso/api/examples/test_api.rs +++ b/crates/espresso/api/examples/test_api.rs @@ -35,6 +35,32 @@ impl v1::RewardApi for TestApi { type RewardAccountQueryData = (u128, Vec); type RewardAmounts = (Vec<(u128, u128)>, u64); type RewardMerkleTreeData = Vec; + type RewardAccountQueryDataV1 = (u128, Vec); + type RewardStatePathV1 = serde_json::Value; + type RewardStatePathV2 = serde_json::Value; + + async fn get_reward_state_height(&self) -> Result { + tracing::info!("v1: get_reward_state_height()"); + Ok(42) + } + + async fn get_reward_state_v2_height(&self) -> Result { + tracing::info!("v1: get_reward_state_v2_height()"); + Ok(42) + } + + async fn get_reward_account_proof_v1( + &self, + height: u64, + address: String, + ) -> Result { + tracing::info!( + "v1: get_reward_account_proof_v1(height={}, address={})", + height, + address + ); + Ok((500_000_000_000_000_000, vec![0xde, 0xad, 0xbe, 0xef])) + } async fn get_reward_claim_input( &self, @@ -119,6 +145,22 @@ impl v1::RewardApi for TestApi { tracing::info!("v1: get_reward_merkle_tree_v2(height={})", height); Ok(vec![0x00, 0x11, 0x22, 0x33, 0x44, 0x55]) } + + async fn get_reward_state_path_v1( + &self, + _snapshot: v1::Snapshot, + _key: String, + ) -> Result { + Ok(serde_json::Value::Null) + } + + async fn get_reward_state_path_v2( + &self, + _snapshot: v1::Snapshot, + _key: String, + ) -> Result { + Ok(serde_json::Value::Null) + } } // Implement v1::AvailabilityApi with test data @@ -133,13 +175,13 @@ impl v1::AvailabilityApi for TestApi { &self, block_id: v1::availability::BlockId, namespace: u32, - ) -> Result> { + ) -> Result { tracing::info!( "v1: get_namespace_proof(block_id={:?}, namespace={})", block_id, namespace ); - Ok(Some((vec![0xaa, 0xbb, 0xcc], Some(vec![0x11, 0x22, 0x33])))) + Ok((vec![0xaa, 0xbb, 0xcc], Some(vec![0x11, 0x22, 0x33]))) } async fn get_namespace_proof_range( @@ -645,6 +687,14 @@ impl v1::LightClientApi for TestApi { ) -> Result> { Ok(vec![]) } + async fn get_lc_namespaces_proof_range( + &self, + _start: u64, + _end: u64, + _namespaces: String, + ) -> Result>> { + Ok(vec![]) + } } #[async_trait] @@ -707,10 +757,15 @@ impl v1::TokenApi for TestApi { #[async_trait] impl v1::DatabaseApi for TestApi { type TableSizes = serde_json::Value; + type MigrationStatus = serde_json::Value; async fn get_table_sizes(&self) -> Result { Ok(serde_json::Value::Null) } + + async fn get_migration_status(&self) -> Result { + Ok(serde_json::Value::Null) + } } // Implement v2::RewardApi (simplified API - latest-only for claim/balance/proof) @@ -1083,8 +1138,16 @@ async fn main() -> Result<()> { let state = TestApi; - // Start Axum server with combined v1 and v2 APIs - espresso_api::serve_axum(API_PORT, state).await?; + // Start Axum server with combined v1 and v2 APIs, all optional modules enabled + let modules = espresso_api::OptionalModules { + submit: true, + catchup: true, + config: true, + hotshot_events: true, + explorer: true, + light_client: true, + }; + espresso_api::serve_axum(API_PORT, state, modules, None).await?; Ok(()) } diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index 7f3b7874d4a..5cdf4e2ae4f 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -2,6 +2,8 @@ pub mod routes; +use std::collections::BTreeMap; + use aide::{ axum::{ApiRouter, routing::get_with}, openapi::{Info, OpenApi}, @@ -14,11 +16,10 @@ use axum::{ body::Bytes, extract::{Path, Request, State, ws::WebSocketUpgrade}, http::{HeaderMap, StatusCode, Uri, header}, - middleware::{self, Next}, response::{Html, IntoResponse, Response}, routing::get, }; -use futures::stream::BoxStream; +use futures::{StreamExt, stream::BoxStream}; use schemars::transform::Transform; use serde::Serialize; use serialization_api::v2::{ @@ -126,7 +127,7 @@ fn decode_body( /// Classify an `anyhow::Error` from an availability handler into the appropriate `ApiError` /// variant. Errors produced via [`AvailabilityError`] in the state implementation carry semantic /// meaning; everything else falls back to a 500 Internal Server Error. -fn classify_availability_error(err: anyhow::Error) -> ApiError { +pub(crate) fn classify_availability_error(err: anyhow::Error) -> ApiError { let is_not_found = err .downcast_ref::() .map(|e| matches!(e, AvailabilityError::NotFound(_))); @@ -151,21 +152,41 @@ async fn serve_swagger_ui() -> Html<&'static str> { Html(include_str!("../templates/swagger.html")) } -/// Middleware to rewrite root paths to /v2 paths -/// -/// Requests to `/rewards/...` get rewritten to `/v2/rewards/...` -/// Paths already prefixed with `/v2` are left unchanged +/// Redirect handler for root path +async fn redirect_to_docs() -> axum::response::Redirect { + axum::response::Redirect::permanent("/v2") +} + +/// Tide-disco served every v1 module at both `//...` and `/v1//...`, and legacy +/// clients (surf-disco, the light-client, tests) still address the unversioned and `/v0` forms. +/// Axum only declares the `/v1/...` and `/v2/...` route shapes, and `Router::layer` middleware +/// runs after routing, so it can never redirect a request onto a route it doesn't already match. +/// This function is instead wrapped around the whole router with `tower::util::MapRequestLayer` +/// (see `serve_axum`), which runs before routing, to rewrite the URI so the declared routes match. /// -/// Note: This middleware is only applied to the v2 router, so v1 routes never pass through it -async fn rewrite_root_to_v2(mut req: Request, next: Next) -> Response { +/// Excludes paths that are intentionally unversioned: `/`, `/healthcheck`, `/version`, and +/// anything already prefixed with `/v1` or `/v2`. +pub(crate) fn rewrite_legacy_uri(mut req: Request) -> Request { let uri = req.uri().clone(); let path = uri.path(); - // Only rewrite unversioned paths (not starting with /v2) - if !path.starts_with("/v2") && path != "/" { - let new_path = format!("/v2{}", path); + let is_reserved = + path == "/" || path == "/healthcheck" || path == "/version" || path.is_empty(); + let is_versioned = + path == "/v1" || path.starts_with("/v1/") || path == "/v2" || path.starts_with("/v2/"); + let new_path = if is_versioned || is_reserved { + None + } else if path == "/v0" { + Some("/v1".to_string()) + } else if let Some(rest) = path.strip_prefix("/v0/") { + Some(format!("/v1/{rest}")) + } else { + Some(format!("/v1{path}")) + }; + + if let Some(new_path) = new_path { let pq = if let Some(q) = uri.query() { - format!("{}?{}", new_path, q) + format!("{new_path}?{q}") } else { new_path }; @@ -174,12 +195,7 @@ async fn rewrite_root_to_v2(mut req: Request, next: Next) -> Response { } } - next.run(req).await -} - -/// Redirect handler for root path -async fn redirect_to_docs() -> axum::response::Redirect { - axum::response::Redirect::permanent("/v2") + req } struct SendQuery(T); @@ -243,7 +259,6 @@ async fn drive_ws_stream( format: WsFormat, ) { use axum::extract::ws::Message; - use futures::StreamExt as _; futures::pin_mut!(stream); while let Some(item) = stream.next().await { let msg = match format { @@ -257,9 +272,13 @@ async fn drive_ws_stream( }, }; if socket.send(msg).await.is_err() { - break; + return; } } + // Close handshake, like tide-disco's socket handler. Without it, dropping the socket resets + // the connection and clients see an error instead of end-of-stream — the finite v0 streams + // rely on a clean close to signal completion. + let _ = socket.send(Message::Close(None)).await; } /// Create a combined router serving both v1 and v2 APIs @@ -290,34 +309,84 @@ where + 'static, { let router_v1 = create_router_v1(state.clone()); - let router_v2 = create_router_v2(state).layer(middleware::from_fn(rewrite_root_to_v2)); + let router_v2 = create_router_v2(state); - router_v2.merge(router_v1).route("/", get(redirect_to_docs)) + with_top_level_routes(router_v2.merge(router_v1)) } -/// Create v1 router without OpenAPI documentation (internal types) -pub fn create_router_v1(state: S) -> Router +/// Add the routes that every mode serves regardless of which API modules are enabled: +/// `/`, `/healthcheck`, and `/version`. +pub(crate) fn with_top_level_routes(router: Router) -> Router { + router + .route("/", get(redirect_to_docs)) + .route("/healthcheck", get(healthcheck)) + .route("/v1/{module}/healthcheck", get(module_healthcheck)) + .route("/version", get(version)) +} + +/// Health status of an application. +/// +/// Wire-compatible with `tide_disco::healthcheck::HealthStatus` 0.9.6: `Available` is its first +/// variant, so JSON emits the same name and vbs/bincode the same ordinal. The server only ever +/// reports `Available`; the remaining tide variants are omitted until a client-side type exists. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + Available, +} + +/// Wire-compatible with `tide_disco::app::AppHealth`: JSON keys, variant casing, and the +/// vbs/bincode field order (status ordinal, then modules map) must not change. +#[derive(Serialize)] +struct AppHealth { + status: HealthStatus, + // Tide populated this with each module's versioned health status; the axum modules don't + // report individual health, so it stays empty. + modules: BTreeMap>, +} + +/// Top-level healthcheck, matching tide-disco's app-level `AppHealth` response for multi-module +/// apps, in JSON or vbs binary depending on `Accept`. +async fn healthcheck(headers: HeaderMap) -> Result { + encode_response( + &headers, + AppHealth { + status: HealthStatus::Available, + modules: BTreeMap::new(), + }, + ) +} + +/// Module-level healthcheck response, matching tide-disco's per-module `/healthcheck`: a bare +/// [`HealthStatus`], in JSON or vbs binary depending on `Accept`. Exported for the standalone +/// axum servers (submit-transactions, nasty-client, dev-node) that tide served as singleton apps. +pub fn healthcheck_response(headers: &HeaderMap) -> Response { + match encode_response(headers, HealthStatus::Available) { + Ok(resp) => resp, + Err(err) => err.into_response(), + } +} + +/// `/v1/{module}/healthcheck`, reached by legacy clients via the `/{module}/healthcheck` rewrite. +/// +/// Divergence from tide-disco: matches any `{module}` string, so unregistered module names report +/// healthy instead of 404. Constraining it to the registered set would have to track which +/// modules each serve mode mounts; not worth it for a liveness probe. +async fn module_healthcheck(headers: HeaderMap) -> Response { + healthcheck_response(&headers) +} + +/// Tide-disco-compatible version response. Tide emits the binary's clap version; we emit the +/// crate version so `surf_disco::Client::connect` and similar polling helpers succeed. +async fn version() -> Json { + Json(serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + })) +} + +pub(crate) fn router_reward(state: S) -> Router where - S: v1::RewardApi - + v1::AvailabilityApi - + v1::HotShotAvailabilityApi - + v1::BlockStateApi - + v1::FeeStateApi - + v1::StatusApi - + v1::ConfigApi - + v1::NodeApi - + v1::CatchupApi - + v1::SubmitApi - + v1::StateSignatureApi - + v1::HotShotEventsApi - + v1::LightClientApi - + v1::ExplorerApi - + v1::TokenApi - + v1::DatabaseApi - + Clone - + Send - + Sync - + 'static, + S: v1::RewardApi + Clone + Send + Sync + 'static, { // Create handler closures that capture the generic state type let get_reward_claim_input = @@ -326,7 +395,7 @@ where .get_reward_claim_input(height, address) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_reward_balance = @@ -335,7 +404,7 @@ where .get_reward_balance(height, address) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_latest_reward_balance = |State(state): State, Path(address): Path| async move { @@ -343,7 +412,7 @@ where .get_latest_reward_balance(address) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_reward_account_proof = @@ -352,7 +421,7 @@ where .get_reward_account_proof(height, address) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_latest_reward_account_proof = |State(state): State, Path(address): Path| async move { @@ -360,7 +429,7 @@ where .get_latest_reward_account_proof(address) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_reward_amounts = @@ -369,16 +438,180 @@ where .get_reward_amounts(height, offset, limit) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) }; let get_reward_merkle_tree_v2 = |State(state): State, Path(height): Path| async move { ::get_reward_merkle_tree_v2(&state, height) .await .map(Json) - .map_err(ApiError::Internal) + .map_err(classify_availability_error) + }; + + let get_reward_state_height = |State(state): State| async move { + state + .get_reward_state_height() + .await + .map(Json) + .map_err(classify_availability_error) }; + let get_reward_state_v2_height = |State(state): State| async move { + state + .get_reward_state_v2_height() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + // Same underlying V2-tree lookup as `reward-state-v2/reward-balance`; tide registers this + // route unconditionally for both merklized-state modules regardless of tree version. + let get_reward_balance_v1 = + |State(state): State, Path((height, address)): Path<(u64, String)>| async move { + state + .get_reward_balance(height, address) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let get_reward_account_proof_v1 = + |State(state): State, Path((height, address)): Path<(u64, String)>| async move { + state + .get_reward_account_proof_v1(height, address) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + // Merklized-state `get_path` handlers, inherited by both reward mounts from + // `hotshot-query-service`'s base `state.toml` routes (mirrors router_block_state / + // router_fee_state below). + let get_reward_state_path_v1_by_height = + |State(state): State, Path((height, key)): Path<(u64, String)>| async move { + ::get_reward_state_path_v1( + &state, + v1::Snapshot::Height(height), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let get_reward_state_path_v1_by_commit = + |State(state): State, Path((commit, key)): Path<(String, String)>| async move { + ::get_reward_state_path_v1( + &state, + v1::Snapshot::Commit(commit), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let get_reward_state_path_v2_by_height = + |State(state): State, Path((height, key)): Path<(u64, String)>| async move { + ::get_reward_state_path_v2( + &state, + v1::Snapshot::Height(height), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let get_reward_state_path_v2_by_commit = + |State(state): State, Path((commit, key)): Path<(String, String)>| async move { + ::get_reward_state_path_v2( + &state, + v1::Snapshot::Commit(commit), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + Router::new() + .route( + routes::v1::REWARD_CLAIM_INPUT_ROUTE, + get(get_reward_claim_input), + ) + .route(routes::v1::REWARD_BALANCE_ROUTE, get(get_reward_balance)) + .route( + routes::v1::LATEST_REWARD_BALANCE_ROUTE, + get(get_latest_reward_balance), + ) + .route( + routes::v1::REWARD_ACCOUNT_PROOF_ROUTE, + get(get_reward_account_proof), + ) + .route( + routes::v1::LATEST_REWARD_ACCOUNT_PROOF_ROUTE, + get(get_latest_reward_account_proof), + ) + .route(routes::v1::REWARD_AMOUNTS_ROUTE, get(get_reward_amounts)) + .route( + routes::v1::REWARD_MERKLE_TREE_V2_ROUTE, + get(get_reward_merkle_tree_v2), + ) + .route( + routes::v1::REWARD_STATE_HEIGHT_ROUTE, + get(get_reward_state_height), + ) + .route( + routes::v1::REWARD_STATE_V2_HEIGHT_ROUTE, + get(get_reward_state_v2_height), + ) + .route( + routes::v1::REWARD_V1_BALANCE_ROUTE, + get(get_reward_balance_v1), + ) + .route( + routes::v1::REWARD_V1_ACCOUNT_PROOF_ROUTE, + get(get_reward_account_proof_v1), + ) + // Tide-disco twins of the reward-state-v2 routes above, registered on the same + // handlers (tide shared them across both merklized-state modules). + .route( + routes::v1::REWARD_V1_LATEST_BALANCE_ROUTE, + get(get_latest_reward_balance), + ) + .route( + routes::v1::REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, + get(get_latest_reward_account_proof), + ) + .route(routes::v1::REWARD_V1_AMOUNTS_ROUTE, get(get_reward_amounts)) + .route( + routes::v1::REWARD_V1_MERKLE_TREE_V2_ROUTE, + get(get_reward_merkle_tree_v2), + ) + .route( + routes::v1::REWARD_STATE_PATH_BY_HEIGHT_ROUTE, + get(get_reward_state_path_v1_by_height), + ) + .route( + routes::v1::REWARD_STATE_PATH_BY_COMMIT_ROUTE, + get(get_reward_state_path_v1_by_commit), + ) + .route( + routes::v1::REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, + get(get_reward_state_path_v2_by_height), + ) + .route( + routes::v1::REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, + get(get_reward_state_path_v2_by_commit), + ) + .with_state(state) +} + +pub(crate) fn router_availability(state: S) -> Router +where + S: v1::AvailabilityApi + v1::HotShotAvailabilityApi + Clone + Send + Sync + 'static, +{ // Availability API handlers // Route: /v1/availability/block/{height}/namespace/{namespace} let get_namespace_proof_by_height = @@ -451,7 +684,6 @@ where }; // HotShot availability API handlers - let get_leaf_by_height = |State(state): State, Path(height): Path| async move { state .get_leaf(v1::LeafId::Height(height)) @@ -459,6 +691,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_leaf_by_hash = |State(state): State, Path(hash): Path| async move { state .get_leaf(v1::LeafId::Hash(hash)) @@ -466,6 +699,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_leaf_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_leaf_range(from, until) @@ -481,6 +715,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_header_by_hash = |State(state): State, Path(hash): Path| async move { state .get_header(v1::BlockId::Hash(hash)) @@ -488,6 +723,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_header_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state .get_header(v1::BlockId::PayloadHash(payload_hash)) @@ -495,6 +731,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_header_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_header_range(from, until) @@ -510,6 +747,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_block_by_hash = |State(state): State, Path(hash): Path| async move { state .get_block(v1::BlockId::Hash(hash)) @@ -517,6 +755,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_block_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state .get_block(v1::BlockId::PayloadHash(payload_hash)) @@ -524,6 +763,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_block_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_block_range(from, until) @@ -539,6 +779,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_payload_by_hash = |State(state): State, Path(hash): Path| async move { state .get_payload(v1::PayloadId::Hash(hash)) @@ -546,6 +787,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_payload_by_block_hash = |State(state): State, Path(block_hash): Path| async move { state .get_payload(v1::PayloadId::BlockHash(block_hash)) @@ -553,6 +795,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_payload_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_payload_range(from, until) @@ -568,6 +811,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_vid_common_by_hash = |State(state): State, Path(hash): Path| async move { state .get_vid_common(v1::BlockId::Hash(hash)) @@ -575,6 +819,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_vid_common_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state @@ -583,6 +828,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_vid_common_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state @@ -600,6 +846,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_transaction_by_hash = |State(state): State, Path(hash): Path| async move { state .get_transaction_by_hash(hash) @@ -607,6 +854,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_transaction_proof_by_position = |State(state): State, Path((height, index)): Path<(u64, u64)>| async move { state @@ -615,6 +863,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_transaction_proof_by_hash = |State(state): State, Path(hash): Path| async move { state .get_transaction_proof_by_hash(hash) @@ -630,6 +879,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let get_block_summary_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state @@ -667,6 +917,7 @@ where } }) }; + let stream_headers = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -679,6 +930,7 @@ where } }) }; + let stream_blocks = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -691,6 +943,7 @@ where } }) }; + let stream_payloads = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -703,6 +956,7 @@ where } }) }; + let stream_vid_common = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -715,6 +969,7 @@ where } }) }; + let stream_transactions = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -727,6 +982,7 @@ where } }) }; + let stream_transactions_ns = |ws: WebSocketUpgrade, State(state): State, @@ -740,6 +996,7 @@ where } }) }; + let stream_namespace_proofs = |ws: WebSocketUpgrade, State(state): State, @@ -754,57 +1011,192 @@ where }) }; - // Merklized state handlers: block-state - let get_block_state_path_by_height = - |State(state): State, Path((height, key)): Path<(u64, String)>| async move { - ::get_block_state_path( - &state, - v1::Snapshot::Height(height), - key, - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let get_block_state_path_by_commit = - |State(state): State, Path((commit, key)): Path<(String, String)>| async move { - ::get_block_state_path( - &state, - v1::Snapshot::Commit(commit), - key, - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let get_block_state_height = |State(state): State| async move { - ::get_block_state_height(&state) - .await - .map(Json) - .map_err(classify_availability_error) - }; - - // Merklized state handlers: fee-state - let get_fee_state_path_by_height = - |State(state): State, Path((height, key)): Path<(u64, String)>| async move { - ::get_fee_state_path(&state, v1::Snapshot::Height(height), key) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let get_fee_state_path_by_commit = - |State(state): State, Path((commit, key)): Path<(String, String)>| async move { - ::get_fee_state_path(&state, v1::Snapshot::Commit(commit), key) - .await - .map(Json) - .map_err(classify_availability_error) + Router::new() + .route( + routes::v1::NAMESPACE_PROOF_BY_HEIGHT_ROUTE, + get(get_namespace_proof_by_height), + ) + .route( + routes::v1::NAMESPACE_PROOF_BY_HASH_ROUTE, + get(get_namespace_proof_by_hash), + ) + .route( + routes::v1::NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, + get(get_namespace_proof_by_payload_hash), + ) + .route( + routes::v1::NAMESPACE_PROOF_RANGE_ROUTE, + get(get_namespace_proof_range), + ) + .route( + routes::v1::INCORRECT_ENCODING_PROOF_ROUTE, + get(get_incorrect_encoding_proof), + ) + .route(routes::v1::STATE_CERT_V1_ROUTE, get(get_state_cert_v1)) + .route(routes::v1::STATE_CERT_V2_ROUTE, get(get_state_cert_v2)) + .route(routes::v1::LEAF_BY_HEIGHT_ROUTE, get(get_leaf_by_height)) + .route(routes::v1::LEAF_BY_HASH_ROUTE, get(get_leaf_by_hash)) + .route(routes::v1::LEAF_RANGE_ROUTE, get(get_leaf_range)) + .route( + routes::v1::HEADER_BY_HEIGHT_ROUTE, + get(get_header_by_height), + ) + .route(routes::v1::HEADER_BY_HASH_ROUTE, get(get_header_by_hash)) + .route( + routes::v1::HEADER_BY_PAYLOAD_HASH_ROUTE, + get(get_header_by_payload_hash), + ) + .route(routes::v1::HEADER_RANGE_ROUTE, get(get_header_range)) + .route(routes::v1::BLOCK_BY_HEIGHT_ROUTE, get(get_block_by_height)) + .route(routes::v1::BLOCK_BY_HASH_ROUTE, get(get_block_by_hash)) + .route( + routes::v1::BLOCK_BY_PAYLOAD_HASH_ROUTE, + get(get_block_by_payload_hash), + ) + .route(routes::v1::BLOCK_RANGE_ROUTE, get(get_block_range)) + .route( + routes::v1::PAYLOAD_BY_HEIGHT_ROUTE, + get(get_payload_by_height), + ) + .route(routes::v1::PAYLOAD_BY_HASH_ROUTE, get(get_payload_by_hash)) + .route( + routes::v1::PAYLOAD_BY_BLOCK_HASH_ROUTE, + get(get_payload_by_block_hash), + ) + .route(routes::v1::PAYLOAD_RANGE_ROUTE, get(get_payload_range)) + .route( + routes::v1::VID_COMMON_BY_HEIGHT_ROUTE, + get(get_vid_common_by_height), + ) + .route( + routes::v1::VID_COMMON_BY_HASH_ROUTE, + get(get_vid_common_by_hash), + ) + .route( + routes::v1::VID_COMMON_BY_PAYLOAD_HASH_ROUTE, + get(get_vid_common_by_payload_hash), + ) + .route( + routes::v1::VID_COMMON_RANGE_ROUTE, + get(get_vid_common_range), + ) + .route( + routes::v1::TRANSACTION_BY_POSITION_NOPROOF_ROUTE, + get(get_transaction_by_position), + ) + .route( + routes::v1::TRANSACTION_BY_HASH_NOPROOF_ROUTE, + get(get_transaction_by_hash), + ) + .route( + routes::v1::TRANSACTION_PROOF_BY_POSITION_ROUTE, + get(get_transaction_proof_by_position), + ) + .route( + routes::v1::TRANSACTION_PROOF_BY_HASH_ROUTE, + get(get_transaction_proof_by_hash), + ) + .route( + routes::v1::TRANSACTION_BY_POSITION_ROUTE, + get(get_transaction_proof_by_position), + ) + .route( + routes::v1::TRANSACTION_BY_HASH_ROUTE, + get(get_transaction_proof_by_hash), + ) + .route( + routes::v1::BLOCK_SUMMARY_BY_HEIGHT_ROUTE, + get(get_block_summary_by_height), + ) + .route( + routes::v1::BLOCK_SUMMARY_RANGE_ROUTE, + get(get_block_summary_range), + ) + .route(routes::v1::LIMITS_ROUTE, get(get_limits)) + .route(routes::v1::CERT2_BY_HEIGHT_ROUTE, get(get_cert2)) + .route(routes::v1::STREAM_LEAVES_ROUTE, get(stream_leaves)) + .route(routes::v1::STREAM_HEADERS_ROUTE, get(stream_headers)) + .route(routes::v1::STREAM_BLOCKS_ROUTE, get(stream_blocks)) + .route(routes::v1::STREAM_PAYLOADS_ROUTE, get(stream_payloads)) + .route(routes::v1::STREAM_VID_COMMON_ROUTE, get(stream_vid_common)) + .route( + routes::v1::STREAM_TRANSACTIONS_ROUTE, + get(stream_transactions), + ) + .route( + routes::v1::STREAM_TRANSACTIONS_NS_ROUTE, + get(stream_transactions_ns), + ) + .route( + routes::v1::STREAM_NAMESPACE_PROOFS_ROUTE, + get(stream_namespace_proofs), + ) + .with_state(state) +} + +pub(crate) fn router_block_state(state: S) -> Router +where + S: v1::BlockStateApi + Clone + Send + Sync + 'static, +{ + let get_block_state_height = |State(state): State| async move { + ::get_block_state_height(&state) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let get_block_state_path_by_commit = + |State(state): State, Path((commit, key)): Path<(String, String)>| async move { + ::get_block_state_path( + &state, + v1::Snapshot::Commit(commit), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + // Merklized state handlers: block-state + let get_block_state_path_by_height = + |State(state): State, Path((height, key)): Path<(u64, String)>| async move { + ::get_block_state_path( + &state, + v1::Snapshot::Height(height), + key, + ) + .await + .map(Json) + .map_err(classify_availability_error) }; + + Router::new() + .route( + routes::v1::BLOCK_STATE_HEIGHT_ROUTE, + get(get_block_state_height), + ) + .route( + routes::v1::BLOCK_STATE_PATH_BY_COMMIT_ROUTE, + get(get_block_state_path_by_commit), + ) + .route( + routes::v1::BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, + get(get_block_state_path_by_height), + ) + .with_state(state) +} + +pub(crate) fn router_fee_state(state: S) -> Router +where + S: v1::FeeStateApi + Clone + Send + Sync + 'static, +{ let get_fee_state_height = |State(state): State| async move { ::get_fee_state_height(&state) .await .map(Json) .map_err(classify_availability_error) }; + let get_fee_balance_latest = |State(state): State, Path(address): Path| async move { state .get_fee_balance_latest(address) @@ -813,24 +1205,68 @@ where .map_err(classify_availability_error) }; + let get_fee_state_path_by_commit = + |State(state): State, Path((commit, key)): Path<(String, String)>| async move { + ::get_fee_state_path(&state, v1::Snapshot::Commit(commit), key) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + // Merklized state handlers: fee-state + let get_fee_state_path_by_height = + |State(state): State, Path((height, key)): Path<(u64, String)>| async move { + ::get_fee_state_path(&state, v1::Snapshot::Height(height), key) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + Router::new() + .route( + routes::v1::FEE_STATE_HEIGHT_ROUTE, + get(get_fee_state_height), + ) + .route( + routes::v1::FEE_STATE_BALANCE_LATEST_ROUTE, + get(get_fee_balance_latest), + ) + .route( + routes::v1::FEE_STATE_PATH_BY_COMMIT_ROUTE, + get(get_fee_state_path_by_commit), + ) + .route( + routes::v1::FEE_STATE_PATH_BY_HEIGHT_ROUTE, + get(get_fee_state_path_by_height), + ) + .with_state(state) +} + +pub(crate) fn router_status(state: S) -> Router +where + S: v1::StatusApi + Clone + Send + Sync + 'static, +{ let status_block_height = |State(state): State| async move { ::block_height(&state) .await .map(Json) .map_err(ApiError::Internal) }; + let status_success_rate = |State(state): State| async move { ::success_rate(&state) .await .map(Json) .map_err(ApiError::Internal) }; + let status_time_since_last_decide = |State(state): State| async move { ::time_since_last_decide(&state) .await .map(Json) .map_err(ApiError::Internal) }; + let status_metrics = |State(state): State| async move { match ::metrics(&state).await { Ok(text) => Ok(( @@ -844,18 +1280,41 @@ where } }; + Router::new() + .route( + routes::v1::STATUS_BLOCK_HEIGHT_ROUTE, + get(status_block_height), + ) + .route( + routes::v1::STATUS_SUCCESS_RATE_ROUTE, + get(status_success_rate), + ) + .route( + routes::v1::STATUS_TIME_SINCE_LAST_DECIDE_ROUTE, + get(status_time_since_last_decide), + ) + .route(routes::v1::STATUS_METRICS_ROUTE, get(status_metrics)) + .with_state(state) +} + +pub(crate) fn router_config(state: S) -> Router +where + S: v1::ConfigApi + Clone + Send + Sync + 'static, +{ let config_hotshot = |State(state): State| async move { ::hotshot_config(&state) .await .map(Json) .map_err(ApiError::Internal) }; + let config_env = |State(state): State| async move { ::env(&state) .await .map(Json) .map_err(ApiError::Internal) }; + let config_runtime = |State(state): State| async move { ::runtime_config(&state) .await @@ -863,6 +1322,17 @@ where .map_err(classify_availability_error) }; + Router::new() + .route(routes::v1::CONFIG_HOTSHOT_ROUTE, get(config_hotshot)) + .route(routes::v1::CONFIG_ENV_ROUTE, get(config_env)) + .route(routes::v1::CONFIG_RUNTIME_ROUTE, get(config_runtime)) + .with_state(state) +} + +pub(crate) fn router_node(state: S) -> Router +where + S: v1::NodeApi + Clone + Send + Sync + 'static, +{ let node_block_height = |State(state): State| async move { ::block_height(&state) .await @@ -877,34 +1347,23 @@ where .map(Json) .map_err(classify_availability_error) }; - let node_count_txs_to = |State(state): State, Path(to): Path| async move { + + let node_count_txs_ns = |State(state): State, Path(namespace): Path| async move { state - .count_transactions(None, Some(to), None) + .count_transactions(None, None, Some(namespace)) .await .map(Json) .map_err(classify_availability_error) }; - let node_count_txs_from_to = |State(state): State, Path((from, to)): Path<(u64, u64)>| async move { + + let node_count_txs_ns_to = |State(state): State, Path((namespace, to)): Path<(u64, u64)>| async move { state - .count_transactions(Some(from), Some(to), None) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let node_count_txs_ns = |State(state): State, Path(namespace): Path| async move { - state - .count_transactions(None, None, Some(namespace)) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let node_count_txs_ns_to = |State(state): State, Path((namespace, to)): Path<(u64, u64)>| async move { - state - .count_transactions(None, Some(to), Some(namespace)) + .count_transactions(None, Some(to), Some(namespace)) .await .map(Json) .map_err(classify_availability_error) }; + let node_count_txs_ns_from_to = |State(state): State, Path((namespace, from, to)): Path<(u64, u64, u64)>| async move { state @@ -914,27 +1373,30 @@ where .map_err(classify_availability_error) }; - let node_payload_size = |State(state): State| async move { + let node_count_txs_to = |State(state): State, Path(to): Path| async move { state - .payload_size(None, None, None) + .count_transactions(None, Some(to), None) .await .map(Json) .map_err(classify_availability_error) }; - let node_payload_size_to = |State(state): State, Path(to): Path| async move { + + let node_count_txs_from_to = |State(state): State, Path((from, to)): Path<(u64, u64)>| async move { state - .payload_size(None, Some(to), None) + .count_transactions(Some(from), Some(to), None) .await .map(Json) .map_err(classify_availability_error) }; - let node_payload_size_from_to = |State(state): State, Path((from, to)): Path<(u64, u64)>| async move { + + let node_payload_size = |State(state): State| async move { state - .payload_size(Some(from), Some(to), None) + .payload_size(None, None, None) .await .map(Json) .map_err(classify_availability_error) }; + let node_payload_size_ns = |State(state): State, Path(namespace): Path| async move { state .payload_size(None, None, Some(namespace)) @@ -942,6 +1404,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let node_payload_size_ns_to = |State(state): State, Path((namespace, to)): Path<(u64, u64)>| async move { state @@ -950,6 +1413,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let node_payload_size_ns_from_to = |State(state): State, Path((namespace, from, to)): Path<(u64, u64, u64)>| async move { state @@ -959,13 +1423,22 @@ where .map_err(classify_availability_error) }; - let node_vid_share_by_height = |State(state): State, Path(height): Path| async move { + let node_payload_size_to = |State(state): State, Path(to): Path| async move { state - .get_vid_share(v1::VidShareId::Height(height)) + .payload_size(None, Some(to), None) .await .map(Json) .map_err(classify_availability_error) }; + + let node_payload_size_from_to = |State(state): State, Path((from, to)): Path<(u64, u64)>| async move { + state + .payload_size(Some(from), Some(to), None) + .await + .map(Json) + .map_err(classify_availability_error) + }; + let node_vid_share_by_hash = |State(state): State, Path(hash): Path| async move { state .get_vid_share(v1::VidShareId::Hash(hash)) @@ -973,6 +1446,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let node_vid_share_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state @@ -982,38 +1456,48 @@ where .map_err(classify_availability_error) }; - let node_sync_status = |State(state): State| async move { + let node_vid_share_by_height = |State(state): State, Path(height): Path| async move { state - .sync_status() + .get_vid_share(v1::VidShareId::Height(height)) .await .map(Json) .map_err(classify_availability_error) }; - let node_header_window_time = |State(state): State, Path((start, end)): Path<(u64, u64)>| async move { + let node_sync_status = |State(state): State| async move { state - .get_header_window(v1::HeaderWindowStart::Time(start), end) + .sync_status() .await .map(Json) .map_err(classify_availability_error) }; - let node_header_window_height = - |State(state): State, Path((height, end)): Path<(u64, u64)>| async move { + + let node_header_window_hash = + |State(state): State, Path((hash, end)): Path<(String, u64)>| async move { state - .get_header_window(v1::HeaderWindowStart::Height(height), end) + .get_header_window(v1::HeaderWindowStart::Hash(hash), end) .await .map(Json) .map_err(classify_availability_error) }; - let node_header_window_hash = - |State(state): State, Path((hash, end)): Path<(String, u64)>| async move { + + let node_header_window_height = + |State(state): State, Path((height, end)): Path<(u64, u64)>| async move { state - .get_header_window(v1::HeaderWindowStart::Hash(hash), end) + .get_header_window(v1::HeaderWindowStart::Height(height), end) .await .map(Json) .map_err(classify_availability_error) }; + let node_header_window_time = |State(state): State, Path((start, end)): Path<(u64, u64)>| async move { + state + .get_header_window(v1::HeaderWindowStart::Time(start), end) + .await + .map(Json) + .map_err(classify_availability_error) + }; + let node_limits = |State(state): State| async move { ::limits(&state) .await @@ -1028,6 +1512,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_stake_table = |State(state): State, Path(epoch): Path| async move { state .stake_table(epoch) @@ -1035,6 +1520,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_da_stake_table_current = |State(state): State| async move { state .da_stake_table_current() @@ -1042,6 +1528,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_da_stake_table = |State(state): State, Path(epoch): Path| async move { state .da_stake_table(epoch) @@ -1057,6 +1544,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_all_validators = |State(state): State, Path((epoch, offset, limit)): Path<(u64, u64, u64)>| async move { state @@ -1073,6 +1561,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_proposal_participation = |State(state): State, Path(epoch): Path| async move { state .proposal_participation(epoch) @@ -1080,6 +1569,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_vote_participation_current = |State(state): State| async move { state .current_vote_participation() @@ -1087,6 +1577,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_vote_participation = |State(state): State, Path(epoch): Path| async move { state .vote_participation(epoch) @@ -1102,6 +1593,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_block_reward_epoch = |State(state): State, Path(epoch): Path| async move { state .get_block_reward(Some(epoch)) @@ -1117,6 +1609,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let node_oldest_leaf = |State(state): State| async move { state .get_oldest_leaf() @@ -1125,6 +1618,131 @@ where .map_err(ApiError::Internal) }; + Router::new() + .route(routes::v1::NODE_BLOCK_HEIGHT_ROUTE, get(node_block_height)) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_ROUTE, + get(node_count_txs), + ) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_ROUTE, + get(node_count_txs_ns), + ) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, + get(node_count_txs_ns_to), + ) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, + get(node_count_txs_ns_from_to), + ) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_TO_ROUTE, + get(node_count_txs_to), + ) + .route( + routes::v1::NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, + get(node_count_txs_from_to), + ) + .route(routes::v1::NODE_PAYLOADS_SIZE_ROUTE, get(node_payload_size)) + .route( + routes::v1::NODE_PAYLOADS_TOTAL_SIZE_ROUTE, + get(node_payload_size), + ) + .route( + routes::v1::NODE_PAYLOADS_SIZE_NS_ROUTE, + get(node_payload_size_ns), + ) + .route( + routes::v1::NODE_PAYLOADS_SIZE_NS_TO_ROUTE, + get(node_payload_size_ns_to), + ) + .route( + routes::v1::NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, + get(node_payload_size_ns_from_to), + ) + .route( + routes::v1::NODE_PAYLOADS_SIZE_TO_ROUTE, + get(node_payload_size_to), + ) + .route( + routes::v1::NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, + get(node_payload_size_from_to), + ) + .route( + routes::v1::NODE_VID_SHARE_BY_HASH_ROUTE, + get(node_vid_share_by_hash), + ) + .route( + routes::v1::NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, + get(node_vid_share_by_payload_hash), + ) + .route( + routes::v1::NODE_VID_SHARE_BY_HEIGHT_ROUTE, + get(node_vid_share_by_height), + ) + .route(routes::v1::NODE_SYNC_STATUS_ROUTE, get(node_sync_status)) + .route( + routes::v1::NODE_HEADER_WINDOW_HASH_ROUTE, + get(node_header_window_hash), + ) + .route( + routes::v1::NODE_HEADER_WINDOW_HEIGHT_ROUTE, + get(node_header_window_height), + ) + .route( + routes::v1::NODE_HEADER_WINDOW_TIME_ROUTE, + get(node_header_window_time), + ) + .route(routes::v1::NODE_LIMITS_ROUTE, get(node_limits)) + .route( + routes::v1::NODE_STAKE_TABLE_CURRENT_ROUTE, + get(node_stake_table_current), + ) + .route(routes::v1::NODE_STAKE_TABLE_ROUTE, get(node_stake_table)) + .route( + routes::v1::NODE_DA_STAKE_TABLE_CURRENT_ROUTE, + get(node_da_stake_table_current), + ) + .route( + routes::v1::NODE_DA_STAKE_TABLE_ROUTE, + get(node_da_stake_table), + ) + .route(routes::v1::NODE_VALIDATORS_ROUTE, get(node_validators)) + .route( + routes::v1::NODE_ALL_VALIDATORS_ROUTE, + get(node_all_validators), + ) + .route( + routes::v1::NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE, + get(node_proposal_participation_current), + ) + .route( + routes::v1::NODE_PROPOSAL_PARTICIPATION_ROUTE, + get(node_proposal_participation), + ) + .route( + routes::v1::NODE_VOTE_PARTICIPATION_CURRENT_ROUTE, + get(node_vote_participation_current), + ) + .route( + routes::v1::NODE_VOTE_PARTICIPATION_ROUTE, + get(node_vote_participation), + ) + .route(routes::v1::NODE_BLOCK_REWARD_ROUTE, get(node_block_reward)) + .route( + routes::v1::NODE_BLOCK_REWARD_EPOCH_ROUTE, + get(node_block_reward_epoch), + ) + .route(routes::v1::NODE_OLDEST_BLOCK_ROUTE, get(node_oldest_block)) + .route(routes::v1::NODE_OLDEST_LEAF_ROUTE, get(node_oldest_leaf)) + .with_state(state) +} + +pub(crate) fn router_catchup(state: S) -> Router +where + S: v1::CatchupApi + Clone + Send + Sync + 'static, +{ // Catchup handlers let catchup_account = |State(state): State, Path((height, view, address)): Path<(u64, u64, String)>| async move { @@ -1134,6 +1752,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_accounts = |State(state): State, Path((height, view)): Path<(u64, u64)>, headers: HeaderMap, @@ -1145,6 +1764,7 @@ where .map_err(classify_availability_error)?; encode_response(&headers, tree) }; + let catchup_blocks = |State(state): State, Path((height, view)): Path<(u64, u64)>| async move { state .get_blocks_frontier(height, view) @@ -1152,12 +1772,14 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_chainconfig = |State(state): State, Path(commitment): Path| async move { ::get_chain_config(&state, commitment) .await .map(Json) .map_err(classify_availability_error) }; + let catchup_leafchain = |State(state): State, Path(height): Path| async move { state .get_leaf_chain(height) @@ -1165,12 +1787,14 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_cert2 = |State(state): State, Path(height): Path| async move { ::get_cert2(&state, height) .await .map(Json) .map_err(classify_availability_error) }; + let catchup_reward_account = |State(state): State, Path((height, view, address)): Path<(u64, u64, String)>| async move { state @@ -1179,6 +1803,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_reward_accounts = |State(state): State, Path((height, view)): Path<(u64, u64)>, headers: HeaderMap, @@ -1190,6 +1815,7 @@ where .map_err(classify_availability_error)?; encode_response(&headers, tree) }; + let catchup_reward_account_v2 = |State(state): State, Path((height, view, address)): Path<(u64, u64, String)>| async move { state @@ -1198,18 +1824,21 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_reward_accounts_v2 = |State(_): State, Path((_height, _view)): Path<(u64, u64)>| async move { Err::, ApiError>(ApiError::NotFound(anyhow::anyhow!( "catchup/reward-accounts-v2 is deprecated" ))) }; + let catchup_reward_amounts = |State(_): State, Path((_height, _limit, _offset)): Path<(u64, u64, u64)>| async move { Err::, ApiError>(ApiError::NotFound(anyhow::anyhow!( "catchup/reward-amounts is deprecated" ))) }; + let catchup_reward_merkle_tree_v2 = |State(state): State, Path((height, view)): Path<(u64, u64)>| async move { ::get_reward_merkle_tree_v2(&state, height, view) @@ -1217,6 +1846,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let catchup_state_cert = |State(state): State, Path(epoch): Path| async move { ::get_state_cert(&state, epoch) .await @@ -1224,6 +1854,54 @@ where .map_err(classify_availability_error) }; + Router::new() + .route(routes::v1::CATCHUP_ACCOUNT_ROUTE, get(catchup_account)) + .route( + routes::v1::CATCHUP_ACCOUNTS_ROUTE, + ::axum::routing::post(catchup_accounts), + ) + .route(routes::v1::CATCHUP_BLOCKS_ROUTE, get(catchup_blocks)) + .route( + routes::v1::CATCHUP_CHAINCONFIG_ROUTE, + get(catchup_chainconfig), + ) + .route(routes::v1::CATCHUP_LEAFCHAIN_ROUTE, get(catchup_leafchain)) + .route(routes::v1::CATCHUP_CERT2_ROUTE, get(catchup_cert2)) + .route( + routes::v1::CATCHUP_REWARD_ACCOUNT_ROUTE, + get(catchup_reward_account), + ) + .route( + routes::v1::CATCHUP_REWARD_ACCOUNTS_ROUTE, + ::axum::routing::post(catchup_reward_accounts), + ) + .route( + routes::v1::CATCHUP_REWARD_ACCOUNT_V2_ROUTE, + get(catchup_reward_account_v2), + ) + .route( + routes::v1::CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, + ::axum::routing::post(catchup_reward_accounts_v2), + ) + .route( + routes::v1::CATCHUP_REWARD_AMOUNTS_ROUTE, + get(catchup_reward_amounts), + ) + .route( + routes::v1::CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, + get(catchup_reward_merkle_tree_v2), + ) + .route( + routes::v1::CATCHUP_STATE_CERT_ROUTE, + get(catchup_state_cert), + ) + .with_state(state) +} + +pub(crate) fn router_submit(state: S) -> Router +where + S: v1::SubmitApi + Clone + Send + Sync + 'static, +{ // Submit handler — body is decoded as VBS (binary) or JSON based on Content-Type, matching // tide-disco's `body_auto`. let submit_submit = |State(state): State, headers: HeaderMap, body: Bytes| async move { @@ -1232,15 +1910,39 @@ where encode_response(&headers, hash) }; - // State signature handler - let state_signature_block = |State(state): State, Path(height): Path| async move { - state - .get_state_signature(height) - .await - .map(Json) + Router::new() + .route( + routes::v1::SUBMIT_ROUTE, + ::axum::routing::post(submit_submit), + ) + .with_state(state) +} + +pub(crate) fn router_state_signature(state: S) -> Router +where + S: v1::StateSignatureApi + Clone + Send + Sync + 'static, +{ + // State signature handler + let state_signature_block = |State(state): State, Path(height): Path| async move { + state + .get_state_signature(height) + .await + .map(Json) .map_err(classify_availability_error) }; + Router::new() + .route( + routes::v1::STATE_SIGNATURE_BLOCK_ROUTE, + get(state_signature_block), + ) + .with_state(state) +} + +pub(crate) fn router_hotshot_events(state: S) -> Router +where + S: v1::HotShotEventsApi + Clone + Send + Sync + 'static, +{ // HotShot events handlers let hotshot_events_startup = |State(state): State| async move { state @@ -1249,6 +1951,7 @@ where .map(Json) .map_err(ApiError::Internal) }; + let hotshot_events_stream = |State(state): State, headers: HeaderMap, ws: WebSocketUpgrade| async move { let format = ws_format(&headers); @@ -1258,6 +1961,22 @@ where } }; + Router::new() + .route( + routes::v1::HOTSHOT_EVENTS_STARTUP_ROUTE, + get(hotshot_events_startup), + ) + .route( + routes::v1::HOTSHOT_EVENTS_STREAM_ROUTE, + get(hotshot_events_stream), + ) + .with_state(state) +} + +pub(crate) fn router_light_client(state: S) -> Router +where + S: v1::LightClientApi + Clone + Send + Sync + 'static, +{ // Light-client handlers let lc_leaf_by_height = |State(state): State, Path(height): Path| async move { state @@ -1266,6 +1985,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_height_finalized = |State(state): State, Path((height, finalized)): Path<(u64, u64)>| async move { state @@ -1274,6 +1994,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_hash = |State(state): State, Path(hash): Path| async move { state .get_leaf_proof(v1::LeafQuery::Hash(hash), None) @@ -1281,6 +2002,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_hash_finalized = |State(state): State, Path((hash, finalized)): Path<(String, u64)>| async move { state @@ -1289,6 +2011,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_block_hash = |State(state): State, Path(block_hash): Path| async move { state .get_leaf_proof(v1::LeafQuery::BlockHash(block_hash), None) @@ -1296,6 +2019,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_block_hash_finalized = |State(state): State, Path((block_hash, finalized)): Path<(String, u64)>| async move { state @@ -1304,6 +2028,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state .get_leaf_proof(v1::LeafQuery::PayloadHash(payload_hash), None) @@ -1311,6 +2036,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_leaf_by_payload_hash_finalized = |State(state): State, Path((payload_hash, finalized)): Path<(String, u64)>| async move { state @@ -1327,6 +2053,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_header_by_hash = |State(state): State, Path((root, hash)): Path<(u64, String)>| async move { state .get_header_proof(root, v1::HeaderQuery::Hash(hash)) @@ -1334,6 +2061,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_header_by_payload_hash = |State(state): State, Path((root, payload_hash)): Path<(u64, String)>| async move { state @@ -1342,6 +2070,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_stake_table = |State(state): State, Path(epoch): Path| async move { state .get_light_client_stake_table(epoch) @@ -1349,6 +2078,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_payload = |State(state): State, Path(height): Path| async move { state .get_payload_proof(height) @@ -1356,6 +2086,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_payload_range = |State(state): State, Path((start, end)): Path<(u64, u64)>| async move { state .get_payload_proof_range(start, end) @@ -1363,6 +2094,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_namespace = |State(state): State, Path((height, namespace)): Path<(u64, u64)>| async move { state .get_lc_namespace_proof(height, namespace) @@ -1370,6 +2102,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let lc_namespace_range = |State(state): State, Path((start, end, namespace)): Path<(u64, u64, u64)>| async move { state @@ -1379,6 +2112,70 @@ where .map_err(classify_availability_error) }; + let lc_namespaces_range = + |State(state): State, Path((start, end, namespaces)): Path<(u64, u64, String)>| async move { + state + .get_lc_namespaces_proof_range(start, end, namespaces) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + Router::new() + .route(routes::v1::LC_LEAF_BY_HEIGHT_ROUTE, get(lc_leaf_by_height)) + .route( + routes::v1::LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, + get(lc_leaf_by_height_finalized), + ) + .route(routes::v1::LC_LEAF_BY_HASH_ROUTE, get(lc_leaf_by_hash)) + .route( + routes::v1::LC_LEAF_BY_HASH_FINALIZED_ROUTE, + get(lc_leaf_by_hash_finalized), + ) + .route( + routes::v1::LC_LEAF_BY_BLOCK_HASH_ROUTE, + get(lc_leaf_by_block_hash), + ) + .route( + routes::v1::LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, + get(lc_leaf_by_block_hash_finalized), + ) + .route( + routes::v1::LC_LEAF_BY_PAYLOAD_HASH_ROUTE, + get(lc_leaf_by_payload_hash), + ) + .route( + routes::v1::LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, + get(lc_leaf_by_payload_hash_finalized), + ) + .route( + routes::v1::LC_HEADER_BY_HEIGHT_ROUTE, + get(lc_header_by_height), + ) + .route(routes::v1::LC_HEADER_BY_HASH_ROUTE, get(lc_header_by_hash)) + .route( + routes::v1::LC_HEADER_BY_PAYLOAD_HASH_ROUTE, + get(lc_header_by_payload_hash), + ) + .route(routes::v1::LC_STAKE_TABLE_ROUTE, get(lc_stake_table)) + .route(routes::v1::LC_PAYLOAD_ROUTE, get(lc_payload)) + .route(routes::v1::LC_PAYLOAD_RANGE_ROUTE, get(lc_payload_range)) + .route(routes::v1::LC_NAMESPACE_ROUTE, get(lc_namespace)) + .route( + routes::v1::LC_NAMESPACE_RANGE_ROUTE, + get(lc_namespace_range), + ) + .route( + routes::v1::LC_NAMESPACES_RANGE_ROUTE, + get(lc_namespaces_range), + ) + .with_state(state) +} + +pub(crate) fn router_explorer(state: S) -> Router +where + S: v1::ExplorerApi + Clone + Send + Sync + 'static, +{ // Explorer handlers let explorer_block_detail_by_height = |State(state): State, Path(height): Path| async move { state @@ -1387,6 +2184,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let explorer_block_detail_by_hash = |State(state): State, Path(hash): Path| async move { state .get_block_detail(v1::BlockIdent::Hash(hash)) @@ -1394,6 +2192,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let explorer_block_summaries_latest = |State(state): State, Path(limit): Path| async move { state .get_block_summaries(v1::BlockIdent::Latest, limit) @@ -1401,6 +2200,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let explorer_block_summaries_from = |State(state): State, Path((from, limit)): Path<(u64, u64)>| async move { state @@ -1409,6 +2209,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let explorer_tx_detail_by_position = |State(state): State, Path((height, offset)): Path<(u64, u64)>| async move { state @@ -1417,6 +2218,7 @@ where .map(Json) .map_err(classify_availability_error) }; + let explorer_tx_detail_by_hash = |State(state): State, Path(hash): Path| async move { state .get_transaction_detail(v1::TxIdent::Hash(hash)) @@ -1424,565 +2226,142 @@ where .map(Json) .map_err(classify_availability_error) }; - let explorer_tx_summaries_latest = |State(state): State, Path(limit): Path| async move { - state - .get_transaction_summaries(v1::TxIdent::Latest, limit, v1::TxSummaryFilter::None) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_from = - |State(state): State, Path((height, offset, limit)): Path<(u64, u64, u64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::HeightAndOffset(height, offset), - limit, - v1::TxSummaryFilter::None, - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_by_hash = - |State(state): State, Path((hash, limit)): Path<(String, u64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::Hash(hash), - limit, - v1::TxSummaryFilter::None, - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; + let explorer_tx_summaries_latest_block = |State(state): State, Path((limit, block)): Path<(u64, u64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::Latest, - limit, - v1::TxSummaryFilter::Block(block), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_from_block = - |State(state): State, - Path((height, offset, limit, block)): Path<(u64, u64, u64, u64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::HeightAndOffset(height, offset), - limit, - v1::TxSummaryFilter::Block(block), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_by_hash_block = - |State(state): State, Path((hash, limit, block)): Path<(String, u64, u64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::Hash(hash), - limit, - v1::TxSummaryFilter::Block(block), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_latest_ns = - |State(state): State, Path((limit, namespace)): Path<(u64, i64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::Latest, - limit, - v1::TxSummaryFilter::Namespace(namespace), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_from_ns = - |State(state): State, - Path((height, offset, limit, namespace)): Path<(u64, u64, u64, i64)>| async move { - state - .get_transaction_summaries( - v1::TxIdent::HeightAndOffset(height, offset), - limit, - v1::TxSummaryFilter::Namespace(namespace), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_tx_summaries_by_hash_ns = |State(state): State, - Path((hash, limit, namespace)): Path<( - String, - u64, - i64, - )>| async move { - state - .get_transaction_summaries( - v1::TxIdent::Hash(hash), - limit, - v1::TxSummaryFilter::Namespace(namespace), - ) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_summary = |State(state): State| async move { - state - .get_explorer_summary() - .await - .map(Json) - .map_err(classify_availability_error) - }; - let explorer_search = |State(state): State, Path(query): Path| async move { - state - .get_search_result(query) - .await - .map(Json) - .map_err(classify_availability_error) - }; - - // Token handlers - let token_total_minted = |State(state): State| async move { - state - .total_minted_supply() - .await - .map(Json) - .map_err(classify_availability_error) - }; - let token_circulating = |State(state): State| async move { - state - .circulating_supply() - .await - .map(Json) - .map_err(classify_availability_error) - }; - let token_circulating_eth = |State(state): State| async move { - state - .circulating_supply_ethereum() - .await - .map(Json) - .map_err(classify_availability_error) - }; - let token_total_issued = |State(state): State| async move { - state - .total_issued_supply() - .await - .map(Json) - .map_err(classify_availability_error) - }; - let token_total_reward_distributed = |State(state): State| async move { - state - .total_reward_distributed() - .await - .map(Json) - .map_err(classify_availability_error) - }; - - // Database handlers - let database_table_sizes = |State(state): State| async move { - ::get_table_sizes(&state) - .await - .map(Json) - .map_err(ApiError::Internal) - }; - - // Build plain Axum router without OpenAPI (for v1 - internal types) - Router::new() - .route( - routes::v1::REWARD_CLAIM_INPUT_ROUTE, - get(get_reward_claim_input), - ) - .route(routes::v1::REWARD_BALANCE_ROUTE, get(get_reward_balance)) - .route( - routes::v1::LATEST_REWARD_BALANCE_ROUTE, - get(get_latest_reward_balance), - ) - .route( - routes::v1::REWARD_ACCOUNT_PROOF_ROUTE, - get(get_reward_account_proof), - ) - .route( - routes::v1::LATEST_REWARD_ACCOUNT_PROOF_ROUTE, - get(get_latest_reward_account_proof), - ) - .route(routes::v1::REWARD_AMOUNTS_ROUTE, get(get_reward_amounts)) - .route( - routes::v1::REWARD_MERKLE_TREE_V2_ROUTE, - get(get_reward_merkle_tree_v2), - ) - // Availability API routes - .route( - routes::v1::NAMESPACE_PROOF_BY_HEIGHT_ROUTE, - get(get_namespace_proof_by_height), - ) - .route( - routes::v1::NAMESPACE_PROOF_BY_HASH_ROUTE, - get(get_namespace_proof_by_hash), - ) - .route( - routes::v1::NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, - get(get_namespace_proof_by_payload_hash), - ) - .route( - routes::v1::NAMESPACE_PROOF_RANGE_ROUTE, - get(get_namespace_proof_range), - ) - .route( - routes::v1::INCORRECT_ENCODING_PROOF_ROUTE, - get(get_incorrect_encoding_proof), - ) - .route(routes::v1::STATE_CERT_V1_ROUTE, get(get_state_cert_v1)) - .route(routes::v1::STATE_CERT_V2_ROUTE, get(get_state_cert_v2)) - // HotShot availability API routes - .route(routes::v1::LEAF_BY_HEIGHT_ROUTE, get(get_leaf_by_height)) - .route(routes::v1::LEAF_BY_HASH_ROUTE, get(get_leaf_by_hash)) - .route(routes::v1::LEAF_RANGE_ROUTE, get(get_leaf_range)) - .route( - routes::v1::HEADER_BY_HEIGHT_ROUTE, - get(get_header_by_height), - ) - .route(routes::v1::HEADER_BY_HASH_ROUTE, get(get_header_by_hash)) - .route( - routes::v1::HEADER_BY_PAYLOAD_HASH_ROUTE, - get(get_header_by_payload_hash), - ) - .route(routes::v1::HEADER_RANGE_ROUTE, get(get_header_range)) - .route(routes::v1::BLOCK_BY_HEIGHT_ROUTE, get(get_block_by_height)) - .route(routes::v1::BLOCK_BY_HASH_ROUTE, get(get_block_by_hash)) - .route( - routes::v1::BLOCK_BY_PAYLOAD_HASH_ROUTE, - get(get_block_by_payload_hash), - ) - .route(routes::v1::BLOCK_RANGE_ROUTE, get(get_block_range)) - .route( - routes::v1::PAYLOAD_BY_HEIGHT_ROUTE, - get(get_payload_by_height), - ) - .route( - routes::v1::PAYLOAD_BY_HASH_ROUTE, - get(get_payload_by_hash), - ) - .route( - routes::v1::PAYLOAD_BY_BLOCK_HASH_ROUTE, - get(get_payload_by_block_hash), - ) - .route(routes::v1::PAYLOAD_RANGE_ROUTE, get(get_payload_range)) - .route( - routes::v1::VID_COMMON_BY_HEIGHT_ROUTE, - get(get_vid_common_by_height), - ) - .route( - routes::v1::VID_COMMON_BY_HASH_ROUTE, - get(get_vid_common_by_hash), - ) - .route( - routes::v1::VID_COMMON_BY_PAYLOAD_HASH_ROUTE, - get(get_vid_common_by_payload_hash), - ) - .route( - routes::v1::VID_COMMON_RANGE_ROUTE, - get(get_vid_common_range), - ) - .route( - routes::v1::TRANSACTION_BY_POSITION_NOPROOF_ROUTE, - get(get_transaction_by_position), - ) - .route( - routes::v1::TRANSACTION_BY_HASH_NOPROOF_ROUTE, - get(get_transaction_by_hash), - ) - .route( - routes::v1::TRANSACTION_PROOF_BY_POSITION_ROUTE, - get(get_transaction_proof_by_position), - ) - .route( - routes::v1::TRANSACTION_PROOF_BY_HASH_ROUTE, - get(get_transaction_proof_by_hash), - ) - .route( - routes::v1::TRANSACTION_BY_POSITION_ROUTE, - get(get_transaction_proof_by_position), - ) - .route( - routes::v1::TRANSACTION_BY_HASH_ROUTE, - get(get_transaction_proof_by_hash), - ) - .route( - routes::v1::BLOCK_SUMMARY_BY_HEIGHT_ROUTE, - get(get_block_summary_by_height), - ) - .route( - routes::v1::BLOCK_SUMMARY_RANGE_ROUTE, - get(get_block_summary_range), - ) - .route(routes::v1::LIMITS_ROUTE, get(get_limits)) - .route(routes::v1::CERT2_BY_HEIGHT_ROUTE, get(get_cert2)) - // WebSocket streaming routes - .route(routes::v1::STREAM_LEAVES_ROUTE, get(stream_leaves)) - .route(routes::v1::STREAM_HEADERS_ROUTE, get(stream_headers)) - .route(routes::v1::STREAM_BLOCKS_ROUTE, get(stream_blocks)) - .route(routes::v1::STREAM_PAYLOADS_ROUTE, get(stream_payloads)) - .route(routes::v1::STREAM_VID_COMMON_ROUTE, get(stream_vid_common)) - .route( - routes::v1::STREAM_TRANSACTIONS_ROUTE, - get(stream_transactions), - ) - .route( - routes::v1::STREAM_TRANSACTIONS_NS_ROUTE, - get(stream_transactions_ns), - ) - .route( - routes::v1::STREAM_NAMESPACE_PROOFS_ROUTE, - get(stream_namespace_proofs), - ) - // Merklized state: block-state. - .route( - routes::v1::BLOCK_STATE_HEIGHT_ROUTE, - get(get_block_state_height), - ) - .route( - routes::v1::BLOCK_STATE_PATH_BY_COMMIT_ROUTE, - get(get_block_state_path_by_commit), - ) - .route( - routes::v1::BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, - get(get_block_state_path_by_height), - ) - // Merklized state: fee-state - .route( - routes::v1::FEE_STATE_HEIGHT_ROUTE, - get(get_fee_state_height), - ) - .route( - routes::v1::FEE_STATE_BALANCE_LATEST_ROUTE, - get(get_fee_balance_latest), - ) - .route( - routes::v1::FEE_STATE_PATH_BY_COMMIT_ROUTE, - get(get_fee_state_path_by_commit), - ) - .route( - routes::v1::FEE_STATE_PATH_BY_HEIGHT_ROUTE, - get(get_fee_state_path_by_height), - ) - // Status routes - .route(routes::v1::STATUS_BLOCK_HEIGHT_ROUTE, get(status_block_height)) - .route(routes::v1::STATUS_SUCCESS_RATE_ROUTE, get(status_success_rate)) - .route( - routes::v1::STATUS_TIME_SINCE_LAST_DECIDE_ROUTE, - get(status_time_since_last_decide), - ) - .route(routes::v1::STATUS_METRICS_ROUTE, get(status_metrics)) - // Config routes - .route(routes::v1::CONFIG_HOTSHOT_ROUTE, get(config_hotshot)) - .route(routes::v1::CONFIG_ENV_ROUTE, get(config_env)) - .route(routes::v1::CONFIG_RUNTIME_ROUTE, get(config_runtime)) - // Node routes - .route(routes::v1::NODE_BLOCK_HEIGHT_ROUTE, get(node_block_height)) - .route(routes::v1::NODE_TRANSACTIONS_COUNT_ROUTE, get(node_count_txs)) - .route( - routes::v1::NODE_TRANSACTIONS_COUNT_NS_ROUTE, - get(node_count_txs_ns), - ) - .route( - routes::v1::NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, - get(node_count_txs_ns_to), - ) - .route( - routes::v1::NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, - get(node_count_txs_ns_from_to), - ) - .route( - routes::v1::NODE_TRANSACTIONS_COUNT_TO_ROUTE, - get(node_count_txs_to), - ) - .route( - routes::v1::NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, - get(node_count_txs_from_to), - ) - .route(routes::v1::NODE_PAYLOADS_SIZE_ROUTE, get(node_payload_size)) - .route( - routes::v1::NODE_PAYLOADS_TOTAL_SIZE_ROUTE, - get(node_payload_size), - ) - .route( - routes::v1::NODE_PAYLOADS_SIZE_NS_ROUTE, - get(node_payload_size_ns), - ) - .route( - routes::v1::NODE_PAYLOADS_SIZE_NS_TO_ROUTE, - get(node_payload_size_ns_to), - ) - .route( - routes::v1::NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, - get(node_payload_size_ns_from_to), - ) - .route( - routes::v1::NODE_PAYLOADS_SIZE_TO_ROUTE, - get(node_payload_size_to), - ) - .route( - routes::v1::NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, - get(node_payload_size_from_to), - ) - .route( - routes::v1::NODE_VID_SHARE_BY_HASH_ROUTE, - get(node_vid_share_by_hash), - ) - .route( - routes::v1::NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, - get(node_vid_share_by_payload_hash), - ) - .route( - routes::v1::NODE_VID_SHARE_BY_HEIGHT_ROUTE, - get(node_vid_share_by_height), - ) - .route(routes::v1::NODE_SYNC_STATUS_ROUTE, get(node_sync_status)) - .route( - routes::v1::NODE_HEADER_WINDOW_HASH_ROUTE, - get(node_header_window_hash), - ) - .route( - routes::v1::NODE_HEADER_WINDOW_HEIGHT_ROUTE, - get(node_header_window_height), - ) - .route( - routes::v1::NODE_HEADER_WINDOW_TIME_ROUTE, - get(node_header_window_time), - ) - .route(routes::v1::NODE_LIMITS_ROUTE, get(node_limits)) - .route( - routes::v1::NODE_STAKE_TABLE_CURRENT_ROUTE, - get(node_stake_table_current), - ) - .route(routes::v1::NODE_STAKE_TABLE_ROUTE, get(node_stake_table)) - .route( - routes::v1::NODE_DA_STAKE_TABLE_CURRENT_ROUTE, - get(node_da_stake_table_current), - ) - .route(routes::v1::NODE_DA_STAKE_TABLE_ROUTE, get(node_da_stake_table)) - .route(routes::v1::NODE_VALIDATORS_ROUTE, get(node_validators)) - .route(routes::v1::NODE_ALL_VALIDATORS_ROUTE, get(node_all_validators)) - .route( - routes::v1::NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE, - get(node_proposal_participation_current), - ) - .route( - routes::v1::NODE_PROPOSAL_PARTICIPATION_ROUTE, - get(node_proposal_participation), - ) - .route( - routes::v1::NODE_VOTE_PARTICIPATION_CURRENT_ROUTE, - get(node_vote_participation_current), - ) - .route( - routes::v1::NODE_VOTE_PARTICIPATION_ROUTE, - get(node_vote_participation), - ) - .route(routes::v1::NODE_BLOCK_REWARD_ROUTE, get(node_block_reward)) - .route( - routes::v1::NODE_BLOCK_REWARD_EPOCH_ROUTE, - get(node_block_reward_epoch), - ) - .route(routes::v1::NODE_OLDEST_BLOCK_ROUTE, get(node_oldest_block)) - .route(routes::v1::NODE_OLDEST_LEAF_ROUTE, get(node_oldest_leaf)) - // Catchup routes - .route(routes::v1::CATCHUP_ACCOUNT_ROUTE, get(catchup_account)) - .route(routes::v1::CATCHUP_ACCOUNTS_ROUTE, ::axum::routing::post(catchup_accounts)) - .route(routes::v1::CATCHUP_BLOCKS_ROUTE, get(catchup_blocks)) - .route(routes::v1::CATCHUP_CHAINCONFIG_ROUTE, get(catchup_chainconfig)) - .route(routes::v1::CATCHUP_LEAFCHAIN_ROUTE, get(catchup_leafchain)) - .route(routes::v1::CATCHUP_CERT2_ROUTE, get(catchup_cert2)) - .route( - routes::v1::CATCHUP_REWARD_ACCOUNT_ROUTE, - get(catchup_reward_account), - ) - .route( - routes::v1::CATCHUP_REWARD_ACCOUNTS_ROUTE, - ::axum::routing::post(catchup_reward_accounts), - ) - .route( - routes::v1::CATCHUP_REWARD_ACCOUNT_V2_ROUTE, - get(catchup_reward_account_v2), - ) - .route( - routes::v1::CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, - ::axum::routing::post(catchup_reward_accounts_v2), - ) - .route( - routes::v1::CATCHUP_REWARD_AMOUNTS_ROUTE, - get(catchup_reward_amounts), - ) - .route( - routes::v1::CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, - get(catchup_reward_merkle_tree_v2), - ) - .route(routes::v1::CATCHUP_STATE_CERT_ROUTE, get(catchup_state_cert)) - // Submit - .route(routes::v1::SUBMIT_ROUTE, ::axum::routing::post(submit_submit)) - // State signature - .route( - routes::v1::STATE_SIGNATURE_BLOCK_ROUTE, - get(state_signature_block), - ) - // HotShot events - .route( - routes::v1::HOTSHOT_EVENTS_STARTUP_ROUTE, - get(hotshot_events_startup), - ) - .route( - routes::v1::HOTSHOT_EVENTS_STREAM_ROUTE, - get(hotshot_events_stream), - ) - // Light client - .route(routes::v1::LC_LEAF_BY_HEIGHT_ROUTE, get(lc_leaf_by_height)) - .route( - routes::v1::LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, - get(lc_leaf_by_height_finalized), - ) - .route(routes::v1::LC_LEAF_BY_HASH_ROUTE, get(lc_leaf_by_hash)) - .route( - routes::v1::LC_LEAF_BY_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_hash_finalized), - ) - .route( - routes::v1::LC_LEAF_BY_BLOCK_HASH_ROUTE, - get(lc_leaf_by_block_hash), - ) - .route( - routes::v1::LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_block_hash_finalized), - ) - .route( - routes::v1::LC_LEAF_BY_PAYLOAD_HASH_ROUTE, - get(lc_leaf_by_payload_hash), - ) - .route( - routes::v1::LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_payload_hash_finalized), - ) - .route(routes::v1::LC_HEADER_BY_HEIGHT_ROUTE, get(lc_header_by_height)) - .route(routes::v1::LC_HEADER_BY_HASH_ROUTE, get(lc_header_by_hash)) - .route( - routes::v1::LC_HEADER_BY_PAYLOAD_HASH_ROUTE, - get(lc_header_by_payload_hash), - ) - .route(routes::v1::LC_STAKE_TABLE_ROUTE, get(lc_stake_table)) - .route(routes::v1::LC_PAYLOAD_ROUTE, get(lc_payload)) - .route(routes::v1::LC_PAYLOAD_RANGE_ROUTE, get(lc_payload_range)) - .route(routes::v1::LC_NAMESPACE_ROUTE, get(lc_namespace)) - .route( - routes::v1::LC_NAMESPACE_RANGE_ROUTE, - get(lc_namespace_range), - ) - // Explorer + state + .get_transaction_summaries( + v1::TxIdent::Latest, + limit, + v1::TxSummaryFilter::Block(block), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_from_block = + |State(state): State, + Path((height, offset, limit, block)): Path<(u64, u64, u64, u64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::HeightAndOffset(height, offset), + limit, + v1::TxSummaryFilter::Block(block), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_by_hash_block = + |State(state): State, Path((hash, limit, block)): Path<(String, u64, u64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::Hash(hash), + limit, + v1::TxSummaryFilter::Block(block), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_latest_ns = + |State(state): State, Path((limit, namespace)): Path<(u64, i64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::Latest, + limit, + v1::TxSummaryFilter::Namespace(namespace), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_from_ns = + |State(state): State, + Path((height, offset, limit, namespace)): Path<(u64, u64, u64, i64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::HeightAndOffset(height, offset), + limit, + v1::TxSummaryFilter::Namespace(namespace), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_by_hash_ns = |State(state): State, + Path((hash, limit, namespace)): Path<( + String, + u64, + i64, + )>| async move { + state + .get_transaction_summaries( + v1::TxIdent::Hash(hash), + limit, + v1::TxSummaryFilter::Namespace(namespace), + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_latest = |State(state): State, Path(limit): Path| async move { + state + .get_transaction_summaries(v1::TxIdent::Latest, limit, v1::TxSummaryFilter::None) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_from = + |State(state): State, Path((height, offset, limit)): Path<(u64, u64, u64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::HeightAndOffset(height, offset), + limit, + v1::TxSummaryFilter::None, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_tx_summaries_by_hash = + |State(state): State, Path((hash, limit)): Path<(String, u64)>| async move { + state + .get_transaction_summaries( + v1::TxIdent::Hash(hash), + limit, + v1::TxSummaryFilter::None, + ) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_summary = |State(state): State| async move { + state + .get_explorer_summary() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let explorer_search = |State(state): State, Path(query): Path| async move { + state + .get_search_result(query) + .await + .map(Json) + .map_err(classify_availability_error) + }; + + Router::new() .route( routes::v1::EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, get(explorer_block_detail_by_height), @@ -2045,7 +2424,55 @@ where ) .route(routes::v1::EXPLORER_SUMMARY_ROUTE, get(explorer_summary)) .route(routes::v1::EXPLORER_SEARCH_ROUTE, get(explorer_search)) - // Token + .with_state(state) +} + +pub(crate) fn router_token(state: S) -> Router +where + S: v1::TokenApi + Clone + Send + Sync + 'static, +{ + // Token handlers + let token_total_minted = |State(state): State| async move { + state + .total_minted_supply() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let token_circulating = |State(state): State| async move { + state + .circulating_supply() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let token_circulating_eth = |State(state): State| async move { + state + .circulating_supply_ethereum() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let token_total_issued = |State(state): State| async move { + state + .total_issued_supply() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + let token_total_reward_distributed = |State(state): State| async move { + state + .total_reward_distributed() + .await + .map(Json) + .map_err(classify_availability_error) + }; + + Router::new() .route( routes::v1::TOKEN_TOTAL_MINTED_SUPPLY_ROUTE, get(token_total_minted), @@ -2066,14 +2493,79 @@ where routes::v1::TOKEN_TOTAL_REWARD_DISTRIBUTED_ROUTE, get(token_total_reward_distributed), ) - // Database (diagnostic) + .with_state(state) +} + +pub(crate) fn router_database(state: S) -> Router +where + S: v1::DatabaseApi + Clone + Send + Sync + 'static, +{ + // Database handlers + let database_table_sizes = |State(state): State| async move { + ::get_table_sizes(&state) + .await + .map(Json) + .map_err(ApiError::Internal) + }; + let database_migration_status = |State(state): State| async move { + ::get_migration_status(&state) + .await + .map(Json) + .map_err(ApiError::Internal) + }; + + Router::new() .route( routes::v1::DATABASE_TABLE_SIZES_ROUTE, get(database_table_sizes), ) + .route( + routes::v1::DATABASE_MIGRATION_STATUS_ROUTE, + get(database_migration_status), + ) .with_state(state) } +/// Create v1 router without OpenAPI documentation (internal types) +pub fn create_router_v1(state: S) -> Router +where + S: v1::RewardApi + + v1::AvailabilityApi + + v1::HotShotAvailabilityApi + + v1::BlockStateApi + + v1::FeeStateApi + + v1::StatusApi + + v1::ConfigApi + + v1::NodeApi + + v1::CatchupApi + + v1::SubmitApi + + v1::StateSignatureApi + + v1::HotShotEventsApi + + v1::LightClientApi + + v1::ExplorerApi + + v1::TokenApi + + v1::DatabaseApi + + Clone + + Send + + Sync + + 'static, +{ + router_reward(state.clone()) + .merge(router_availability(state.clone())) + .merge(router_block_state(state.clone())) + .merge(router_fee_state(state.clone())) + .merge(router_status(state.clone())) + .merge(router_config(state.clone())) + .merge(router_node(state.clone())) + .merge(router_catchup(state.clone())) + .merge(router_submit(state.clone())) + .merge(router_state_signature(state.clone())) + .merge(router_hotshot_events(state.clone())) + .merge(router_light_client(state.clone())) + .merge(router_explorer(state.clone())) + .merge(router_token(state.clone())) + .merge(router_database(state)) +} /// Create v2 router with OpenAPI documentation (proto types) pub fn create_router_v2(state: S) -> Router where @@ -2286,3 +2778,101 @@ where .layer(Extension(api)) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + + fn rewritten_uri(uri: &str) -> String { + let req = Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(); + rewrite_legacy_uri(req).uri().to_string() + } + + #[test] + fn rewrite_legacy_uri_prefixes_unversioned_paths() { + assert_eq!( + rewritten_uri("/status/block-height"), + "/v1/status/block-height" + ); + } + + #[test] + fn rewrite_legacy_uri_rewrites_v0_to_v1() { + assert_eq!( + rewritten_uri("/v0/status/block-height"), + "/v1/status/block-height" + ); + assert_eq!(rewritten_uri("/v0"), "/v1"); + } + + #[test] + fn rewrite_legacy_uri_rewrites_v0_availability_paths() { + assert_eq!( + rewritten_uri("/v0/availability/block/1/namespace/2"), + "/v1/availability/block/1/namespace/2" + ); + assert_eq!( + rewritten_uri("/v0/availability/leaf/1"), + "/v1/availability/leaf/1" + ); + assert_eq!( + rewritten_uri("/v0/availability/vid/common/1"), + "/v1/availability/vid/common/1" + ); + assert_eq!( + rewritten_uri("/v0/availability/stream/leaves/0"), + "/v1/availability/stream/leaves/0" + ); + assert_eq!( + rewritten_uri("/availability/block/1/namespace/2"), + "/v1/availability/block/1/namespace/2" + ); + assert_eq!( + rewritten_uri("/availability/leaf/1"), + "/v1/availability/leaf/1" + ); + } + + #[test] + fn rewrite_legacy_uri_leaves_v1_unchanged() { + assert_eq!( + rewritten_uri("/v1/node/block-height"), + "/v1/node/block-height" + ); + } + + #[test] + fn rewrite_legacy_uri_leaves_v2_unchanged() { + assert_eq!( + rewritten_uri("/v2/rewards/balance/0xabc"), + "/v2/rewards/balance/0xabc" + ); + } + + #[test] + fn rewrite_legacy_uri_respects_version_prefix_boundaries() { + assert_eq!(rewritten_uri("/v1"), "/v1"); + assert_eq!(rewritten_uri("/v2"), "/v2"); + assert_eq!(rewritten_uri("/v1x"), "/v1/v1x"); + assert_eq!(rewritten_uri("/v2-foo/bar"), "/v1/v2-foo/bar"); + assert_eq!(rewritten_uri("/v0x/leaf"), "/v1/v0x/leaf"); + } + + #[test] + fn rewrite_legacy_uri_leaves_reserved_paths_unchanged() { + assert_eq!(rewritten_uri("/"), "/"); + assert_eq!(rewritten_uri("/healthcheck"), "/healthcheck"); + assert_eq!(rewritten_uri("/version"), "/version"); + } + + #[test] + fn rewrite_legacy_uri_preserves_query_string() { + assert_eq!( + rewritten_uri("/availability/leaf/1?foo=bar"), + "/v1/availability/leaf/1?foo=bar" + ); + } +} diff --git a/crates/espresso/api/src/axum/routes.rs b/crates/espresso/api/src/axum/routes.rs index 92b13cf0e0f..32bdb1f945c 100644 --- a/crates/espresso/api/src/axum/routes.rs +++ b/crates/espresso/api/src/axum/routes.rs @@ -1,3 +1,28 @@ +/// Generate a path-builder function from a route constant. +/// +/// Substitutes each `{placeholder}` segment with the corresponding argument +/// (formatted via `Display`). Used to keep request URLs in sync with the +/// route definitions registered with the Axum router. +macro_rules! path_fn { + ($name:ident, $route:expr $(,)?) => { + pub fn $name() -> String { + ::std::string::String::from($route) + } + }; + ($name:ident, $route:expr, $($placeholder:literal => $param:ident),+ $(,)?) => { + pub fn $name($($param: impl ::std::fmt::Display),+) -> String { + let mut path = ::std::string::String::from($route); + $( + path = path.replace( + concat!("{", $placeholder, "}"), + &$param.to_string(), + ); + )+ + path + } + }; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Route { /// HTTP path for Axum handler (e.g., "/v2/rewards/balance/{height}/{address}") @@ -23,13 +48,36 @@ pub mod v1 { "/v1/reward-state-v2/reward-amounts/{height}/{offset}/{limit}"; pub const REWARD_MERKLE_TREE_V2_ROUTE: &str = "/v1/reward-state-v2/reward-merkle-tree-v2/{height}"; + pub const REWARD_STATE_HEIGHT_ROUTE: &str = "/v1/reward-state/block-height"; + pub const REWARD_STATE_V2_HEIGHT_ROUTE: &str = "/v1/reward-state-v2/block-height"; + pub const REWARD_V1_ACCOUNT_PROOF_ROUTE: &str = "/v1/reward-state/proof/{height}/{address}"; + pub const REWARD_V1_BALANCE_ROUTE: &str = "/v1/reward-state/reward-balance/{height}/{address}"; + // Tide registered the same reward.toml handlers on both the reward-state and + // reward-state-v2 mounts; these four mirror LATEST_REWARD_BALANCE_ROUTE, + // LATEST_REWARD_ACCOUNT_PROOF_ROUTE, REWARD_AMOUNTS_ROUTE, and REWARD_MERKLE_TREE_V2_ROUTE. + pub const REWARD_V1_LATEST_BALANCE_ROUTE: &str = + "/v1/reward-state/reward-balance/latest/{address}"; + pub const REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE: &str = + "/v1/reward-state/proof/latest/{address}"; + pub const REWARD_V1_AMOUNTS_ROUTE: &str = + "/v1/reward-state/reward-amounts/{height}/{offset}/{limit}"; + pub const REWARD_V1_MERKLE_TREE_V2_ROUTE: &str = + "/v1/reward-state/reward-merkle-tree-v2/{height}"; + + // Merklized-state `get_path` base routes, inherited from + // `hotshot-query-service`'s `merklized_state::define_api` for both reward mounts. + pub const REWARD_STATE_PATH_BY_HEIGHT_ROUTE: &str = "/v1/reward-state/{height}/{key}"; + pub const REWARD_STATE_PATH_BY_COMMIT_ROUTE: &str = "/v1/reward-state/commit/{commit}/{key}"; + pub const REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE: &str = "/v1/reward-state-v2/{height}/{key}"; + pub const REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE: &str = + "/v1/reward-state-v2/commit/{commit}/{key}"; pub const NAMESPACE_PROOF_BY_HEIGHT_ROUTE: &str = "/v1/availability/block/{height}/namespace/{namespace}"; pub const NAMESPACE_PROOF_BY_HASH_ROUTE: &str = "/v1/availability/block/hash/{hash}/namespace/{namespace}"; pub const NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE: &str = - "/v1/availability/block/payload-hash/{payload-hash}/namespace/{namespace}"; + "/v1/availability/block/payload-hash/{payload_hash}/namespace/{namespace}"; pub const NAMESPACE_PROOF_RANGE_ROUTE: &str = "/v1/availability/block/{from}/{until}/namespace/{namespace}"; pub const INCORRECT_ENCODING_PROOF_ROUTE: &str = @@ -228,6 +276,8 @@ pub mod v1 { pub const LC_NAMESPACE_ROUTE: &str = "/v1/light-client/namespace/{height}/{namespace}"; pub const LC_NAMESPACE_RANGE_ROUTE: &str = "/v1/light-client/namespace/{start}/{end}/{namespace}"; + pub const LC_NAMESPACES_RANGE_ROUTE: &str = + "/v1/light-client/namespaces/{start}/{end}/{namespaces}"; // Explorer pub const EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE: &str = "/v1/explorer/block/{height}"; @@ -271,6 +321,266 @@ pub mod v1 { // Database (diagnostic) pub const DATABASE_TABLE_SIZES_ROUTE: &str = "/v1/database/table-sizes"; + pub const DATABASE_MIGRATION_STATUS_ROUTE: &str = "/v1/database/migration-status"; + + // --------------------------------------------------------------------- + // Path builders + // + // For each constant above, generate a function that returns the path + // with `{placeholder}` segments substituted. Use these instead of + // hand-formatted URL strings so that the route definition and the + // request site stay in sync. + // --------------------------------------------------------------------- + + // Reward state v2 + path_fn!(reward_claim_input, REWARD_CLAIM_INPUT_ROUTE, "height" => height, "address" => address); + path_fn!(reward_balance, REWARD_BALANCE_ROUTE, "height" => height, "address" => address); + path_fn!(latest_reward_balance, LATEST_REWARD_BALANCE_ROUTE, "address" => address); + path_fn!(reward_account_proof, REWARD_ACCOUNT_PROOF_ROUTE, "height" => height, "address" => address); + path_fn!(latest_reward_account_proof, LATEST_REWARD_ACCOUNT_PROOF_ROUTE, "address" => address); + path_fn!(reward_amounts, REWARD_AMOUNTS_ROUTE, "height" => height, "offset" => offset, "limit" => limit); + path_fn!(reward_merkle_tree_v2, REWARD_MERKLE_TREE_V2_ROUTE, "height" => height); + path_fn!(reward_state_height, REWARD_STATE_HEIGHT_ROUTE); + path_fn!(reward_state_v2_height, REWARD_STATE_V2_HEIGHT_ROUTE); + path_fn!(reward_v1_account_proof, REWARD_V1_ACCOUNT_PROOF_ROUTE, "height" => height, "address" => address); + path_fn!(reward_v1_balance, REWARD_V1_BALANCE_ROUTE, "height" => height, "address" => address); + path_fn!(reward_v1_latest_balance, REWARD_V1_LATEST_BALANCE_ROUTE, "address" => address); + path_fn!(reward_v1_latest_account_proof, REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, "address" => address); + path_fn!(reward_v1_amounts, REWARD_V1_AMOUNTS_ROUTE, "height" => height, "offset" => offset, "limit" => limit); + path_fn!(reward_v1_merkle_tree_v2, REWARD_V1_MERKLE_TREE_V2_ROUTE, "height" => height); + path_fn!(reward_state_path_by_height, REWARD_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); + path_fn!(reward_state_path_by_commit, REWARD_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!(reward_state_v2_path_by_height, REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); + path_fn!(reward_state_v2_path_by_commit, REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + + // Availability — namespace proofs + path_fn!(namespace_proof_by_height, NAMESPACE_PROOF_BY_HEIGHT_ROUTE, "height" => height, "namespace" => namespace); + path_fn!(namespace_proof_by_hash, NAMESPACE_PROOF_BY_HASH_ROUTE, "hash" => hash, "namespace" => namespace); + path_fn!(namespace_proof_by_payload_hash, NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, "payload-hash" => payload_hash, "namespace" => namespace); + path_fn!(namespace_proof_range, NAMESPACE_PROOF_RANGE_ROUTE, "from" => from, "until" => until, "namespace" => namespace); + path_fn!(incorrect_encoding_proof, INCORRECT_ENCODING_PROOF_ROUTE, "block_number" => block_number, "namespace" => namespace); + + // Availability — state certificates + path_fn!(state_cert_v1, STATE_CERT_V1_ROUTE, "epoch" => epoch); + path_fn!(state_cert_v2, STATE_CERT_V2_ROUTE, "epoch" => epoch); + + // Availability — leaves + path_fn!(leaf_by_height, LEAF_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(leaf_by_hash, LEAF_BY_HASH_ROUTE, "hash" => hash); + path_fn!(leaf_range, LEAF_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — headers + path_fn!(header_by_height, HEADER_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(header_by_hash, HEADER_BY_HASH_ROUTE, "hash" => hash); + path_fn!(header_by_payload_hash, HEADER_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + path_fn!(header_range, HEADER_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — blocks + path_fn!(block_by_height, BLOCK_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(block_by_hash, BLOCK_BY_HASH_ROUTE, "hash" => hash); + path_fn!(block_by_payload_hash, BLOCK_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + path_fn!(block_range, BLOCK_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — payloads + path_fn!(payload_by_height, PAYLOAD_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(payload_by_hash, PAYLOAD_BY_HASH_ROUTE, "hash" => hash); + path_fn!(payload_by_block_hash, PAYLOAD_BY_BLOCK_HASH_ROUTE, "block_hash" => block_hash); + path_fn!(payload_range, PAYLOAD_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — VID common + path_fn!(vid_common_by_height, VID_COMMON_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(vid_common_by_hash, VID_COMMON_BY_HASH_ROUTE, "hash" => hash); + path_fn!(vid_common_by_payload_hash, VID_COMMON_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + path_fn!(vid_common_range, VID_COMMON_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — transactions + path_fn!(transaction_by_position_noproof, TRANSACTION_BY_POSITION_NOPROOF_ROUTE, "height" => height, "index" => index); + path_fn!(transaction_by_hash_noproof, TRANSACTION_BY_HASH_NOPROOF_ROUTE, "hash" => hash); + path_fn!(transaction_proof_by_position, TRANSACTION_PROOF_BY_POSITION_ROUTE, "height" => height, "index" => index); + path_fn!(transaction_proof_by_hash, TRANSACTION_PROOF_BY_HASH_ROUTE, "hash" => hash); + path_fn!(transaction_by_position, TRANSACTION_BY_POSITION_ROUTE, "height" => height, "index" => index); + path_fn!(transaction_by_hash, TRANSACTION_BY_HASH_ROUTE, "hash" => hash); + + // Availability — block summaries + path_fn!(block_summary_by_height, BLOCK_SUMMARY_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(block_summary_range, BLOCK_SUMMARY_RANGE_ROUTE, "from" => from, "until" => until); + + // Availability — misc + path_fn!(limits, LIMITS_ROUTE); + path_fn!(cert2_by_height, CERT2_BY_HEIGHT_ROUTE, "height" => height); + + // Availability — streams + path_fn!(stream_leaves, STREAM_LEAVES_ROUTE, "height" => height); + path_fn!(stream_headers, STREAM_HEADERS_ROUTE, "height" => height); + path_fn!(stream_blocks, STREAM_BLOCKS_ROUTE, "height" => height); + path_fn!(stream_payloads, STREAM_PAYLOADS_ROUTE, "height" => height); + path_fn!(stream_vid_common, STREAM_VID_COMMON_ROUTE, "height" => height); + path_fn!(stream_transactions, STREAM_TRANSACTIONS_ROUTE, "height" => height); + path_fn!(stream_transactions_ns, STREAM_TRANSACTIONS_NS_ROUTE, "height" => height, "namespace" => namespace); + path_fn!(stream_namespace_proofs, STREAM_NAMESPACE_PROOFS_ROUTE, "height" => height, "namespace" => namespace); + + // Block state + path_fn!(block_state_path_by_height, BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); + path_fn!(block_state_path_by_commit, BLOCK_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!(block_state_height, BLOCK_STATE_HEIGHT_ROUTE); + + // Fee state + path_fn!(fee_state_path_by_height, FEE_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); + path_fn!(fee_state_path_by_commit, FEE_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!(fee_state_height, FEE_STATE_HEIGHT_ROUTE); + path_fn!(fee_state_balance_latest, FEE_STATE_BALANCE_LATEST_ROUTE, "address" => address); + + // Status + path_fn!(status_block_height, STATUS_BLOCK_HEIGHT_ROUTE); + path_fn!(status_success_rate, STATUS_SUCCESS_RATE_ROUTE); + path_fn!( + status_time_since_last_decide, + STATUS_TIME_SINCE_LAST_DECIDE_ROUTE + ); + path_fn!(status_metrics, STATUS_METRICS_ROUTE); + + // Config + path_fn!(config_hotshot, CONFIG_HOTSHOT_ROUTE); + path_fn!(config_env, CONFIG_ENV_ROUTE); + path_fn!(config_runtime, CONFIG_RUNTIME_ROUTE); + + // Node — block height / counts / sizes + path_fn!(node_block_height, NODE_BLOCK_HEIGHT_ROUTE); + path_fn!(node_transactions_count, NODE_TRANSACTIONS_COUNT_ROUTE); + path_fn!(node_transactions_count_to, NODE_TRANSACTIONS_COUNT_TO_ROUTE, "to" => to); + path_fn!(node_transactions_count_from_to, NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, "from" => from, "to" => to); + path_fn!(node_transactions_count_ns, NODE_TRANSACTIONS_COUNT_NS_ROUTE, "namespace" => namespace); + path_fn!(node_transactions_count_ns_to, NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, "namespace" => namespace, "to" => to); + path_fn!(node_transactions_count_ns_from_to, NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, "namespace" => namespace, "from" => from, "to" => to); + + path_fn!(node_payloads_size, NODE_PAYLOADS_SIZE_ROUTE); + path_fn!(node_payloads_size_to, NODE_PAYLOADS_SIZE_TO_ROUTE, "to" => to); + path_fn!(node_payloads_size_from_to, NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, "from" => from, "to" => to); + path_fn!(node_payloads_total_size, NODE_PAYLOADS_TOTAL_SIZE_ROUTE); + path_fn!(node_payloads_size_ns, NODE_PAYLOADS_SIZE_NS_ROUTE, "namespace" => namespace); + path_fn!(node_payloads_size_ns_to, NODE_PAYLOADS_SIZE_NS_TO_ROUTE, "namespace" => namespace, "to" => to); + path_fn!(node_payloads_size_ns_from_to, NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, "namespace" => namespace, "from" => from, "to" => to); + + // Node — VID shares + path_fn!(node_vid_share_by_height, NODE_VID_SHARE_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(node_vid_share_by_hash, NODE_VID_SHARE_BY_HASH_ROUTE, "hash" => hash); + path_fn!(node_vid_share_by_payload_hash, NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + + // Node — sync, header windows + path_fn!(node_sync_status, NODE_SYNC_STATUS_ROUTE); + path_fn!(node_header_window_time, NODE_HEADER_WINDOW_TIME_ROUTE, "start" => start, "end" => end); + path_fn!(node_header_window_height, NODE_HEADER_WINDOW_HEIGHT_ROUTE, "height" => height, "end" => end); + path_fn!(node_header_window_hash, NODE_HEADER_WINDOW_HASH_ROUTE, "hash" => hash, "end" => end); + path_fn!(node_limits, NODE_LIMITS_ROUTE); + + // Node — stake table / validators / participation + path_fn!(node_stake_table_current, NODE_STAKE_TABLE_CURRENT_ROUTE); + path_fn!(node_stake_table, NODE_STAKE_TABLE_ROUTE, "epoch_number" => epoch_number); + path_fn!( + node_da_stake_table_current, + NODE_DA_STAKE_TABLE_CURRENT_ROUTE + ); + path_fn!(node_da_stake_table, NODE_DA_STAKE_TABLE_ROUTE, "epoch_number" => epoch_number); + path_fn!(node_validators, NODE_VALIDATORS_ROUTE, "epoch_number" => epoch_number); + path_fn!(node_all_validators, NODE_ALL_VALIDATORS_ROUTE, "epoch_number" => epoch_number, "offset" => offset, "limit" => limit); + path_fn!( + node_proposal_participation_current, + NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE + ); + path_fn!(node_proposal_participation, NODE_PROPOSAL_PARTICIPATION_ROUTE, "epoch" => epoch); + path_fn!( + node_vote_participation_current, + NODE_VOTE_PARTICIPATION_CURRENT_ROUTE + ); + path_fn!(node_vote_participation, NODE_VOTE_PARTICIPATION_ROUTE, "epoch" => epoch); + + // Node — block reward / oldest + path_fn!(node_block_reward, NODE_BLOCK_REWARD_ROUTE); + path_fn!(node_block_reward_epoch, NODE_BLOCK_REWARD_EPOCH_ROUTE, "epoch_number" => epoch_number); + path_fn!(node_oldest_block, NODE_OLDEST_BLOCK_ROUTE); + path_fn!(node_oldest_leaf, NODE_OLDEST_LEAF_ROUTE); + + // Catchup + path_fn!(catchup_account, CATCHUP_ACCOUNT_ROUTE, "height" => height, "view" => view, "address" => address); + path_fn!(catchup_accounts, CATCHUP_ACCOUNTS_ROUTE, "height" => height, "view" => view); + path_fn!(catchup_blocks, CATCHUP_BLOCKS_ROUTE, "height" => height, "view" => view); + path_fn!(catchup_chainconfig, CATCHUP_CHAINCONFIG_ROUTE, "commitment" => commitment); + path_fn!(catchup_leafchain, CATCHUP_LEAFCHAIN_ROUTE, "height" => height); + path_fn!(catchup_cert2, CATCHUP_CERT2_ROUTE, "height" => height); + path_fn!(catchup_reward_account, CATCHUP_REWARD_ACCOUNT_ROUTE, "height" => height, "view" => view, "address" => address); + path_fn!(catchup_reward_accounts, CATCHUP_REWARD_ACCOUNTS_ROUTE, "height" => height, "view" => view); + path_fn!(catchup_reward_account_v2, CATCHUP_REWARD_ACCOUNT_V2_ROUTE, "height" => height, "view" => view, "address" => address); + path_fn!(catchup_reward_accounts_v2, CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, "height" => height, "view" => view); + path_fn!(catchup_reward_amounts, CATCHUP_REWARD_AMOUNTS_ROUTE, "height" => height, "limit" => limit, "offset" => offset); + path_fn!(catchup_reward_merkle_tree_v2, CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, "height" => height, "view" => view); + path_fn!(catchup_state_cert, CATCHUP_STATE_CERT_ROUTE, "epoch" => epoch); + + // Submit + path_fn!(submit, SUBMIT_ROUTE); + + // State signature + path_fn!(state_signature_block, STATE_SIGNATURE_BLOCK_ROUTE, "height" => height); + + // HotShot events + path_fn!(hotshot_events_stream, HOTSHOT_EVENTS_STREAM_ROUTE); + path_fn!(hotshot_events_startup, HOTSHOT_EVENTS_STARTUP_ROUTE); + + // Light client + path_fn!(lc_leaf_by_height, LC_LEAF_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(lc_leaf_by_height_finalized, LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, "height" => height, "finalized" => finalized); + path_fn!(lc_leaf_by_hash, LC_LEAF_BY_HASH_ROUTE, "hash" => hash); + path_fn!(lc_leaf_by_hash_finalized, LC_LEAF_BY_HASH_FINALIZED_ROUTE, "hash" => hash, "finalized" => finalized); + path_fn!(lc_leaf_by_block_hash, LC_LEAF_BY_BLOCK_HASH_ROUTE, "block_hash" => block_hash); + path_fn!(lc_leaf_by_block_hash_finalized, LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, "block_hash" => block_hash, "finalized" => finalized); + path_fn!(lc_leaf_by_payload_hash, LC_LEAF_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + path_fn!(lc_leaf_by_payload_hash_finalized, LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, "payload_hash" => payload_hash, "finalized" => finalized); + path_fn!(lc_header_by_height, LC_HEADER_BY_HEIGHT_ROUTE, "root" => root, "height" => height); + path_fn!(lc_header_by_hash, LC_HEADER_BY_HASH_ROUTE, "root" => root, "hash" => hash); + path_fn!(lc_header_by_payload_hash, LC_HEADER_BY_PAYLOAD_HASH_ROUTE, "root" => root, "payload_hash" => payload_hash); + path_fn!(lc_stake_table, LC_STAKE_TABLE_ROUTE, "epoch" => epoch); + path_fn!(lc_payload, LC_PAYLOAD_ROUTE, "height" => height); + path_fn!(lc_payload_range, LC_PAYLOAD_RANGE_ROUTE, "start" => start, "end" => end); + path_fn!(lc_namespace, LC_NAMESPACE_ROUTE, "height" => height, "namespace" => namespace); + path_fn!(lc_namespace_range, LC_NAMESPACE_RANGE_ROUTE, "start" => start, "end" => end, "namespace" => namespace); + path_fn!(lc_namespaces_range, LC_NAMESPACES_RANGE_ROUTE, "start" => start, "end" => end, "namespaces" => namespaces); + + // Explorer — blocks + path_fn!(explorer_block_detail_by_height, EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(explorer_block_detail_by_hash, EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, "hash" => hash); + path_fn!(explorer_block_summaries_latest, EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, "limit" => limit); + path_fn!(explorer_block_summaries_from, EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, "from" => from, "limit" => limit); + + // Explorer — transactions + path_fn!(explorer_tx_detail_by_position, EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, "height" => height, "offset" => offset); + path_fn!(explorer_tx_detail_by_hash, EXPLORER_TX_DETAIL_BY_HASH_ROUTE, "hash" => hash); + path_fn!(explorer_tx_summaries_latest, EXPLORER_TX_SUMMARIES_LATEST_ROUTE, "limit" => limit); + path_fn!(explorer_tx_summaries_from, EXPLORER_TX_SUMMARIES_FROM_ROUTE, "height" => height, "offset" => offset, "limit" => limit); + path_fn!(explorer_tx_summaries_by_hash, EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, "hash" => hash, "limit" => limit); + path_fn!(explorer_tx_summaries_latest_block, EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, "limit" => limit, "block" => block); + path_fn!(explorer_tx_summaries_from_block, EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, "height" => height, "offset" => offset, "limit" => limit, "block" => block); + path_fn!(explorer_tx_summaries_by_hash_block, EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, "hash" => hash, "limit" => limit, "block" => block); + path_fn!(explorer_tx_summaries_latest_ns, EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, "limit" => limit, "namespace" => namespace); + path_fn!(explorer_tx_summaries_from_ns, EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, "height" => height, "offset" => offset, "limit" => limit, "namespace" => namespace); + path_fn!(explorer_tx_summaries_by_hash_ns, EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, "hash" => hash, "limit" => limit, "namespace" => namespace); + path_fn!(explorer_summary, EXPLORER_SUMMARY_ROUTE); + path_fn!(explorer_search, EXPLORER_SEARCH_ROUTE, "query" => query); + + // Token + path_fn!(token_total_minted_supply, TOKEN_TOTAL_MINTED_SUPPLY_ROUTE); + path_fn!(token_circulating_supply, TOKEN_CIRCULATING_SUPPLY_ROUTE); + path_fn!( + token_circulating_supply_ethereum, + TOKEN_CIRCULATING_SUPPLY_ETHEREUM_ROUTE + ); + path_fn!(token_total_issued_supply, TOKEN_TOTAL_ISSUED_SUPPLY_ROUTE); + path_fn!( + token_total_reward_distributed, + TOKEN_TOTAL_REWARD_DISTRIBUTED_ROUTE + ); + + // Database (diagnostic) + path_fn!(database_table_sizes, DATABASE_TABLE_SIZES_ROUTE); + path_fn!(database_migration_status, DATABASE_MIGRATION_STATUS_ROUTE); } pub mod v2 { diff --git a/crates/espresso/api/src/handlers.rs b/crates/espresso/api/src/handlers.rs index cb76adae247..630881e808f 100644 --- a/crates/espresso/api/src/handlers.rs +++ b/crates/espresso/api/src/handlers.rs @@ -4,6 +4,7 @@ use serialization_api::v2::*; use crate::{ + axum::classify_availability_error, error::ApiError, v2::{ConsensusApi, DataApi, RewardApi}, }; @@ -24,7 +25,7 @@ where let result = state .get_reward_claim_input(address) .await - .map_err(ApiError::Internal)?; + .map_err(classify_availability_error)?; state .serialize_reward_claim_input(&address_string, &result) @@ -45,7 +46,7 @@ where let result = state .get_reward_balance(address) .await - .map_err(ApiError::Internal)?; + .map_err(classify_availability_error)?; state .serialize_reward_balance(&result) @@ -66,7 +67,7 @@ where let result = state .get_reward_account_proof(address) .await - .map_err(ApiError::Internal)?; + .map_err(classify_availability_error)?; state .serialize_reward_account_query_data(&result) @@ -83,7 +84,7 @@ where let result = state .get_reward_balances(request.height, request.offset, request.limit) .await - .map_err(ApiError::Internal)?; + .map_err(classify_availability_error)?; state .serialize_reward_balances(&result) @@ -100,7 +101,7 @@ where let result = state .get_reward_merkle_tree_v2(request.height) .await - .map_err(ApiError::Internal)?; + .map_err(classify_availability_error)?; state .serialize_reward_merkle_tree_data(&result) diff --git a/crates/espresso/api/src/lib.rs b/crates/espresso/api/src/lib.rs index cd7369603e4..8b75faad0d9 100644 --- a/crates/espresso/api/src/lib.rs +++ b/crates/espresso/api/src/lib.rs @@ -8,6 +8,8 @@ mod tonic; pub mod v1; pub mod v2; +use tower::Layer; + // Generated gRPC service code - committed to git for visibility in code review pub mod proto { include!("espresso.api.v2.rs"); @@ -15,14 +17,37 @@ pub mod proto { // Re-exports pub use self::{ - axum::{create_combined_router, create_router_v1, create_router_v2, routes}, + axum::{ + create_combined_router, create_router_v1, create_router_v2, healthcheck_response, routes, + }, tonic::create_reward_service, }; +/// Build a full request URL from a server base URL and a path produced by one of the +/// `routes::v1::*` (or `routes::v2::*`) builders. +/// +/// Use this from test/CLI sites that have a `url::Url` pointing at the API server and want +/// the absolute URL for a single request. Internally this is just `base.join(path)`; the +/// helper exists so the (path-const, builder, joiner) trio reads as one chain. +pub fn url(base: &::url::Url, path: impl AsRef) -> ::url::Url { + base.join(path.as_ref()) + .expect("path produced by routes::*::path_fn is always a valid relative URL") +} + /// Start Axum HTTP server with combined v1 and v2 APIs /// /// This serves both APIs at /v1/* and /v2/* from a single state implementation. -pub async fn serve_axum(port: u16, state: S) -> anyhow::Result<()> +/// +/// `catchup`, like the query-service modules (`status`, `availability`, `node`, `token`, +/// `block-state`, `fee-state`, `reward-state`, `database`) and `v2`, is always on: tide-disco's +/// SQL mode registered it unconditionally. `submit`, `config`, `explorer`, `light-client`, and +/// `hotshot-events` follow `Options`, matching `Options::init_with_query_module_sql`. +pub async fn serve_axum( + port: u16, + state: S, + modules: OptionalModules, + max_connections: Option, +) -> anyhow::Result<()> where S: v1::RewardApi + v1::AvailabilityApi @@ -48,19 +73,212 @@ where + Sync + 'static, { - tracing::info!("Starting Axum server on port {} with v1 and v2 APIs", port); + let mut router = axum::router_reward(state.clone()) + .merge(axum::router_availability(state.clone())) + .merge(axum::router_block_state(state.clone())) + .merge(axum::router_fee_state(state.clone())) + .merge(axum::router_status(state.clone())) + .merge(axum::router_node(state.clone())) + .merge(axum::router_catchup(state.clone())) + .merge(axum::router_state_signature(state.clone())) + .merge(axum::router_token(state.clone())) + .merge(axum::router_database(state.clone())) + .merge(axum::create_router_v2(state.clone())); + if modules.submit { + router = router.merge(axum::router_submit(state.clone())); + } + if modules.config { + router = router.merge(axum::router_config(state.clone())); + } + if modules.explorer { + router = router.merge(axum::router_explorer(state.clone())); + } + if modules.light_client { + router = router.merge(axum::router_light_client(state.clone())); + } + if modules.hotshot_events { + router = router.merge(axum::router_hotshot_events(state)); + } + serve_router(port, "v1 and v2", router, max_connections).await +} + +/// Which of the optional API modules to serve, for modes that make them conditional +/// (mirroring `Options::submit`/`Options::config`/`Options::explorer`/`Options::light_client`/ +/// `Options::hotshot_events`). +#[derive(Default, Clone, Copy, Debug)] +pub struct OptionalModules { + pub submit: bool, + pub catchup: bool, + pub config: bool, + pub hotshot_events: bool, + pub explorer: bool, + pub light_client: bool, +} + +/// Serve the query API used by the filesystem-backed storage mode: status, availability, node, +/// token, catchup, and state-signature are always on (tide registered them unconditionally); +/// submit, config, and hotshot-events follow `Options`. Filesystem storage doesn't implement the +/// reward/merklized-state/explorer/database traits, so those modules aren't served (a request to +/// one of their routes 404s, matching tide). +pub async fn serve_axum_fs( + port: u16, + state: S, + modules: OptionalModules, + max_connections: Option, +) -> anyhow::Result<()> +where + S: v1::StatusApi + + v1::AvailabilityApi + + v1::HotShotAvailabilityApi + + v1::NodeApi + + v1::TokenApi + + v1::CatchupApi + + v1::SubmitApi + + v1::StateSignatureApi + + v1::ConfigApi + + v1::HotShotEventsApi + + Clone + + Send + + Sync + + 'static, +{ + let mut router = axum::router_status(state.clone()) + .merge(axum::router_availability(state.clone())) + .merge(axum::router_node(state.clone())) + .merge(axum::router_token(state.clone())) + .merge(axum::router_catchup(state.clone())) + .merge(axum::router_state_signature(state.clone())); + if modules.submit { + router = router.merge(axum::router_submit(state.clone())); + } + if modules.config { + router = router.merge(axum::router_config(state.clone())); + } + if modules.hotshot_events { + router = router.merge(axum::router_hotshot_events(state)); + } + serve_router(port, "fs", router, max_connections).await +} + +/// Serve the status-only API: no availability/node/token data source is available, so only +/// status and the HotShot modules (submit, catchup, state-signature, config, hotshot-events) can +/// be served. State-signature is always on; the rest follow `Options`. +pub async fn serve_axum_status( + port: u16, + state: S, + modules: OptionalModules, + max_connections: Option, +) -> anyhow::Result<()> +where + S: v1::StatusApi + + v1::SubmitApi + + v1::CatchupApi + + v1::StateSignatureApi + + v1::ConfigApi + + v1::HotShotEventsApi + + Clone + + Send + + Sync + + 'static, +{ + let mut router = + axum::router_status(state.clone()).merge(axum::router_state_signature(state.clone())); + router = merge_hotshot_modules(router, &state, modules); + serve_router(port, "status", router, max_connections).await +} + +/// Serve the bare API (no query or status module): only the HotShot modules are available, +/// since the only app state is the HotShot handle. State-signature is always on; the rest follow +/// `Options`, matching `Options::init_hotshot_modules`. +pub async fn serve_axum_bare( + port: u16, + state: S, + modules: OptionalModules, + max_connections: Option, +) -> anyhow::Result<()> +where + S: v1::SubmitApi + + v1::CatchupApi + + v1::StateSignatureApi + + v1::ConfigApi + + v1::HotShotEventsApi + + Clone + + Send + + Sync + + 'static, +{ + let router = axum::router_state_signature(state.clone()); + let router = merge_hotshot_modules(router, &state, modules); + serve_router(port, "bare", router, max_connections).await +} + +fn merge_hotshot_modules( + mut router: ::axum::Router, + state: &S, + modules: OptionalModules, +) -> ::axum::Router +where + S: v1::SubmitApi + + v1::CatchupApi + + v1::ConfigApi + + v1::HotShotEventsApi + + Clone + + Send + + Sync + + 'static, +{ + if modules.submit { + router = router.merge(axum::router_submit(state.clone())); + } + if modules.catchup { + router = router.merge(axum::router_catchup(state.clone())); + } + if modules.config { + router = router.merge(axum::router_config(state.clone())); + } + if modules.hotshot_events { + router = router.merge(axum::router_hotshot_events(state.clone())); + } + router +} + +/// Add the reserved top-level routes, apply the optional concurrency limit, rewrite legacy URIs, +/// and bind/serve the router. Shared by all `serve_axum*` entry points. +/// +/// `max_connections` matches tide-disco's `RateLimitListener` semantics: at most that many +/// requests are in flight at once, and excess requests fail immediately with 429 Too Many +/// Requests instead of queueing. The load-shed layer converts the concurrency limit's "not +/// ready" into an error, which `HandleErrorLayer` maps to the 429 response. +async fn serve_router( + port: u16, + mode: &str, + router: ::axum::Router, + max_connections: Option, +) -> anyhow::Result<()> { + tracing::info!("Starting Axum server on port {} ({} mode)", port, mode); - let app = create_combined_router(state); + let mut router = axum::with_top_level_routes(router); + if let Some(limit) = max_connections { + router = router.layer( + tower::ServiceBuilder::new() + .layer(::axum::error_handling::HandleErrorLayer::new( + |_: tower::BoxError| async { ::axum::http::StatusCode::TOO_MANY_REQUESTS }, + )) + .layer(tower::load_shed::LoadShedLayer::new()) + .layer(tower::limit::GlobalConcurrencyLimitLayer::new(limit)), + ); + } + // `Router::layer` middleware runs after routing, so it can't rewrite a URI to match a + // different route. Wrapping the whole router with `MapRequestLayer` instead runs the + // rewrite before routing, per the axum-documented pattern for this case. + let router = tower::util::MapRequestLayer::new(axum::rewrite_legacy_uri).layer(router); let addr = format!("0.0.0.0:{}", port); tracing::info!("Binding to {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!( - "Axum API server listening on {} (v1 and v2 routes available)", - addr - ); - ::axum::serve(listener, app.into_make_service()).await?; + tracing::info!("Axum API server listening on {} ({} mode)", addr, mode); + ::axum::serve(listener, ::axum::ServiceExt::into_make_service(router)).await?; tracing::info!("Axum server stopped"); Ok(()) diff --git a/crates/espresso/api/src/v1/availability.rs b/crates/espresso/api/src/v1/availability.rs index a5d9fe69335..02357bfe935 100644 --- a/crates/espresso/api/src/v1/availability.rs +++ b/crates/espresso/api/src/v1/availability.rs @@ -36,7 +36,7 @@ pub trait AvailabilityApi { &self, block_id: BlockId, namespace: u32, - ) -> anyhow::Result>; + ) -> anyhow::Result; async fn get_namespace_proof_range( &self, diff --git a/crates/espresso/api/src/v1/database.rs b/crates/espresso/api/src/v1/database.rs index 8887713a681..5e86d64d0ac 100644 --- a/crates/espresso/api/src/v1/database.rs +++ b/crates/espresso/api/src/v1/database.rs @@ -9,6 +9,8 @@ use serde::Serialize; #[async_trait] pub trait DatabaseApi { type TableSizes: Serialize + Send + Sync + 'static; + type MigrationStatus: Serialize + Send + Sync + 'static; async fn get_table_sizes(&self) -> anyhow::Result; + async fn get_migration_status(&self) -> anyhow::Result; } diff --git a/crates/espresso/api/src/v1/light_client.rs b/crates/espresso/api/src/v1/light_client.rs index e88c3f34e85..6cf245297d1 100644 --- a/crates/espresso/api/src/v1/light_client.rs +++ b/crates/espresso/api/src/v1/light_client.rs @@ -2,6 +2,8 @@ //! //! Mirrors the tide-disco endpoints defined in `crates/espresso/node/api/light-client.toml`. +use std::collections::HashMap; + use async_trait::async_trait; use serde::Serialize; @@ -65,4 +67,14 @@ pub trait LightClientApi { end: u64, namespace: u64, ) -> anyhow::Result>; + + /// `namespaces` is the raw `TaggedBase64`-encoded path segment produced by the light-client + /// client (tag `NS`, wrapping a JSON `Vec`); decoding it is left to the implementation + /// so this crate does not need a `tagged-base64` dependency. + async fn get_lc_namespaces_proof_range( + &self, + start: u64, + end: u64, + namespaces: String, + ) -> anyhow::Result>>; } diff --git a/crates/espresso/api/src/v1/reward_state_v2.rs b/crates/espresso/api/src/v1/reward_state_v2.rs index e88b27246c0..2690df607cd 100644 --- a/crates/espresso/api/src/v1/reward_state_v2.rs +++ b/crates/espresso/api/src/v1/reward_state_v2.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use serde::Serialize; +use crate::v1::merklized_state::Snapshot; + /// Reward API trait - returns internal types /// /// Uses associated types to avoid importing espresso-types in this crate. @@ -27,6 +29,32 @@ pub trait RewardApi { /// Type for raw merkle tree snapshots (must be serializable to JSON) type RewardMerkleTreeData: Serialize + Send + Sync; + /// Type for reward account proof queries against the V1 (RewardMerkleTreeV1) tree + type RewardAccountQueryDataV1: Serialize + Send + Sync; + + /// Type for a raw Merkle path into the reward-state (RewardMerkleTreeV1) tree + type RewardStatePathV1: Serialize + Send + Sync; + + /// Type for a raw Merkle path into the reward-state-v2 (RewardMerkleTreeV2) tree + type RewardStatePathV2: Serialize + Send + Sync; + + /// Get the height of the last persisted reward-state-v1 merklized state snapshot + async fn get_reward_state_height(&self) -> anyhow::Result; + + /// Get the height of the last persisted reward-state-v2 merklized state snapshot + async fn get_reward_state_v2_height(&self) -> anyhow::Result; + + /// Get Merkle proof for a reward account against the V1 (RewardMerkleTreeV1) tree + /// + /// # Arguments + /// * `height` - Block height to query + /// * `address` - Ethereum address to query proof for + async fn get_reward_account_proof_v1( + &self, + height: u64, + address: String, + ) -> anyhow::Result; + /// Get reward claim input for L1 contract submission /// /// Returns all data needed to call the claimRewards function on the L1 contract, @@ -108,4 +136,29 @@ pub trait RewardApi { &self, height: u64, ) -> anyhow::Result; + + /// Get the Merkle path for a key in the reward-state (RewardMerkleTreeV1) tree + /// + /// Mirrors `merklized_state::get_path`, inherited by the reward-state mount from + /// `hotshot-query-service`'s base `state.toml` routes (same as block-state/fee-state). + /// + /// # Arguments + /// * `snapshot` - Height or commitment identifying the tree snapshot + /// * `key` - Reward account address to query + async fn get_reward_state_path_v1( + &self, + snapshot: Snapshot, + key: String, + ) -> anyhow::Result; + + /// Get the Merkle path for a key in the reward-state-v2 (RewardMerkleTreeV2) tree + /// + /// # Arguments + /// * `snapshot` - Height or commitment identifying the tree snapshot + /// * `key` - Reward account address to query + async fn get_reward_state_path_v2( + &self, + snapshot: Snapshot, + key: String, + ) -> anyhow::Result; } diff --git a/crates/espresso/dev-node/src/main.rs b/crates/espresso/dev-node/src/main.rs index 3eccb0c0575..4e0ba69e0e3 100644 --- a/crates/espresso/dev-node/src/main.rs +++ b/crates/espresso/dev-node/src/main.rs @@ -176,10 +176,6 @@ struct Args { #[clap(long, env = "ESPRESSO_NODE_API_MAX_CONNECTIONS")] sequencer_api_max_connections: Option, - /// Optional port for new Axum API server (skeleton implementation). - #[clap(long, env = "ESPRESSO_NODE_AXUM_PORT")] - axum_port: Option, - /// Optional port for Tonic gRPC API server. #[clap(long, env = "ESPRESSO_NODE_TONIC_PORT")] tonic_port: Option, @@ -271,7 +267,6 @@ async fn async_main(migrated_envs: Vec<(&str, &str)>) -> anyhow::Result<()> { alt_multisig_addresses, sequencer_api_port, sequencer_api_max_connections, - axum_port, tonic_port, builder_port, prover_port, @@ -664,7 +659,6 @@ async fn async_main(migrated_envs: Vec<(&str, &str)>) -> anyhow::Result<()> { let api_options = options::Options::from(options::Http { port: sequencer_api_port, max_connections: sequencer_api_max_connections, - axum_port, tonic_port, }) .submit(Default::default()) diff --git a/crates/espresso/node/src/api.rs b/crates/espresso/node/src/api.rs index 8e128424b48..c3bbfe53999 100644 --- a/crates/espresso/node/src/api.rs +++ b/crates/espresso/node/src/api.rs @@ -3199,10 +3199,10 @@ mod test { upgrade_stake_table_v3, }; use espresso_types::{ - ADVZNamespaceProofQueryData, FeeAmount, Header, L1Client, L1ClientOptions, - MOCK_SEQUENCER_VERSIONS, NamespaceId, NamespaceProofQueryData, NsProof, - RegisteredValidatorMap, RewardDistributor, StakeTableState, StateCertQueryDataV1, - StateCertQueryDataV2, ValidatedState, ValidatorLeaderCounts, + FeeAmount, Header, L1Client, L1ClientOptions, MOCK_SEQUENCER_VERSIONS, NamespaceId, + NamespaceProofQueryData, NsProof, RegisteredValidatorMap, RewardDistributor, + StakeTableState, StateCertQueryDataV1, StateCertQueryDataV2, ValidatedState, + ValidatorLeaderCounts, config::PublicHotShotConfig, traits::{MembershipPersistence, NullEventConsumer, PersistenceOptions}, v0_3::{COMMISSION_BASIS_POINTS, Fetcher, RewardAmount, RewardMerkleProofV1}, @@ -3434,6 +3434,40 @@ mod test { .unwrap_err(); } + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_database_metadata_endpoints() { + let port = reserve_tcp_port().expect("OS should have ephemeral ports available"); + + let storage = SqlDataSource::create_storage().await; + let options = SqlDataSource::options(&storage, Options::with_port(port)); + + let network_config = TestConfigBuilder::default().build(); + let config = TestNetworkConfigBuilder::default() + .api_config(options) + .network_config(network_config) + .build(); + let _network = TestNetwork::new(config, MOCK_SEQUENCER_VERSIONS).await; + let url = format!("http://localhost:{port}").parse().unwrap(); + let client: Client = Client::new(url); + client.connect(Some(Duration::from_secs(15))).await; + + let table_sizes = client + .get::>("database/table-sizes") + .send() + .await + .unwrap(); + assert!(!table_sizes.is_empty()); + + // Deferred backfill migrations register tracking rows at node startup, so the list may + // be non-empty; just check the entries are well-formed. + let migration_status = client + .get::>("database/migration-status") + .send() + .await + .unwrap(); + assert!(migration_status.iter().all(|m| !m.name.is_empty())); + } + async fn run_catchup_test(url_suffix: &str) { // Start a sequencer network, using the query service for catchup. let port = reserve_tcp_port().expect("OS should have ephemeral ports available"); @@ -3872,7 +3906,6 @@ mod test { .api_config(Options::from(options::Http { port, max_connections: None, - axum_port: None, tonic_port: None, })) .states(states) @@ -8208,9 +8241,11 @@ mod test { .build(); let api_port = reserve_tcp_port().expect("OS should have ephemeral ports available"); - let axum_port = reserve_tcp_port().expect("OS should have ephemeral ports available"); + // After the tide-disco cutover the single `port` field serves the Axum API. + // The parity-test helpers still accept two ports — wire both to the same Axum + // server so the existing call sites remain unchanged. + let axum_port = api_port; println!("API PORT = {api_port}"); - println!("AXUM PORT = {axum_port}"); let storage = join_all((0..NUM_NODES).map(|_| SqlDataSource::create_storage())).await; let persistence: [_; NUM_NODES] = storage @@ -8220,14 +8255,12 @@ mod test { .try_into() .unwrap(); - let mut api_opts = Options::with_port(api_port) + let api_opts = Options::with_port(api_port) .catchup(Default::default()) .config(Default::default()) - .submit(Default::default()) .explorer(Default::default()) .light_client(Default::default()) - .hotshot_events(HotshotEvents); - api_opts.http.axum_port = Some(axum_port); + .hotshot_events(Default::default()); let config = TestNetworkConfigBuilder::with_num_nodes() .api_config(SqlDataSource::options(&storage[0], api_opts)) @@ -8401,6 +8434,26 @@ mod test { assert_eq!(reward_claim_input, res.to_reward_claim_input()?); + // Tide contract relied on by scripts/claim-rewards-loop: an account with no + // rewards yields 404; any other error status makes the claim loop exit and + // process-compose tear down the whole demo. + let absent = alloy::primitives::Address::with_last_byte(0xaa); + assert!( + validated_state + .reward_merkle_tree_v2 + .iter() + .all(|(addr, _)| addr.0 != absent), + "sentinel address unexpectedly present in reward tree" + ); + let err = client + .get::(&format!( + "reward-state-v2/reward-claim-input/{height}/{absent}" + )) + .send() + .await + .unwrap_err(); + assert_matches!(err, ServerError { status, .. } if status == StatusCode::NOT_FOUND); + // Both servers share the same underlying SQL data source; compare responses // for each per-address endpoint under reward-state-v2. compare_endpoints( @@ -8438,6 +8491,24 @@ mod test { &format!("reward-state-v2/reward-balance/latest/{address}"), ) .await?; + + // Tide-disco registered the same reward.toml handlers on both the + // reward-state and reward-state-v2 mounts, so these two routes hit the + // same v2-tree-backed handlers as the pair above, just under reward-state. + compare_endpoints( + &http, + api_port, + axum_port, + &format!("reward-state/proof/latest/{address}"), + ) + .await?; + compare_endpoints( + &http, + api_port, + axum_port, + &format!("reward-state/reward-balance/latest/{address}"), + ) + .await?; } compare_endpoints( @@ -8454,6 +8525,60 @@ mod test { &format!("reward-state-v2/reward-merkle-tree-v2/{height}"), ) .await?; + compare_endpoints( + &http, + api_port, + axum_port, + &format!("reward-state/reward-amounts/{height}/0/1000"), + ) + .await?; + compare_endpoints( + &http, + api_port, + axum_port, + &format!("reward-state/reward-merkle-tree-v2/{height}"), + ) + .await?; + + // Merklized-state `get_path` routes, inherited by both reward mounts from + // `hotshot-query-service`'s base `state.toml` (mirrors the block-state / + // fee-state checks below). Nothing in this codebase populates the generic + // merklized-state tables for the reward trees today; the reward-state modules + // persist snapshots via the separate `persist_tree`/`load_tree` bincode-blob + // mechanism instead, so these routes 404 in practice. We only assert that both + // mounts, in both height and commit form, return well-formed (and identical + // between the two "servers") JSON. + let reward_address = validated_state + .reward_merkle_tree_v2 + .iter() + .next() + .map(|(addr, _)| *addr) + .expect("reward tree should have at least one account"); + let reward_header: Header = client + .get(&format!("availability/header/{height}")) + .send() + .await + .unwrap(); + let reward_mt_commit = match reward_header.reward_merkle_tree_root() { + either::Either::Left(commit) => commit.to_string(), + either::Either::Right(commit) => commit.to_string(), + }; + for mount in ["reward-state", "reward-state-v2"] { + compare_endpoints( + &http, + api_port, + axum_port, + &format!("{mount}/{height}/{reward_address}"), + ) + .await?; + compare_endpoints( + &http, + api_port, + axum_port, + &format!("{mount}/commit/{reward_mt_commit}/{reward_address}"), + ) + .await?; + } // Availability v1 parity: verify the axum v1 routes return the same JSON as tide. @@ -9229,6 +9354,24 @@ mod test { ) .await?; + // Regression: an oversized range on the plural namespaces route must return + // 400 Bad Request (the status carried by the query-service error), not 500. + let encoded_ns = tagged_base64::TaggedBase64::new( + ::light_client::client::NAMESPACES_PARAM_TAG, + &serde_json::to_vec(&vec![u64::from(avail_ns)])?, + )?; + compare_error_endpoints( + &http, + api_port, + axum_port, + &format!( + "light-client/namespaces/{avail_block}/{}/{encoded_ns}", + avail_block + 200 + ), + 400, + ) + .await?; + // hotshot-events startup info: both must return matching JSON. compare_endpoints(&http, api_port, axum_port, "hotshot-events/startup_info") .await?; @@ -9525,7 +9668,6 @@ mod test { .api_config(Options::from(options::Http { port, max_connections: None, - axum_port: None, tonic_port: None, })) .catchups(std::array::from_fn(|_| { @@ -9634,87 +9776,6 @@ mod test { } } - // The legacy version of the API only works for old VID. - tracing::info!("test namespace API version: v0"); - if version < EPOCH_VERSION { - let ns_proof: ADVZNamespaceProofQueryData = client - .get(&format!("v0/availability/block/{block}/namespace/{ns}")) - .send() - .await - .unwrap(); - let proof = ns_proof.proof.as_ref().unwrap(); - let VidCommon::V0(common) = common.common() else { - panic!("wrong VID common version"); - }; - let (txs, ns_from_proof) = proof - .verify(header.ns_table(), &header.payload_commitment(), common) - .unwrap(); - assert_eq!(ns_from_proof, ns); - assert_eq!(txs, ns_proof.transactions); - assert_eq!(&txs, std::slice::from_ref(&tx)); - - // Test range endpoint. - let ns_proofs: Vec = client - .get(&format!( - "v0/availability/block/{}/{}/namespace/{ns}", - block, - block + 1 - )) - .send() - .await - .unwrap(); - assert_eq!(&ns_proofs, std::slice::from_ref(&ns_proof)); - } else { - // It will fail if we ask for a proof for a block using new VID. - client - .get::(&format!( - "v0/availability/block/{block}/namespace/{ns}" - )) - .send() - .await - .unwrap_err(); - } - - // Any API version can correctly tell us that the namespace does not exist. - let ns_proof: ADVZNamespaceProofQueryData = client - .get(&format!( - "v0/availability/block/{}/namespace/{ns}", - block - 1 - )) - .send() - .await - .unwrap(); - assert_eq!(ns_proof.proof, None); - assert_eq!(ns_proof.transactions, vec![]); - - // Use the legacy API to stream namespace proofs until we get to a non-trivial proof or a - // VID version we can't deal with. - let mut proofs = client - .socket(&format!("v0/availability/stream/blocks/0/namespace/{ns}")) - .subscribe() - .await - .unwrap(); - for i in 0.. { - tracing::info!(i, "stream proof"); - let proof: ADVZNamespaceProofQueryData = match proofs.next().await { - Some(proof) => proof.unwrap(), - None => { - // Steam not expected to end on legacy consensus version. - assert!( - version >= EPOCH_VERSION, - "legacy steam ended while still on legacy consensus" - ); - break; - }, - }; - if proof.proof.is_none() { - tracing::info!("waiting for non-trivial proof from stream"); - continue; - } - assert_eq!(&proof.transactions, std::slice::from_ref(&tx)); - break; - } - network.server.shut_down().await; } diff --git a/crates/espresso/node/src/api/light_client.rs b/crates/espresso/node/src/api/light_client.rs index 1ae0ae09419..7fcca98f596 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -27,6 +27,7 @@ use light_client::{ header::HeaderProof, leaf::LeafProof, namespace::NamespaceProof, payload::PayloadProof, }, }; +use tagged_base64::TaggedBase64; use tide_disco::{Api, RequestParams, StatusCode, method::ReadState}; use vbs::version::StaticVersionType; use versions::NEW_PROTOCOL_VERSION; @@ -352,7 +353,7 @@ where .collect()) } -async fn get_namespaces_proof_range( +pub(crate) async fn get_namespaces_proof_range( state: &State, start: usize, end: usize, @@ -381,21 +382,28 @@ where .collect() } +/// Decode the `namespaces` path segment: a `TaggedBase64` string tagged `NS` wrapping a +/// JSON-encoded `Vec`. +pub(crate) fn parse_namespaces_str(encoded: &str) -> anyhow::Result> { + let encoded: TaggedBase64 = encoded + .parse() + .map_err(|err| anyhow::anyhow!("invalid namespaces parameter: {err}"))?; + if encoded.tag() != NAMESPACES_PARAM_TAG { + anyhow::bail!( + "invalid namespaces parameter tag: expected {NAMESPACES_PARAM_TAG}, got {}", + encoded.tag() + ); + } + serde_json::from_slice(&encoded.value()) + .map_err(|err| anyhow::anyhow!("invalid namespaces parameter: {err}")) +} + fn parse_namespaces_param(req: &RequestParams) -> Result, Error> { let encoded = req .tagged_base64_param("namespaces") .map_err(bad_param("namespaces"))?; - if encoded.tag() != NAMESPACES_PARAM_TAG { - return Err(Error::Custom { - message: format!( - "invalid namespaces parameter tag: expected {NAMESPACES_PARAM_TAG}, got {}", - encoded.tag() - ), - status: StatusCode::BAD_REQUEST, - }); - } - serde_json::from_slice(&encoded.value()).map_err(|err| Error::Custom { - message: format!("invalid namespaces parameter: {err}"), + parse_namespaces_str(&encoded.to_string()).map_err(|err| Error::Custom { + message: err.to_string(), status: StatusCode::BAD_REQUEST, }) } diff --git a/crates/espresso/node/src/api/options.rs b/crates/espresso/node/src/api/options.rs index 81b263bb718..54db72caaae 100644 --- a/crates/espresso/node/src/api/options.rs +++ b/crates/espresso/node/src/api/options.rs @@ -225,6 +225,7 @@ impl Options { let metrics = ds.populate_metrics(); telemetry::set_registry(Arc::new(ds.metrics().registry().clone())); tasks.spawn("process_metrics", ProcessMetrics::new(ds.metrics()).run()); + let axum_ds = Arc::new(ExtensibleDataSource::new(ds.clone(), state.clone())); let mut app = App::<_, Error>::with_state(AppState::from(ExtensibleDataSource::new( ds, state.clone(), @@ -242,17 +243,30 @@ impl Options { if self.hotshot_events.is_some() { self.init_hotshot_events_module(&mut app)?; } + drop(app); - tasks.spawn( - "API server", - self.listen(self.http.port, app, SequencerApiVersion::instance()), - ); - - // Spawn new Axum and gRPC servers if ports are configured - // TODO: Use NodeApiStateImpl with real data source once available for status-only mode - if self.http.axum_port.is_some() { - tracing::warn!("Axum reward API not available in status-only mode"); - } + let port = self.http.port; + let env_vars = endpoints::get_public_env_vars().unwrap_or_default(); + let node_cfg = self.public_node_config.as_deref().cloned(); + let modules = espresso_api::OptionalModules { + submit: self.submit.is_some(), + catchup: self.catchup.is_some(), + config: self.config.is_some(), + hotshot_events: self.hotshot_events.is_some(), + ..Default::default() + }; + let max_connections = self.http.max_connections; + tasks.spawn("API server", async move { + let state = NodeApiStateImpl::new(axum_ds) + .with_env_vars(env_vars) + .with_public_node_config(node_cfg); + if let Err(e) = + espresso_api::serve_axum_status(port, state, modules, max_connections).await + { + tracing::error!("Axum server error: {}", e); + } + anyhow::Ok(()) + }); if self.http.tonic_port.is_some() { tracing::warn!("gRPC reward API not available in status-only mode"); @@ -274,11 +288,31 @@ impl Options { if self.hotshot_events.is_some() { self.init_hotshot_events_module(&mut app)?; } + drop(app); - tasks.spawn( - "API server", - self.listen(self.http.port, app, SequencerApiVersion::instance()), - ); + let port = self.http.port; + let env_vars = endpoints::get_public_env_vars().unwrap_or_default(); + let node_cfg = self.public_node_config.as_deref().cloned(); + let modules = espresso_api::OptionalModules { + submit: self.submit.is_some(), + catchup: self.catchup.is_some(), + config: self.config.is_some(), + hotshot_events: self.hotshot_events.is_some(), + ..Default::default() + }; + let axum_ds = Arc::new(state.clone()); + let max_connections = self.http.max_connections; + tasks.spawn("API server", async move { + let state = NodeApiStateImpl::new(axum_ds) + .with_env_vars(env_vars) + .with_public_node_config(node_cfg); + if let Err(e) = + espresso_api::serve_axum_bare(port, state, modules, max_connections).await + { + tracing::error!("Axum server error: {}", e); + } + anyhow::Ok(()) + }); (Box::new(NoMetrics), Box::new(NullEventConsumer), None) }; @@ -409,14 +443,29 @@ impl Options { if self.hotshot_events.is_some() { self.init_hotshot_events_module(&mut app)?; } - - tasks.spawn("API server", self.listen(self.http.port, app, bind_version)); - - // Reward APIs not available with filesystem storage - // Note: Filesystem storage doesn't support RewardMerkleTreeDataSource - if self.http.axum_port.is_some() { - tracing::warn!("Axum reward API not available with filesystem storage"); - } + drop(app); + + let port = self.http.port; + let ds_for_axum = ds.clone(); + let env_vars = endpoints::get_public_env_vars().unwrap_or_default(); + let node_cfg = self.public_node_config.as_deref().cloned(); + let modules = espresso_api::OptionalModules { + submit: self.submit.is_some(), + config: self.config.is_some(), + hotshot_events: self.hotshot_events.is_some(), + ..Default::default() + }; + let max_connections = self.http.max_connections; + tasks.spawn("API server", async move { + let state = NodeApiStateImpl::new(ds_for_axum) + .with_env_vars(env_vars) + .with_public_node_config(node_cfg); + if let Err(e) = espresso_api::serve_axum_fs(port, state, modules, max_connections).await + { + tracing::error!("Axum server error: {}", e); + } + anyhow::Ok(()) + }); if self.http.tonic_port.is_some() { tracing::warn!("gRPC reward API not available with filesystem storage"); @@ -539,25 +588,34 @@ impl Options { })?; } - tasks.spawn( - "API server", - self.listen(self.http.port, app, SequencerApiVersion::instance()), - ); - - // Spawn new Axum and gRPC servers if ports are configured - if let Some(axum_port) = self.http.axum_port { - let ds_for_axum = ds.clone(); - let env_vars = endpoints::get_public_env_vars().unwrap_or_default(); - let node_cfg = self.public_node_config.as_deref().cloned(); - tasks.spawn("Axum API server", async move { - let state = NodeApiStateImpl::new(ds_for_axum) - .with_env_vars(env_vars) - .with_public_node_config(node_cfg); - if let Err(e) = espresso_api::serve_axum(axum_port, state).await { - tracing::error!("Axum server error: {}", e); - } - }); - } + // Drop the tide-disco app — SQL mode is fully served by Axum. The unused `app` here + // is kept above only so the registrations exercise the tide-disco module definitions + // (which still compile against `App::register_module`) until all callers are off + // tide-disco. TODO: stop building `app` once the unused tide-disco branches are gone. + drop(app); + + let port = self.http.port; + let ds_for_axum = ds.clone(); + let env_vars = endpoints::get_public_env_vars().unwrap_or_default(); + let node_cfg = self.public_node_config.as_deref().cloned(); + let modules = espresso_api::OptionalModules { + submit: self.submit.is_some(), + config: self.config.is_some(), + explorer: self.explorer.is_some(), + light_client: self.light_client.is_some(), + hotshot_events: self.hotshot_events.is_some(), + ..Default::default() + }; + let max_connections = self.http.max_connections; + tasks.spawn("API server", async move { + let state = NodeApiStateImpl::new(ds_for_axum) + .with_env_vars(env_vars) + .with_public_node_config(node_cfg); + if let Err(e) = espresso_api::serve_axum(port, state, modules, max_connections).await { + tracing::error!("Axum server error: {}", e); + } + anyhow::Ok(()) + }); if let Some(tonic_port) = self.http.tonic_port { let ds_for_tonic = ds.clone(); @@ -649,6 +707,8 @@ impl Options { Ok(()) } + // Kept until tide-disco removal; no longer called since the axum cutover. + #[allow(dead_code)] fn listen( &self, port: u16, @@ -692,10 +752,6 @@ pub struct Http { #[clap(long, env = "ESPRESSO_NODE_API_MAX_CONNECTIONS")] pub max_connections: Option, - /// Optional port for new Axum API server (skeleton implementation). - #[clap(long, env = "ESPRESSO_NODE_AXUM_PORT")] - pub axum_port: Option, - /// Optional port for Tonic gRPC API server. #[clap(long, env = "ESPRESSO_NODE_TONIC_PORT")] pub tonic_port: Option, @@ -707,7 +763,6 @@ impl Http { Self { port, max_connections: None, - axum_port: None, tonic_port: None, } } diff --git a/crates/espresso/node/src/api/state.rs b/crates/espresso/node/src/api/state.rs index ca49348406f..ac76e242ebb 100644 --- a/crates/espresso/node/src/api/state.rs +++ b/crates/espresso/node/src/api/state.rs @@ -47,6 +47,7 @@ use serialization_api::v2::{ reward_merkle_proof_v2::ProofType, }; use tagged_base64::TaggedBase64; +use tide_disco::{Error as _, StatusCode}; use super::{ RewardMerkleTreeDataSource, RewardMerkleTreeV2Data as InternalRewardTreeData, @@ -531,17 +532,16 @@ where .load_latest_reward_account_proof_v2(address.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load latest reward account {:?}: {}", - address, - err - ) + address, err + )) })?; // Convert the proof to reward claim input and return internal type proof.to_reward_claim_input().map_err(|err| match err { RewardClaimError::ZeroRewardError => { - anyhow::anyhow!("zero reward balance for {:?}", address) + not_found(format!("zero reward balance for {:?}", address)) }, RewardClaimError::ProofConversionError(e) => { anyhow::anyhow!("failed to create solidity proof for {:?}: {}", address, e) @@ -559,11 +559,10 @@ where .load_latest_reward_account_proof_v2(address.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load latest reward account {:?}: {}", - address, - err - ) + address, err + )) })?; // Return internal balance type @@ -579,11 +578,10 @@ where .load_latest_reward_account_proof_v2(address.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load latest reward account proof for {:?}: {}", - address, - err - ) + address, err + )) }) } @@ -595,25 +593,27 @@ where ) -> anyhow::Result { // Validate limit (from reward.toml: limit <= 10000) if limit > 10000 { - return Err(anyhow::anyhow!( + return Err(bad_request(format!( "limit {} exceeds maximum allowed value of 10000", limit - )); + ))); } // Load the merkle tree at the given height let tree_bytes = self.data_source.load_tree(height).await.map_err(|err| { - anyhow::anyhow!("failed to load reward tree at height {}: {}", height, err) + not_found(format!( + "failed to load reward tree at height {}: {}", + height, err + )) })?; // Deserialize the tree into internal format let tree_data: InternalRewardTreeData = bincode::deserialize(&tree_bytes).map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to deserialize RewardMerkleTreeV2Data at height {}: {}", - height, - err - ) + height, err + )) })?; let offset_usize = offset as usize; @@ -622,7 +622,7 @@ where // Validate offset is within bounds if offset_usize > tree_data.balances.len() { - return Err(anyhow::anyhow!("offset {} out of bounds", offset)); + return Err(not_found(format!("offset {} out of bounds", offset))); } let end = std::cmp::min(offset_usize + limit_usize, tree_data.balances.len()); @@ -639,16 +639,18 @@ where ) -> anyhow::Result { // Load the raw merkle tree bytes let tree_bytes = self.data_source.load_tree(height).await.map_err(|err| { - anyhow::anyhow!("failed to load reward tree at height {}: {}", height, err) + not_found(format!( + "failed to load reward tree at height {}: {}", + height, err + )) })?; // Deserialize and return internal type bincode::deserialize(&tree_bytes).map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to deserialize RewardMerkleTreeV2Data at height {}: {}", - height, - err - ) + height, err + )) }) } } @@ -660,13 +662,82 @@ where #[async_trait] impl espresso_api::v1::RewardApi for NodeApiStateImpl where - D: RewardMerkleTreeDataSource, + D: RewardMerkleTreeDataSource + std::ops::Deref, + D::Target: hotshot_query_service::merklized_state::MerklizedStateHeightPersistence + + hotshot_query_service::merklized_state::MerklizedStateDataSource< + espresso_types::SeqTypes, + espresso_types::v0_3::RewardMerkleTreeV1, + { + ::ARITY + }, + > + hotshot_query_service::merklized_state::MerklizedStateDataSource< + espresso_types::SeqTypes, + espresso_types::v0_4::RewardMerkleTreeV2, + { + ::ARITY + }, + > + Send + + Sync, { type RewardClaimInput = InternalRewardClaimInput; type RewardBalance = InternalRewardAmount; type RewardAccountQueryData = InternalRewardAccountQueryData; type RewardAmounts = Vec<(alloy::primitives::Address, InternalRewardAmount)>; type RewardMerkleTreeData = Vec; + type RewardAccountQueryDataV1 = espresso_types::v0_3::RewardAccountQueryDataV1; + type RewardStatePathV1 = InternalMerkleProof< + InternalRewardAmount, + espresso_types::v0_3::RewardAccountV1, + jf_merkle_tree_compat::prelude::Sha3Node, + { + ::ARITY + }, + >; + type RewardStatePathV2 = InternalMerkleProof< + InternalRewardAmount, + RewardAccountV2, + KeccakNode, + { + ::ARITY + }, + >; + + async fn get_reward_state_height(&self) -> anyhow::Result { + use hotshot_query_service::merklized_state::MerklizedStateHeightPersistence; + + let ds = &*self.data_source; + ds.get_last_state_height() + .await + .map(|h| h as u64) + .map_err(classify_query_error) + } + + async fn get_reward_state_v2_height(&self) -> anyhow::Result { + // Both the V1 and V2 reward merklized-state modules share the same underlying + // `last_merklized_state_height` row; mirrors tide registering the height route + // once per module against the same data source. + self.get_reward_state_height().await + } + + async fn get_reward_account_proof_v1( + &self, + height: u64, + address: String, + ) -> anyhow::Result { + let account: espresso_types::v0_3::RewardAccountV1 = address + .parse() + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; + + self.data_source + .load_v1_reward_account_proof(height, account) + .await + .map_err(|err| { + not_found(format!( + "failed to load v1 reward account {} at height {}: {}", + address, height, err + )) + }) + } async fn get_reward_claim_input( &self, @@ -676,7 +747,7 @@ where // Parse the Ethereum address let addr: alloy::primitives::Address = address .parse() - .map_err(|_| anyhow::anyhow!("invalid ethereum address: {}", address))?; + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; // Load the reward account proof from the data source let proof = self @@ -684,23 +755,18 @@ where .load_reward_account_proof_v2(block_height, addr.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load reward account {} at height {}: {}", - address, - block_height, - err - ) + address, block_height, err + )) })?; // Convert the proof to reward claim input (internal type) let claim_input = proof.to_reward_claim_input().map_err(|err| match err { - RewardClaimError::ZeroRewardError => { - anyhow::anyhow!( - "zero reward balance for {} at height {}", - address, - block_height - ) - }, + RewardClaimError::ZeroRewardError => not_found(format!( + "zero reward balance for {} at height {}", + address, block_height + )), RewardClaimError::ProofConversionError(e) => { anyhow::anyhow!( "failed to create solidity proof for {} at height {}: {}", @@ -722,7 +788,7 @@ where // Parse the Ethereum address let addr: alloy::primitives::Address = address .parse() - .map_err(|_| anyhow::anyhow!("invalid ethereum address: {}", address))?; + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; // Load the reward account proof from the data source let proof = self @@ -730,12 +796,10 @@ where .load_reward_account_proof_v2(height, addr.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load reward account {} at height {}: {}", - address, - height, - err - ) + address, height, err + )) })?; Ok(InternalRewardAmount(proof.balance)) @@ -747,14 +811,17 @@ where ) -> anyhow::Result { let addr: alloy::primitives::Address = address .parse() - .map_err(|_| anyhow::anyhow!("invalid ethereum address: {}", address))?; + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; let proof = self .data_source .load_latest_reward_account_proof_v2(addr.into()) .await .map_err(|err| { - anyhow::anyhow!("failed to load latest reward account {}: {}", address, err) + not_found(format!( + "failed to load latest reward account {}: {}", + address, err + )) })?; Ok(InternalRewardAmount(proof.balance)) @@ -768,7 +835,7 @@ where // Parse the Ethereum address let addr: alloy::primitives::Address = address .parse() - .map_err(|_| anyhow::anyhow!("invalid ethereum address: {}", address))?; + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; // Load and return the reward account proof directly (internal type) let proof = self @@ -776,12 +843,10 @@ where .load_reward_account_proof_v2(height, addr.into()) .await .map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to load reward account {} at height {}: {}", - address, - height, - err - ) + address, height, err + )) })?; Ok(proof) @@ -794,7 +859,7 @@ where // Parse the Ethereum address let addr: alloy::primitives::Address = address .parse() - .map_err(|_| anyhow::anyhow!("invalid ethereum address: {}", address))?; + .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; // Load and return the latest reward account proof directly (internal type) let proof = self @@ -802,7 +867,10 @@ where .load_latest_reward_account_proof_v2(addr.into()) .await .map_err(|err| { - anyhow::anyhow!("failed to load latest reward account {}: {}", address, err) + not_found(format!( + "failed to load latest reward account {}: {}", + address, err + )) })?; Ok(proof) @@ -816,25 +884,27 @@ where ) -> anyhow::Result { // Validate limit (from reward.toml: limit <= 10000) if limit > 10000 { - return Err(anyhow::anyhow!( + return Err(bad_request(format!( "limit {} exceeds maximum allowed value of 10000", limit - )); + ))); } // Load the merkle tree at the given height let tree_bytes = self.data_source.load_tree(height).await.map_err(|err| { - anyhow::anyhow!("failed to load reward tree at height {}: {}", height, err) + not_found(format!( + "failed to load reward tree at height {}: {}", + height, err + )) })?; // Deserialize the tree into internal format let tree_data: InternalRewardTreeData = bincode::deserialize(&tree_bytes).map_err(|err| { - anyhow::anyhow!( + not_found(format!( "failed to deserialize RewardMerkleTreeV2Data at height {}: {}", - height, - err - ) + height, err + )) })?; let offset_usize = offset as usize; @@ -842,7 +912,7 @@ where // Validate offset is within bounds if offset_usize > tree_data.balances.len() { - return Err(anyhow::anyhow!("offset {} out of bounds", offset)); + return Err(not_found(format!("offset {} out of bounds", offset))); } let end = std::cmp::min(offset_usize + limit_usize, tree_data.balances.len()); @@ -862,9 +932,80 @@ where height: u64, ) -> anyhow::Result { self.data_source.load_tree(height).await.map_err(|err| { - anyhow::anyhow!("failed to load reward tree at height {}: {}", height, err) + not_found(format!( + "failed to load reward tree at height {}: {}", + height, err + )) }) } + + async fn get_reward_state_path_v1( + &self, + snapshot: espresso_api::v1::Snapshot, + key: String, + ) -> anyhow::Result { + use hotshot_query_service::merklized_state::{ + MerklizedStateDataSource, Snapshot as HsSnapshot, + }; + + let hs_snapshot = match snapshot { + espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), + espresso_api::v1::Snapshot::Commit(c) => { + let tb64: TaggedBase64 = c + .parse() + .map_err(|_| bad_request("failed to parse commit param"))?; + let commit = (&tb64) + .try_into() + .map_err(|_| bad_request("failed to parse commit param"))?; + HsSnapshot::Commit(commit) + }, + }; + let key: espresso_types::v0_3::RewardAccountV1 = key + .parse() + .map_err(|_| bad_request("failed to parse Key param"))?; + let ds = &*self.data_source; + MerklizedStateDataSource::< + espresso_types::SeqTypes, + espresso_types::v0_3::RewardMerkleTreeV1, + _, + >::get_path(ds, hs_snapshot, key) + .await + .map_err(classify_query_error) + } + + async fn get_reward_state_path_v2( + &self, + snapshot: espresso_api::v1::Snapshot, + key: String, + ) -> anyhow::Result { + use hotshot_query_service::merklized_state::{ + MerklizedStateDataSource, Snapshot as HsSnapshot, + }; + + let hs_snapshot = match snapshot { + espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), + espresso_api::v1::Snapshot::Commit(c) => { + let tb64: TaggedBase64 = c + .parse() + .map_err(|_| bad_request("failed to parse commit param"))?; + let commit = (&tb64) + .try_into() + .map_err(|_| bad_request("failed to parse commit param"))?; + HsSnapshot::Commit(commit) + }, + }; + let key: RewardAccountV2 = key + .parse() + .map_err(|_| bad_request("failed to parse Key param"))?; + let ds = &*self.data_source; + MerklizedStateDataSource::< + espresso_types::SeqTypes, + espresso_types::v0_4::RewardMerkleTreeV2, + _, + >::get_path(ds, hs_snapshot, key) + .await + .map_err(classify_query_error) + } } // ============================================================================ @@ -1142,8 +1283,10 @@ where impl espresso_api::v1::AvailabilityApi for NodeApiStateImpl where D: std::ops::Deref + Clone + Send + Sync + 'static, - D::Target: RewardMerkleTreeDataSource - + hotshot_query_service::availability::AvailabilityDataSource + // No `RewardMerkleTreeDataSource` bound here: unlike `v1::RewardApi`, none of these methods + // touch the reward merkle tree, so filesystem storage (which doesn't implement it) can serve + // this module too. + D::Target: hotshot_query_service::availability::AvailabilityDataSource + hotshot_query_service::node::NodeDataSource + super::data_source::RequestResponseDataSource + super::data_source::StateCertDataSource @@ -1160,7 +1303,7 @@ where &self, block_id: espresso_api::v1::availability::BlockId, namespace: u32, - ) -> anyhow::Result> { + ) -> anyhow::Result { let ns_id = NamespaceId::from(namespace); // Convert v1 BlockId to hotshot BlockId @@ -1169,13 +1312,13 @@ where espresso_api::v1::availability::BlockId::Hash(h) => { let hash = h .parse() - .map_err(|_| anyhow::anyhow!("invalid block hash: {}", h))?; + .map_err(|_| bad_request(format!("invalid block hash: {}", h)))?; HsBlockId::Hash(hash) }, espresso_api::v1::availability::BlockId::PayloadHash(h) => { let payload_hash = h .parse() - .map_err(|_| anyhow::anyhow!("invalid payload hash: {}", h))?; + .map_err(|_| bad_request(format!("invalid payload hash: {}", h)))?; HsBlockId::PayloadHash(payload_hash) }, }; @@ -1190,34 +1333,39 @@ where vid_fetch.with_timeout(timeout) ); - let Some(block) = block else { - return Ok(None); - }; - let Some(vid_common) = vid_common else { - return Ok(None); - }; + let block = + block.ok_or_else(|| not_found(format!("block {} not available", hs_block_id)))?; + let vid_common = vid_common.ok_or_else(|| { + not_found(format!( + "VID common for block {} not available", + hs_block_id + )) + })?; - // Check if namespace is present + // Namespace absent from the block: matches tide's empty-result semantics. let ns_table = block.payload().ns_table(); let Some(ns_index) = ns_table.find_ns_id(&ns_id) else { - return Ok(None); + return Ok(espresso_types::NamespaceProofQueryData { + transactions: vec![], + proof: None, + }); }; // Generate namespace proof let Some(proof) = NsProof::new(block.payload(), &ns_index, vid_common.common()) else { // Failed to generate proof - namespace exists but proof generation failed - return Ok(Some(espresso_types::NamespaceProofQueryData { + return Ok(espresso_types::NamespaceProofQueryData { transactions: vec![], proof: None, - })); + }); }; let transactions = proof.export_all_txs(&ns_id); - Ok(Some(espresso_types::NamespaceProofQueryData { + Ok(espresso_types::NamespaceProofQueryData { transactions, proof: Some(proof), - })) + }) } async fn get_namespace_proof_range( @@ -2681,6 +2829,19 @@ where } } +// Bare mode (no query/status API) has no `ExtensibleDataSource` wrapper: the app state is +// `ApiState` directly, so it needs its own erased forwarding impl. +#[async_trait] +impl SubmitDataSourceErased for crate::api::ApiState +where + N: hotshot_types::traits::network::ConnectedNetwork, + P: espresso_types::v0::traits::SequencerPersistence, +{ + async fn submit_erased(&self, tx: espresso_types::Transaction) -> anyhow::Result<()> { + >::submit(self, tx).await + } +} + // ============================================================================ // v1::StateSignatureApi implementation // ============================================================================ @@ -2725,6 +2886,22 @@ where } } +// Bare mode (no query/status API) has no `ExtensibleDataSource` wrapper: the app state is +// `ApiState` directly, so it needs its own erased forwarding impl. +#[async_trait] +impl StateSignatureDataSourceErased for crate::api::ApiState +where + N: hotshot_types::traits::network::ConnectedNetwork, + P: espresso_types::v0::traits::SequencerPersistence, +{ + async fn get_state_signature_erased( + &self, + height: u64, + ) -> Option { + >::get_state_signature(self, height).await + } +} + // ============================================================================ // v1::ExplorerApi implementation // ============================================================================ @@ -3142,7 +3319,7 @@ where lc_large_object_range_limit(), ) .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + .map_err(lc_error)?; if proofs.len() != 1 { return Err(anyhow::anyhow!("internal consistency error")); } @@ -3166,7 +3343,29 @@ where lc_large_object_range_limit(), ) .await - .map_err(|err| anyhow::anyhow!("{err}")) + .map_err(lc_error) + } + + async fn get_lc_namespaces_proof_range( + &self, + start: u64, + end: u64, + namespaces: String, + ) -> anyhow::Result>> { + let namespaces = crate::api::light_client::parse_namespaces_str(&namespaces) + .map_err(|err| bad_request(err.to_string()))?; + let ds = &*self.data_source; + let fetch_timeout = lc_fetch_timeout(); + crate::api::light_client::get_namespaces_proof_range( + ds, + start as usize, + end as usize, + &namespaces, + fetch_timeout, + lc_large_object_range_limit(), + ) + .await + .map_err(lc_error) } } @@ -3178,6 +3377,16 @@ fn lc_large_object_range_limit() -> usize { hotshot_query_service::availability::Options::default().large_object_range_limit } +/// Convert a query-service error to an [`AvailabilityError`]-carrying anyhow error so the HTTP +/// layer returns the status tide-disco returned (400/404) instead of 500. +pub(crate) fn lc_error(err: hotshot_query_service::Error) -> anyhow::Error { + match err.status() { + StatusCode::NOT_FOUND => not_found(err.to_string()), + StatusCode::BAD_REQUEST => bad_request(err.to_string()), + _ => anyhow::anyhow!("{err}"), + } +} + // ============================================================================ // v1::HotShotEventsApi implementation // ============================================================================ @@ -3304,10 +3513,55 @@ where D::Target: super::data_source::DatabaseMetadataSource + Send + Sync, { type TableSizes = Vec; + type MigrationStatus = Vec; async fn get_table_sizes(&self) -> anyhow::Result { use super::data_source::DatabaseMetadataSource as _; let ds = &*self.data_source; ds.get_table_sizes().await } + + async fn get_migration_status(&self) -> anyhow::Result { + use super::data_source::DatabaseMetadataSource as _; + let ds = &*self.data_source; + ds.get_migration_status().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn custom(status: StatusCode) -> hotshot_query_service::Error { + hotshot_query_service::Error::Custom { + message: "boom".into(), + status, + } + } + + // Regression: the light-client trait methods used to map query-service errors through + // `anyhow::anyhow!("{err}")`, erasing the status; every 400/404 became a 500. + #[test] + fn lc_error_preserves_bad_request() { + let err = lc_error(custom(StatusCode::BAD_REQUEST)); + assert!(matches!( + err.downcast_ref::(), + Some(AvailabilityError::BadRequest(_)) + )); + } + + #[test] + fn lc_error_preserves_not_found() { + let err = lc_error(custom(StatusCode::NOT_FOUND)); + assert!(matches!( + err.downcast_ref::(), + Some(AvailabilityError::NotFound(_)) + )); + } + + #[test] + fn lc_error_other_statuses_stay_internal() { + let err = lc_error(custom(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(err.downcast_ref::().is_none()); + } } diff --git a/crates/espresso/node/src/catchup.rs b/crates/espresso/node/src/catchup.rs index da5e938a13f..900b9ce0df7 100644 --- a/crates/espresso/node/src/catchup.rs +++ b/crates/espresso/node/src/catchup.rs @@ -10,6 +10,7 @@ use anyhow::{Context, anyhow, bail, ensure}; use async_lock::RwLock; use async_trait::async_trait; use committable::{Commitment, Committable}; +use espresso_api::routes::v1 as paths; use espresso_types::{ BackoffParams, BlockMerkleTree, Certificate2, FeeAccount, FeeAccountProof, FeeMerkleCommitment, FeeMerkleTree, Leaf2, NodeState, SeqTypes, ValidatedState, @@ -262,7 +263,7 @@ impl StatePeers { async move { let cfg: PublicNetworkConfig = provider .fetch(retry, |client| { - let url = client.url.join("config/hotshot").unwrap(); + let url = client.url.join(&paths::config_hotshot()).unwrap(); reqwest::get(url.clone()) }) @@ -316,7 +317,7 @@ impl StateCatchup for StatePeers { self.fetch(retry, |client| async move { let tree = client .inner - .post::(&format!("catchup/{height}/{}/accounts", view.u64())) + .post::(&paths::catchup_accounts(height, view.u64())) .body_binary(&accounts.to_vec())? .send() .await?; @@ -351,7 +352,7 @@ impl StateCatchup for StatePeers { let mut mt = mt.clone(); async move { let frontier = client - .get::(&format!("catchup/{height}/{}/blocks", view.u64())) + .get::(&paths::catchup_blocks(height, view.u64())) .send() .await?; let elem = frontier @@ -373,7 +374,7 @@ impl StateCatchup for StatePeers { ) -> anyhow::Result { self.fetch(retry, |client| async move { let cf = client - .get::(&format!("catchup/chain-config/{commitment}")) + .get::(&paths::catchup_chainconfig(commitment)) .send() .await?; ensure!( @@ -398,7 +399,7 @@ impl StateCatchup for StatePeers { let leaf_chain = self .fetch(retry, |client| async move { let chain = client - .get::>(&format!("catchup/{height}/leafchain")) + .get::>(&paths::catchup_leafchain(height)) .send() .await?; anyhow::Ok(chain) @@ -423,7 +424,7 @@ impl StateCatchup for StatePeers { let cert2 = self .fetch(retry, |client| async move { let cert2 = client - .get::>(&format!("catchup/{cert2_height}/cert2")) + .get::>(&paths::catchup_cert2(cert2_height)) .send() .await?; anyhow::Ok(cert2) @@ -456,7 +457,7 @@ impl StateCatchup for StatePeers { // the reward-state-v2 endpoint which returns from storage decided state let tree_bytes = match client .inner - .get::>(&format!("catchup/reward-merkle-tree-v2/{height}/{}", *view)) + .get::>(&paths::catchup_reward_merkle_tree_v2(height, *view)) .send() .await { @@ -467,9 +468,7 @@ impl StateCatchup for StatePeers { ); client .inner - .get::>(&format!( - "reward-state-v2/reward-merkle-tree-v2/{height}" - )) + .get::>(&paths::reward_merkle_tree_v2(height)) .send() .await? }, @@ -508,10 +507,7 @@ impl StateCatchup for StatePeers { self.fetch(retry, |client| async move { let tree = client .inner - .post::(&format!( - "catchup/{height}/{}/reward-accounts", - view.u64() - )) + .post::(&paths::catchup_reward_accounts(height, view.u64())) .body_binary(&accounts.to_vec())? .send() .await?; @@ -540,8 +536,8 @@ impl StateCatchup for StatePeers { ) -> anyhow::Result> { self.fetch(retry, |client| async move { client - .get::>(&format!( - "catchup/{epoch}/state-cert" + .get::>(&paths::catchup_state_cert( + epoch, )) .send() .await diff --git a/crates/espresso/node/src/options.rs b/crates/espresso/node/src/options.rs index 3b31220a2fd..076548e5627 100644 --- a/crates/espresso/node/src/options.rs +++ b/crates/espresso/node/src/options.rs @@ -787,7 +787,6 @@ pub struct ApiModulesConfig { pub struct HttpConfig { pub port: u16, pub max_connections: Option, - pub axum_port: Option, pub tonic_port: Option, } @@ -870,7 +869,6 @@ impl From<&api::options::Http> for HttpConfig { Self { port: o.port, max_connections: o.max_connections, - axum_port: o.axum_port, tonic_port: o.tonic_port, } } diff --git a/crates/espresso/node/src/run.rs b/crates/espresso/node/src/run.rs index ab77953a7ea..188cf30aec1 100644 --- a/crates/espresso/node/src/run.rs +++ b/crates/espresso/node/src/run.rs @@ -429,9 +429,12 @@ mod test { // The metrics should include information about the node and software version. surf-disco // doesn't currently support fetching a plaintext file, so we use a raw reqwest client. - let res = reqwest::get(url.join("/status/metrics").unwrap()) - .await - .unwrap(); + let res = reqwest::get( + url.join(&espresso_api::routes::v1::status_metrics()) + .unwrap(), + ) + .await + .unwrap(); assert!(res.status().is_success(), "{}", res.status()); let metrics = res.text().await.unwrap(); let lines = metrics.lines().collect::>(); @@ -488,7 +491,10 @@ mod test { // reqwest client to fetch JSON, since surf-disco defaults to bincode encoding which can't // round-trip arbitrary JSON via `serde_json::Value`. let res = reqwest::Client::new() - .get(url.join("/config/runtime").unwrap()) + .get( + url.join(&espresso_api::routes::v1::config_runtime()) + .unwrap(), + ) .header(reqwest::header::ACCEPT, "application/json") .send() .await diff --git a/crates/espresso/node/src/snapshots/espresso_node__options__tests__config_node_response_postgres.snap b/crates/espresso/node/src/snapshots/espresso_node__options__tests__config_node_response_postgres.snap index a3ea822a024..0621ab11bd8 100644 --- a/crates/espresso/node/src/snapshots/espresso_node__options__tests__config_node_response_postgres.snap +++ b/crates/espresso/node/src/snapshots/espresso_node__options__tests__config_node_response_postgres.snap @@ -230,7 +230,6 @@ modules: http: port: 24000 max_connections: ~ - axum_port: ~ tonic_port: ~ query: peers: [] diff --git a/crates/process-metrics/scripts/soak.py b/crates/process-metrics/scripts/soak.py index 66f7839f678..eb61ef92871 100755 --- a/crates/process-metrics/scripts/soak.py +++ b/crates/process-metrics/scripts/soak.py @@ -68,11 +68,11 @@ def scrape_node(idx: int, port: int) -> list[dict]: - """Scrape one node's /v0/status/metrics endpoint.""" + """Scrape one node's /v1/status/metrics endpoint.""" name = f"espresso-node-{idx}" try: with urllib.request.urlopen( - f"http://localhost:{port}/v0/status/metrics", timeout=2.0 + f"http://localhost:{port}/v1/status/metrics", timeout=2.0 ) as resp: body = resp.read().decode() except OSError as e: @@ -361,7 +361,7 @@ def cli(log_level: str) -> None: type=PathOpt, ) def sample(duration_seconds: int, output_dir: Path) -> None: - """Scrape docker stats + each node's /v0/status/metrics into JSONL.""" + """Scrape docker stats + each node's /v1/status/metrics into JSONL.""" output_dir.mkdir(parents=True, exist_ok=True) env_path = REPO_ROOT / ".env" if not env_path.exists(): diff --git a/docker-compose.yaml b/docker-compose.yaml index 3137b847489..afd82e1db84 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -777,8 +777,8 @@ services: - RUST_LOG - RUST_LOG_FORMAT - ESPRESSO_NODE_VALIDATOR_PORT - - ESPRESSO_NODE_VALIDATOR_STAKE_TABLE_SOURCE_BASE_URL=http://espresso-node-0:$ESPRESSO_NODE_0_API_PORT/v0/ - - ESPRESSO_NODE_VALIDATOR_LEAF_STREAM_SOURCE_BASE_URL=http://espresso-node-0:$ESPRESSO_NODE_0_API_PORT/v0/ + - ESPRESSO_NODE_VALIDATOR_STAKE_TABLE_SOURCE_BASE_URL=http://espresso-node-0:$ESPRESSO_NODE_0_API_PORT/v1/ + - ESPRESSO_NODE_VALIDATOR_LEAF_STREAM_SOURCE_BASE_URL=http://espresso-node-0:$ESPRESSO_NODE_0_API_PORT/v1/ - ESPRESSO_NODE_VALIDATOR_INITIAL_NODE_PUBLIC_BASE_URLS=http://espresso-node-0:$ESPRESSO_NODE_0_API_PORT/,http://espresso-node-1:$ESPRESSO_NODE_1_API_PORT/,http://espresso-node-2:$ESPRESSO_NODE_2_API_PORT/,http://espresso-node-3:$ESPRESSO_NODE_3_API_PORT/,http://espresso-node-4:$ESPRESSO_NODE_4_API_PORT/ depends_on: espresso-node-0: @@ -831,7 +831,7 @@ services: ports: - "$ESPRESSO_BLOCK_EXPLORER_PORT:3000" environment: - - QUERY_SERVICE_URI=http://localhost:$ESPRESSO_NODE_1_API_PORT/v0/ + - QUERY_SERVICE_URI=http://localhost:$ESPRESSO_NODE_1_API_PORT/v1/ - NODE_VALIDATOR_URI=ws://localhost:$ESPRESSO_NODE_VALIDATOR_PORT/v0/ depends_on: espresso-node-1: diff --git a/process-compose.yaml b/process-compose.yaml index b0781ae81cc..355c5a8a858 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -21,7 +21,7 @@ environment: - ESPRESSO_L1_PROVIDER=http://localhost:${ESPRESSO_L1_PORT} - ESPRESSO_NODE_GENESIS_FILE=${ESPRESSO_NODE_GENESIS_FILE:-data/genesis/demo.toml} - ESPRESSO_STATE_RELAY_SERVER_URL=http://localhost:${ESPRESSO_STATE_RELAY_SERVER_PORT} - - QUERY_SERVICE_URI=http://localhost:${ESPRESSO_NODE_1_API_PORT}/v0/ + - QUERY_SERVICE_URI=http://localhost:${ESPRESSO_NODE_1_API_PORT}/v1/ - NODE_VALIDATOR_URI=ws://localhost:${ESPRESSO_NODE_VALIDATOR_PORT}/v0/ processes: # Cheating a bit here but since we don't usually have to debug go-ethereum @@ -242,7 +242,6 @@ processes: - ESPRESSO_NODE_TELEMETRY_ENDPOINT=http://intentionally-unreachable-telemetry-endpoint - ESPRESSO_NODE_TELEMETRY_LOG=info - ESPRESSO_NODE_API_PORT=${ESPRESSO_NODE_0_API_PORT} - - ESPRESSO_NODE_AXUM_PORT=${ESPRESSO_NODE_0_AXUM_PORT} - ESPRESSO_NODE_TONIC_PORT=${ESPRESSO_NODE_0_TONIC_PORT} - ESPRESSO_NODE_LIBP2P_BIND_ADDRESS=0.0.0.0:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_0} - ESPRESSO_NODE_LIBP2P_ADVERTISE_ADDRESS=localhost:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_0} @@ -306,7 +305,6 @@ processes: - ESPRESSO_NODE_TELEMETRY_METRICS_ENABLE=true - ESPRESSO_NODE_TELEMETRY_ENDPOINT=http://localhost:${ESPRESSO_DEMO_TELEMETRY_PRW_PORT} - ESPRESSO_NODE_API_PORT=${ESPRESSO_NODE_1_API_PORT} - - ESPRESSO_NODE_AXUM_PORT=${ESPRESSO_NODE_1_AXUM_PORT} - ESPRESSO_NODE_TONIC_PORT=${ESPRESSO_NODE_1_TONIC_PORT} - ESPRESSO_NODE_LIBP2P_BIND_ADDRESS=0.0.0.0:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_1} - ESPRESSO_NODE_LIBP2P_ADVERTISE_ADDRESS=localhost:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_1} @@ -368,7 +366,6 @@ processes: command: espresso-node -- http -- config environment: - ESPRESSO_NODE_API_PORT=${ESPRESSO_NODE_2_API_PORT} - - ESPRESSO_NODE_AXUM_PORT=${ESPRESSO_NODE_2_AXUM_PORT} - ESPRESSO_NODE_TONIC_PORT=${ESPRESSO_NODE_2_TONIC_PORT} - ESPRESSO_NODE_LIBP2P_BIND_ADDRESS=0.0.0.0:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_2} - ESPRESSO_NODE_LIBP2P_ADVERTISE_ADDRESS=localhost:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_2} @@ -426,7 +423,6 @@ processes: - ESPRESSO_NODE_TELEMETRY_ENDPOINT=http://localhost:${ESPRESSO_DEMO_TELEMETRY_OTLP_PORT} - ESPRESSO_NODE_TELEMETRY_LOG=info - ESPRESSO_NODE_API_PORT=${ESPRESSO_NODE_3_API_PORT} - - ESPRESSO_NODE_AXUM_PORT=${ESPRESSO_NODE_3_AXUM_PORT} - ESPRESSO_NODE_TONIC_PORT=${ESPRESSO_NODE_3_TONIC_PORT} - ESPRESSO_NODE_LIBP2P_BIND_ADDRESS=0.0.0.0:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_3} - ESPRESSO_NODE_LIBP2P_ADVERTISE_ADDRESS=localhost:${ESPRESSO_DEMO_NODE_LIBP2P_PORT_3} @@ -482,7 +478,6 @@ processes: environment: - ESPRESSO_NODE_LIGHTWEIGHT=true - ESPRESSO_NODE_API_PORT=${ESPRESSO_NODE_4_API_PORT} - - ESPRESSO_NODE_AXUM_PORT=${ESPRESSO_NODE_4_AXUM_PORT} - ESPRESSO_NODE_TONIC_PORT=${ESPRESSO_NODE_4_TONIC_PORT} - ESPRESSO_NODE_STATE_PEERS=http://localhost:${ESPRESSO_NODE_0_API_PORT},http://localhost:${ESPRESSO_NODE_1_API_PORT} - ESPRESSO_NODE_API_PEERS=http://localhost:${ESPRESSO_NODE_0_API_PORT} @@ -536,8 +531,8 @@ processes: node_validator: command: node-metrics -- environment: - - ESPRESSO_NODE_VALIDATOR_STAKE_TABLE_SOURCE_BASE_URL=http://localhost:${ESPRESSO_NODE_0_API_PORT}/v0/ - - ESPRESSO_NODE_VALIDATOR_LEAF_STREAM_SOURCE_BASE_URL=http://localhost:${ESPRESSO_NODE_0_API_PORT}/v0/ + - ESPRESSO_NODE_VALIDATOR_STAKE_TABLE_SOURCE_BASE_URL=http://localhost:${ESPRESSO_NODE_0_API_PORT}/v1/ + - ESPRESSO_NODE_VALIDATOR_LEAF_STREAM_SOURCE_BASE_URL=http://localhost:${ESPRESSO_NODE_0_API_PORT}/v1/ - ESPRESSO_NODE_VALIDATOR_INITIAL_NODE_PUBLIC_BASE_URLS=http://localhost:${ESPRESSO_NODE_0_API_PORT}/,http://localhost:${ESPRESSO_NODE_1_API_PORT}/,http://localhost:${ESPRESSO_NODE_2_API_PORT}/,http://localhost:${ESPRESSO_NODE_3_API_PORT}/,http://localhost:${ESPRESSO_NODE_4_API_PORT}/ depends_on: broker_0: diff --git a/scripts/wait-for-header b/scripts/wait-for-header index f4f6ae7a091..ce2a2ed9ff1 100755 --- a/scripts/wait-for-header +++ b/scripts/wait-for-header @@ -36,12 +36,12 @@ EOF } get_block_height() { - curl -sL "${ESPRESSO_API_URL}/status/block-height" 2>/dev/null || echo "" + curl -sL "${ESPRESSO_API_URL}/v1/status/block-height" 2>/dev/null || echo "" } get_header_at_height() { local height="$1" - curl -sL "${ESPRESSO_API_URL}/v0/availability/header/${height}" 2>/dev/null || echo "" + curl -sL "${ESPRESSO_API_URL}/v1/availability/header/${height}" 2>/dev/null || echo "" } is_valid_value() { @@ -164,8 +164,8 @@ new-epochs) ;; esac -echo "Getting hotshot config from: ${ESPRESSO_API_URL}/config/hotshot" -blocks_per_epoch=$(curl -sL "${ESPRESSO_API_URL}/config/hotshot" | jq -r '.config.epoch_height') +echo "Getting hotshot config from: ${ESPRESSO_API_URL}/v1/config/hotshot" +blocks_per_epoch=$(curl -sL "${ESPRESSO_API_URL}/v1/config/hotshot" | jq -r '.config.epoch_height') echo "Blocks per epoch: ${blocks_per_epoch}" if [ "$MODE" = "new-epochs" ]; then From cd9abec91eb5782bbde0abf39838caea130f6cc8 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 08:46:31 +0800 Subject: [PATCH 02/14] chore(node): drop unused ESPRESSO_NODE_*_AXUM_PORT env vars The separate axum_port option was removed when axum took over the main ESPRESSO_NODE_API_PORT; these .env entries have no consumer. --- .env | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.env b/.env index f6b638a3d4b..3acb2767b99 100644 --- a/.env +++ b/.env @@ -46,11 +46,6 @@ ESPRESSO_NODE_1_API_PORT=24001 ESPRESSO_NODE_2_API_PORT=24002 ESPRESSO_NODE_3_API_PORT=24003 ESPRESSO_NODE_4_API_PORT=24004 -ESPRESSO_NODE_0_AXUM_PORT=24100 -ESPRESSO_NODE_1_AXUM_PORT=24101 -ESPRESSO_NODE_2_AXUM_PORT=24102 -ESPRESSO_NODE_3_AXUM_PORT=24103 -ESPRESSO_NODE_4_AXUM_PORT=24104 ESPRESSO_NODE_0_TONIC_PORT=24200 ESPRESSO_NODE_1_TONIC_PORT=24201 ESPRESSO_NODE_2_TONIC_PORT=24202 From 7b1791f8a6977f90e469d8906887febfbb4ef112 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 14:53:02 +0800 Subject: [PATCH 03/14] docs(api): route-level OpenAPI docs for the v1 API Convert the 15 v1 module routers to aide's ApiRouter and annotate all 171 routes with summaries and descriptions lifted from the legacy tide-disco toml specs, restoring the docs that died with the tide App. Serve the spec at /v1/docs/openapi.json with Swagger UI at /v1 and Scalar at /v1/scalar. Point / at /v1 (temporary redirect) while the v2 API is a work in progress. Wire-neutral: ApiJson delegates to axum::Json, so response bytes, statuses, and headers are unchanged; the OpenAPI spec reports untyped 200 responses instead of adding JsonSchema derives to domain types. --- Cargo.toml | 1 + crates/espresso/api/src/axum.rs | 2735 ++++++++++++++++---- crates/espresso/api/src/axum/routes.rs | 5 + crates/espresso/api/src/lib.rs | 10 +- crates/espresso/api/templates/swagger.html | 2 +- 5 files changed, 2259 insertions(+), 494 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 24cf4b36b98..0467d0202e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,7 @@ edition = "2024" aide = { version = "0.15.1", features = [ "axum", "axum-json", + "axum-ws", "scalar", "swagger", "redoc", diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index 5cdf4e2ae4f..d84c52b4f39 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -5,7 +5,10 @@ pub mod routes; use std::collections::BTreeMap; use aide::{ - axum::{ApiRouter, routing::get_with}, + axum::{ + ApiRouter, + routing::{get_with, post_with}, + }, openapi::{Info, OpenApi}, operation::OperationOutput, redoc::Redoc, @@ -142,19 +145,56 @@ impl OperationOutput for ApiError { type Inner = Self; } +/// Successful JSON response for v1 handlers, most of which return domain types (from +/// `espresso-types`, `hotshot-query-service`, etc.) that don't implement `schemars::JsonSchema` — +/// this crate doesn't add OpenAPI derives to domain types. Wire format is identical to +/// `axum::Json`; only the OpenAPI operation gets an untyped 200 response instead of a generated +/// schema. +struct ApiJson(T); + +impl IntoResponse for ApiJson { + fn into_response(self) -> Response { + Json(self.0).into_response() + } +} + +impl OperationOutput for ApiJson { + type Inner = T; + + fn inferred_responses( + _ctx: &mut aide::generate::GenContext, + _operation: &mut aide::openapi::Operation, + ) -> Vec<(Option, aide::openapi::Response)> { + vec![(Some(200), aide::openapi::Response::default())] + } +} + /// Serve the OpenAPI spec (extracted from Extension) async fn serve_openapi_spec(Extension(api): Extension) -> Json { Json(api) } -/// Serve custom Swagger UI with collapsed defaults -async fn serve_swagger_ui() -> Html<&'static str> { - Html(include_str!("../templates/swagger.html")) +/// The v2 router's `Extension` layer only covers routes registered on the v2 +/// `ApiRouter`; this newtype lets v1 layer its own `OpenApi` extension without the two `Extension` +/// lookups being ambiguous if the routers are ever merged and inspected by type. +#[derive(Clone)] +struct OpenApiV1(OpenApi); + +/// Serve the v1 OpenAPI spec (extracted from Extension) +async fn serve_openapi_spec_v1(Extension(OpenApiV1(api)): Extension) -> Json { + Json(api) +} + +/// Serve custom Swagger UI with collapsed defaults, pointed at the given OpenAPI spec route. +fn swagger_html(spec_route: &str) -> Html { + Html(include_str!("../templates/swagger.html").replace("{{OPENAPI_SPEC_ROUTE}}", spec_route)) } -/// Redirect handler for root path +/// Redirect handler for root path. v2 is a work in progress, so `/` points at the stable v1 docs. +/// Temporary (307), not permanent: browsers cache permanent redirects, and this target moves back +/// to `/v2` once that API is complete. async fn redirect_to_docs() -> axum::response::Redirect { - axum::response::Redirect::permanent("/v2") + axum::response::Redirect::temporary("/v1") } /// Tide-disco served every v1 module at both `//...` and `/v1//...`, and legacy @@ -384,7 +424,7 @@ async fn version() -> Json { })) } -pub(crate) fn router_reward(state: S) -> Router +pub(crate) fn router_reward(state: S) -> ApiRouter where S: v1::RewardApi + Clone + Send + Sync + 'static, { @@ -394,7 +434,7 @@ where state .get_reward_claim_input(height, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -403,7 +443,7 @@ where state .get_reward_balance(height, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -411,7 +451,7 @@ where state .get_latest_reward_balance(address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -420,7 +460,7 @@ where state .get_reward_account_proof(height, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -428,7 +468,7 @@ where state .get_latest_reward_account_proof(address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -437,14 +477,14 @@ where state .get_reward_amounts(height, offset, limit) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let get_reward_merkle_tree_v2 = |State(state): State, Path(height): Path| async move { ::get_reward_merkle_tree_v2(&state, height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -452,7 +492,7 @@ where state .get_reward_state_height() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -460,7 +500,7 @@ where state .get_reward_state_v2_height() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -471,7 +511,7 @@ where state .get_reward_balance(height, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -480,7 +520,7 @@ where state .get_reward_account_proof_v1(height, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -495,7 +535,7 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -507,7 +547,7 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -519,7 +559,7 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -531,84 +571,131 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::REWARD_CLAIM_INPUT_ROUTE, - get(get_reward_claim_input), + get_with(get_reward_claim_input, |op| { + op.summary("Get reward claim input").description("Returns the RewardClaimInput needed to call claimRewards() on L1: lifetime rewards, Merkle proof, and auth root inputs, for the account at the given block height finalized by the light client contract.") + }), ) - .route(routes::v1::REWARD_BALANCE_ROUTE, get(get_reward_balance)) - .route( + .api_route( + routes::v1::REWARD_BALANCE_ROUTE, + get_with(get_reward_balance, |op| { + op.summary("Get reward balance at height").description("Get balance in reward state at a specific height for an Ethereum address.") + }), + ) + .api_route( routes::v1::LATEST_REWARD_BALANCE_ROUTE, - get(get_latest_reward_balance), + get_with(get_latest_reward_balance, |op| { + op.summary("Get latest reward balance").description("Get current balance in reward state for an Ethereum address.") + }), ) - .route( + .api_route( routes::v1::REWARD_ACCOUNT_PROOF_ROUTE, - get(get_reward_account_proof), + get_with(get_reward_account_proof, |op| { + op.summary("Get reward account proof").description("Get the Merkle proof for a reward account at a given block height (RewardAccountProofV1 pre-V4, RewardAccountProofV2 from V4 onward).") + }), ) - .route( + .api_route( routes::v1::LATEST_REWARD_ACCOUNT_PROOF_ROUTE, - get(get_latest_reward_account_proof), + get_with(get_latest_reward_account_proof, |op| { + op.summary("Get latest reward account proof").description("Get the Merkle proof (RewardAccountProofV2) for a reward account at the latest block height finalized by the light client contract.") + }), ) - .route(routes::v1::REWARD_AMOUNTS_ROUTE, get(get_reward_amounts)) - .route( + .api_route( + routes::v1::REWARD_AMOUNTS_ROUTE, + get_with(get_reward_amounts, |op| { + op.summary("List reward amounts").description("Return all RewardMerkleTreeV2 accounts stored for the requested height, paginated by offset and limit (limit must be <= 10000).") + }), + ) + .api_route( routes::v1::REWARD_MERKLE_TREE_V2_ROUTE, - get(get_reward_merkle_tree_v2), + get_with(get_reward_merkle_tree_v2, |op| { + op.summary("Get RewardMerkleTreeV2 snapshot").description("Get the snapshot of this node's RewardMerkleTreeV2 at the given block height, serialized as RewardMerkleTreeV2Data.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_HEIGHT_ROUTE, - get(get_reward_state_height), + get_with(get_reward_state_height, |op| { + op.summary("Get reward-state block height").description("Latest block height for which the merklized reward state (V1) is available.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_V2_HEIGHT_ROUTE, - get(get_reward_state_v2_height), + get_with(get_reward_state_v2_height, |op| { + op.summary("Get reward-state-v2 block height").description("Latest block height for which the merklized reward state (V2) is available.") + }), ) - .route( + .api_route( routes::v1::REWARD_V1_BALANCE_ROUTE, - get(get_reward_balance_v1), + get_with(get_reward_balance_v1, |op| { + op.summary("Get reward balance at height (v1 mount)").description("Same handler as reward-state-v2/reward-balance, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), ) - .route( + .api_route( routes::v1::REWARD_V1_ACCOUNT_PROOF_ROUTE, - get(get_reward_account_proof_v1), + get_with(get_reward_account_proof_v1, |op| { + op.summary("Get reward account proof (v1 mount)").description("Same handler as reward-state-v2/proof, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), ) // Tide-disco twins of the reward-state-v2 routes above, registered on the same // handlers (tide shared them across both merklized-state modules). - .route( + .api_route( routes::v1::REWARD_V1_LATEST_BALANCE_ROUTE, - get(get_latest_reward_balance), + get_with(get_latest_reward_balance, |op| { + op.summary("Get latest reward balance (v1 mount)").description("Same handler as reward-state-v2/reward-balance/latest, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), ) - .route( + .api_route( routes::v1::REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, - get(get_latest_reward_account_proof), + get_with(get_latest_reward_account_proof, |op| { + op.summary("Get latest reward account proof (v1 mount)").description("Same handler as reward-state-v2/proof/latest, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), ) - .route(routes::v1::REWARD_V1_AMOUNTS_ROUTE, get(get_reward_amounts)) - .route( + .api_route( + routes::v1::REWARD_V1_AMOUNTS_ROUTE, + get_with(get_reward_amounts, |op| { + op.summary("List reward amounts (v1 mount)").description("Same handler as reward-state-v2/reward-amounts, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), + ) + .api_route( routes::v1::REWARD_V1_MERKLE_TREE_V2_ROUTE, - get(get_reward_merkle_tree_v2), + get_with(get_reward_merkle_tree_v2, |op| { + op.summary("Get RewardMerkleTreeV2 snapshot (v1 mount)").description("Same handler as reward-state-v2/reward-merkle-tree-v2, registered on the reward-state mount; tide-disco shared this handler across both merklized-state mounts.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_PATH_BY_HEIGHT_ROUTE, - get(get_reward_state_path_v1_by_height), + get_with(get_reward_state_path_v1_by_height, |op| { + op.summary("Get reward-state Merkle path by height").description("Retrieve the Merkle path for the membership proof of a leaf in the reward-state (V1) tree, by block height and key.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_PATH_BY_COMMIT_ROUTE, - get(get_reward_state_path_v1_by_commit), + get_with(get_reward_state_path_v1_by_commit, |op| { + op.summary("Get reward-state Merkle path by commitment").description("Retrieve the Merkle path for the membership proof of a leaf in the reward-state (V1) tree, by tree commitment and key.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, - get(get_reward_state_path_v2_by_height), + get_with(get_reward_state_path_v2_by_height, |op| { + op.summary("Get reward-state-v2 Merkle path by height").description("Retrieve the Merkle path for the membership proof of a leaf in the reward-state-v2 tree, by block height and key.") + }), ) - .route( + .api_route( routes::v1::REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, - get(get_reward_state_path_v2_by_commit), + get_with(get_reward_state_path_v2_by_commit, |op| { + op.summary("Get reward-state-v2 Merkle path by commitment").description("Retrieve the Merkle path for the membership proof of a leaf in the reward-state-v2 tree, by tree commitment and key.") + }), ) .with_state(state) } -pub(crate) fn router_availability(state: S) -> Router +pub(crate) fn router_availability(state: S) -> ApiRouter where S: v1::AvailabilityApi + v1::HotShotAvailabilityApi + Clone + Send + Sync + 'static, { @@ -619,7 +706,7 @@ where state .get_namespace_proof(v1::availability::BlockId::Height(height), namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -629,7 +716,7 @@ where state .get_namespace_proof(v1::availability::BlockId::Hash(hash), namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -642,7 +729,7 @@ where namespace, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -652,7 +739,7 @@ where state .get_namespace_proof_range(from, until, namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -664,14 +751,14 @@ where namespace, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let get_state_cert_v1 = |State(state): State, Path(epoch): Path| async move { ::get_state_cert(&state, epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -679,7 +766,7 @@ where state .get_state_cert_v2(epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -688,7 +775,7 @@ where state .get_leaf(v1::LeafId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -696,7 +783,7 @@ where state .get_leaf(v1::LeafId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -704,7 +791,7 @@ where state .get_leaf_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -712,7 +799,7 @@ where state .get_header(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -720,7 +807,7 @@ where state .get_header(v1::BlockId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -728,7 +815,7 @@ where state .get_header(v1::BlockId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -736,7 +823,7 @@ where state .get_header_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -744,7 +831,7 @@ where state .get_block(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -752,7 +839,7 @@ where state .get_block(v1::BlockId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -760,7 +847,7 @@ where state .get_block(v1::BlockId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -768,7 +855,7 @@ where state .get_block_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -776,7 +863,7 @@ where state .get_payload(v1::PayloadId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -784,7 +871,7 @@ where state .get_payload(v1::PayloadId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -792,7 +879,7 @@ where state .get_payload(v1::PayloadId::BlockHash(block_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -800,7 +887,7 @@ where state .get_payload_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -808,7 +895,7 @@ where state .get_vid_common(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -816,7 +903,7 @@ where state .get_vid_common(v1::BlockId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -825,7 +912,7 @@ where state .get_vid_common(v1::BlockId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -834,7 +921,7 @@ where state .get_vid_common_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -843,7 +930,7 @@ where state .get_transaction_by_position(height, index) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -851,7 +938,7 @@ where state .get_transaction_by_hash(hash) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -860,7 +947,7 @@ where state .get_transaction_proof_by_position(height, index) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -868,7 +955,7 @@ where state .get_transaction_proof_by_hash(hash) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -876,7 +963,7 @@ where state .get_block_summary(height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -885,7 +972,7 @@ where state .get_block_summary_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -893,14 +980,14 @@ where state .get_limits() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let get_cert2 = |State(state): State, Path(height): Path| async move { ::get_cert2(&state, height) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1011,137 +1098,405 @@ where }) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::NAMESPACE_PROOF_BY_HEIGHT_ROUTE, - get(get_namespace_proof_by_height), + get_with(get_namespace_proof_by_height, |op| { + op.summary("Get namespace proof").description( + "Get the transactions in a namespace of the given block, along with a proof \ + of completeness.", + ) + }), ) - .route( + .api_route( routes::v1::NAMESPACE_PROOF_BY_HASH_ROUTE, - get(get_namespace_proof_by_hash), + get_with(get_namespace_proof_by_hash, |op| { + op.summary("Get namespace proof").description( + "Get the transactions in a namespace of the given block, along with a proof \ + of completeness.", + ) + }), ) - .route( + .api_route( routes::v1::NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, - get(get_namespace_proof_by_payload_hash), + get_with(get_namespace_proof_by_payload_hash, |op| { + op.summary("Get namespace proof").description( + "Get the transactions in a namespace of the given block, along with a proof \ + of completeness.", + ) + }), ) - .route( + .api_route( routes::v1::NAMESPACE_PROOF_RANGE_ROUTE, - get(get_namespace_proof_range), + get_with(get_namespace_proof_range, |op| { + op.summary("Get namespace proofs for a range").description( + "Get the transactions in the specified namespace from each block in a range, \ + with proofs.", + ) + }), ) - .route( + .api_route( routes::v1::INCORRECT_ENCODING_PROOF_ROUTE, - get(get_incorrect_encoding_proof), + get_with(get_incorrect_encoding_proof, |op| { + op.summary("Get incorrect-encoding proof").description( + "Generate a proof of incorrect namespace encoding for the given block number.", + ) + }), ) - .route(routes::v1::STATE_CERT_V1_ROUTE, get(get_state_cert_v1)) - .route(routes::v1::STATE_CERT_V2_ROUTE, get(get_state_cert_v2)) - .route(routes::v1::LEAF_BY_HEIGHT_ROUTE, get(get_leaf_by_height)) - .route(routes::v1::LEAF_BY_HASH_ROUTE, get(get_leaf_by_hash)) - .route(routes::v1::LEAF_RANGE_ROUTE, get(get_leaf_range)) - .route( + .api_route( + routes::v1::STATE_CERT_V1_ROUTE, + get_with(get_state_cert_v1, |op| { + op.summary("Get state certificate (V1)").description( + "Get the light client state update certificate (V1) for the given epoch, used \ + to update the light client contract's stake table.", + ) + }), + ) + .api_route( + routes::v1::STATE_CERT_V2_ROUTE, + get_with(get_state_cert_v2, |op| { + op.summary("Get state certificate (V2)").description( + "Get the light client state update certificate (V2) for the given epoch; \ + includes the auth_root Keccak-256 hash of the reward Merkle tree roots.", + ) + }), + ) + .api_route( + routes::v1::LEAF_BY_HEIGHT_ROUTE, + get_with(get_leaf_by_height, |op| { + op.summary("Get leaf").description( + "Get a leaf by its position in the ledger (0 is genesis) or its hash.", + ) + }), + ) + .api_route( + routes::v1::LEAF_BY_HASH_ROUTE, + get_with(get_leaf_by_hash, |op| { + op.summary("Get leaf").description( + "Get a leaf by its position in the ledger (0 is genesis) or its hash.", + ) + }), + ) + .api_route( + routes::v1::LEAF_RANGE_ROUTE, + get_with(get_leaf_range, |op| { + op.summary("Get leaves in range").description( + "Get leaves by position in the ledger, from the given `from` up to `until`.", + ) + }), + ) + .api_route( routes::v1::HEADER_BY_HEIGHT_ROUTE, - get(get_header_by_height), + get_with(get_header_by_height, |op| { + op.summary("Get header").description( + "Get a header by its position in the ledger (0 is genesis) or its hash.", + ) + }), ) - .route(routes::v1::HEADER_BY_HASH_ROUTE, get(get_header_by_hash)) - .route( + .api_route( + routes::v1::HEADER_BY_HASH_ROUTE, + get_with(get_header_by_hash, |op| { + op.summary("Get header").description( + "Get a header by its position in the ledger (0 is genesis) or its hash.", + ) + }), + ) + .api_route( routes::v1::HEADER_BY_PAYLOAD_HASH_ROUTE, - get(get_header_by_payload_hash), + get_with(get_header_by_payload_hash, |op| { + op.summary("Get header").description( + "Get a header by its position in the ledger (0 is genesis) or its hash.", + ) + }), ) - .route(routes::v1::HEADER_RANGE_ROUTE, get(get_header_range)) - .route(routes::v1::BLOCK_BY_HEIGHT_ROUTE, get(get_block_by_height)) - .route(routes::v1::BLOCK_BY_HASH_ROUTE, get(get_block_by_hash)) - .route( + .api_route( + routes::v1::HEADER_RANGE_ROUTE, + get_with(get_header_range, |op| { + op.summary("Get headers in range").description( + "Get headers by position in the ledger, from the given `from` up to `until`.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_BY_HEIGHT_ROUTE, + get_with(get_block_by_height, |op| { + op.summary("Get block").description( + "Get a block (header, payload, hash, size) by its position in the ledger or \ + its hash.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_BY_HASH_ROUTE, + get_with(get_block_by_hash, |op| { + op.summary("Get block").description( + "Get a block (header, payload, hash, size) by its position in the ledger or \ + its hash.", + ) + }), + ) + .api_route( routes::v1::BLOCK_BY_PAYLOAD_HASH_ROUTE, - get(get_block_by_payload_hash), + get_with(get_block_by_payload_hash, |op| { + op.summary("Get block").description( + "Get a block (header, payload, hash, size) by its position in the ledger or \ + its hash.", + ) + }), ) - .route(routes::v1::BLOCK_RANGE_ROUTE, get(get_block_range)) - .route( + .api_route( + routes::v1::BLOCK_RANGE_ROUTE, + get_with(get_block_range, |op| { + op.summary("Get blocks in range").description( + "Get blocks by position in the ledger, from the given `from` up to `until`.", + ) + }), + ) + .api_route( routes::v1::PAYLOAD_BY_HEIGHT_ROUTE, - get(get_payload_by_height), + get_with(get_payload_by_height, |op| { + op.summary("Get payload").description( + "Get the payload of a block by its position in the ledger or its hash.", + ) + }), ) - .route(routes::v1::PAYLOAD_BY_HASH_ROUTE, get(get_payload_by_hash)) - .route( + .api_route( + routes::v1::PAYLOAD_BY_HASH_ROUTE, + get_with(get_payload_by_hash, |op| { + op.summary("Get payload").description( + "Get the payload of a block by its position in the ledger or its hash.", + ) + }), + ) + .api_route( routes::v1::PAYLOAD_BY_BLOCK_HASH_ROUTE, - get(get_payload_by_block_hash), + get_with(get_payload_by_block_hash, |op| { + op.summary("Get payload").description( + "Get the payload of a block by its position in the ledger or its hash.", + ) + }), ) - .route(routes::v1::PAYLOAD_RANGE_ROUTE, get(get_payload_range)) - .route( + .api_route( + routes::v1::PAYLOAD_RANGE_ROUTE, + get_with(get_payload_range, |op| { + op.summary("Get payloads in range").description( + "Get payloads by block position, from the given `from` up to `until`.", + ) + }), + ) + .api_route( routes::v1::VID_COMMON_BY_HEIGHT_ROUTE, - get(get_vid_common_by_height), + get_with(get_vid_common_by_height, |op| { + op.summary("Get VID common data").description( + "Get common VID data for a block; data shared by all storage nodes, not a VID \ + share.", + ) + }), ) - .route( + .api_route( routes::v1::VID_COMMON_BY_HASH_ROUTE, - get(get_vid_common_by_hash), + get_with(get_vid_common_by_hash, |op| { + op.summary("Get VID common data").description( + "Get common VID data for a block; data shared by all storage nodes, not a VID \ + share.", + ) + }), ) - .route( + .api_route( routes::v1::VID_COMMON_BY_PAYLOAD_HASH_ROUTE, - get(get_vid_common_by_payload_hash), + get_with(get_vid_common_by_payload_hash, |op| { + op.summary("Get VID common data").description( + "Get common VID data for a block; data shared by all storage nodes, not a VID \ + share.", + ) + }), ) - .route( + .api_route( routes::v1::VID_COMMON_RANGE_ROUTE, - get(get_vid_common_range), + get_with(get_vid_common_range, |op| { + op.summary("Get VID common data in range").description( + "Get VID common objects by block position, from the given `from` up to \ + `until`.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_BY_POSITION_NOPROOF_ROUTE, - get(get_transaction_by_position), + get_with(get_transaction_by_position, |op| { + op.summary("Get transaction (no proof)").description( + "Get a transaction by its index in a block or by its hash, without an \ + inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_BY_HASH_NOPROOF_ROUTE, - get(get_transaction_by_hash), + get_with(get_transaction_by_hash, |op| { + op.summary("Get transaction (no proof)").description( + "Get a transaction by its index in a block or by its hash, without an \ + inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_PROOF_BY_POSITION_ROUTE, - get(get_transaction_proof_by_position), + get_with(get_transaction_proof_by_position, |op| { + op.summary("Get transaction with inclusion proof") + .description( + "Get a transaction by its index in a block or by its hash, along with an \ + application-defined inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_PROOF_BY_HASH_ROUTE, - get(get_transaction_proof_by_hash), + get_with(get_transaction_proof_by_hash, |op| { + op.summary("Get transaction with inclusion proof") + .description( + "Get a transaction by its index in a block or by its hash, along with an \ + application-defined inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_BY_POSITION_ROUTE, - get(get_transaction_proof_by_position), + get_with(get_transaction_proof_by_position, |op| { + op.summary("Get transaction with inclusion proof") + .description( + "Get a transaction by its index in a block or by its hash, along with an \ + application-defined inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::TRANSACTION_BY_HASH_ROUTE, - get(get_transaction_proof_by_hash), + get_with(get_transaction_proof_by_hash, |op| { + op.summary("Get transaction with inclusion proof") + .description( + "Get a transaction by its index in a block or by its hash, along with an \ + application-defined inclusion proof.", + ) + }), ) - .route( + .api_route( routes::v1::BLOCK_SUMMARY_BY_HEIGHT_ROUTE, - get(get_block_summary_by_height), + get_with(get_block_summary_by_height, |op| { + op.summary("Get block summary").description( + "Get the block summary for a block based on its position in the ledger.", + ) + }), ) - .route( + .api_route( routes::v1::BLOCK_SUMMARY_RANGE_ROUTE, - get(get_block_summary_range), - ) - .route(routes::v1::LIMITS_ROUTE, get(get_limits)) - .route(routes::v1::CERT2_BY_HEIGHT_ROUTE, get(get_cert2)) - .route(routes::v1::STREAM_LEAVES_ROUTE, get(stream_leaves)) - .route(routes::v1::STREAM_HEADERS_ROUTE, get(stream_headers)) - .route(routes::v1::STREAM_BLOCKS_ROUTE, get(stream_blocks)) - .route(routes::v1::STREAM_PAYLOADS_ROUTE, get(stream_payloads)) - .route(routes::v1::STREAM_VID_COMMON_ROUTE, get(stream_vid_common)) - .route( + get_with(get_block_summary_range, |op| { + op.summary("Get block summaries in range").description( + "Get block summaries by position, from the given `from` up to `until`.", + ) + }), + ) + .api_route( + routes::v1::LIMITS_ROUTE, + get_with(get_limits, |op| { + op.summary("Get availability limits").description( + "Get implementation-defined limits restricting availability range queries \ + (small/large object range limits).", + ) + }), + ) + .api_route( + routes::v1::CERT2_BY_HEIGHT_ROUTE, + get_with(get_cert2, |op| { + op.summary("Get finality certificate").description( + "Get the finality certificate (Certificate2) at the given block height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_LEAVES_ROUTE, + get_with(stream_leaves, |op| { + op.summary("Stream leaves (websocket)").description( + "Websocket endpoint: subscribe to a stream of leaves in sequence order, \ + starting at the given height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_HEADERS_ROUTE, + get_with(stream_headers, |op| { + op.summary("Stream headers (websocket)").description( + "Websocket endpoint: subscribe to a stream of headers in sequence order, \ + starting at the given height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_BLOCKS_ROUTE, + get_with(stream_blocks, |op| { + op.summary("Stream blocks (websocket)").description( + "Websocket endpoint: subscribe to a stream of blocks in sequence order, \ + starting at the given height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_PAYLOADS_ROUTE, + get_with(stream_payloads, |op| { + op.summary("Stream payloads (websocket)").description( + "Websocket endpoint: subscribe to a stream of block payloads in sequence \ + order, starting at the given height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_VID_COMMON_ROUTE, + get_with(stream_vid_common, |op| { + op.summary("Stream VID common data (websocket)") + .description( + "Websocket endpoint: subscribe to a stream of VID common data in sequence \ + order, starting at the given height.", + ) + }), + ) + .api_route( routes::v1::STREAM_TRANSACTIONS_ROUTE, - get(stream_transactions), + get_with(stream_transactions, |op| { + op.summary("Stream transactions (websocket)").description( + "Websocket endpoint: subscribe to a stream of all transactions starting at \ + the given height.", + ) + }), ) - .route( + .api_route( routes::v1::STREAM_TRANSACTIONS_NS_ROUTE, - get(stream_transactions_ns), + get_with(stream_transactions_ns, |op| { + op.summary("Stream namespace transactions (websocket)") + .description( + "Websocket endpoint: subscribe to a stream of transactions in one \ + namespace, starting at the given height.", + ) + }), ) - .route( + .api_route( routes::v1::STREAM_NAMESPACE_PROOFS_ROUTE, - get(stream_namespace_proofs), + get_with(stream_namespace_proofs, |op| { + op.summary("Stream namespace proofs (websocket)") + .description( + "Websocket endpoint: subscribe to namespace data and proofs for each \ + block, starting at the given height.", + ) + }), ) .with_state(state) } -pub(crate) fn router_block_state(state: S) -> Router +pub(crate) fn router_block_state(state: S) -> ApiRouter where S: v1::BlockStateApi + Clone + Send + Sync + 'static, { let get_block_state_height = |State(state): State| async move { ::get_block_state_height(&state) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1153,7 +1508,7 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1166,34 +1521,51 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::BLOCK_STATE_HEIGHT_ROUTE, - get(get_block_state_height), + get_with(get_block_state_height, |op| { + op.summary("Get block-state height").description( + "Latest block height for which the merklized blocks-Merkle-tree state is \ + available.", + ) + }), ) - .route( + .api_route( routes::v1::BLOCK_STATE_PATH_BY_COMMIT_ROUTE, - get(get_block_state_path_by_commit), + get_with(get_block_state_path_by_commit, |op| { + op.summary("Get block-state Merkle path by commitment") + .description( + "Retrieve the Merkle path for a leaf in the blocks Merkle tree, by tree \ + commitment and key.", + ) + }), ) - .route( + .api_route( routes::v1::BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, - get(get_block_state_path_by_height), + get_with(get_block_state_path_by_height, |op| { + op.summary("Get block-state Merkle path by height") + .description( + "Retrieve the Merkle path for a leaf in the blocks Merkle tree, by block \ + height and key.", + ) + }), ) .with_state(state) } -pub(crate) fn router_fee_state(state: S) -> Router +pub(crate) fn router_fee_state(state: S) -> ApiRouter where S: v1::FeeStateApi + Clone + Send + Sync + 'static, { let get_fee_state_height = |State(state): State| async move { ::get_fee_state_height(&state) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1201,7 +1573,7 @@ where state .get_fee_balance_latest(address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1209,7 +1581,7 @@ where |State(state): State, Path((commit, key)): Path<(String, String)>| async move { ::get_fee_state_path(&state, v1::Snapshot::Commit(commit), key) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1218,125 +1590,182 @@ where |State(state): State, Path((height, key)): Path<(u64, String)>| async move { ::get_fee_state_path(&state, v1::Snapshot::Height(height), key) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::FEE_STATE_HEIGHT_ROUTE, - get(get_fee_state_height), + get_with(get_fee_state_height, |op| { + op.summary("Get fee-state height").description( + "Latest block height for which the merklized fee state is available.", + ) + }), ) - .route( + .api_route( routes::v1::FEE_STATE_BALANCE_LATEST_ROUTE, - get(get_fee_balance_latest), + get_with(get_fee_balance_latest, |op| { + op.summary("Get latest fee balance").description( + "Get the latest fee account balance for an address from the fee Merkle tree.", + ) + }), ) - .route( + .api_route( routes::v1::FEE_STATE_PATH_BY_COMMIT_ROUTE, - get(get_fee_state_path_by_commit), + get_with(get_fee_state_path_by_commit, |op| { + op.summary("Get fee-state Merkle path by commitment") + .description( + "Retrieve the Merkle path for a leaf in the fee state tree, by tree \ + commitment and key.", + ) + }), ) - .route( + .api_route( routes::v1::FEE_STATE_PATH_BY_HEIGHT_ROUTE, - get(get_fee_state_path_by_height), + get_with(get_fee_state_path_by_height, |op| { + op.summary("Get fee-state Merkle path by height") + .description( + "Retrieve the Merkle path for a leaf in the fee state tree, by block \ + height and key.", + ) + }), ) .with_state(state) } -pub(crate) fn router_status(state: S) -> Router +pub(crate) fn router_status(state: S) -> ApiRouter where S: v1::StatusApi + Clone + Send + Sync + 'static, { let status_block_height = |State(state): State| async move { ::block_height(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let status_success_rate = |State(state): State| async move { ::success_rate(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let status_time_since_last_decide = |State(state): State| async move { ::time_since_last_decide(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let status_metrics = |State(state): State| async move { match ::metrics(&state).await { - Ok(text) => Ok(( + Ok(text) => ( [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], text, - )), - Err(e) => Err(ApiError::Internal(e)), + ) + .into_response(), + Err(e) => ApiError::Internal(e).into_response(), } }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::STATUS_BLOCK_HEIGHT_ROUTE, - get(status_block_height), + get_with(status_block_height, |op| { + op.summary("Get latest committed block height") + .description("Get the height of the latest committed block.") + }), ) - .route( + .api_route( routes::v1::STATUS_SUCCESS_RATE_ROUTE, - get(status_success_rate), + get_with(status_success_rate, |op| { + op.summary("Get view success rate") + .description("Get the fraction of views which resulted in a committed block.") + }), ) - .route( + .api_route( routes::v1::STATUS_TIME_SINCE_LAST_DECIDE_ROUTE, - get(status_time_since_last_decide), + get_with(status_time_since_last_decide, |op| { + op.summary("Get time since last decide") + .description("Get the time elapsed in seconds since the last decided view.") + }), + ) + .api_route( + routes::v1::STATUS_METRICS_ROUTE, + get_with(status_metrics, |op| { + op.summary("Get Prometheus metrics") + .description("Prometheus endpoint exposing consensus-related metrics.") + }), ) - .route(routes::v1::STATUS_METRICS_ROUTE, get(status_metrics)) .with_state(state) } -pub(crate) fn router_config(state: S) -> Router +pub(crate) fn router_config(state: S) -> ApiRouter where S: v1::ConfigApi + Clone + Send + Sync + 'static, { let config_hotshot = |State(state): State| async move { ::hotshot_config(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let config_env = |State(state): State| async move { ::env(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let config_runtime = |State(state): State| async move { ::runtime_config(&state) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route(routes::v1::CONFIG_HOTSHOT_ROUTE, get(config_hotshot)) - .route(routes::v1::CONFIG_ENV_ROUTE, get(config_env)) - .route(routes::v1::CONFIG_RUNTIME_ROUTE, get(config_runtime)) + ApiRouter::new() + .api_route( + routes::v1::CONFIG_HOTSHOT_ROUTE, + get_with(config_hotshot, |op| { + op.summary("Get HotShot config") + .description("Get the HotShot configuration for the current node.") + }), + ) + .api_route( + routes::v1::CONFIG_ENV_ROUTE, + get_with(config_env, |op| { + op.summary("Get environment variables").description( + "Get all ESPRESSO_ environment variables set for the current node.", + ) + }), + ) + .api_route( + routes::v1::CONFIG_RUNTIME_ROUTE, + get_with(config_runtime, |op| { + op.summary("Get runtime config").description( + "Get the merged runtime configuration (CLI flags + env vars + defaults); \ + secrets and L1 RPC URLs are redacted.", + ) + }), + ) .with_state(state) } -pub(crate) fn router_node(state: S) -> Router +pub(crate) fn router_node(state: S) -> ApiRouter where S: v1::NodeApi + Clone + Send + Sync + 'static, { let node_block_height = |State(state): State| async move { ::block_height(&state) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1344,7 +1773,7 @@ where state .count_transactions(None, None, None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1352,7 +1781,7 @@ where state .count_transactions(None, None, Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1360,7 +1789,7 @@ where state .count_transactions(None, Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1369,7 +1798,7 @@ where state .count_transactions(Some(from), Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1377,7 +1806,7 @@ where state .count_transactions(None, Some(to), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1385,7 +1814,7 @@ where state .count_transactions(Some(from), Some(to), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1393,7 +1822,7 @@ where state .payload_size(None, None, None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1401,7 +1830,7 @@ where state .payload_size(None, None, Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1410,7 +1839,7 @@ where state .payload_size(None, Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1419,7 +1848,7 @@ where state .payload_size(Some(from), Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1427,7 +1856,7 @@ where state .payload_size(None, Some(to), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1435,7 +1864,7 @@ where state .payload_size(Some(from), Some(to), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1443,7 +1872,7 @@ where state .get_vid_share(v1::VidShareId::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1452,7 +1881,7 @@ where state .get_vid_share(v1::VidShareId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1460,7 +1889,7 @@ where state .get_vid_share(v1::VidShareId::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1468,7 +1897,7 @@ where state .sync_status() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1477,7 +1906,7 @@ where state .get_header_window(v1::HeaderWindowStart::Hash(hash), end) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1486,7 +1915,7 @@ where state .get_header_window(v1::HeaderWindowStart::Height(height), end) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1494,14 +1923,14 @@ where state .get_header_window(v1::HeaderWindowStart::Time(start), end) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let node_limits = |State(state): State| async move { ::limits(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1509,7 +1938,7 @@ where state .stake_table_current() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1517,7 +1946,7 @@ where state .stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1525,7 +1954,7 @@ where state .da_stake_table_current() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1533,7 +1962,7 @@ where state .da_stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1541,7 +1970,7 @@ where state .get_validators(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1550,7 +1979,7 @@ where state .get_all_validators(epoch, offset, limit) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::BadRequest) }; @@ -1558,7 +1987,7 @@ where state .current_proposal_participation() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1566,7 +1995,7 @@ where state .proposal_participation(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1574,7 +2003,7 @@ where state .current_vote_participation() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1582,7 +2011,7 @@ where state .vote_participation(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1590,7 +2019,7 @@ where state .get_block_reward(None) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1598,7 +2027,7 @@ where state .get_block_reward(Some(epoch)) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1606,7 +2035,7 @@ where state .get_oldest_block() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1614,132 +2043,321 @@ where state .get_oldest_leaf() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; - Router::new() - .route(routes::v1::NODE_BLOCK_HEIGHT_ROUTE, get(node_block_height)) - .route( + ApiRouter::new() + .api_route( + routes::v1::NODE_BLOCK_HEIGHT_ROUTE, + get_with(node_block_height, |op| { + op.summary("Get node's block height") + .description("The current height of the chain, as observed by this node.") + }), + ) + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_ROUTE, - get(node_count_txs), + get_with(node_count_txs, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_NS_ROUTE, - get(node_count_txs_ns), + get_with(node_count_txs_ns, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, - get(node_count_txs_ns_to), + get_with(node_count_txs_ns_to, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, - get(node_count_txs_ns_from_to), + get_with(node_count_txs_ns_from_to, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_TO_ROUTE, - get(node_count_txs_to), + get_with(node_count_txs_to, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, - get(node_count_txs_from_to), + get_with(node_count_txs_from_to, |op| { + op.summary("Count transactions").description( + "Get the number of transactions in the chain, optionally restricted by block \ + range and/or namespace.", + ) + }), ) - .route(routes::v1::NODE_PAYLOADS_SIZE_ROUTE, get(node_payload_size)) - .route( + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_ROUTE, + get_with(node_payload_size, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), + ) + .api_route( routes::v1::NODE_PAYLOADS_TOTAL_SIZE_ROUTE, - get(node_payload_size), + get_with(node_payload_size, |op| { + op.summary("Get payload size") + .description("Deprecated alias for payloads/size.") + }), ) - .route( + .api_route( routes::v1::NODE_PAYLOADS_SIZE_NS_ROUTE, - get(node_payload_size_ns), + get_with(node_payload_size_ns, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PAYLOADS_SIZE_NS_TO_ROUTE, - get(node_payload_size_ns_to), + get_with(node_payload_size_ns_to, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, - get(node_payload_size_ns_from_to), + get_with(node_payload_size_ns_from_to, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PAYLOADS_SIZE_TO_ROUTE, - get(node_payload_size_to), + get_with(node_payload_size_to, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, - get(node_payload_size_from_to), + get_with(node_payload_size_from_to, |op| { + op.summary("Get payload size").description( + "Get the cumulative size (bytes) of payload data in the chain, optionally \ + restricted by block range and/or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_VID_SHARE_BY_HASH_ROUTE, - get(node_vid_share_by_hash), + get_with(node_vid_share_by_hash, |op| { + op.summary("Get this node's VID share").description( + "Get information needed to run the VID reconstruction protocol for a block: \ + this node's VID share, if available.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, - get(node_vid_share_by_payload_hash), + get_with(node_vid_share_by_payload_hash, |op| { + op.summary("Get this node's VID share").description( + "Get information needed to run the VID reconstruction protocol for a block: \ + this node's VID share, if available.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_VID_SHARE_BY_HEIGHT_ROUTE, - get(node_vid_share_by_height), + get_with(node_vid_share_by_height, |op| { + op.summary("Get this node's VID share").description( + "Get information needed to run the VID reconstruction protocol for a block: \ + this node's VID share, if available.", + ) + }), ) - .route(routes::v1::NODE_SYNC_STATUS_ROUTE, get(node_sync_status)) - .route( + .api_route( + routes::v1::NODE_SYNC_STATUS_ROUTE, + get_with(node_sync_status, |op| { + op.summary("Get node sync status").description( + "Get the node's progress syncing with the latest chain state \ + (missing/present/pruned ranges for blocks, leaves, and VID common).", + ) + }), + ) + .api_route( routes::v1::NODE_HEADER_WINDOW_HASH_ROUTE, - get(node_header_window_hash), + get_with(node_header_window_hash, |op| { + op.summary("Get header window").description( + "Get block headers whose timestamps fall in a time window, plus one header \ + before and after to prove completeness.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_HEADER_WINDOW_HEIGHT_ROUTE, - get(node_header_window_height), + get_with(node_header_window_height, |op| { + op.summary("Get header window").description( + "Get block headers whose timestamps fall in a time window, plus one header \ + before and after to prove completeness.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_HEADER_WINDOW_TIME_ROUTE, - get(node_header_window_time), + get_with(node_header_window_time, |op| { + op.summary("Get header window").description( + "Get block headers whose timestamps fall in a time window, plus one header \ + before and after to prove completeness.", + ) + }), ) - .route(routes::v1::NODE_LIMITS_ROUTE, get(node_limits)) - .route( + .api_route( + routes::v1::NODE_LIMITS_ROUTE, + get_with(node_limits, |op| { + op.summary("Get node limits").description( + "Get implementation-defined limits restricting node API requests (e.g. \ + header/window query size).", + ) + }), + ) + .api_route( routes::v1::NODE_STAKE_TABLE_CURRENT_ROUTE, - get(node_stake_table_current), + get_with(node_stake_table_current, |op| { + op.summary("Get current stake table") + .description("Get the stake table for the current epoch.") + }), ) - .route(routes::v1::NODE_STAKE_TABLE_ROUTE, get(node_stake_table)) - .route( + .api_route( + routes::v1::NODE_STAKE_TABLE_ROUTE, + get_with(node_stake_table, |op| { + op.summary("Get stake table for epoch") + .description("Get the stake table for the given epoch.") + }), + ) + .api_route( routes::v1::NODE_DA_STAKE_TABLE_CURRENT_ROUTE, - get(node_da_stake_table_current), + get_with(node_da_stake_table_current, |op| { + op.summary("Get current DA stake table") + .description("Get the DA stake table for the current epoch.") + }), ) - .route( + .api_route( routes::v1::NODE_DA_STAKE_TABLE_ROUTE, - get(node_da_stake_table), + get_with(node_da_stake_table, |op| { + op.summary("Get DA stake table for epoch") + .description("Get the DA stake table for the given epoch.") + }), ) - .route(routes::v1::NODE_VALIDATORS_ROUTE, get(node_validators)) - .route( + .api_route( + routes::v1::NODE_VALIDATORS_ROUTE, + get_with(node_validators, |op| { + op.summary("Get validators for epoch") + .description("Get the validators map for the given epoch.") + }), + ) + .api_route( routes::v1::NODE_ALL_VALIDATORS_ROUTE, - get(node_all_validators), + get_with(node_all_validators, |op| { + op.summary("Get all validators for epoch").description( + "Get all validators, including inactive ones, for the given epoch, paginated \ + by offset and limit.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE, - get(node_proposal_participation_current), + get_with(node_proposal_participation_current, |op| { + op.summary("Get current proposal participation") + .description( + "Get the mapping from leader key to the fraction of views proposed \ + properly as leader.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_PROPOSAL_PARTICIPATION_ROUTE, - get(node_proposal_participation), + get_with(node_proposal_participation, |op| { + op.summary("Get proposal participation for epoch") + .description( + "Get the mapping from leader key to proposal participation rate for the \ + given epoch.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_VOTE_PARTICIPATION_CURRENT_ROUTE, - get(node_vote_participation_current), + get_with(node_vote_participation_current, |op| { + op.summary("Get current vote participation").description( + "Get the mapping from node key to the fraction of views properly voted.", + ) + }), ) - .route( + .api_route( routes::v1::NODE_VOTE_PARTICIPATION_ROUTE, - get(node_vote_participation), + get_with(node_vote_participation, |op| { + op.summary("Get vote participation for epoch").description( + "Get the mapping from node key to vote participation rate for the given epoch.", + ) + }), ) - .route(routes::v1::NODE_BLOCK_REWARD_ROUTE, get(node_block_reward)) - .route( - routes::v1::NODE_BLOCK_REWARD_EPOCH_ROUTE, - get(node_block_reward_epoch), + .api_route( + routes::v1::NODE_BLOCK_REWARD_ROUTE, + get_with(node_block_reward, |op| { + op.summary("Get block reward") + .description("Get the block reward.") + }), + ) + .api_route( + routes::v1::NODE_BLOCK_REWARD_EPOCH_ROUTE, + get_with(node_block_reward_epoch, |op| { + op.summary("Get block reward for epoch") + .description("Get the block reward for the given epoch.") + }), + ) + .api_route( + routes::v1::NODE_OLDEST_BLOCK_ROUTE, + get_with(node_oldest_block, |op| { + op.summary("Get oldest block").description( + "Get the oldest (smallest height) block present in storage, or null if none \ + is stored.", + ) + }), + ) + .api_route( + routes::v1::NODE_OLDEST_LEAF_ROUTE, + get_with(node_oldest_leaf, |op| { + op.summary("Get oldest leaf").description( + "Get the oldest (smallest height) leaf present in storage, or null if none is \ + stored.", + ) + }), ) - .route(routes::v1::NODE_OLDEST_BLOCK_ROUTE, get(node_oldest_block)) - .route(routes::v1::NODE_OLDEST_LEAF_ROUTE, get(node_oldest_leaf)) .with_state(state) } -pub(crate) fn router_catchup(state: S) -> Router +pub(crate) fn router_catchup(state: S) -> ApiRouter where S: v1::CatchupApi + Clone + Send + Sync + 'static, { @@ -1749,7 +2367,7 @@ where state .get_account(height, view, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1769,14 +2387,14 @@ where state .get_blocks_frontier(height, view) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let catchup_chainconfig = |State(state): State, Path(commitment): Path| async move { ::get_chain_config(&state, commitment) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1784,14 +2402,14 @@ where state .get_leaf_chain(height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let catchup_cert2 = |State(state): State, Path(height): Path| async move { ::get_cert2(&state, height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1800,7 +2418,7 @@ where state .get_reward_account_v1(height, view, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1821,7 +2439,7 @@ where state .get_reward_account_v2(height, view, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1843,62 +2461,133 @@ where |State(state): State, Path((height, view)): Path<(u64, u64)>| async move { ::get_reward_merkle_tree_v2(&state, height, view) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; let catchup_state_cert = |State(state): State, Path(epoch): Path| async move { ::get_state_cert(&state, epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route(routes::v1::CATCHUP_ACCOUNT_ROUTE, get(catchup_account)) - .route( + ApiRouter::new() + .api_route( + routes::v1::CATCHUP_ACCOUNT_ROUTE, + get_with(catchup_account, |op| { + op.summary("Catch up fee account balance").description( + "Get the fee account balance and Merkle proof for an address at the given \ + block height and view, for catchup.", + ) + }), + ) + .api_route( routes::v1::CATCHUP_ACCOUNTS_ROUTE, - ::axum::routing::post(catchup_accounts), + post_with(catchup_accounts, |op| { + op.summary("Catch up fee accounts (bulk)").description( + "Bulk version of the fee account endpoint; request body is a JSON array of \ + TaggedBase64 fee accounts, response is a FeeMerkleTree.", + ) + }), ) - .route(routes::v1::CATCHUP_BLOCKS_ROUTE, get(catchup_blocks)) - .route( + .api_route( + routes::v1::CATCHUP_BLOCKS_ROUTE, + get_with(catchup_blocks, |op| { + op.summary("Catch up blocks Merkle frontier").description( + "Get the blocks Merkle tree frontier at the given block height and view, for \ + catchup.", + ) + }), + ) + .api_route( routes::v1::CATCHUP_CHAINCONFIG_ROUTE, - get(catchup_chainconfig), + get_with(catchup_chainconfig, |op| { + op.summary("Catch up chain config").description( + "Retrieve the chain config matching the given commitment from a peer; used \ + when a node missed a protocol upgrade.", + ) + }), ) - .route(routes::v1::CATCHUP_LEAFCHAIN_ROUTE, get(catchup_leafchain)) - .route(routes::v1::CATCHUP_CERT2_ROUTE, get(catchup_cert2)) - .route( + .api_route( + routes::v1::CATCHUP_LEAFCHAIN_ROUTE, + get_with(catchup_leafchain, |op| { + op.summary("Catch up leaf chain").description( + "Fetch a leaf chain that decides the block at the given height, for catching \ + up the stake table.", + ) + }), + ) + .api_route( + routes::v1::CATCHUP_CERT2_ROUTE, + get_with(catchup_cert2, |op| { + op.summary("Catch up cert2").description( + "Fetch the cert2 stored at exactly the given height, if one exists; 404 \ + otherwise.", + ) + }), + ) + .api_route( routes::v1::CATCHUP_REWARD_ACCOUNT_ROUTE, - get(catchup_reward_account), + get_with(catchup_reward_account, |op| { + op.summary("Catch up reward account (V1)").description( + "Get the reward account balance for an address at the given height and view.", + ) + }), ) - .route( + .api_route( routes::v1::CATCHUP_REWARD_ACCOUNTS_ROUTE, - ::axum::routing::post(catchup_reward_accounts), + post_with(catchup_reward_accounts, |op| { + op.summary("Catch up reward accounts (bulk, V1)") + .description( + "Bulk version of the reward account endpoint; request body is a JSON \ + array of TaggedBase64 reward accounts, response is a RewardMerkleTreeV1.", + ) + }), ) - .route( + .api_route( routes::v1::CATCHUP_REWARD_ACCOUNT_V2_ROUTE, - get(catchup_reward_account_v2), + get_with(catchup_reward_account_v2, |op| { + op.summary("Catch up reward account (V2)").description( + "Get the reward account balance for an address at the given height and view, \ + from RewardMerkleTreeV2.", + ) + }), ) - .route( + .api_route( routes::v1::CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, - ::axum::routing::post(catchup_reward_accounts_v2), + post_with(catchup_reward_accounts_v2, |op| { + op.summary("Catch up reward accounts (bulk, V2) — deprecated") + .description("Deprecated: this endpoint always returns 404 Not Found.") + }), ) - .route( + .api_route( routes::v1::CATCHUP_REWARD_AMOUNTS_ROUTE, - get(catchup_reward_amounts), + get_with(catchup_reward_amounts, |op| { + op.summary("List reward amounts — deprecated") + .description("Deprecated: this endpoint always returns 404 Not Found.") + }), ) - .route( + .api_route( routes::v1::CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, - get(catchup_reward_merkle_tree_v2), + get_with(catchup_reward_merkle_tree_v2, |op| { + op.summary("Catch up RewardMerkleTreeV2").description( + "Get the RewardMerkleTreeV2 from consensus state at the given height and \ + view, serialized as RewardMerkleTreeV2Data.", + ) + }), ) - .route( + .api_route( routes::v1::CATCHUP_STATE_CERT_ROUTE, - get(catchup_state_cert), + get_with(catchup_state_cert, |op| { + op.summary("Catch up state certificate") + .description("Get the light client state certificate for the given epoch.") + }), ) .with_state(state) } -pub(crate) fn router_submit(state: S) -> Router +pub(crate) fn router_submit(state: S) -> ApiRouter where S: v1::SubmitApi + Clone + Send + Sync + 'static, { @@ -1910,15 +2599,18 @@ where encode_response(&headers, hash) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::SUBMIT_ROUTE, - ::axum::routing::post(submit_submit), + post_with(submit_submit, |op| { + op.summary("Submit transaction") + .description("Submit a transaction to the HotShot handle for sequencing.") + }), ) .with_state(state) } -pub(crate) fn router_state_signature(state: S) -> Router +pub(crate) fn router_state_signature(state: S) -> ApiRouter where S: v1::StateSignatureApi + Clone + Send + Sync + 'static, { @@ -1927,19 +2619,24 @@ where state .get_state_signature(height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::STATE_SIGNATURE_BLOCK_ROUTE, - get(state_signature_block), + get_with(state_signature_block, |op| { + op.summary("Get light client state signature").description( + "Get this node's signature for the light client state at the given block \ + height.", + ) + }), ) .with_state(state) } -pub(crate) fn router_hotshot_events(state: S) -> Router +pub(crate) fn router_hotshot_events(state: S) -> ApiRouter where S: v1::HotShotEventsApi + Clone + Send + Sync + 'static, { @@ -1948,7 +2645,7 @@ where state .startup_info() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1961,19 +2658,27 @@ where } }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::HOTSHOT_EVENTS_STARTUP_ROUTE, - get(hotshot_events_startup), + get_with(hotshot_events_startup, |op| { + op.summary("Get startup info").description( + "Get startup info: known nodes with stake and their public keys, and the \ + count of non-staked nodes.", + ) + }), ) - .route( + .api_route( routes::v1::HOTSHOT_EVENTS_STREAM_ROUTE, - get(hotshot_events_stream), + get_with(hotshot_events_stream, |op| { + op.summary("Stream HotShot events (websocket)") + .description("Websocket endpoint: get legacy HotShot events starting now.") + }), ) .with_state(state) } -pub(crate) fn router_light_client(state: S) -> Router +pub(crate) fn router_light_client(state: S) -> ApiRouter where S: v1::LightClientApi + Clone + Send + Sync + 'static, { @@ -1982,7 +2687,7 @@ where state .get_leaf_proof(v1::LeafQuery::Height(height), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1991,7 +2696,7 @@ where state .get_leaf_proof(v1::LeafQuery::Height(height), Some(finalized)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1999,7 +2704,7 @@ where state .get_leaf_proof(v1::LeafQuery::Hash(hash), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2008,7 +2713,7 @@ where state .get_leaf_proof(v1::LeafQuery::Hash(hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2016,7 +2721,7 @@ where state .get_leaf_proof(v1::LeafQuery::BlockHash(block_hash), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2025,7 +2730,7 @@ where state .get_leaf_proof(v1::LeafQuery::BlockHash(block_hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2033,7 +2738,7 @@ where state .get_leaf_proof(v1::LeafQuery::PayloadHash(payload_hash), None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2042,7 +2747,7 @@ where state .get_leaf_proof(v1::LeafQuery::PayloadHash(payload_hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2050,7 +2755,7 @@ where state .get_header_proof(root, v1::HeaderQuery::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2058,7 +2763,7 @@ where state .get_header_proof(root, v1::HeaderQuery::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2067,7 +2772,7 @@ where state .get_header_proof(root, v1::HeaderQuery::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2075,7 +2780,7 @@ where state .get_light_client_stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2083,7 +2788,7 @@ where state .get_payload_proof(height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2091,7 +2796,7 @@ where state .get_payload_proof_range(start, end) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2099,7 +2804,7 @@ where state .get_lc_namespace_proof(height, namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2108,7 +2813,7 @@ where state .get_lc_namespace_proof_range(start, end, namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2117,62 +2822,167 @@ where state .get_lc_namespaces_proof_range(start, end, namespaces) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route(routes::v1::LC_LEAF_BY_HEIGHT_ROUTE, get(lc_leaf_by_height)) - .route( + ApiRouter::new() + .api_route( + routes::v1::LC_LEAF_BY_HEIGHT_ROUTE, + get_with(lc_leaf_by_height, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by height plus a proof of its finality, optionally relative to \ + an already-known-finalized height.", + ) + }), + ) + .api_route( routes::v1::LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, - get(lc_leaf_by_height_finalized), + get_with(lc_leaf_by_height_finalized, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by height plus a proof of its finality, optionally relative to \ + an already-known-finalized height.", + ) + }), ) - .route(routes::v1::LC_LEAF_BY_HASH_ROUTE, get(lc_leaf_by_hash)) - .route( + .api_route( + routes::v1::LC_LEAF_BY_HASH_ROUTE, + get_with(lc_leaf_by_hash, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by hash plus a proof of its finality, optionally relative to an \ + already-known-finalized height.", + ) + }), + ) + .api_route( routes::v1::LC_LEAF_BY_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_hash_finalized), + get_with(lc_leaf_by_hash_finalized, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by hash plus a proof of its finality, optionally relative to an \ + already-known-finalized height.", + ) + }), ) - .route( + .api_route( routes::v1::LC_LEAF_BY_BLOCK_HASH_ROUTE, - get(lc_leaf_by_block_hash), + get_with(lc_leaf_by_block_hash, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by block hash plus a proof of its finality, optionally relative \ + to an already-known-finalized height.", + ) + }), ) - .route( + .api_route( routes::v1::LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_block_hash_finalized), + get_with(lc_leaf_by_block_hash_finalized, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by block hash plus a proof of its finality, optionally relative \ + to an already-known-finalized height.", + ) + }), ) - .route( + .api_route( routes::v1::LC_LEAF_BY_PAYLOAD_HASH_ROUTE, - get(lc_leaf_by_payload_hash), + get_with(lc_leaf_by_payload_hash, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by payload hash plus a proof of its finality, optionally \ + relative to an already-known-finalized height.", + ) + }), ) - .route( + .api_route( routes::v1::LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, - get(lc_leaf_by_payload_hash_finalized), + get_with(lc_leaf_by_payload_hash_finalized, |op| { + op.summary("Get leaf with finality proof").description( + "Fetch a leaf by payload hash plus a proof of its finality, optionally \ + relative to an already-known-finalized height.", + ) + }), ) - .route( + .api_route( routes::v1::LC_HEADER_BY_HEIGHT_ROUTE, - get(lc_header_by_height), + get_with(lc_header_by_height, |op| { + op.summary("Get header with inclusion proof").description( + "Fetch a header plus a Merkle proof that it belongs to the blocks Merkle tree \ + rooted at the given root height.", + ) + }), ) - .route(routes::v1::LC_HEADER_BY_HASH_ROUTE, get(lc_header_by_hash)) - .route( + .api_route( + routes::v1::LC_HEADER_BY_HASH_ROUTE, + get_with(lc_header_by_hash, |op| { + op.summary("Get header with inclusion proof").description( + "Fetch a header plus a Merkle proof that it belongs to the blocks Merkle tree \ + rooted at the given root height.", + ) + }), + ) + .api_route( routes::v1::LC_HEADER_BY_PAYLOAD_HASH_ROUTE, - get(lc_header_by_payload_hash), + get_with(lc_header_by_payload_hash, |op| { + op.summary("Get header with inclusion proof").description( + "Fetch a header plus a Merkle proof that it belongs to the blocks Merkle tree \ + rooted at the given root height.", + ) + }), ) - .route(routes::v1::LC_STAKE_TABLE_ROUTE, get(lc_stake_table)) - .route(routes::v1::LC_PAYLOAD_ROUTE, get(lc_payload)) - .route(routes::v1::LC_PAYLOAD_RANGE_ROUTE, get(lc_payload_range)) - .route(routes::v1::LC_NAMESPACE_ROUTE, get(lc_namespace)) - .route( + .api_route( + routes::v1::LC_STAKE_TABLE_ROUTE, + get_with(lc_stake_table, |op| { + op.summary("Get stake table events for epoch").description( + "Get the events needed to transform the stake table from the previous epoch \ + into the given epoch.", + ) + }), + ) + .api_route( + routes::v1::LC_PAYLOAD_ROUTE, + get_with(lc_payload, |op| { + op.summary("Get payload with VID common data").description( + "Fetch a payload plus the VID common data needed to recompute and verify its \ + hash.", + ) + }), + ) + .api_route( + routes::v1::LC_PAYLOAD_RANGE_ROUTE, + get_with(lc_payload_range, |op| { + op.summary("Get payload proofs in range").description( + "Fetch a list of payload proofs for each block in the given range.", + ) + }), + ) + .api_route( + routes::v1::LC_NAMESPACE_ROUTE, + get_with(lc_namespace, |op| { + op.summary("Get namespace proof with VID common data") + .description( + "Fetch a namespace proof plus the VID common data needed to verify it.", + ) + }), + ) + .api_route( routes::v1::LC_NAMESPACE_RANGE_ROUTE, - get(lc_namespace_range), + get_with(lc_namespace_range, |op| { + op.summary("Get namespace proofs in range").description( + "Fetch a list of namespace proofs for each block in the given range.", + ) + }), ) - .route( + .api_route( routes::v1::LC_NAMESPACES_RANGE_ROUTE, - get(lc_namespaces_range), + get_with(lc_namespaces_range, |op| { + op.summary("Get proofs for multiple namespaces in range") + .description( + "Fetch namespace proofs for each block in the given range, restricted to \ + a caller-specified set of namespaces.", + ) + }), ) .with_state(state) } -pub(crate) fn router_explorer(state: S) -> Router +pub(crate) fn router_explorer(state: S) -> ApiRouter where S: v1::ExplorerApi + Clone + Send + Sync + 'static, { @@ -2181,7 +2991,7 @@ where state .get_block_detail(v1::BlockIdent::Height(height)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2189,7 +2999,7 @@ where state .get_block_detail(v1::BlockIdent::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2197,7 +3007,7 @@ where state .get_block_summaries(v1::BlockIdent::Latest, limit) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2206,7 +3016,7 @@ where state .get_block_summaries(v1::BlockIdent::Height(from), limit) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2215,7 +3025,7 @@ where state .get_transaction_detail(v1::TxIdent::HeightAndOffset(height, offset)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2223,7 +3033,7 @@ where state .get_transaction_detail(v1::TxIdent::Hash(hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2236,7 +3046,7 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2250,7 +3060,7 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2263,7 +3073,7 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2276,7 +3086,7 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2290,7 +3100,7 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2307,7 +3117,7 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2315,7 +3125,7 @@ where state .get_transaction_summaries(v1::TxIdent::Latest, limit, v1::TxSummaryFilter::None) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2328,7 +3138,7 @@ where v1::TxSummaryFilter::None, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2341,7 +3151,7 @@ where v1::TxSummaryFilter::None, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2349,7 +3159,7 @@ where state .get_explorer_summary() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2357,77 +3167,169 @@ where state .get_search_result(query) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, - get(explorer_block_detail_by_height), + get_with(explorer_block_detail_by_height, |op| { + op.summary("Get block detail") + .description("Get details for a block identified by height or hash.") + }), ) - .route( + .api_route( routes::v1::EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, - get(explorer_block_detail_by_hash), + get_with(explorer_block_detail_by_hash, |op| { + op.summary("Get block detail") + .description("Get details for a block identified by height or hash.") + }), ) - .route( + .api_route( routes::v1::EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, - get(explorer_block_summaries_latest), + get_with(explorer_block_summaries_latest, |op| { + op.summary("List block summaries").description( + "Retrieve up to `limit` block summaries, targeting the latest block or a \ + block identified by height.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, - get(explorer_block_summaries_from), + get_with(explorer_block_summaries_from, |op| { + op.summary("List block summaries").description( + "Retrieve up to `limit` block summaries, targeting the latest block or a \ + block identified by height.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, - get(explorer_tx_detail_by_position), + get_with(explorer_tx_detail_by_position, |op| { + op.summary("Get transaction detail").description( + "Get details for a transaction identified by height and offset, or by hash.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_DETAIL_BY_HASH_ROUTE, - get(explorer_tx_detail_by_hash), + get_with(explorer_tx_detail_by_hash, |op| { + op.summary("Get transaction detail").description( + "Get details for a transaction identified by height and offset, or by hash.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, - get(explorer_tx_summaries_latest_block), + get_with(explorer_tx_summaries_latest_block, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, - get(explorer_tx_summaries_from_block), + get_with(explorer_tx_summaries_from_block, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, - get(explorer_tx_summaries_by_hash_block), + get_with(explorer_tx_summaries_by_hash_block, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, - get(explorer_tx_summaries_latest_ns), + get_with(explorer_tx_summaries_latest_ns, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, - get(explorer_tx_summaries_from_ns), + get_with(explorer_tx_summaries_from_ns, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, - get(explorer_tx_summaries_by_hash_ns), + get_with(explorer_tx_summaries_by_hash_ns, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_LATEST_ROUTE, - get(explorer_tx_summaries_latest), + get_with(explorer_tx_summaries_latest, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_FROM_ROUTE, - get(explorer_tx_summaries_from), + get_with(explorer_tx_summaries_from, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), ) - .route( + .api_route( routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, - get(explorer_tx_summaries_by_hash), + get_with(explorer_tx_summaries_by_hash, |op| { + op.summary("List transaction summaries").description( + "Retrieve up to `limit` transaction summaries, targeting the latest \ + transaction, one identified by height/offset, or by hash; optionally \ + filtered by block or namespace.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_SUMMARY_ROUTE, + get_with(explorer_summary, |op| { + op.summary("Get explorer summary") + .description("Get the current chain explorer summary.") + }), + ) + .api_route( + routes::v1::EXPLORER_SEARCH_ROUTE, + get_with(explorer_search, |op| { + op.summary("Search blocks and transactions").description( + "Search for blocks or transactions matching the given query string; currently \ + matched against hash.", + ) + }), ) - .route(routes::v1::EXPLORER_SUMMARY_ROUTE, get(explorer_summary)) - .route(routes::v1::EXPLORER_SEARCH_ROUTE, get(explorer_search)) .with_state(state) } -pub(crate) fn router_token(state: S) -> Router +pub(crate) fn router_token(state: S) -> ApiRouter where S: v1::TokenApi + Clone + Send + Sync + 'static, { @@ -2436,7 +3338,7 @@ where state .total_minted_supply() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2444,7 +3346,7 @@ where state .circulating_supply() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2452,7 +3354,7 @@ where state .circulating_supply_ethereum() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2460,7 +3362,7 @@ where state .total_issued_supply() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -2468,35 +3370,61 @@ where state .total_reward_distributed() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::TOKEN_TOTAL_MINTED_SUPPLY_ROUTE, - get(token_total_minted), + get_with(token_total_minted, |op| { + op.summary("Get total minted supply").description( + "Total supply of the ESP token minted on Ethereum; excludes unclaimed \ + rewards. Cached for an hour.", + ) + }), ) - .route( + .api_route( routes::v1::TOKEN_CIRCULATING_SUPPLY_ROUTE, - get(token_circulating), + get_with(token_circulating, |op| { + op.summary("Get circulating supply").description( + "Circulating supply: initial_supply + reward_distributed - locked, following \ + the mainnet unlock schedule.", + ) + }), ) - .route( + .api_route( routes::v1::TOKEN_CIRCULATING_SUPPLY_ETHEREUM_ROUTE, - get(token_circulating_eth), + get_with(token_circulating_eth, |op| { + op.summary("Get circulating supply (Ethereum L1)") + .description( + "Circulating supply of ESP tokens on Ethereum L1: total_supply_l1 - \ + locked.", + ) + }), ) - .route( + .api_route( routes::v1::TOKEN_TOTAL_ISSUED_SUPPLY_ROUTE, - get(token_total_issued), + get_with(token_total_issued, |op| { + op.summary("Get total issued supply").description( + "Total issued supply: initial_supply + total_reward_distributed, including \ + rewards not yet claimed on Ethereum.", + ) + }), ) - .route( + .api_route( routes::v1::TOKEN_TOTAL_REWARD_DISTRIBUTED_ROUTE, - get(token_total_reward_distributed), + get_with(token_total_reward_distributed, |op| { + op.summary("Get total reward distributed").description( + "Total rewards distributed by consensus, including rewards not yet claimed on \ + Ethereum.", + ) + }), ) .with_state(state) } -pub(crate) fn router_database(state: S) -> Router +pub(crate) fn router_database(state: S) -> ApiRouter where S: v1::DatabaseApi + Clone + Send + Sync + 'static, { @@ -2504,29 +3432,42 @@ where let database_table_sizes = |State(state): State| async move { ::get_table_sizes(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; let database_migration_status = |State(state): State| async move { ::get_migration_status(&state) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; - Router::new() - .route( + ApiRouter::new() + .api_route( routes::v1::DATABASE_TABLE_SIZES_ROUTE, - get(database_table_sizes), + get_with(database_table_sizes, |op| { + op.summary("Get database table sizes") + .description("Get the sizes of all database tables: row counts and disk usage.") + }), ) - .route( + .api_route( routes::v1::DATABASE_MIGRATION_STATUS_ROUTE, - get(database_migration_status), + get_with(database_migration_status, |op| { + op.summary("Get migration status").description( + "Get the status of all deferred background migrations: start/completion time \ + and last processed offset.", + ) + }), ) .with_state(state) } -/// Create v1 router without OpenAPI documentation (internal types) +/// Create v1 router with OpenAPI documentation. +/// +/// Unlike v2 (which documents proto request/response types with real JSON schemas), most v1 +/// handlers return internal domain types that don't implement `schemars::JsonSchema` by design — +/// see [`ApiJson`]. The generated spec therefore documents routes, parameters, and summaries, but +/// response bodies are mostly untyped. pub fn create_router_v1(state: S) -> Router where S: v1::RewardApi @@ -2550,7 +3491,19 @@ where + Sync + 'static, { - router_reward(state.clone()) + let mut api = OpenApi { + info: Info { + title: "Espresso Node API v1".to_string(), + description: None, + version: "1.0.0".to_string(), + ..Default::default() + }, + ..Default::default() + }; + + // Each `router_*` function already calls `with_state`, so the merged router is already + // stateless (`ApiRouter<()>`) by the time it reaches `finish_api`. + let router = router_reward(state.clone()) .merge(router_availability(state.clone())) .merge(router_block_state(state.clone())) .merge(router_fee_state(state.clone())) @@ -2565,6 +3518,34 @@ where .merge(router_explorer(state.clone())) .merge(router_token(state.clone())) .merge(router_database(state)) + .finish_api(&mut api); + + // Transform examples (array) to example (singular) for OpenAPI 3.0/Swagger compatibility, + // matching create_router_v2 (a no-op unless a future v1 route adds a JsonSchema body/query). + if let Some(ref mut components) = api.components { + let mut transform = schemars::transform::SetSingleExample::default(); + for schema in components.schemas.values_mut() { + transform.transform(&mut schema.json_schema); + } + } + + router + .route(routes::v1::OPENAPI_SPEC_ROUTE, get(serve_openapi_spec_v1)) + .route( + routes::v1::SWAGGER_ROUTE, + get(|| async { swagger_html(routes::v1::OPENAPI_SPEC_ROUTE) }), + ) + .route( + "/v1/", + get(|| async { swagger_html(routes::v1::OPENAPI_SPEC_ROUTE) }), + ) + .route( + routes::v1::SCALAR_ROUTE, + get(Scalar::new(routes::v1::OPENAPI_SPEC_ROUTE) + .with_title("Espresso Node API v1") + .axum_handler()), + ) + .layer(Extension(OpenApiV1(api))) } /// Create v2 router with OpenAPI documentation (proto types) pub fn create_router_v2(state: S) -> Router @@ -2761,8 +3742,14 @@ where router .route(routes::v2::OPENAPI_SPEC_ROUTE, get(serve_openapi_spec)) - .route(routes::v2::SWAGGER_ROUTE, get(serve_swagger_ui)) - .route("/v2/", get(serve_swagger_ui)) + .route( + routes::v2::SWAGGER_ROUTE, + get(|| async { swagger_html(routes::v2::OPENAPI_SPEC_ROUTE) }), + ) + .route( + "/v2/", + get(|| async { swagger_html(routes::v2::OPENAPI_SPEC_ROUTE) }), + ) .route( routes::v2::SCALAR_ROUTE, get(Scalar::new(routes::v2::OPENAPI_SPEC_ROUTE) @@ -2875,4 +3862,776 @@ mod tests { "/v1/availability/leaf/1?foo=bar" ); } + + /// Implements every v1 API trait with `unimplemented!()` bodies, purely so `create_router_v1` + /// can be instantiated in tests that only exercise the static docs routes (root redirect, + /// swagger UI, OpenAPI spec) and never call into a handler. + #[derive(Clone)] + struct MockState; + + #[async_trait::async_trait] + impl v1::RewardApi for MockState { + type RewardClaimInput = (); + type RewardBalance = (); + type RewardAccountQueryData = (); + type RewardAmounts = (); + type RewardMerkleTreeData = (); + type RewardAccountQueryDataV1 = (); + type RewardStatePathV1 = (); + type RewardStatePathV2 = (); + + async fn get_reward_state_height(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_state_v2_height(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_account_proof_v1( + &self, + _height: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_claim_input( + &self, + _block_height: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_balance( + &self, + _height: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_latest_reward_balance( + &self, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_account_proof( + &self, + _height: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_latest_reward_account_proof( + &self, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_amounts( + &self, + _height: u64, + _offset: u64, + _limit: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_merkle_tree_v2( + &self, + _height: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_state_path_v1( + &self, + _snapshot: v1::merklized_state::Snapshot, + _key: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_state_path_v2( + &self, + _snapshot: v1::merklized_state::Snapshot, + _key: String, + ) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::AvailabilityApi for MockState { + type NamespaceProofQueryData = (); + type IncorrectEncodingProof = (); + type StateCertQueryDataV1 = (); + type StateCertQueryDataV2 = (); + + async fn get_namespace_proof( + &self, + _block_id: v1::BlockId, + _namespace: u32, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_namespace_proof_range( + &self, + _from: u64, + _until: u64, + _namespace: u32, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_namespace_proofs( + &self, + _from: usize, + _namespace: u32, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_incorrect_encoding_proof( + &self, + _block_id: v1::BlockId, + _namespace: u32, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_state_cert(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_state_cert_v2( + &self, + _epoch: u64, + ) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::HotShotAvailabilityApi for MockState { + type Leaf = (); + type Block = (); + type Header = (); + type Payload = (); + type VidCommon = (); + type Transaction = (); + type TransactionWithProof = (); + type BlockSummary = (); + type Limits = (); + type Cert2 = (); + + async fn get_leaf(&self, _id: v1::LeafId) -> anyhow::Result { + unimplemented!() + } + async fn get_leaf_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_header(&self, _id: v1::BlockId) -> anyhow::Result { + unimplemented!() + } + async fn get_header_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_block(&self, _id: v1::BlockId) -> anyhow::Result { + unimplemented!() + } + async fn get_block_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_payload(&self, _id: v1::PayloadId) -> anyhow::Result { + unimplemented!() + } + async fn get_payload_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_vid_common(&self, _id: v1::BlockId) -> anyhow::Result { + unimplemented!() + } + async fn get_vid_common_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_transaction_by_position( + &self, + _height: u64, + _index: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_transaction_by_hash( + &self, + _hash: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_transaction_proof_by_position( + &self, + _height: u64, + _index: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_transaction_proof_by_hash( + &self, + _hash: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_block_summary(&self, _height: usize) -> anyhow::Result { + unimplemented!() + } + async fn get_block_summary_range( + &self, + _from: usize, + _until: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_limits(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_cert2(&self, _height: u64) -> anyhow::Result> { + unimplemented!() + } + async fn stream_leaves( + &self, + _from: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_headers( + &self, + _from: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_blocks( + &self, + _from: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_payloads( + &self, + _from: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_vid_common( + &self, + _from: usize, + ) -> anyhow::Result> { + unimplemented!() + } + async fn stream_transactions( + &self, + _from: usize, + _namespace: Option, + ) -> anyhow::Result> { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::BlockStateApi for MockState { + type MerkleProof = (); + + async fn get_block_state_path( + &self, + _snapshot: v1::merklized_state::Snapshot, + _key: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_block_state_height(&self) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::FeeStateApi for MockState { + type MerkleProof = (); + type FeeAmount = (); + + async fn get_fee_state_path( + &self, + _snapshot: v1::merklized_state::Snapshot, + _key: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_fee_state_height(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_fee_balance_latest( + &self, + _address: String, + ) -> anyhow::Result> { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::StatusApi for MockState { + async fn block_height(&self) -> anyhow::Result { + unimplemented!() + } + async fn success_rate(&self) -> anyhow::Result { + unimplemented!() + } + async fn time_since_last_decide(&self) -> anyhow::Result { + unimplemented!() + } + async fn metrics(&self) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::ConfigApi for MockState { + type HotShotConfig = (); + type RuntimeConfig = (); + + async fn hotshot_config(&self) -> anyhow::Result { + unimplemented!() + } + async fn env(&self) -> anyhow::Result> { + unimplemented!() + } + async fn runtime_config(&self) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::NodeApi for MockState { + type VidShare = (); + type SyncStatus = (); + type HeaderWindow = (); + type Limits = (); + type StakeTable = (); + type StakeTableCurrent = (); + type Validators = (); + type AllValidators = (); + type Participation = (); + type BlockReward = (); + type Block = (); + type Leaf = (); + + async fn block_height(&self) -> anyhow::Result { + unimplemented!() + } + async fn count_transactions( + &self, + _from: Option, + _to: Option, + _namespace: Option, + ) -> anyhow::Result { + unimplemented!() + } + async fn payload_size( + &self, + _from: Option, + _to: Option, + _namespace: Option, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_vid_share(&self, _id: v1::VidShareId) -> anyhow::Result { + unimplemented!() + } + async fn sync_status(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_header_window( + &self, + _start: v1::HeaderWindowStart, + _end: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn limits(&self) -> anyhow::Result { + unimplemented!() + } + async fn stake_table(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn stake_table_current(&self) -> anyhow::Result { + unimplemented!() + } + async fn da_stake_table(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn da_stake_table_current(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_validators(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_all_validators( + &self, + _epoch: u64, + _offset: u64, + _limit: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn current_proposal_participation(&self) -> anyhow::Result { + unimplemented!() + } + async fn proposal_participation(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn current_vote_participation(&self) -> anyhow::Result { + unimplemented!() + } + async fn vote_participation(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_block_reward(&self, _epoch: Option) -> anyhow::Result { + unimplemented!() + } + async fn get_oldest_block(&self) -> anyhow::Result> { + unimplemented!() + } + async fn get_oldest_leaf(&self) -> anyhow::Result> { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::CatchupApi for MockState { + type FeeAccount = (); + type RewardAccountV1 = (); + type RewardAccountV2 = (); + type AccountQueryData = (); + type FeeMerkleTree = (); + type BlocksFrontier = (); + type ChainConfig = (); + type LeafChain = (); + type Cert2 = (); + type RewardAccountQueryDataV1 = (); + type RewardMerkleTreeV1 = (); + type RewardAccountQueryDataV2 = (); + type RewardMerkleTreeV2Data = (); + type StateCert = (); + + async fn get_account( + &self, + _height: u64, + _view: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_accounts( + &self, + _height: u64, + _view: u64, + _accounts: Vec, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_blocks_frontier( + &self, + _height: u64, + _view: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_chain_config(&self, _commitment: String) -> anyhow::Result { + unimplemented!() + } + async fn get_leaf_chain(&self, _height: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_cert2(&self, _height: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_account_v1( + &self, + _height: u64, + _view: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_accounts_v1( + &self, + _height: u64, + _view: u64, + _accounts: Vec, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_account_v2( + &self, + _height: u64, + _view: u64, + _address: String, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_reward_merkle_tree_v2( + &self, + _height: u64, + _view: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_state_cert(&self, _epoch: u64) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::SubmitApi for MockState { + type Transaction = (); + type TxHash = (); + + async fn submit(&self, _tx: Self::Transaction) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::StateSignatureApi for MockState { + type Signature = (); + + async fn get_state_signature(&self, _height: u64) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::HotShotEventsApi for MockState { + type Event = (); + type StartupInfo = (); + + async fn startup_info(&self) -> anyhow::Result { + unimplemented!() + } + async fn events(&self) -> anyhow::Result> { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::LightClientApi for MockState { + type LeafProof = (); + type HeaderProof = (); + type StakeTableEvents = (); + type PayloadProof = (); + type NamespaceProof = (); + + async fn get_leaf_proof( + &self, + _query: v1::LeafQuery, + _finalized: Option, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_header_proof( + &self, + _root: u64, + _requested: v1::HeaderQuery, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_light_client_stake_table( + &self, + _epoch: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_payload_proof(&self, _height: u64) -> anyhow::Result { + unimplemented!() + } + async fn get_payload_proof_range( + &self, + _start: u64, + _end: u64, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_lc_namespace_proof( + &self, + _height: u64, + _namespace: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_lc_namespace_proof_range( + &self, + _start: u64, + _end: u64, + _namespace: u64, + ) -> anyhow::Result> { + unimplemented!() + } + async fn get_lc_namespaces_proof_range( + &self, + _start: u64, + _end: u64, + _namespaces: String, + ) -> anyhow::Result>> { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::ExplorerApi for MockState { + type BlockDetail = (); + type BlockSummaries = (); + type TransactionDetail = (); + type TransactionSummaries = (); + type ExplorerSummary = (); + type SearchResult = (); + + async fn get_block_detail( + &self, + _ident: v1::BlockIdent, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_block_summaries( + &self, + _target: v1::BlockIdent, + _limit: u64, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_transaction_detail( + &self, + _ident: v1::TxIdent, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_transaction_summaries( + &self, + _target: v1::TxIdent, + _limit: u64, + _filter: v1::TxSummaryFilter, + ) -> anyhow::Result { + unimplemented!() + } + async fn get_explorer_summary(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_search_result(&self, _query: String) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::TokenApi for MockState { + async fn total_minted_supply(&self) -> anyhow::Result { + unimplemented!() + } + async fn circulating_supply(&self) -> anyhow::Result { + unimplemented!() + } + async fn circulating_supply_ethereum(&self) -> anyhow::Result { + unimplemented!() + } + async fn total_issued_supply(&self) -> anyhow::Result { + unimplemented!() + } + async fn total_reward_distributed(&self) -> anyhow::Result { + unimplemented!() + } + } + + #[async_trait::async_trait] + impl v1::DatabaseApi for MockState { + type TableSizes = (); + type MigrationStatus = (); + + async fn get_table_sizes(&self) -> anyhow::Result { + unimplemented!() + } + async fn get_migration_status(&self) -> anyhow::Result { + unimplemented!() + } + } + + async fn body_string(resp: Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read response body"); + String::from_utf8(bytes.to_vec()).expect("response body is utf8") + } + + #[tokio::test] + async fn root_redirects_to_v1() { + let router = with_top_level_routes(Router::new()); + let req = Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = tower::ServiceExt::oneshot(router, req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + resp.headers().get(axum::http::header::LOCATION).unwrap(), + "/v1" + ); + } + + #[tokio::test] + async fn v1_swagger_ui_serves_html() { + let router = create_router_v1(MockState); + let req = Request::builder() + .uri("/v1") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = tower::ServiceExt::oneshot(router, req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let content_type = resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(content_type.contains("text/html")); + let body = body_string(resp).await; + assert!(body.contains(routes::v1::OPENAPI_SPEC_ROUTE)); + } + + #[tokio::test] + async fn v1_openapi_spec_contains_known_route() { + let router = create_router_v1(MockState); + let req = Request::builder() + .uri(routes::v1::OPENAPI_SPEC_ROUTE) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = tower::ServiceExt::oneshot(router, req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp).await; + let spec: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!( + spec["paths"] + .as_object() + .expect("spec has paths") + .contains_key(routes::v1::STATUS_BLOCK_HEIGHT_ROUTE), + "expected {} in spec paths: {}", + routes::v1::STATUS_BLOCK_HEIGHT_ROUTE, + body + ); + } } diff --git a/crates/espresso/api/src/axum/routes.rs b/crates/espresso/api/src/axum/routes.rs index 32bdb1f945c..95f8ddae812 100644 --- a/crates/espresso/api/src/axum/routes.rs +++ b/crates/espresso/api/src/axum/routes.rs @@ -323,6 +323,11 @@ pub mod v1 { pub const DATABASE_TABLE_SIZES_ROUTE: &str = "/v1/database/table-sizes"; pub const DATABASE_MIGRATION_STATUS_ROUTE: &str = "/v1/database/migration-status"; + // API docs + pub const OPENAPI_SPEC_ROUTE: &str = "/v1/docs/openapi.json"; + pub const SWAGGER_ROUTE: &str = "/v1"; + pub const SCALAR_ROUTE: &str = "/v1/scalar"; + // --------------------------------------------------------------------- // Path builders // diff --git a/crates/espresso/api/src/lib.rs b/crates/espresso/api/src/lib.rs index 8b75faad0d9..3eadd169302 100644 --- a/crates/espresso/api/src/lib.rs +++ b/crates/espresso/api/src/lib.rs @@ -99,7 +99,7 @@ where if modules.hotshot_events { router = router.merge(axum::router_hotshot_events(state)); } - serve_router(port, "v1 and v2", router, max_connections).await + serve_router(port, "v1 and v2", router.into(), max_connections).await } /// Which of the optional API modules to serve, for modes that make them conditional @@ -157,7 +157,7 @@ where if modules.hotshot_events { router = router.merge(axum::router_hotshot_events(state)); } - serve_router(port, "fs", router, max_connections).await + serve_router(port, "fs", router.into(), max_connections).await } /// Serve the status-only API: no availability/node/token data source is available, so only @@ -181,9 +181,9 @@ where + Sync + 'static, { - let mut router = + let router = axum::router_status(state.clone()).merge(axum::router_state_signature(state.clone())); - router = merge_hotshot_modules(router, &state, modules); + let router = merge_hotshot_modules(router.into(), &state, modules); serve_router(port, "status", router, max_connections).await } @@ -208,7 +208,7 @@ where + 'static, { let router = axum::router_state_signature(state.clone()); - let router = merge_hotshot_modules(router, &state, modules); + let router = merge_hotshot_modules(router.into(), &state, modules); serve_router(port, "bare", router, max_connections).await } diff --git a/crates/espresso/api/templates/swagger.html b/crates/espresso/api/templates/swagger.html index 67157f88f30..289641369d2 100644 --- a/crates/espresso/api/templates/swagger.html +++ b/crates/espresso/api/templates/swagger.html @@ -12,7 +12,7 @@ From fbc12dcf2ffb5f19a058139f06fc3909f0eb54e1 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 16:45:22 +0800 Subject: [PATCH 06/14] docs(api): type v1 path parameters from their handler extractors Numeric template segments (height, from/until, epoch, view, index, limit, offset, namespace, finalized, ...) are declared as unsigned integers, matching each handler's Path extractor; hash, key, and address segments stay strings. Unknown future names default to string. Documentation-only: handlers parse the raw segment regardless. --- crates/espresso/api/src/axum.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index f470a179fec..d22bc7a1709 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -3568,7 +3568,27 @@ pub fn finish_v1_docs(router: ApiRouter) -> Router { /// aide only derives path parameters from `Path` extractors whose `T` is a named-field /// struct; the v1 handlers all use primitives and tuples (`Path`, `Path<(u64, String)>`), /// so nothing is derived and Swagger's try-it-out cannot fill the URL templates. The template -/// itself names every parameter, so declare them from it (as strings; the handlers parse). +/// itself names every parameter, so declare them from it. +/// +/// Parameter types come from [`path_parameter_schema`]; the handlers parse the raw segment +/// either way, so a wrong entry there affects only documentation, not behavior. +/// OpenAPI schema for a v1 path template parameter, by segment name. +/// +/// The names form a closed set and each type was read off the handler's `Path` extractor: +/// every name listed as integer binds an unsigned integer in all its handlers (including +/// `finalized`, a `u64` flag-like argument, and `namespace`, a `u32` id). `namespaces` is a +/// comma-separated list bound as `String`. Unknown names (future routes) default to string, +/// which any handler can parse from the raw segment. +fn path_parameter_schema(name: &str) -> schemars::Schema { + match name { + "height" | "block_number" | "from" | "until" | "to" | "start" | "end" | "epoch" + | "epoch_number" | "view" | "index" | "limit" | "offset" | "namespace" | "finalized" => { + schemars::json_schema!({"type": "integer", "minimum": 0}) + }, + _ => schemars::json_schema!({"type": "string"}), + } +} + fn declare_path_template_parameters(api: &mut OpenApi) { let Some(ref mut paths) = api.paths else { return; @@ -3619,7 +3639,7 @@ fn declare_path_template_parameters(api: &mut OpenApi) { required: true, deprecated: None, format: ParameterSchemaOrContent::Schema(SchemaObject { - json_schema: schemars::json_schema!({"type": "string"}), + json_schema: path_parameter_schema(name), external_docs: None, example: None, }), @@ -4778,7 +4798,7 @@ mod tests { ); assert_eq!(params[0]["in"], "path"); assert_eq!(params[0]["required"], true); - assert_eq!(params[0]["schema"]["type"], "string"); + assert_eq!(params[0]["schema"]["type"], "integer"); } /// Multi-segment templates declare one parameter per `{name}`, in template order. @@ -4814,5 +4834,12 @@ mod tests { ); } } + + // Numeric segments are typed integer, hash/key-like segments string. + let key_path = &paths[routes::v1::REWARD_STATE_PATH_BY_HEIGHT_ROUTE]["get"]["parameters"]; + assert_eq!(key_path[0]["name"], "height"); + assert_eq!(key_path[0]["schema"]["type"], "integer"); + assert_eq!(key_path[1]["name"], "key"); + assert_eq!(key_path[1]["schema"]["type"], "string"); } } From 4629ca077bece5d95e52c1b1a8a7df26b479b2f2 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 16:55:50 +0800 Subject: [PATCH 07/14] chore(api): drop noise comment on the example port --- crates/espresso/api/examples/test_api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/espresso/api/examples/test_api.rs b/crates/espresso/api/examples/test_api.rs index 057a1784cb7..453b07496d3 100644 --- a/crates/espresso/api/examples/test_api.rs +++ b/crates/espresso/api/examples/test_api.rs @@ -13,7 +13,7 @@ use espresso_api::{v1, v2}; use serde::Serialize; use serialization_api::ApiSerializations; -/// Port for the test API server. 5000 would collide with macOS AirPlay (ControlCenter). +/// Port for the test API server const API_PORT: u16 = 5001; /// Test API implementation with hardcoded mock data From 83bb9835cadf1ba49354bf434dd4699522ec6f8f Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 17:04:22 +0800 Subject: [PATCH 08/14] feat(api): group v1 docs operations by module tag The first path segment after /v1/ becomes the operation's OpenAPI tag, so Swagger renders one collapsible section per module (availability, status, catchup, ...), sorted. --- crates/espresso/api/src/axum.rs | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index d22bc7a1709..6178bf32a4a 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -3534,6 +3534,7 @@ pub fn finish_v1_docs(router: ApiRouter) -> Router { let router = router.finish_api(&mut api); declare_path_template_parameters(&mut api); + tag_operations_by_module(&mut api); // Transform examples (array) to example (singular) for OpenAPI 3.0/Swagger compatibility, // matching create_router_v2 (a no-op unless a future v1 route adds a JsonSchema body/query). @@ -3572,6 +3573,47 @@ pub fn finish_v1_docs(router: ApiRouter) -> Router { /// /// Parameter types come from [`path_parameter_schema`]; the handlers parse the raw segment /// either way, so a wrong entry there affects only documentation, not behavior. +/// Group operations by API module: the first path segment after `/v1/` (`availability`, +/// `status`, `catchup`, ...) becomes the operation's tag, and the tag list is registered sorted +/// so Swagger renders one collapsible section per module in a stable order. +fn tag_operations_by_module(api: &mut OpenApi) { + let Some(ref mut paths) = api.paths else { + return; + }; + let mut modules = std::collections::BTreeSet::new(); + for (path, path_item_ref) in paths.paths.iter_mut() { + let ReferenceOr::Item(path_item) = path_item_ref else { + continue; + }; + let Some(module) = path + .strip_prefix("/v1/") + .and_then(|rest| rest.split('/').next()) + else { + continue; + }; + modules.insert(module.to_string()); + for operation in [ + &mut path_item.get, + &mut path_item.post, + &mut path_item.put, + &mut path_item.delete, + &mut path_item.patch, + ] + .into_iter() + .flatten() + { + operation.tags = vec![module.to_string()]; + } + } + api.tags = modules + .into_iter() + .map(|name| aide::openapi::Tag { + name, + ..Default::default() + }) + .collect(); +} + /// OpenAPI schema for a v1 path template parameter, by segment name. /// /// The names form a closed set and each type was read off the handler's `Path` extractor: @@ -4841,5 +4883,18 @@ mod tests { assert_eq!(key_path[0]["schema"]["type"], "integer"); assert_eq!(key_path[1]["name"], "key"); assert_eq!(key_path[1]["schema"]["type"], "string"); + + // Operations are grouped by module tag. + assert_eq!( + paths[routes::v1::LEAF_BY_HEIGHT_ROUTE]["get"]["tags"][0], + "availability" + ); + assert!( + spec["tags"] + .as_array() + .expect("spec has tags") + .iter() + .any(|t| t["name"] == "status") + ); } } From 07e42b89f8dc788afc8c28bd7fc2308d5d8cb817 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 17:04:23 +0800 Subject: [PATCH 09/14] style(api): espresso-brand the Swagger UI Palette from the staking UI: cream background, coffee headings, cookie-brown GET and orange POST accents; topbar hidden. --- crates/espresso/api/templates/swagger.html | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/espresso/api/templates/swagger.html b/crates/espresso/api/templates/swagger.html index 83180c33ac5..f910bf7e6f9 100644 --- a/crates/espresso/api/templates/swagger.html +++ b/crates/espresso/api/templates/swagger.html @@ -5,6 +5,59 @@ Espresso API Documentation - Swagger UI +
From 78ccb4403a7b0d619509e308b4dc33b1e6a28c40 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 18:17:11 +0800 Subject: [PATCH 10/14] refactor(node): hoist function-local imports in api/state.rs Review feedback: 44 in-function use statements move to the file header; long v0_3/v0_4 turbofish paths shortened via imports; the shared last_merklized_state_height comment now names all state modules. --- crates/espresso/node/src/api/state.rs | 136 +++++++------------------- 1 file changed, 38 insertions(+), 98 deletions(-) diff --git a/crates/espresso/node/src/api/state.rs b/crates/espresso/node/src/api/state.rs index ac76e242ebb..6880e625aa2 100644 --- a/crates/espresso/node/src/api/state.rs +++ b/crates/espresso/node/src/api/state.rs @@ -3,42 +3,55 @@ //! This module provides implementations for both v1::RewardApi (internal types) //! and v2::RewardApi (proto types), backed by the same data source. -use std::time::Duration; +use std::{ops::Bound, time::Duration}; use alloy::primitives::U256; use async_trait::async_trait; +use committable::Committable as _; use espresso_api::{error::AvailabilityError, v1::HotShotAvailabilityApi}; use espresso_types::{ NamespaceId, NamespaceProofQueryData, NsProof, SeqTypes, v0::sparse_mt::KeccakNode, - v0_3::RewardAmount as InternalRewardAmount, + v0_3::{RewardAccountV1, RewardAmount as InternalRewardAmount, RewardMerkleTreeV1}, v0_4::{ RewardAccountProofV2 as InternalRewardAccountProofV2, RewardAccountQueryDataV2 as InternalRewardAccountQueryData, RewardAccountV2, - RewardMerkleProofV2 as InternalRewardMerkleProofV2, + RewardMerkleProofV2 as InternalRewardMerkleProofV2, RewardMerkleTreeV2, }, v0_6::RewardClaimError, }; use futures::{StreamExt as _, join, stream::BoxStream}; use hotshot_contract_adapter::reward::RewardClaimInput as InternalRewardClaimInput; +use hotshot_events_service::events_source::EventsSource as _; use hotshot_new_protocol::message::Certificate2; use hotshot_query_service::{ - Header as HsHeader, + Header as HsHeader, QueryError, availability::{ AvailabilityDataSource, BlockId as HsBlockId, BlockQueryData, BlockSummaryQueryData, LeafId as HsLeafId, LeafQueryData, Limits as HsLimits, PayloadQueryData, QueryablePayload as _, TransactionQueryData, TransactionWithProofQueryData, VidCommonQueryData, }, - node::NodeDataSource as _, + explorer::{ + BlockIdentifier, BlockRange, ExplorerDataSource as _, GetBlockSummariesRequest, + GetTransactionSummariesRequest, TransactionIdentifier, TransactionRange, + TransactionSummaryFilter, + }, + merklized_state::{ + MerklizedStateDataSource, MerklizedStateHeightPersistence, Snapshot as HsSnapshot, + }, + node::{NodeDataSource as _, WindowStart}, + status::HasMetrics, types::HeightIndexed as _, }; use hotshot_types::{ data::{EpochNumber, VidShare}, + utils::{epoch_from_block_number, root_block_in_epoch}, vid::avidm::AvidMShare, }; use jf_merkle_tree_compat::prelude::{ MerkleNode as InternalMerkleNode, MerkleProof as InternalMerkleProof, + MerkleProof as JfMerkleProof, }; use serde_json; use serialization_api::v2::{ @@ -47,13 +60,15 @@ use serialization_api::v2::{ reward_merkle_proof_v2::ProofType, }; use tagged_base64::TaggedBase64; -use tide_disco::{Error as _, StatusCode}; +use tide_disco::{Error as _, StatusCode, metrics::Metrics as _}; use super::{ RewardMerkleTreeDataSource, RewardMerkleTreeV2Data as InternalRewardTreeData, data_source::{ - RequestResponseDataSource as _, StakeTableDataSource, StateCertDataSource, - StateCertFetchingDataSource, StateSignatureDataSource, + CatchupDataSource as _, DatabaseMetadataSource as _, HotShotConfigDataSource as _, + NodeStateDataSource as _, PruningDataSource as _, RequestResponseDataSource as _, + StakeTableDataSource, StateCertDataSource, StateCertFetchingDataSource, + StateSignatureDataSource, TokenDataSource as _, }, }; @@ -703,8 +718,6 @@ where >; async fn get_reward_state_height(&self) -> anyhow::Result { - use hotshot_query_service::merklized_state::MerklizedStateHeightPersistence; - let ds = &*self.data_source; ds.get_last_state_height() .await @@ -713,9 +726,9 @@ where } async fn get_reward_state_v2_height(&self) -> anyhow::Result { - // Both the V1 and V2 reward merklized-state modules share the same underlying - // `last_merklized_state_height` row; mirrors tide registering the height route - // once per module against the same data source. + // `last_merklized_state_height` is the same row for every merklized-state module in + // this file (reward V1/V2, block-state, fee-state), not just these two; mirrors tide + // registering the height route once per module against the same data source. self.get_reward_state_height().await } @@ -724,7 +737,7 @@ where height: u64, address: String, ) -> anyhow::Result { - let account: espresso_types::v0_3::RewardAccountV1 = address + let account: RewardAccountV1 = address .parse() .map_err(|_| bad_request(format!("invalid ethereum address: {}", address)))?; @@ -944,10 +957,6 @@ where snapshot: espresso_api::v1::Snapshot, key: String, ) -> anyhow::Result { - use hotshot_query_service::merklized_state::{ - MerklizedStateDataSource, Snapshot as HsSnapshot, - }; - let hs_snapshot = match snapshot { espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), espresso_api::v1::Snapshot::Commit(c) => { @@ -960,17 +969,13 @@ where HsSnapshot::Commit(commit) }, }; - let key: espresso_types::v0_3::RewardAccountV1 = key + let key: RewardAccountV1 = key .parse() .map_err(|_| bad_request("failed to parse Key param"))?; let ds = &*self.data_source; - MerklizedStateDataSource::< - espresso_types::SeqTypes, - espresso_types::v0_3::RewardMerkleTreeV1, - _, - >::get_path(ds, hs_snapshot, key) - .await - .map_err(classify_query_error) + MerklizedStateDataSource::::get_path(ds, hs_snapshot, key) + .await + .map_err(classify_query_error) } async fn get_reward_state_path_v2( @@ -978,10 +983,6 @@ where snapshot: espresso_api::v1::Snapshot, key: String, ) -> anyhow::Result { - use hotshot_query_service::merklized_state::{ - MerklizedStateDataSource, Snapshot as HsSnapshot, - }; - let hs_snapshot = match snapshot { espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), espresso_api::v1::Snapshot::Commit(c) => { @@ -998,13 +999,9 @@ where .parse() .map_err(|_| bad_request("failed to parse Key param"))?; let ds = &*self.data_source; - MerklizedStateDataSource::< - espresso_types::SeqTypes, - espresso_types::v0_4::RewardMerkleTreeV2, - _, - >::get_path(ds, hs_snapshot, key) - .await - .map_err(classify_query_error) + MerklizedStateDataSource::::get_path(ds, hs_snapshot, key) + .await + .map_err(classify_query_error) } } @@ -2127,7 +2124,6 @@ fn payload_id_to_hs( } fn classify_query_error(err: hotshot_query_service::QueryError) -> anyhow::Error { - use hotshot_query_service::QueryError; match err { QueryError::NotFound | QueryError::Missing => not_found(err.to_string()), QueryError::Error { .. } => anyhow::anyhow!(err.to_string()), @@ -2158,10 +2154,6 @@ where snapshot: espresso_api::v1::Snapshot, key: String, ) -> anyhow::Result { - use hotshot_query_service::merklized_state::{ - MerklizedStateDataSource, Snapshot as HsSnapshot, - }; - let hs_snapshot = match snapshot { espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), espresso_api::v1::Snapshot::Commit(c) => { @@ -2188,8 +2180,6 @@ where } async fn get_block_state_height(&self) -> anyhow::Result { - use hotshot_query_service::merklized_state::MerklizedStateHeightPersistence; - let ds = &*self.data_source; ds.get_last_state_height() .await @@ -2223,10 +2213,6 @@ where snapshot: espresso_api::v1::Snapshot, key: String, ) -> anyhow::Result { - use hotshot_query_service::merklized_state::{ - MerklizedStateDataSource, Snapshot as HsSnapshot, - }; - let hs_snapshot = match snapshot { espresso_api::v1::Snapshot::Height(h) => HsSnapshot::Index(h), espresso_api::v1::Snapshot::Commit(c) => { @@ -2253,8 +2239,6 @@ where } async fn get_fee_state_height(&self) -> anyhow::Result { - use hotshot_query_service::merklized_state::MerklizedStateHeightPersistence; - let ds = &*self.data_source; ds.get_last_state_height() .await @@ -2266,11 +2250,6 @@ where &self, address: String, ) -> anyhow::Result> { - use hotshot_query_service::merklized_state::{ - MerklizedStateDataSource, MerklizedStateHeightPersistence, Snapshot as HsSnapshot, - }; - use jf_merkle_tree_compat::prelude::MerkleProof as JfMerkleProof; - let key: espresso_types::FeeAccount = address .parse() .map_err(|_| bad_request("failed to parse address"))?; @@ -2328,8 +2307,6 @@ where } async fn metrics(&self) -> anyhow::Result { - use hotshot_query_service::status::HasMetrics; - use tide_disco::metrics::Metrics as _; let ds = &*self.data_source; ds.metrics().export().map_err(|e| anyhow::anyhow!("{e}")) } @@ -2349,7 +2326,6 @@ where type RuntimeConfig = crate::options::PublicNodeConfig; async fn hotshot_config(&self) -> anyhow::Result { - use super::data_source::HotShotConfigDataSource as _; let ds = &*self.data_source; Ok(ds.get_config().await) } @@ -2415,7 +2391,6 @@ where to: Option, namespace: Option, ) -> anyhow::Result { - use std::ops::Bound; let ds = &*self.data_source; let from = match from { Some(f) => Bound::Included(f as usize), @@ -2439,7 +2414,6 @@ where to: Option, namespace: Option, ) -> anyhow::Result { - use std::ops::Bound; let ds = &*self.data_source; let from = match from { Some(f) => Bound::Included(f as usize), @@ -2490,7 +2464,6 @@ where start: espresso_api::v1::HeaderWindowStart, end: u64, ) -> anyhow::Result { - use hotshot_query_service::node::WindowStart; let ds = &*self.data_source; let start: WindowStart = match start { espresso_api::v1::HeaderWindowStart::Time(t) => WindowStart::Time(t), @@ -2584,13 +2557,11 @@ where } async fn get_oldest_block(&self) -> anyhow::Result> { - use super::data_source::PruningDataSource as _; let ds = &*self.data_source; ds.get_oldest_block().await } async fn get_oldest_leaf(&self) -> anyhow::Result> { - use super::data_source::PruningDataSource as _; let ds = &*self.data_source; ds.get_oldest_leaf().await } @@ -2637,7 +2608,6 @@ where view: u64, address: String, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let account: espresso_types::FeeAccount = address @@ -2655,7 +2625,6 @@ where view: u64, accounts: Vec, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let instance = ds.node_state().await; @@ -2669,7 +2638,6 @@ where height: u64, view: u64, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let instance = ds.node_state().await; @@ -2679,7 +2647,6 @@ where } async fn get_chain_config(&self, commitment: String) -> anyhow::Result { - use super::data_source::CatchupDataSource as _; let ds = &*self.data_source; let parsed: committable::Commitment = commitment .parse() @@ -2690,7 +2657,6 @@ where } async fn get_leaf_chain(&self, height: u64) -> anyhow::Result { - use super::data_source::CatchupDataSource as _; let ds = &*self.data_source; ds.get_leaf_chain(height) .await @@ -2698,7 +2664,6 @@ where } async fn get_cert2(&self, height: u64) -> anyhow::Result { - use super::data_source::CatchupDataSource as _; let ds = &*self.data_source; let response = ds .get_cert2(height) @@ -2713,7 +2678,6 @@ where view: u64, address: String, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let account: espresso_types::v0_4::RewardAccountV2 = address @@ -2731,7 +2695,6 @@ where view: u64, accounts: Vec, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let instance = ds.node_state().await; @@ -2746,7 +2709,6 @@ where view: u64, address: String, ) -> anyhow::Result { - use super::data_source::{CatchupDataSource as _, NodeStateDataSource as _}; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let account: espresso_types::v0_4::RewardAccountV2 = address @@ -2763,7 +2725,6 @@ where height: u64, view: u64, ) -> anyhow::Result { - use super::data_source::CatchupDataSource as _; let ds = &*self.data_source; let view = hotshot_types::data::ViewNumber::new(view); let bytes = ds @@ -2776,7 +2737,6 @@ where } async fn get_state_cert(&self, epoch: u64) -> anyhow::Result { - use super::data_source::CatchupDataSource as _; let ds = &*self.data_source; ds.get_state_cert(epoch) .await @@ -2798,7 +2758,6 @@ where type TxHash = committable::Commitment; async fn submit(&self, tx: Self::Transaction) -> anyhow::Result { - use committable::Committable as _; let hash = tx.commit(); let ds = &*self.data_source; ds.submit_erased(tx) @@ -2930,7 +2889,6 @@ where &self, ident: espresso_api::v1::BlockIdent, ) -> anyhow::Result { - use hotshot_query_service::explorer::{BlockIdentifier, ExplorerDataSource as _}; let ds = &*self.data_source; let target = match ident { espresso_api::v1::BlockIdent::Height(h) => BlockIdentifier::Height(h as usize), @@ -2951,9 +2909,6 @@ where target: espresso_api::v1::BlockIdent, limit: u64, ) -> anyhow::Result { - use hotshot_query_service::explorer::{ - BlockIdentifier, BlockRange, ExplorerDataSource as _, GetBlockSummariesRequest, - }; let ds = &*self.data_source; let num_blocks = std::num::NonZeroUsize::new(limit as usize) .ok_or_else(|| bad_request("limit must be greater than 0"))?; @@ -2978,7 +2933,6 @@ where &self, ident: espresso_api::v1::TxIdent, ) -> anyhow::Result { - use hotshot_query_service::explorer::{ExplorerDataSource as _, TransactionIdentifier}; let ds = &*self.data_source; let target = match ident { espresso_api::v1::TxIdent::HeightAndOffset(h, o) => { @@ -3002,10 +2956,6 @@ where limit: u64, filter: espresso_api::v1::TxSummaryFilter, ) -> anyhow::Result { - use hotshot_query_service::explorer::{ - ExplorerDataSource as _, GetTransactionSummariesRequest, TransactionIdentifier, - TransactionRange, TransactionSummaryFilter, - }; let ds = &*self.data_source; let num_transactions = std::num::NonZeroUsize::new(limit as usize) .ok_or_else(|| bad_request("limit must be greater than 0"))?; @@ -3044,7 +2994,6 @@ where } async fn get_explorer_summary(&self) -> anyhow::Result { - use hotshot_query_service::explorer::ExplorerDataSource as _; let ds = &*self.data_source; ds.get_explorer_summary() .await @@ -3053,7 +3002,6 @@ where } async fn get_search_result(&self, query: String) -> anyhow::Result { - use hotshot_query_service::explorer::ExplorerDataSource as _; let ds = &*self.data_source; let parsed: tagged_base64::TaggedBase64 = query .parse() @@ -3098,13 +3046,12 @@ where query: espresso_api::v1::LeafQuery, finalized: Option, ) -> anyhow::Result { - use hotshot_query_service::availability::LeafId; let ds = &*self.data_source; let fetch_timeout = lc_fetch_timeout(); let requested = match query { - espresso_api::v1::LeafQuery::Height(h) => LeafId::Number(h as usize), - espresso_api::v1::LeafQuery::Hash(h) => LeafId::Hash( + espresso_api::v1::LeafQuery::Height(h) => HsLeafId::Number(h as usize), + espresso_api::v1::LeafQuery::Hash(h) => HsLeafId::Hash( h.parse() .map_err(|err| bad_request(format!("invalid leaf hash {h}: {err}")))?, ), @@ -3117,7 +3064,7 @@ where .with_timeout(fetch_timeout) .await .ok_or_else(|| not_found(format!("unknown block hash {h}")))?; - LeafId::Number(header.height() as usize) + HsLeafId::Number(header.height() as usize) }, espresso_api::v1::LeafQuery::PayloadHash(h) => { let parsed = h @@ -3128,7 +3075,7 @@ where .with_timeout(fetch_timeout) .await .ok_or_else(|| not_found(format!("unknown payload hash {h}")))?; - LeafId::Number(header.height() as usize) + HsLeafId::Number(header.height() as usize) }, }; @@ -3187,7 +3134,6 @@ where &self, epoch: u64, ) -> anyhow::Result { - use hotshot_types::utils::{epoch_from_block_number, root_block_in_epoch}; let ds = &*self.data_source; let fetch_timeout = lc_fetch_timeout(); @@ -3270,7 +3216,6 @@ where start: u64, end: u64, ) -> anyhow::Result> { - use futures::StreamExt as _; let ds = &*self.data_source; let fetch_timeout = lc_fetch_timeout(); let start = start as usize; @@ -3402,13 +3347,11 @@ where type StartupInfo = hotshot_events_service::events_source::StartupInfo; async fn startup_info(&self) -> anyhow::Result { - use hotshot_events_service::events_source::EventsSource as _; let ds = &*self.data_source; Ok(ds.get_startup_info().await) } async fn events(&self) -> anyhow::Result> { - use hotshot_events_service::events_source::EventsSource as _; let ds = &*self.data_source; let stream = ds.get_event_stream(None).await; Ok(Box::pin(stream)) @@ -3429,7 +3372,6 @@ where + Sync, { async fn total_minted_supply(&self) -> anyhow::Result { - use super::data_source::TokenDataSource as _; let ds = &*self.data_source; let value = ds .get_total_supply_l1() @@ -3516,13 +3458,11 @@ where type MigrationStatus = Vec; async fn get_table_sizes(&self) -> anyhow::Result { - use super::data_source::DatabaseMetadataSource as _; let ds = &*self.data_source; ds.get_table_sizes().await } async fn get_migration_status(&self) -> anyhow::Result { - use super::data_source::DatabaseMetadataSource as _; let ds = &*self.data_source; ds.get_migration_status().await } From 14eb9672d99dba1b3f3c3fbaca8372d4eb965971 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 18:17:12 +0800 Subject: [PATCH 11/14] refactor(api): derive path_fn placeholders from parameter names Review feedback: the macro now stringifies the parameter ident instead of taking a redundant string. Also fixes namespace_proof_by_payload_hash, whose "payload-hash" literal never matched the {payload_hash} placeholder, leaving it unsubstituted in built paths. --- crates/espresso/api/src/axum/routes.rs | 680 ++++++++++++++++++++----- 1 file changed, 543 insertions(+), 137 deletions(-) diff --git a/crates/espresso/api/src/axum/routes.rs b/crates/espresso/api/src/axum/routes.rs index 95f8ddae812..fada8a9e259 100644 --- a/crates/espresso/api/src/axum/routes.rs +++ b/crates/espresso/api/src/axum/routes.rs @@ -9,12 +9,12 @@ macro_rules! path_fn { ::std::string::String::from($route) } }; - ($name:ident, $route:expr, $($placeholder:literal => $param:ident),+ $(,)?) => { + ($name:ident, $route:expr, $($param:ident),+ $(,)?) => { pub fn $name($($param: impl ::std::fmt::Display),+) -> String { let mut path = ::std::string::String::from($route); $( path = path.replace( - concat!("{", $placeholder, "}"), + concat!("{", stringify!($param), "}"), &$param.to_string(), ); )+ @@ -338,102 +338,262 @@ pub mod v1 { // --------------------------------------------------------------------- // Reward state v2 - path_fn!(reward_claim_input, REWARD_CLAIM_INPUT_ROUTE, "height" => height, "address" => address); - path_fn!(reward_balance, REWARD_BALANCE_ROUTE, "height" => height, "address" => address); - path_fn!(latest_reward_balance, LATEST_REWARD_BALANCE_ROUTE, "address" => address); - path_fn!(reward_account_proof, REWARD_ACCOUNT_PROOF_ROUTE, "height" => height, "address" => address); - path_fn!(latest_reward_account_proof, LATEST_REWARD_ACCOUNT_PROOF_ROUTE, "address" => address); - path_fn!(reward_amounts, REWARD_AMOUNTS_ROUTE, "height" => height, "offset" => offset, "limit" => limit); - path_fn!(reward_merkle_tree_v2, REWARD_MERKLE_TREE_V2_ROUTE, "height" => height); + path_fn!( + reward_claim_input, + REWARD_CLAIM_INPUT_ROUTE, + height, + address + ); + path_fn!(reward_balance, REWARD_BALANCE_ROUTE, height, address); + path_fn!(latest_reward_balance, LATEST_REWARD_BALANCE_ROUTE, address); + path_fn!( + reward_account_proof, + REWARD_ACCOUNT_PROOF_ROUTE, + height, + address + ); + path_fn!( + latest_reward_account_proof, + LATEST_REWARD_ACCOUNT_PROOF_ROUTE, + address + ); + path_fn!(reward_amounts, REWARD_AMOUNTS_ROUTE, height, offset, limit); + path_fn!(reward_merkle_tree_v2, REWARD_MERKLE_TREE_V2_ROUTE, height); path_fn!(reward_state_height, REWARD_STATE_HEIGHT_ROUTE); path_fn!(reward_state_v2_height, REWARD_STATE_V2_HEIGHT_ROUTE); - path_fn!(reward_v1_account_proof, REWARD_V1_ACCOUNT_PROOF_ROUTE, "height" => height, "address" => address); - path_fn!(reward_v1_balance, REWARD_V1_BALANCE_ROUTE, "height" => height, "address" => address); - path_fn!(reward_v1_latest_balance, REWARD_V1_LATEST_BALANCE_ROUTE, "address" => address); - path_fn!(reward_v1_latest_account_proof, REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, "address" => address); - path_fn!(reward_v1_amounts, REWARD_V1_AMOUNTS_ROUTE, "height" => height, "offset" => offset, "limit" => limit); - path_fn!(reward_v1_merkle_tree_v2, REWARD_V1_MERKLE_TREE_V2_ROUTE, "height" => height); - path_fn!(reward_state_path_by_height, REWARD_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); - path_fn!(reward_state_path_by_commit, REWARD_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); - path_fn!(reward_state_v2_path_by_height, REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); - path_fn!(reward_state_v2_path_by_commit, REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!( + reward_v1_account_proof, + REWARD_V1_ACCOUNT_PROOF_ROUTE, + height, + address + ); + path_fn!(reward_v1_balance, REWARD_V1_BALANCE_ROUTE, height, address); + path_fn!( + reward_v1_latest_balance, + REWARD_V1_LATEST_BALANCE_ROUTE, + address + ); + path_fn!( + reward_v1_latest_account_proof, + REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, + address + ); + path_fn!( + reward_v1_amounts, + REWARD_V1_AMOUNTS_ROUTE, + height, + offset, + limit + ); + path_fn!( + reward_v1_merkle_tree_v2, + REWARD_V1_MERKLE_TREE_V2_ROUTE, + height + ); + path_fn!( + reward_state_path_by_height, + REWARD_STATE_PATH_BY_HEIGHT_ROUTE, + height, + key + ); + path_fn!( + reward_state_path_by_commit, + REWARD_STATE_PATH_BY_COMMIT_ROUTE, + commit, + key + ); + path_fn!( + reward_state_v2_path_by_height, + REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, + height, + key + ); + path_fn!( + reward_state_v2_path_by_commit, + REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, + commit, + key + ); // Availability — namespace proofs - path_fn!(namespace_proof_by_height, NAMESPACE_PROOF_BY_HEIGHT_ROUTE, "height" => height, "namespace" => namespace); - path_fn!(namespace_proof_by_hash, NAMESPACE_PROOF_BY_HASH_ROUTE, "hash" => hash, "namespace" => namespace); - path_fn!(namespace_proof_by_payload_hash, NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, "payload-hash" => payload_hash, "namespace" => namespace); - path_fn!(namespace_proof_range, NAMESPACE_PROOF_RANGE_ROUTE, "from" => from, "until" => until, "namespace" => namespace); - path_fn!(incorrect_encoding_proof, INCORRECT_ENCODING_PROOF_ROUTE, "block_number" => block_number, "namespace" => namespace); + path_fn!( + namespace_proof_by_height, + NAMESPACE_PROOF_BY_HEIGHT_ROUTE, + height, + namespace + ); + path_fn!( + namespace_proof_by_hash, + NAMESPACE_PROOF_BY_HASH_ROUTE, + hash, + namespace + ); + path_fn!( + namespace_proof_by_payload_hash, + NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, + payload_hash, + namespace + ); + path_fn!( + namespace_proof_range, + NAMESPACE_PROOF_RANGE_ROUTE, + from, + until, + namespace + ); + path_fn!( + incorrect_encoding_proof, + INCORRECT_ENCODING_PROOF_ROUTE, + block_number, + namespace + ); // Availability — state certificates - path_fn!(state_cert_v1, STATE_CERT_V1_ROUTE, "epoch" => epoch); - path_fn!(state_cert_v2, STATE_CERT_V2_ROUTE, "epoch" => epoch); + path_fn!(state_cert_v1, STATE_CERT_V1_ROUTE, epoch); + path_fn!(state_cert_v2, STATE_CERT_V2_ROUTE, epoch); // Availability — leaves - path_fn!(leaf_by_height, LEAF_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(leaf_by_hash, LEAF_BY_HASH_ROUTE, "hash" => hash); - path_fn!(leaf_range, LEAF_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!(leaf_by_height, LEAF_BY_HEIGHT_ROUTE, height); + path_fn!(leaf_by_hash, LEAF_BY_HASH_ROUTE, hash); + path_fn!(leaf_range, LEAF_RANGE_ROUTE, from, until); // Availability — headers - path_fn!(header_by_height, HEADER_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(header_by_hash, HEADER_BY_HASH_ROUTE, "hash" => hash); - path_fn!(header_by_payload_hash, HEADER_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); - path_fn!(header_range, HEADER_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!(header_by_height, HEADER_BY_HEIGHT_ROUTE, height); + path_fn!(header_by_hash, HEADER_BY_HASH_ROUTE, hash); + path_fn!( + header_by_payload_hash, + HEADER_BY_PAYLOAD_HASH_ROUTE, + payload_hash + ); + path_fn!(header_range, HEADER_RANGE_ROUTE, from, until); // Availability — blocks - path_fn!(block_by_height, BLOCK_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(block_by_hash, BLOCK_BY_HASH_ROUTE, "hash" => hash); - path_fn!(block_by_payload_hash, BLOCK_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); - path_fn!(block_range, BLOCK_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!(block_by_height, BLOCK_BY_HEIGHT_ROUTE, height); + path_fn!(block_by_hash, BLOCK_BY_HASH_ROUTE, hash); + path_fn!( + block_by_payload_hash, + BLOCK_BY_PAYLOAD_HASH_ROUTE, + payload_hash + ); + path_fn!(block_range, BLOCK_RANGE_ROUTE, from, until); // Availability — payloads - path_fn!(payload_by_height, PAYLOAD_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(payload_by_hash, PAYLOAD_BY_HASH_ROUTE, "hash" => hash); - path_fn!(payload_by_block_hash, PAYLOAD_BY_BLOCK_HASH_ROUTE, "block_hash" => block_hash); - path_fn!(payload_range, PAYLOAD_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!(payload_by_height, PAYLOAD_BY_HEIGHT_ROUTE, height); + path_fn!(payload_by_hash, PAYLOAD_BY_HASH_ROUTE, hash); + path_fn!( + payload_by_block_hash, + PAYLOAD_BY_BLOCK_HASH_ROUTE, + block_hash + ); + path_fn!(payload_range, PAYLOAD_RANGE_ROUTE, from, until); // Availability — VID common - path_fn!(vid_common_by_height, VID_COMMON_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(vid_common_by_hash, VID_COMMON_BY_HASH_ROUTE, "hash" => hash); - path_fn!(vid_common_by_payload_hash, VID_COMMON_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); - path_fn!(vid_common_range, VID_COMMON_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!(vid_common_by_height, VID_COMMON_BY_HEIGHT_ROUTE, height); + path_fn!(vid_common_by_hash, VID_COMMON_BY_HASH_ROUTE, hash); + path_fn!( + vid_common_by_payload_hash, + VID_COMMON_BY_PAYLOAD_HASH_ROUTE, + payload_hash + ); + path_fn!(vid_common_range, VID_COMMON_RANGE_ROUTE, from, until); // Availability — transactions - path_fn!(transaction_by_position_noproof, TRANSACTION_BY_POSITION_NOPROOF_ROUTE, "height" => height, "index" => index); - path_fn!(transaction_by_hash_noproof, TRANSACTION_BY_HASH_NOPROOF_ROUTE, "hash" => hash); - path_fn!(transaction_proof_by_position, TRANSACTION_PROOF_BY_POSITION_ROUTE, "height" => height, "index" => index); - path_fn!(transaction_proof_by_hash, TRANSACTION_PROOF_BY_HASH_ROUTE, "hash" => hash); - path_fn!(transaction_by_position, TRANSACTION_BY_POSITION_ROUTE, "height" => height, "index" => index); - path_fn!(transaction_by_hash, TRANSACTION_BY_HASH_ROUTE, "hash" => hash); + path_fn!( + transaction_by_position_noproof, + TRANSACTION_BY_POSITION_NOPROOF_ROUTE, + height, + index + ); + path_fn!( + transaction_by_hash_noproof, + TRANSACTION_BY_HASH_NOPROOF_ROUTE, + hash + ); + path_fn!( + transaction_proof_by_position, + TRANSACTION_PROOF_BY_POSITION_ROUTE, + height, + index + ); + path_fn!( + transaction_proof_by_hash, + TRANSACTION_PROOF_BY_HASH_ROUTE, + hash + ); + path_fn!( + transaction_by_position, + TRANSACTION_BY_POSITION_ROUTE, + height, + index + ); + path_fn!(transaction_by_hash, TRANSACTION_BY_HASH_ROUTE, hash); // Availability — block summaries - path_fn!(block_summary_by_height, BLOCK_SUMMARY_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(block_summary_range, BLOCK_SUMMARY_RANGE_ROUTE, "from" => from, "until" => until); + path_fn!( + block_summary_by_height, + BLOCK_SUMMARY_BY_HEIGHT_ROUTE, + height + ); + path_fn!(block_summary_range, BLOCK_SUMMARY_RANGE_ROUTE, from, until); // Availability — misc path_fn!(limits, LIMITS_ROUTE); - path_fn!(cert2_by_height, CERT2_BY_HEIGHT_ROUTE, "height" => height); + path_fn!(cert2_by_height, CERT2_BY_HEIGHT_ROUTE, height); // Availability — streams - path_fn!(stream_leaves, STREAM_LEAVES_ROUTE, "height" => height); - path_fn!(stream_headers, STREAM_HEADERS_ROUTE, "height" => height); - path_fn!(stream_blocks, STREAM_BLOCKS_ROUTE, "height" => height); - path_fn!(stream_payloads, STREAM_PAYLOADS_ROUTE, "height" => height); - path_fn!(stream_vid_common, STREAM_VID_COMMON_ROUTE, "height" => height); - path_fn!(stream_transactions, STREAM_TRANSACTIONS_ROUTE, "height" => height); - path_fn!(stream_transactions_ns, STREAM_TRANSACTIONS_NS_ROUTE, "height" => height, "namespace" => namespace); - path_fn!(stream_namespace_proofs, STREAM_NAMESPACE_PROOFS_ROUTE, "height" => height, "namespace" => namespace); + path_fn!(stream_leaves, STREAM_LEAVES_ROUTE, height); + path_fn!(stream_headers, STREAM_HEADERS_ROUTE, height); + path_fn!(stream_blocks, STREAM_BLOCKS_ROUTE, height); + path_fn!(stream_payloads, STREAM_PAYLOADS_ROUTE, height); + path_fn!(stream_vid_common, STREAM_VID_COMMON_ROUTE, height); + path_fn!(stream_transactions, STREAM_TRANSACTIONS_ROUTE, height); + path_fn!( + stream_transactions_ns, + STREAM_TRANSACTIONS_NS_ROUTE, + height, + namespace + ); + path_fn!( + stream_namespace_proofs, + STREAM_NAMESPACE_PROOFS_ROUTE, + height, + namespace + ); // Block state - path_fn!(block_state_path_by_height, BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); - path_fn!(block_state_path_by_commit, BLOCK_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!( + block_state_path_by_height, + BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, + height, + key + ); + path_fn!( + block_state_path_by_commit, + BLOCK_STATE_PATH_BY_COMMIT_ROUTE, + commit, + key + ); path_fn!(block_state_height, BLOCK_STATE_HEIGHT_ROUTE); // Fee state - path_fn!(fee_state_path_by_height, FEE_STATE_PATH_BY_HEIGHT_ROUTE, "height" => height, "key" => key); - path_fn!(fee_state_path_by_commit, FEE_STATE_PATH_BY_COMMIT_ROUTE, "commit" => commit, "key" => key); + path_fn!( + fee_state_path_by_height, + FEE_STATE_PATH_BY_HEIGHT_ROUTE, + height, + key + ); + path_fn!( + fee_state_path_by_commit, + FEE_STATE_PATH_BY_COMMIT_ROUTE, + commit, + key + ); path_fn!(fee_state_height, FEE_STATE_HEIGHT_ROUTE); - path_fn!(fee_state_balance_latest, FEE_STATE_BALANCE_LATEST_ROUTE, "address" => address); + path_fn!( + fee_state_balance_latest, + FEE_STATE_BALANCE_LATEST_ROUTE, + address + ); // Status path_fn!(status_block_height, STATUS_BLOCK_HEIGHT_ROUTE); @@ -452,123 +612,369 @@ pub mod v1 { // Node — block height / counts / sizes path_fn!(node_block_height, NODE_BLOCK_HEIGHT_ROUTE); path_fn!(node_transactions_count, NODE_TRANSACTIONS_COUNT_ROUTE); - path_fn!(node_transactions_count_to, NODE_TRANSACTIONS_COUNT_TO_ROUTE, "to" => to); - path_fn!(node_transactions_count_from_to, NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, "from" => from, "to" => to); - path_fn!(node_transactions_count_ns, NODE_TRANSACTIONS_COUNT_NS_ROUTE, "namespace" => namespace); - path_fn!(node_transactions_count_ns_to, NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, "namespace" => namespace, "to" => to); - path_fn!(node_transactions_count_ns_from_to, NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, "namespace" => namespace, "from" => from, "to" => to); + path_fn!( + node_transactions_count_to, + NODE_TRANSACTIONS_COUNT_TO_ROUTE, + to + ); + path_fn!( + node_transactions_count_from_to, + NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, + from, + to + ); + path_fn!( + node_transactions_count_ns, + NODE_TRANSACTIONS_COUNT_NS_ROUTE, + namespace + ); + path_fn!( + node_transactions_count_ns_to, + NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, + namespace, + to + ); + path_fn!( + node_transactions_count_ns_from_to, + NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, + namespace, + from, + to + ); path_fn!(node_payloads_size, NODE_PAYLOADS_SIZE_ROUTE); - path_fn!(node_payloads_size_to, NODE_PAYLOADS_SIZE_TO_ROUTE, "to" => to); - path_fn!(node_payloads_size_from_to, NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, "from" => from, "to" => to); + path_fn!(node_payloads_size_to, NODE_PAYLOADS_SIZE_TO_ROUTE, to); + path_fn!( + node_payloads_size_from_to, + NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, + from, + to + ); path_fn!(node_payloads_total_size, NODE_PAYLOADS_TOTAL_SIZE_ROUTE); - path_fn!(node_payloads_size_ns, NODE_PAYLOADS_SIZE_NS_ROUTE, "namespace" => namespace); - path_fn!(node_payloads_size_ns_to, NODE_PAYLOADS_SIZE_NS_TO_ROUTE, "namespace" => namespace, "to" => to); - path_fn!(node_payloads_size_ns_from_to, NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, "namespace" => namespace, "from" => from, "to" => to); + path_fn!( + node_payloads_size_ns, + NODE_PAYLOADS_SIZE_NS_ROUTE, + namespace + ); + path_fn!( + node_payloads_size_ns_to, + NODE_PAYLOADS_SIZE_NS_TO_ROUTE, + namespace, + to + ); + path_fn!( + node_payloads_size_ns_from_to, + NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, + namespace, + from, + to + ); // Node — VID shares - path_fn!(node_vid_share_by_height, NODE_VID_SHARE_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(node_vid_share_by_hash, NODE_VID_SHARE_BY_HASH_ROUTE, "hash" => hash); - path_fn!(node_vid_share_by_payload_hash, NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); + path_fn!( + node_vid_share_by_height, + NODE_VID_SHARE_BY_HEIGHT_ROUTE, + height + ); + path_fn!(node_vid_share_by_hash, NODE_VID_SHARE_BY_HASH_ROUTE, hash); + path_fn!( + node_vid_share_by_payload_hash, + NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, + payload_hash + ); // Node — sync, header windows path_fn!(node_sync_status, NODE_SYNC_STATUS_ROUTE); - path_fn!(node_header_window_time, NODE_HEADER_WINDOW_TIME_ROUTE, "start" => start, "end" => end); - path_fn!(node_header_window_height, NODE_HEADER_WINDOW_HEIGHT_ROUTE, "height" => height, "end" => end); - path_fn!(node_header_window_hash, NODE_HEADER_WINDOW_HASH_ROUTE, "hash" => hash, "end" => end); + path_fn!( + node_header_window_time, + NODE_HEADER_WINDOW_TIME_ROUTE, + start, + end + ); + path_fn!( + node_header_window_height, + NODE_HEADER_WINDOW_HEIGHT_ROUTE, + height, + end + ); + path_fn!( + node_header_window_hash, + NODE_HEADER_WINDOW_HASH_ROUTE, + hash, + end + ); path_fn!(node_limits, NODE_LIMITS_ROUTE); // Node — stake table / validators / participation path_fn!(node_stake_table_current, NODE_STAKE_TABLE_CURRENT_ROUTE); - path_fn!(node_stake_table, NODE_STAKE_TABLE_ROUTE, "epoch_number" => epoch_number); + path_fn!(node_stake_table, NODE_STAKE_TABLE_ROUTE, epoch_number); path_fn!( node_da_stake_table_current, NODE_DA_STAKE_TABLE_CURRENT_ROUTE ); - path_fn!(node_da_stake_table, NODE_DA_STAKE_TABLE_ROUTE, "epoch_number" => epoch_number); - path_fn!(node_validators, NODE_VALIDATORS_ROUTE, "epoch_number" => epoch_number); - path_fn!(node_all_validators, NODE_ALL_VALIDATORS_ROUTE, "epoch_number" => epoch_number, "offset" => offset, "limit" => limit); + path_fn!(node_da_stake_table, NODE_DA_STAKE_TABLE_ROUTE, epoch_number); + path_fn!(node_validators, NODE_VALIDATORS_ROUTE, epoch_number); + path_fn!( + node_all_validators, + NODE_ALL_VALIDATORS_ROUTE, + epoch_number, + offset, + limit + ); path_fn!( node_proposal_participation_current, NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE ); - path_fn!(node_proposal_participation, NODE_PROPOSAL_PARTICIPATION_ROUTE, "epoch" => epoch); + path_fn!( + node_proposal_participation, + NODE_PROPOSAL_PARTICIPATION_ROUTE, + epoch + ); path_fn!( node_vote_participation_current, NODE_VOTE_PARTICIPATION_CURRENT_ROUTE ); - path_fn!(node_vote_participation, NODE_VOTE_PARTICIPATION_ROUTE, "epoch" => epoch); + path_fn!( + node_vote_participation, + NODE_VOTE_PARTICIPATION_ROUTE, + epoch + ); // Node — block reward / oldest path_fn!(node_block_reward, NODE_BLOCK_REWARD_ROUTE); - path_fn!(node_block_reward_epoch, NODE_BLOCK_REWARD_EPOCH_ROUTE, "epoch_number" => epoch_number); + path_fn!( + node_block_reward_epoch, + NODE_BLOCK_REWARD_EPOCH_ROUTE, + epoch_number + ); path_fn!(node_oldest_block, NODE_OLDEST_BLOCK_ROUTE); path_fn!(node_oldest_leaf, NODE_OLDEST_LEAF_ROUTE); // Catchup - path_fn!(catchup_account, CATCHUP_ACCOUNT_ROUTE, "height" => height, "view" => view, "address" => address); - path_fn!(catchup_accounts, CATCHUP_ACCOUNTS_ROUTE, "height" => height, "view" => view); - path_fn!(catchup_blocks, CATCHUP_BLOCKS_ROUTE, "height" => height, "view" => view); - path_fn!(catchup_chainconfig, CATCHUP_CHAINCONFIG_ROUTE, "commitment" => commitment); - path_fn!(catchup_leafchain, CATCHUP_LEAFCHAIN_ROUTE, "height" => height); - path_fn!(catchup_cert2, CATCHUP_CERT2_ROUTE, "height" => height); - path_fn!(catchup_reward_account, CATCHUP_REWARD_ACCOUNT_ROUTE, "height" => height, "view" => view, "address" => address); - path_fn!(catchup_reward_accounts, CATCHUP_REWARD_ACCOUNTS_ROUTE, "height" => height, "view" => view); - path_fn!(catchup_reward_account_v2, CATCHUP_REWARD_ACCOUNT_V2_ROUTE, "height" => height, "view" => view, "address" => address); - path_fn!(catchup_reward_accounts_v2, CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, "height" => height, "view" => view); - path_fn!(catchup_reward_amounts, CATCHUP_REWARD_AMOUNTS_ROUTE, "height" => height, "limit" => limit, "offset" => offset); - path_fn!(catchup_reward_merkle_tree_v2, CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, "height" => height, "view" => view); - path_fn!(catchup_state_cert, CATCHUP_STATE_CERT_ROUTE, "epoch" => epoch); + path_fn!( + catchup_account, + CATCHUP_ACCOUNT_ROUTE, + height, + view, + address + ); + path_fn!(catchup_accounts, CATCHUP_ACCOUNTS_ROUTE, height, view); + path_fn!(catchup_blocks, CATCHUP_BLOCKS_ROUTE, height, view); + path_fn!(catchup_chainconfig, CATCHUP_CHAINCONFIG_ROUTE, commitment); + path_fn!(catchup_leafchain, CATCHUP_LEAFCHAIN_ROUTE, height); + path_fn!(catchup_cert2, CATCHUP_CERT2_ROUTE, height); + path_fn!( + catchup_reward_account, + CATCHUP_REWARD_ACCOUNT_ROUTE, + height, + view, + address + ); + path_fn!( + catchup_reward_accounts, + CATCHUP_REWARD_ACCOUNTS_ROUTE, + height, + view + ); + path_fn!( + catchup_reward_account_v2, + CATCHUP_REWARD_ACCOUNT_V2_ROUTE, + height, + view, + address + ); + path_fn!( + catchup_reward_accounts_v2, + CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, + height, + view + ); + path_fn!( + catchup_reward_amounts, + CATCHUP_REWARD_AMOUNTS_ROUTE, + height, + limit, + offset + ); + path_fn!( + catchup_reward_merkle_tree_v2, + CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, + height, + view + ); + path_fn!(catchup_state_cert, CATCHUP_STATE_CERT_ROUTE, epoch); // Submit path_fn!(submit, SUBMIT_ROUTE); // State signature - path_fn!(state_signature_block, STATE_SIGNATURE_BLOCK_ROUTE, "height" => height); + path_fn!(state_signature_block, STATE_SIGNATURE_BLOCK_ROUTE, height); // HotShot events path_fn!(hotshot_events_stream, HOTSHOT_EVENTS_STREAM_ROUTE); path_fn!(hotshot_events_startup, HOTSHOT_EVENTS_STARTUP_ROUTE); // Light client - path_fn!(lc_leaf_by_height, LC_LEAF_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(lc_leaf_by_height_finalized, LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, "height" => height, "finalized" => finalized); - path_fn!(lc_leaf_by_hash, LC_LEAF_BY_HASH_ROUTE, "hash" => hash); - path_fn!(lc_leaf_by_hash_finalized, LC_LEAF_BY_HASH_FINALIZED_ROUTE, "hash" => hash, "finalized" => finalized); - path_fn!(lc_leaf_by_block_hash, LC_LEAF_BY_BLOCK_HASH_ROUTE, "block_hash" => block_hash); - path_fn!(lc_leaf_by_block_hash_finalized, LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, "block_hash" => block_hash, "finalized" => finalized); - path_fn!(lc_leaf_by_payload_hash, LC_LEAF_BY_PAYLOAD_HASH_ROUTE, "payload_hash" => payload_hash); - path_fn!(lc_leaf_by_payload_hash_finalized, LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, "payload_hash" => payload_hash, "finalized" => finalized); - path_fn!(lc_header_by_height, LC_HEADER_BY_HEIGHT_ROUTE, "root" => root, "height" => height); - path_fn!(lc_header_by_hash, LC_HEADER_BY_HASH_ROUTE, "root" => root, "hash" => hash); - path_fn!(lc_header_by_payload_hash, LC_HEADER_BY_PAYLOAD_HASH_ROUTE, "root" => root, "payload_hash" => payload_hash); - path_fn!(lc_stake_table, LC_STAKE_TABLE_ROUTE, "epoch" => epoch); - path_fn!(lc_payload, LC_PAYLOAD_ROUTE, "height" => height); - path_fn!(lc_payload_range, LC_PAYLOAD_RANGE_ROUTE, "start" => start, "end" => end); - path_fn!(lc_namespace, LC_NAMESPACE_ROUTE, "height" => height, "namespace" => namespace); - path_fn!(lc_namespace_range, LC_NAMESPACE_RANGE_ROUTE, "start" => start, "end" => end, "namespace" => namespace); - path_fn!(lc_namespaces_range, LC_NAMESPACES_RANGE_ROUTE, "start" => start, "end" => end, "namespaces" => namespaces); + path_fn!(lc_leaf_by_height, LC_LEAF_BY_HEIGHT_ROUTE, height); + path_fn!( + lc_leaf_by_height_finalized, + LC_LEAF_BY_HEIGHT_FINALIZED_ROUTE, + height, + finalized + ); + path_fn!(lc_leaf_by_hash, LC_LEAF_BY_HASH_ROUTE, hash); + path_fn!( + lc_leaf_by_hash_finalized, + LC_LEAF_BY_HASH_FINALIZED_ROUTE, + hash, + finalized + ); + path_fn!( + lc_leaf_by_block_hash, + LC_LEAF_BY_BLOCK_HASH_ROUTE, + block_hash + ); + path_fn!( + lc_leaf_by_block_hash_finalized, + LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, + block_hash, + finalized + ); + path_fn!( + lc_leaf_by_payload_hash, + LC_LEAF_BY_PAYLOAD_HASH_ROUTE, + payload_hash + ); + path_fn!( + lc_leaf_by_payload_hash_finalized, + LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, + payload_hash, + finalized + ); + path_fn!(lc_header_by_height, LC_HEADER_BY_HEIGHT_ROUTE, root, height); + path_fn!(lc_header_by_hash, LC_HEADER_BY_HASH_ROUTE, root, hash); + path_fn!( + lc_header_by_payload_hash, + LC_HEADER_BY_PAYLOAD_HASH_ROUTE, + root, + payload_hash + ); + path_fn!(lc_stake_table, LC_STAKE_TABLE_ROUTE, epoch); + path_fn!(lc_payload, LC_PAYLOAD_ROUTE, height); + path_fn!(lc_payload_range, LC_PAYLOAD_RANGE_ROUTE, start, end); + path_fn!(lc_namespace, LC_NAMESPACE_ROUTE, height, namespace); + path_fn!( + lc_namespace_range, + LC_NAMESPACE_RANGE_ROUTE, + start, + end, + namespace + ); + path_fn!( + lc_namespaces_range, + LC_NAMESPACES_RANGE_ROUTE, + start, + end, + namespaces + ); // Explorer — blocks - path_fn!(explorer_block_detail_by_height, EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, "height" => height); - path_fn!(explorer_block_detail_by_hash, EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, "hash" => hash); - path_fn!(explorer_block_summaries_latest, EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, "limit" => limit); - path_fn!(explorer_block_summaries_from, EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, "from" => from, "limit" => limit); + path_fn!( + explorer_block_detail_by_height, + EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, + height + ); + path_fn!( + explorer_block_detail_by_hash, + EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, + hash + ); + path_fn!( + explorer_block_summaries_latest, + EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, + limit + ); + path_fn!( + explorer_block_summaries_from, + EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, + from, + limit + ); // Explorer — transactions - path_fn!(explorer_tx_detail_by_position, EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, "height" => height, "offset" => offset); - path_fn!(explorer_tx_detail_by_hash, EXPLORER_TX_DETAIL_BY_HASH_ROUTE, "hash" => hash); - path_fn!(explorer_tx_summaries_latest, EXPLORER_TX_SUMMARIES_LATEST_ROUTE, "limit" => limit); - path_fn!(explorer_tx_summaries_from, EXPLORER_TX_SUMMARIES_FROM_ROUTE, "height" => height, "offset" => offset, "limit" => limit); - path_fn!(explorer_tx_summaries_by_hash, EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, "hash" => hash, "limit" => limit); - path_fn!(explorer_tx_summaries_latest_block, EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, "limit" => limit, "block" => block); - path_fn!(explorer_tx_summaries_from_block, EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, "height" => height, "offset" => offset, "limit" => limit, "block" => block); - path_fn!(explorer_tx_summaries_by_hash_block, EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, "hash" => hash, "limit" => limit, "block" => block); - path_fn!(explorer_tx_summaries_latest_ns, EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, "limit" => limit, "namespace" => namespace); - path_fn!(explorer_tx_summaries_from_ns, EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, "height" => height, "offset" => offset, "limit" => limit, "namespace" => namespace); - path_fn!(explorer_tx_summaries_by_hash_ns, EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, "hash" => hash, "limit" => limit, "namespace" => namespace); + path_fn!( + explorer_tx_detail_by_position, + EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, + height, + offset + ); + path_fn!( + explorer_tx_detail_by_hash, + EXPLORER_TX_DETAIL_BY_HASH_ROUTE, + hash + ); + path_fn!( + explorer_tx_summaries_latest, + EXPLORER_TX_SUMMARIES_LATEST_ROUTE, + limit + ); + path_fn!( + explorer_tx_summaries_from, + EXPLORER_TX_SUMMARIES_FROM_ROUTE, + height, + offset, + limit + ); + path_fn!( + explorer_tx_summaries_by_hash, + EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, + hash, + limit + ); + path_fn!( + explorer_tx_summaries_latest_block, + EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, + limit, + block + ); + path_fn!( + explorer_tx_summaries_from_block, + EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, + height, + offset, + limit, + block + ); + path_fn!( + explorer_tx_summaries_by_hash_block, + EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, + hash, + limit, + block + ); + path_fn!( + explorer_tx_summaries_latest_ns, + EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, + limit, + namespace + ); + path_fn!( + explorer_tx_summaries_from_ns, + EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, + height, + offset, + limit, + namespace + ); + path_fn!( + explorer_tx_summaries_by_hash_ns, + EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, + hash, + limit, + namespace + ); path_fn!(explorer_summary, EXPLORER_SUMMARY_ROUTE); - path_fn!(explorer_search, EXPLORER_SEARCH_ROUTE, "query" => query); + path_fn!(explorer_search, EXPLORER_SEARCH_ROUTE, query); // Token path_fn!(token_total_minted_supply, TOKEN_TOTAL_MINTED_SUPPLY_ROUTE); From f73e05db0c9e682dc5ed2c347643ded2cfb17dde Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 18:17:12 +0800 Subject: [PATCH 12/14] fix(api): count streaming sockets in max_connections, bind port first Review findings. tower's concurrency permit is released at the 101 upgrade, so websockets escaped ESPRESSO_NODE_API_MAX_CONNECTIONS entirely; a shared semaphore now gives plain requests a slot while in flight and streaming sockets one for their lifetime, with a real-TCP regression test. The API port also binds before router composition: OpenAPI generation takes ~0.5s in debug builds, and clients connecting during it now queue in the accept backlog instead of being refused. This is what broke the test-postgres shards: first-request test helpers panic on a refused connection. --- crates/espresso/api/src/axum.rs | 237 ++++++++++++++++++++++++++------ crates/espresso/api/src/lib.rs | 69 ++++++---- 2 files changed, 238 insertions(+), 68 deletions(-) diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index 6178bf32a4a..b14bfc3e09b 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -2,7 +2,7 @@ pub mod routes; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc}; use aide::{ axum::{ @@ -33,6 +33,7 @@ use serialization_api::v2::{ GetRewardBalanceRequest, GetRewardBalancesRequest, GetRewardClaimInputRequest, GetRewardMerkleTreeRequest, GetStakeTableRequest, GetStateCertificateRequest, }; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use vbs::{BinarySerializer, Serializer, version::StaticVersion}; use crate::{ @@ -177,6 +178,42 @@ async fn serve_openapi_spec(Extension(api): Extension) -> Json Json(api) } +/// Lifetime slots for streaming sockets; tower's permit is released at the 101 upgrade. +#[derive(Clone)] +pub(crate) struct StreamLimit(pub(crate) Arc); + +/// Websocket upgrades skip the request slot (the socket takes a lifetime slot in its handler); +/// everything else holds a slot for the request duration. +pub(crate) async fn limit_plain_requests( + Extension(StreamLimit(semaphore)): Extension, + req: Request, + next: axum::middleware::Next, +) -> Response { + let is_upgrade = req + .headers() + .get(header::UPGRADE) + .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket")); + if is_upgrade { + return next.run(req).await; + } + match semaphore.try_acquire_owned() { + Ok(_permit) => next.run(req).await, + Err(_) => StatusCode::TOO_MANY_REQUESTS.into_response(), + } +} + +fn acquire_stream_permit( + limit: Option>, +) -> Result, StatusCode> { + match limit { + None => Ok(None), + Some(Extension(StreamLimit(semaphore))) => match semaphore.try_acquire_owned() { + Ok(permit) => Ok(Some(permit)), + Err(_) => Err(StatusCode::TOO_MANY_REQUESTS), + }, + } +} + /// The v2 router's `Extension` layer only covers routes registered on the v2 /// `ApiRouter`; this newtype lets v1 layer its own `OpenApi` extension without the two `Extension` /// lookups being ambiguous if the routers are ever merged and inspected by type. @@ -193,9 +230,7 @@ fn swagger_html(spec_route: &str) -> Html { Html(include_str!("../templates/swagger.html").replace("{{OPENAPI_SPEC_ROUTE}}", spec_route)) } -/// Redirect handler for root path. v2 is a work in progress, so `/` points at the stable v1 docs. -/// Temporary (307), not permanent: browsers cache permanent redirects, and this target moves back -/// to `/v2` once that API is complete. +/// v2 is WIP, so `/` points at the v1 docs; 307 so browsers don't cache the redirect. async fn redirect_to_docs() -> axum::response::Redirect { axum::response::Redirect::temporary("/v1") } @@ -998,9 +1033,15 @@ where let stream_leaves = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path| async move { + Path(height): Path, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_leaves(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_leaves: {e}"), @@ -1011,9 +1052,15 @@ where let stream_headers = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path| async move { + Path(height): Path, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_headers(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_headers: {e}"), @@ -1024,9 +1071,15 @@ where let stream_blocks = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path| async move { + Path(height): Path, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_blocks(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_blocks: {e}"), @@ -1037,9 +1090,15 @@ where let stream_payloads = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path| async move { + Path(height): Path, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_payloads(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_payloads: {e}"), @@ -1050,9 +1109,15 @@ where let stream_vid_common = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path| async move { + Path(height): Path, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_vid_common(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_vid_common: {e}"), @@ -1060,26 +1125,39 @@ where }) }; - let stream_transactions = |ws: WebSocketUpgrade, - State(state): State, - headers: HeaderMap, - Path(height): Path| async move { - let format = ws_format(&headers); - ws.on_upgrade(move |socket| async move { - match state.stream_transactions(height, None).await { - Ok(stream) => drive_ws_stream(socket, stream, format).await, - Err(e) => tracing::warn!("stream_transactions: {e}"), - } - }) - }; + let stream_transactions = + |ws: WebSocketUpgrade, + State(state): State, + headers: HeaderMap, + Path(height): Path, + limit: Option>| async move { + let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; + ws.on_upgrade(move |socket| async move { + let _permit = permit; + match state.stream_transactions(height, None).await { + Ok(stream) => drive_ws_stream(socket, stream, format).await, + Err(e) => tracing::warn!("stream_transactions: {e}"), + } + }) + }; let stream_transactions_ns = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path((height, namespace)): Path<(usize, u32)>| async move { + Path((height, namespace)): Path<(usize, u32)>, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_transactions(height, Some(namespace)).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_transactions_ns: {e}"), @@ -1091,9 +1169,15 @@ where |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path((height, namespace)): Path<(usize, u32)>| async move { + Path((height, namespace)): Path<(usize, u32)>, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; ws.on_upgrade(move |socket| async move { + let _permit = permit; match state.stream_namespace_proofs(height, namespace).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_namespace_proofs: {e}"), @@ -2653,10 +2737,20 @@ where }; let hotshot_events_stream = - |State(state): State, headers: HeaderMap, ws: WebSocketUpgrade| async move { + |State(state): State, + headers: HeaderMap, + ws: WebSocketUpgrade, + limit: Option>| async move { let format = ws_format(&headers); + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; match ::events(&state).await { - Ok(stream) => ws.on_upgrade(move |socket| drive_ws_stream(socket, stream, format)), + Ok(stream) => ws.on_upgrade(move |socket| async move { + let _permit = permit; + drive_ws_stream(socket, stream, format).await + }), Err(err) => ApiError::Internal(err).into_response(), } }; @@ -3515,11 +3609,8 @@ where finish_v1_docs(router) } -/// Finish a composed v1 [`ApiRouter`]: generate the OpenAPI spec from whatever routes the caller -/// actually mounted, and attach the docs routes (spec JSON, Swagger UI, Scalar). Every serve -/// mode must go through this — routes registered with `api_route` carry their documentation, but -/// the spec and the `/v1` docs pages only exist once the router is finished here. The spec -/// therefore reflects exactly the modules the running mode serves. +/// Build the OpenAPI spec for the mounted routes and attach the docs routes; every serve mode +/// must route through this. pub fn finish_v1_docs(router: ApiRouter) -> Router { let mut api = OpenApi { info: Info { @@ -3573,9 +3664,7 @@ pub fn finish_v1_docs(router: ApiRouter) -> Router { /// /// Parameter types come from [`path_parameter_schema`]; the handlers parse the raw segment /// either way, so a wrong entry there affects only documentation, not behavior. -/// Group operations by API module: the first path segment after `/v1/` (`availability`, -/// `status`, `catchup`, ...) becomes the operation's tag, and the tag list is registered sorted -/// so Swagger renders one collapsible section per module in a stable order. +/// Tag each operation with its module (first path segment after `/v1/`) so Swagger groups them. fn tag_operations_by_module(api: &mut OpenApi) { let Some(ref mut paths) = api.paths else { return; @@ -3614,13 +3703,7 @@ fn tag_operations_by_module(api: &mut OpenApi) { .collect(); } -/// OpenAPI schema for a v1 path template parameter, by segment name. -/// -/// The names form a closed set and each type was read off the handler's `Path` extractor: -/// every name listed as integer binds an unsigned integer in all its handlers (including -/// `finalized`, a `u64` flag-like argument, and `namespace`, a `u32` id). `namespaces` is a -/// comma-separated list bound as `String`. Unknown names (future routes) default to string, -/// which any handler can parse from the raw segment. +/// Types read off the handlers' `Path` extractors; unknown names are strings. fn path_parameter_schema(name: &str) -> schemars::Schema { match name { "height" | "block_number" | "from" | "until" | "to" | "start" | "end" | "epoch" @@ -3646,9 +3729,6 @@ fn declare_path_template_parameters(api: &mut OpenApi) { if names.is_empty() { continue; } - // v1 only registers GET and POST routes; the other methods are covered so a future PUT, - // DELETE, or PATCH route keeps its parameters. head/options/trace are not used by axum - // routers here. for operation in [ &mut path_item.get, &mut path_item.post, @@ -4785,6 +4865,77 @@ mod tests { ); } + #[tokio::test] + async fn max_connections_bounds_streaming_sockets() { + let ws_route = |ws: WebSocketUpgrade, limit: Option>| async move { + let permit = match acquire_stream_permit(limit) { + Ok(permit) => permit, + Err(status) => return status.into_response(), + }; + ws.on_upgrade(move |socket| async move { + let _permit = permit; + let stream: BoxStream<'static, u64> = + Box::pin(futures::stream::unfold((), |()| async { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + Some((0u64, ())) + })); + drive_ws_stream(socket, stream, WsFormat::Json).await + }) + }; + let router = Router::new().route("/ws", get(ws_route)); + let router = crate::apply_connection_limit(router, 2); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + async fn upgrade(addr: std::net::SocketAddr) -> (tokio::net::TcpStream, String) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap(); + sock.write_all( + b"GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\n\ + Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await + .unwrap(); + let mut buf = [0u8; 64]; + let n = sock.read(&mut buf).await.unwrap(); + let status = String::from_utf8_lossy(&buf[..n]) + .lines() + .next() + .unwrap_or_default() + .to_string(); + (sock, status) + } + + let (_s1, status) = upgrade(addr).await; + assert!(status.contains("101"), "first socket: {status}"); + let (_s2, status) = upgrade(addr).await; + assert!(status.contains("101"), "second socket: {status}"); + let (_s3, status) = upgrade(addr).await; + assert!( + status.contains("429"), + "third socket must be limited: {status}" + ); + + // Closing a socket frees its slot once the server notices on the next send. + drop(_s1); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let (_s4, status) = upgrade(addr).await; + if status.contains("101") { + break; + } + assert!( + std::time::Instant::now() < deadline, + "slot was not released after socket close: {status}" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + /// Regression test: the docs routes must exist in the app a serve mode actually builds, not /// only in `create_router_v1` (which the serve modes don't call). Assembles a router the way /// `serve_axum_status` does, wrapped in the same top-level routes and legacy-URI rewrite diff --git a/crates/espresso/api/src/lib.rs b/crates/espresso/api/src/lib.rs index 8ea2f092143..95b1ba46ee4 100644 --- a/crates/espresso/api/src/lib.rs +++ b/crates/espresso/api/src/lib.rs @@ -73,6 +73,7 @@ where + Sync + 'static, { + let listener = bind_api(port).await?; let mut router = axum::router_reward(state.clone()) .merge(axum::router_availability(state.clone())) .merge(axum::router_block_state(state.clone())) @@ -99,7 +100,7 @@ where router = router.merge(axum::router_hotshot_events(state.clone())); } let router = axum::finish_v1_docs(router).merge(axum::create_router_v2(state)); - serve_router(port, "v1 and v2", router, max_connections).await + serve_router(listener, "v1 and v2", router, max_connections).await } /// Which of the optional API modules to serve, for modes that make them conditional @@ -142,6 +143,7 @@ where + Sync + 'static, { + let listener = bind_api(port).await?; let mut router = axum::router_status(state.clone()) .merge(axum::router_availability(state.clone())) .merge(axum::router_node(state.clone())) @@ -157,7 +159,13 @@ where if modules.hotshot_events { router = router.merge(axum::router_hotshot_events(state)); } - serve_router(port, "fs", axum::finish_v1_docs(router), max_connections).await + serve_router( + listener, + "fs", + axum::finish_v1_docs(router), + max_connections, + ) + .await } /// Serve the status-only API: no availability/node/token data source is available, so only @@ -181,11 +189,12 @@ where + Sync + 'static, { + let listener = bind_api(port).await?; let router = axum::router_status(state.clone()).merge(axum::router_state_signature(state.clone())); let router = merge_hotshot_modules(router, &state, modules); serve_router( - port, + listener, "status", axum::finish_v1_docs(router), max_connections, @@ -213,9 +222,16 @@ where + Sync + 'static, { + let listener = bind_api(port).await?; let router = axum::router_state_signature(state.clone()); let router = merge_hotshot_modules(router, &state, modules); - serve_router(port, "bare", axum::finish_v1_docs(router), max_connections).await + serve_router( + listener, + "bare", + axum::finish_v1_docs(router), + max_connections, + ) + .await } fn merge_hotshot_modules( @@ -250,46 +266,49 @@ where /// Add the reserved top-level routes, apply the optional concurrency limit, rewrite legacy URIs, /// and bind/serve the router. Shared by all `serve_axum*` entry points. -/// -/// `max_connections` matches tide-disco's `RateLimitListener` semantics: at most that many -/// requests are in flight at once, and excess requests fail immediately with 429 Too Many -/// Requests instead of queueing. The load-shed layer converts the concurrency limit's "not -/// ready" into an error, which `HandleErrorLayer` maps to the 429 response. +/// Bind before composing routers: OpenAPI generation takes ~0.5s in debug builds, and clients +/// connecting during it should queue in the accept backlog rather than get refused. +async fn bind_api(port: u16) -> anyhow::Result { + let addr = format!("0.0.0.0:{}", port); + tracing::info!("Binding to {}", addr); + Ok(tokio::net::TcpListener::bind(&addr).await?) +} + async fn serve_router( - port: u16, + listener: tokio::net::TcpListener, mode: &str, router: ::axum::Router, max_connections: Option, ) -> anyhow::Result<()> { - tracing::info!("Starting Axum server on port {} ({} mode)", port, mode); - let mut router = axum::with_top_level_routes(router); if let Some(limit) = max_connections { - router = router.layer( - tower::ServiceBuilder::new() - .layer(::axum::error_handling::HandleErrorLayer::new( - |_: tower::BoxError| async { ::axum::http::StatusCode::TOO_MANY_REQUESTS }, - )) - .layer(tower::load_shed::LoadShedLayer::new()) - .layer(tower::limit::GlobalConcurrencyLimitLayer::new(limit)), - ); + router = apply_connection_limit(router, limit); } // `Router::layer` middleware runs after routing, so it can't rewrite a URI to match a // different route. Wrapping the whole router with `MapRequestLayer` instead runs the // rewrite before routing, per the axum-documented pattern for this case. let router = tower::util::MapRequestLayer::new(axum::rewrite_legacy_uri).layer(router); - let addr = format!("0.0.0.0:{}", port); - tracing::info!("Binding to {}", addr); - let listener = tokio::net::TcpListener::bind(&addr).await?; - - tracing::info!("Axum API server listening on {} ({} mode)", addr, mode); + tracing::info!( + "Axum API server listening on {:?} ({} mode)", + listener.local_addr()?, + mode + ); ::axum::serve(listener, ::axum::ServiceExt::into_make_service(router)).await?; tracing::info!("Axum server stopped"); Ok(()) } +/// Shared budget: plain requests hold a slot while in flight, streaming sockets for their +/// lifetime; excess gets 429. +fn apply_connection_limit(router: ::axum::Router, limit: usize) -> ::axum::Router { + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(limit)); + router + .layer(::axum::middleware::from_fn(axum::limit_plain_requests)) + .layer(::axum::Extension(axum::StreamLimit(semaphore))) +} + /// Start Tonic gRPC server pub async fn serve_tonic(port: u16, state: S) -> anyhow::Result<()> where From 01bc25dd7cb957f554d63c356f672fd712100cea Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Fri, 17 Jul 2026 21:48:42 +0800 Subject: [PATCH 13/14] fix(api): end ws stream tasks when the client disconnects drive_ws_stream only noticed a dead client on the next send, so a socket whose stream was quiet held its max_connections slot and its stream task forever; reconnect churn exhausted the demo's budget of 25 and every plain request got 429, killing the native-demo e2e tests (claim-rewards-loop exits on non-404 and tears the demo down). Poll the client side with select so disconnects release the slot and end the task immediately. Regression test now uses a never-yielding stream. --- crates/espresso/api/src/axum.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index b14bfc3e09b..6629f72db2b 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -338,7 +338,17 @@ async fn drive_ws_stream( ) { use axum::extract::ws::Message; futures::pin_mut!(stream); - while let Some(item) = stream.next().await { + loop { + // Also poll the client side: a disconnect must end this task even while the stream is + // quiet, or the socket's connection slot and the stream task leak until the next send. + let item = tokio::select! { + item = stream.next() => item, + msg = socket.recv() => match msg { + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => return, + Some(Ok(_)) => continue, + }, + }; + let Some(item) = item else { break }; let msg = match format { WsFormat::Binary => match Serializer::>::serialize(&item) { Ok(bytes) => Message::Binary(bytes.into()), @@ -4874,11 +4884,9 @@ mod tests { }; ws.on_upgrade(move |socket| async move { let _permit = permit; - let stream: BoxStream<'static, u64> = - Box::pin(futures::stream::unfold((), |()| async { - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - Some((0u64, ())) - })); + // Never yields: slot release on disconnect must come from polling the client + // side, not from a failed send. + let stream: BoxStream<'static, u64> = Box::pin(futures::stream::pending()); drive_ws_stream(socket, stream, WsFormat::Json).await }) }; From 2632cc6505ff8c43d1e7e8de69502ddf43738114 Mon Sep 17 00:00:00 2001 From: Mathis Antony Date: Sat, 18 Jul 2026 10:17:08 +0800 Subject: [PATCH 14/14] fix(api): stop counting websocket lifetimes in max_connections The demo's nasty-client holds up to 100 open streams per resource type by design, against a request budget of 25; counting sockets for their lifetime starved every plain request on node-1 with 429 and killed the native-demo e2e tests. max_connections goes back to bounding in-flight requests only (as in every green run); a socket's slot is released at the 101 upgrade. Stream tasks still end promptly on client disconnect, which was the actual leak. --- crates/espresso/api/src/axum.rs | 209 +++++++++----------------------- crates/espresso/api/src/lib.rs | 4 +- 2 files changed, 57 insertions(+), 156 deletions(-) diff --git a/crates/espresso/api/src/axum.rs b/crates/espresso/api/src/axum.rs index 6629f72db2b..08f3580ebea 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -33,7 +33,7 @@ use serialization_api::v2::{ GetRewardBalanceRequest, GetRewardBalancesRequest, GetRewardClaimInputRequest, GetRewardMerkleTreeRequest, GetStakeTableRequest, GetStateCertificateRequest, }; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::sync::Semaphore; use vbs::{BinarySerializer, Serializer, version::StaticVersion}; use crate::{ @@ -178,42 +178,24 @@ async fn serve_openapi_spec(Extension(api): Extension) -> Json Json(api) } -/// Lifetime slots for streaming sockets; tower's permit is released at the 101 upgrade. +/// In-flight request slots for `max_connections`. #[derive(Clone)] -pub(crate) struct StreamLimit(pub(crate) Arc); +pub(crate) struct RequestLimit(pub(crate) Arc); -/// Websocket upgrades skip the request slot (the socket takes a lifetime slot in its handler); -/// everything else holds a slot for the request duration. -pub(crate) async fn limit_plain_requests( - Extension(StreamLimit(semaphore)): Extension, +/// Each request holds a slot while in flight; excess gets 429. A websocket's slot is released +/// at the 101 upgrade: long-lived streams are deliberately unbounded here, since demo workloads +/// (nasty-client holds hundreds of streams by design) dwarf the request budget of 25. +pub(crate) async fn limit_requests( + Extension(RequestLimit(semaphore)): Extension, req: Request, next: axum::middleware::Next, ) -> Response { - let is_upgrade = req - .headers() - .get(header::UPGRADE) - .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket")); - if is_upgrade { - return next.run(req).await; - } match semaphore.try_acquire_owned() { Ok(_permit) => next.run(req).await, Err(_) => StatusCode::TOO_MANY_REQUESTS.into_response(), } } -fn acquire_stream_permit( - limit: Option>, -) -> Result, StatusCode> { - match limit { - None => Ok(None), - Some(Extension(StreamLimit(semaphore))) => match semaphore.try_acquire_owned() { - Ok(permit) => Ok(Some(permit)), - Err(_) => Err(StatusCode::TOO_MANY_REQUESTS), - }, - } -} - /// The v2 router's `Extension` layer only covers routes registered on the v2 /// `ApiRouter`; this newtype lets v1 layer its own `OpenApi` extension without the two `Extension` /// lookups being ambiguous if the routers are ever merged and inspected by type. @@ -1043,15 +1025,9 @@ where let stream_leaves = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { + Path(height): Path| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_leaves(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_leaves: {e}"), @@ -1062,15 +1038,9 @@ where let stream_headers = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { + Path(height): Path| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_headers(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_headers: {e}"), @@ -1081,15 +1051,9 @@ where let stream_blocks = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { + Path(height): Path| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_blocks(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_blocks: {e}"), @@ -1100,15 +1064,9 @@ where let stream_payloads = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { + Path(height): Path| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_payloads(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_payloads: {e}"), @@ -1119,15 +1077,9 @@ where let stream_vid_common = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { + Path(height): Path| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_vid_common(height).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_vid_common: {e}"), @@ -1135,39 +1087,26 @@ where }) }; - let stream_transactions = - |ws: WebSocketUpgrade, - State(state): State, - headers: HeaderMap, - Path(height): Path, - limit: Option>| async move { - let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; - ws.on_upgrade(move |socket| async move { - let _permit = permit; - match state.stream_transactions(height, None).await { - Ok(stream) => drive_ws_stream(socket, stream, format).await, - Err(e) => tracing::warn!("stream_transactions: {e}"), - } - }) - }; + let stream_transactions = |ws: WebSocketUpgrade, + State(state): State, + headers: HeaderMap, + Path(height): Path| async move { + let format = ws_format(&headers); + ws.on_upgrade(move |socket| async move { + match state.stream_transactions(height, None).await { + Ok(stream) => drive_ws_stream(socket, stream, format).await, + Err(e) => tracing::warn!("stream_transactions: {e}"), + } + }) + }; let stream_transactions_ns = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path((height, namespace)): Path<(usize, u32)>, - limit: Option>| async move { + Path((height, namespace)): Path<(usize, u32)>| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_transactions(height, Some(namespace)).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_transactions_ns: {e}"), @@ -1179,15 +1118,9 @@ where |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, - Path((height, namespace)): Path<(usize, u32)>, - limit: Option>| async move { + Path((height, namespace)): Path<(usize, u32)>| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; ws.on_upgrade(move |socket| async move { - let _permit = permit; match state.stream_namespace_proofs(height, namespace).await { Ok(stream) => drive_ws_stream(socket, stream, format).await, Err(e) => tracing::warn!("stream_namespace_proofs: {e}"), @@ -2747,18 +2680,10 @@ where }; let hotshot_events_stream = - |State(state): State, - headers: HeaderMap, - ws: WebSocketUpgrade, - limit: Option>| async move { + |State(state): State, headers: HeaderMap, ws: WebSocketUpgrade| async move { let format = ws_format(&headers); - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; match ::events(&state).await { Ok(stream) => ws.on_upgrade(move |socket| async move { - let _permit = permit; drive_ws_stream(socket, stream, format).await }), Err(err) => ApiError::Internal(err).into_response(), @@ -4876,21 +4801,14 @@ mod tests { } #[tokio::test] - async fn max_connections_bounds_streaming_sockets() { - let ws_route = |ws: WebSocketUpgrade, limit: Option>| async move { - let permit = match acquire_stream_permit(limit) { - Ok(permit) => permit, - Err(status) => return status.into_response(), - }; - ws.on_upgrade(move |socket| async move { - let _permit = permit; - // Never yields: slot release on disconnect must come from polling the client - // side, not from a failed send. - let stream: BoxStream<'static, u64> = Box::pin(futures::stream::pending()); - drive_ws_stream(socket, stream, WsFormat::Json).await - }) - }; - let router = Router::new().route("/ws", get(ws_route)); + async fn max_connections_limits_in_flight_requests() { + let router = Router::new().route( + "/slow", + get(|| async { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + "ok" + }), + ); let router = crate::apply_connection_limit(router, 2); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -4898,50 +4816,33 @@ mod tests { axum::serve(listener, router).await.unwrap(); }); - async fn upgrade(addr: std::net::SocketAddr) -> (tokio::net::TcpStream, String) { + async fn get_slow(addr: std::net::SocketAddr) -> (tokio::net::TcpStream, String) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap(); - sock.write_all( - b"GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\n\ - Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n\ - Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", - ) - .await - .unwrap(); - let mut buf = [0u8; 64]; + sock.write_all(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut buf = [0u8; 32]; let n = sock.read(&mut buf).await.unwrap(); - let status = String::from_utf8_lossy(&buf[..n]) - .lines() - .next() - .unwrap_or_default() - .to_string(); - (sock, status) + (sock, String::from_utf8_lossy(&buf[..n]).to_string()) } - let (_s1, status) = upgrade(addr).await; - assert!(status.contains("101"), "first socket: {status}"); - let (_s2, status) = upgrade(addr).await; - assert!(status.contains("101"), "second socket: {status}"); - let (_s3, status) = upgrade(addr).await; + use tokio::io::AsyncWriteExt; + let mut s1 = tokio::net::TcpStream::connect(addr).await.unwrap(); + s1.write_all(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut s2 = tokio::net::TcpStream::connect(addr).await.unwrap(); + s2.write_all(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + // Both requests in flight (each sleeps 2s); the third must be shed. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let (_s3, status) = get_slow(addr).await; assert!( status.contains("429"), - "third socket must be limited: {status}" + "third request must be limited: {status}" ); - - // Closing a socket frees its slot once the server notices on the next send. - drop(_s1); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - let (_s4, status) = upgrade(addr).await; - if status.contains("101") { - break; - } - assert!( - std::time::Instant::now() < deadline, - "slot was not released after socket close: {status}" - ); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } } /// Regression test: the docs routes must exist in the app a serve mode actually builds, not diff --git a/crates/espresso/api/src/lib.rs b/crates/espresso/api/src/lib.rs index 95b1ba46ee4..c622dcf6fe7 100644 --- a/crates/espresso/api/src/lib.rs +++ b/crates/espresso/api/src/lib.rs @@ -305,8 +305,8 @@ async fn serve_router( fn apply_connection_limit(router: ::axum::Router, limit: usize) -> ::axum::Router { let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(limit)); router - .layer(::axum::middleware::from_fn(axum::limit_plain_requests)) - .layer(::axum::Extension(axum::StreamLimit(semaphore))) + .layer(::axum::middleware::from_fn(axum::limit_requests)) + .layer(::axum::Extension(axum::RequestLimit(semaphore))) } /// Start Tonic gRPC server