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 diff --git a/Cargo.lock b/Cargo.lock index 68c0857d3c2..a5a1a3d0223 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/Cargo.toml b/Cargo.toml index 83897c34d1b..ea1aa31fe5c 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/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..453b07496d3 100644 --- a/crates/espresso/api/examples/test_api.rs +++ b/crates/espresso/api/examples/test_api.rs @@ -14,7 +14,7 @@ use serde::Serialize; use serialization_api::ApiSerializations; /// Port for the test API server -const API_PORT: u16 = 5000; +const API_PORT: u16 = 5001; /// Test API implementation with hardcoded mock data #[derive(Clone)] @@ -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) @@ -1058,11 +1113,16 @@ async fn main() -> Result<()> { tracing::info!("Serving API at 127.0.0.1:{}", API_PORT); tracing::info!(""); tracing::info!("API documentation: http://localhost:{}/", API_PORT); - tracing::info!("Swagger UI: http://localhost:{}/v2", API_PORT); - tracing::info!("Scalar UI: http://localhost:{}/v2/scalar", API_PORT); - tracing::info!("Redoc UI: http://localhost:{}/v2/redoc", API_PORT); + tracing::info!("v1 Swagger UI: http://localhost:{}/v1", API_PORT); + tracing::info!("v1 Scalar UI: http://localhost:{}/v1/scalar", API_PORT); + tracing::info!( + "v1 OpenAPI spec: http://localhost:{}/v1/docs/openapi.json", + API_PORT + ); + tracing::info!("v2 Swagger UI: http://localhost:{}/v2", API_PORT); + tracing::info!("v2 Scalar UI: http://localhost:{}/v2/scalar", API_PORT); tracing::info!( - "OpenAPI spec: http://localhost:{}/v2/docs/openapi.json", + "v2 OpenAPI spec: http://localhost:{}/v2/docs/openapi.json", API_PORT ); tracing::info!(""); @@ -1083,8 +1143,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..08f3580ebea 100644 --- a/crates/espresso/api/src/axum.rs +++ b/crates/espresso/api/src/axum.rs @@ -2,9 +2,17 @@ pub mod routes; +use std::{collections::BTreeMap, sync::Arc}; + use aide::{ - axum::{ApiRouter, routing::get_with}, - openapi::{Info, OpenApi}, + axum::{ + ApiRouter, + routing::{get_with, post_with}, + }, + openapi::{ + Info, OpenApi, Parameter, ParameterData, ParameterSchemaOrContent, PathStyle, ReferenceOr, + SchemaObject, + }, operation::OperationOutput, redoc::Redoc, scalar::Scalar, @@ -14,11 +22,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::{ @@ -26,6 +33,7 @@ use serialization_api::v2::{ GetRewardBalanceRequest, GetRewardBalancesRequest, GetRewardClaimInputRequest, GetRewardMerkleTreeRequest, GetStakeTableRequest, GetStateCertificateRequest, }; +use tokio::sync::Semaphore; use vbs::{BinarySerializer, Serializer, version::StaticVersion}; use crate::{ @@ -126,7 +134,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(_))); @@ -141,31 +149,104 @@ 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")) +/// In-flight request slots for `max_connections`. +#[derive(Clone)] +pub(crate) struct RequestLimit(pub(crate) Arc); + +/// 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 { + match semaphore.try_acquire_owned() { + Ok(_permit) => next.run(req).await, + Err(_) => StatusCode::TOO_MANY_REQUESTS.into_response(), + } } -/// Middleware to rewrite root paths to /v2 paths -/// -/// Requests to `/rewards/...` get rewritten to `/v2/rewards/...` -/// Paths already prefixed with `/v2` are left unchanged +/// 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)) +} + +/// 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") +} + +/// 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 +255,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,9 +319,18 @@ 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 { + 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()), @@ -257,9 +342,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 +379,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) -> ApiRouter 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 = @@ -325,8 +464,8 @@ where state .get_reward_claim_input(height, address) .await - .map(Json) - .map_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) }; let get_reward_balance = @@ -334,16 +473,16 @@ where state .get_reward_balance(height, address) .await - .map(Json) - .map_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) }; let get_latest_reward_balance = |State(state): State, Path(address): Path| async move { state .get_latest_reward_balance(address) .await - .map(Json) - .map_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) }; let get_reward_account_proof = @@ -351,16 +490,16 @@ where state .get_reward_account_proof(height, address) .await - .map(Json) - .map_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) }; let get_latest_reward_account_proof = |State(state): State, Path(address): Path| async move { state .get_latest_reward_account_proof(address) .await - .map(Json) - .map_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) }; let get_reward_amounts = @@ -368,17 +507,228 @@ where state .get_reward_amounts(height, offset, limit) .await - .map(Json) - .map_err(ApiError::Internal) + .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_err(ApiError::Internal) + .map(ApiJson) + .map_err(classify_availability_error) + }; + + let get_reward_state_height = |State(state): State| async move { + state + .get_reward_state_height() + .await + .map(ApiJson) + .map_err(classify_availability_error) + }; + + let get_reward_state_v2_height = |State(state): State| async move { + state + .get_reward_state_v2_height() + .await + .map(ApiJson) + .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(ApiJson) + .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(ApiJson) + .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(ApiJson) + .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(ApiJson) + .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(ApiJson) + .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(ApiJson) + .map_err(classify_availability_error) + }; + + ApiRouter::new() + .api_route( + routes::v1::REWARD_CLAIM_INPUT_ROUTE, + 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.") + }), + ) + .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_with(get_latest_reward_balance, |op| { + op.summary("Get latest reward balance").description("Get current balance in reward state for an Ethereum address.") + }), + ) + .api_route( + routes::v1::REWARD_ACCOUNT_PROOF_ROUTE, + 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).") + }), + ) + .api_route( + routes::v1::LATEST_REWARD_ACCOUNT_PROOF_ROUTE, + 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.") + }), + ) + .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_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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_HEIGHT_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_V2_HEIGHT_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_V1_BALANCE_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_V1_ACCOUNT_PROOF_ROUTE, + 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). + .api_route( + routes::v1::REWARD_V1_LATEST_BALANCE_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_V1_LATEST_ACCOUNT_PROOF_ROUTE, + 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.") + }), + ) + .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_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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_PATH_BY_HEIGHT_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_PATH_BY_COMMIT_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_V2_PATH_BY_HEIGHT_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::REWARD_STATE_V2_PATH_BY_COMMIT_ROUTE, + 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) -> ApiRouter +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 = @@ -386,7 +736,7 @@ where state .get_namespace_proof(v1::availability::BlockId::Height(height), namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -396,7 +746,7 @@ where state .get_namespace_proof(v1::availability::BlockId::Hash(hash), namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -409,7 +759,7 @@ where namespace, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -419,7 +769,7 @@ where state .get_namespace_proof_range(from, until, namespace) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -431,14 +781,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) }; @@ -446,31 +796,32 @@ where state .get_state_cert_v2(epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; // HotShot availability API handlers - let get_leaf_by_height = |State(state): State, Path(height): Path| async move { state .get_leaf(v1::LeafId::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -478,28 +829,31 @@ where state .get_header(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -507,28 +861,31 @@ where state .get_block(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -536,28 +893,31 @@ where state .get_payload(v1::PayloadId::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -565,30 +925,33 @@ where state .get_vid_common(v1::BlockId::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let get_vid_common_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state .get_vid_common(v1::BlockId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let get_vid_common_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_vid_common_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -597,29 +960,32 @@ where state .get_transaction_by_position(height, index) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let get_transaction_by_hash = |State(state): State, Path(hash): Path| async move { state .get_transaction_by_hash(hash) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let get_transaction_proof_by_position = |State(state): State, Path((height, index)): Path<(u64, u64)>| async move { state .get_transaction_proof_by_position(height, index) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -627,15 +993,16 @@ where state .get_block_summary(height) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let get_block_summary_range = |State(state): State, Path((from, until)): Path<(usize, usize)>| async move { state .get_block_summary_range(from, until) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -643,14 +1010,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) }; @@ -667,6 +1034,7 @@ where } }) }; + let stream_headers = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -679,6 +1047,7 @@ where } }) }; + let stream_blocks = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -691,6 +1060,7 @@ where } }) }; + let stream_payloads = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -703,6 +1073,7 @@ where } }) }; + let stream_vid_common = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -715,6 +1086,7 @@ where } }) }; + let stream_transactions = |ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, @@ -727,6 +1099,7 @@ where } }) }; + let stream_transactions_ns = |ws: WebSocketUpgrade, State(state): State, @@ -740,6 +1113,7 @@ where } }) }; + let stream_namespace_proofs = |ws: WebSocketUpgrade, State(state): State, @@ -754,18 +1128,408 @@ 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, - ) + ApiRouter::new() + .api_route( + routes::v1::NAMESPACE_PROOF_BY_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NAMESPACE_PROOF_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NAMESPACE_PROOF_BY_PAYLOAD_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NAMESPACE_PROOF_RANGE_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::INCORRECT_ENCODING_PROOF_ROUTE, + 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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::VID_COMMON_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::VID_COMMON_BY_PAYLOAD_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::VID_COMMON_RANGE_ROUTE, + 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`.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_BY_POSITION_NOPROOF_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_BY_HASH_NOPROOF_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_PROOF_BY_POSITION_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_PROOF_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_BY_POSITION_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TRANSACTION_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_SUMMARY_BY_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_SUMMARY_RANGE_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_with(stream_transactions, |op| { + op.summary("Stream transactions (websocket)").description( + "Websocket endpoint: subscribe to a stream of all transactions starting at \ + the given height.", + ) + }), + ) + .api_route( + routes::v1::STREAM_TRANSACTIONS_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::STREAM_NAMESPACE_PROOFS_ROUTE, + 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) -> 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) - }; + }; + let get_block_state_path_by_commit = |State(state): State, Path((commit, key)): Path<(String, String)>| async move { ::get_block_state_path( @@ -774,99 +1538,264 @@ where key, ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; - let get_block_state_height = |State(state): State| async move { - ::get_block_state_height(&state) + + // 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(ApiJson) .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) - }; + ApiRouter::new() + .api_route( + routes::v1::BLOCK_STATE_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_STATE_PATH_BY_COMMIT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::BLOCK_STATE_PATH_BY_HEIGHT_ROUTE, + 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) -> 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) }; + let get_fee_balance_latest = |State(state): State, Path(address): Path| async move { state .get_fee_balance_latest(address) .await - .map(Json) + .map(ApiJson) .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(ApiJson) + .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(ApiJson) + .map_err(classify_availability_error) + }; + + ApiRouter::new() + .api_route( + routes::v1::FEE_STATE_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::FEE_STATE_BALANCE_LATEST_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::FEE_STATE_PATH_BY_COMMIT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::FEE_STATE_PATH_BY_HEIGHT_ROUTE, + 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) -> 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(), } }; + ApiRouter::new() + .api_route( + routes::v1::STATUS_BLOCK_HEIGHT_ROUTE, + get_with(status_block_height, |op| { + op.summary("Get latest committed block height") + .description("Get the height of the latest committed block.") + }), + ) + .api_route( + routes::v1::STATUS_SUCCESS_RATE_ROUTE, + get_with(status_success_rate, |op| { + op.summary("Get view success rate") + .description("Get the fraction of views which resulted in a committed block.") + }), + ) + .api_route( + routes::v1::STATUS_TIME_SINCE_LAST_DECIDE_ROUTE, + 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.") + }), + ) + .with_state(state) +} + +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) }; + 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) -> 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) }; @@ -874,150 +1803,164 @@ where state .count_transactions(None, None, None) .await - .map(Json) - .map_err(classify_availability_error) - }; - let node_count_txs_to = |State(state): State, Path(to): Path| async move { - state - .count_transactions(None, Some(to), None) - .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 { - state - .count_transactions(Some(from), Some(to), None) - .await - .map(Json) + .map(ApiJson) .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(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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 .count_transactions(Some(from), Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .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(ApiJson) .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(ApiJson) .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(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let node_payload_size_ns_to = |State(state): State, Path((namespace, to)): Path<(u64, u64)>| async move { state .payload_size(None, Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .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 .payload_size(Some(from), Some(to), Some(namespace)) .await - .map(Json) + .map(ApiJson) .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(ApiJson) + .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(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let node_vid_share_by_payload_hash = |State(state): State, Path(payload_hash): Path| async move { state .get_vid_share(v1::VidShareId::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .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(ApiJson) .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(ApiJson) .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(ApiJson) .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(ApiJson) .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(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) }; @@ -1025,28 +1968,31 @@ where state .stake_table_current() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_stake_table = |State(state): State, Path(epoch): Path| async move { state .stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_da_stake_table_current = |State(state): State| async move { state .da_stake_table_current() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_da_stake_table = |State(state): State, Path(epoch): Path| async move { state .da_stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1054,15 +2000,16 @@ where state .get_validators(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_all_validators = |State(state): State, Path((epoch, offset, limit)): Path<(u64, u64, u64)>| async move { state .get_all_validators(epoch, offset, limit) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::BadRequest) }; @@ -1070,28 +2017,31 @@ where state .current_proposal_participation() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_proposal_participation = |State(state): State, Path(epoch): Path| async move { state .proposal_participation(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_vote_participation_current = |State(state): State| async move { state .current_vote_participation() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_vote_participation = |State(state): State, Path(epoch): Path| async move { state .vote_participation(epoch) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1099,14 +2049,15 @@ where state .get_block_reward(None) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_block_reward_epoch = |State(state): State, Path(epoch): Path| async move { state .get_block_reward(Some(epoch)) .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; @@ -1114,71 +2065,393 @@ where state .get_oldest_block() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; + let node_oldest_leaf = |State(state): State| async move { state .get_oldest_leaf() .await - .map(Json) + .map(ApiJson) .map_err(ApiError::Internal) }; - // Catchup handlers - let catchup_account = - |State(state): State, Path((height, view, address)): Path<(u64, u64, String)>| async move { - state - .get_account(height, view, address) - .await - .map(Json) - .map_err(classify_availability_error) - }; - let catchup_accounts = |State(state): State, - Path((height, view)): Path<(u64, u64)>, - headers: HeaderMap, - body: Bytes| async move { - let accounts: Vec<::FeeAccount> = decode_body(&headers, &body)?; - let tree = state - .get_accounts(height, view, accounts) - .await - .map_err(classify_availability_error)?; - encode_response(&headers, tree) - }; - let catchup_blocks = |State(state): State, Path((height, view)): Path<(u64, u64)>| async move { - state + 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_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.", + ) + }), + ) + .api_route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_TRANSACTIONS_COUNT_NS_FROM_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_TRANSACTIONS_COUNT_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_TRANSACTIONS_COUNT_FROM_TO_ROUTE, + 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.", + ) + }), + ) + .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_with(node_payload_size, |op| { + op.summary("Get payload size") + .description("Deprecated alias for payloads/size.") + }), + ) + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_NS_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_NS_FROM_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PAYLOADS_SIZE_FROM_TO_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_VID_SHARE_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_VID_SHARE_BY_PAYLOAD_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_VID_SHARE_BY_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::NODE_HEADER_WINDOW_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_HEADER_WINDOW_TIME_ROUTE, + 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.", + ) + }), + ) + .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_with(node_stake_table_current, |op| { + op.summary("Get current stake table") + .description("Get the stake table for the current epoch.") + }), + ) + .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_with(node_da_stake_table_current, |op| { + op.summary("Get current DA stake table") + .description("Get the DA stake table for the current epoch.") + }), + ) + .api_route( + routes::v1::NODE_DA_STAKE_TABLE_ROUTE, + 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.") + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PROPOSAL_PARTICIPATION_CURRENT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_PROPOSAL_PARTICIPATION_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_VOTE_PARTICIPATION_CURRENT_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::NODE_VOTE_PARTICIPATION_ROUTE, + 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.", + ) + }), + ) + .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.", + ) + }), + ) + .with_state(state) +} + +pub(crate) fn router_catchup(state: S) -> ApiRouter +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 { + state + .get_account(height, view, address) + .await + .map(ApiJson) + .map_err(classify_availability_error) + }; + + let catchup_accounts = |State(state): State, + Path((height, view)): Path<(u64, u64)>, + headers: HeaderMap, + body: Bytes| async move { + let accounts: Vec<::FeeAccount> = decode_body(&headers, &body)?; + let tree = state + .get_accounts(height, view, accounts) + .await + .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) .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) }; + let catchup_leafchain = |State(state): State, Path(height): Path| async move { 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) }; + let catchup_reward_account = |State(state): State, Path((height, view, address)): Path<(u64, u64, String)>| async move { state .get_reward_account_v1(height, view, address) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let catchup_reward_accounts = |State(state): State, Path((height, view)): Path<(u64, u64)>, headers: HeaderMap, @@ -1190,133 +2463,323 @@ 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 .get_reward_account_v2(height, view, address) .await - .map(Json) + .map(ApiJson) .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) .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_err(classify_availability_error) - }; - - // 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 { - let tx: ::Transaction = decode_body(&headers, &body)?; - let hash = state.submit(tx).await.map_err(ApiError::Internal)?; - 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) + .map(ApiJson) .map_err(classify_availability_error) }; - // HotShot events handlers - let hotshot_events_startup = |State(state): State| async move { - state - .startup_info() - .await - .map(Json) - .map_err(ApiError::Internal) - }; - let hotshot_events_stream = - |State(state): State, headers: HeaderMap, ws: WebSocketUpgrade| async move { - let format = ws_format(&headers); - match ::events(&state).await { - Ok(stream) => ws.on_upgrade(move |socket| drive_ws_stream(socket, stream, format)), - Err(err) => ApiError::Internal(err).into_response(), - } - }; - - // Light-client handlers - let lc_leaf_by_height = |State(state): State, Path(height): Path| async move { - state - .get_leaf_proof(v1::LeafQuery::Height(height), None) - .await - .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 - .get_leaf_proof(v1::LeafQuery::Height(height), Some(finalized)) - .await - .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) - .await - .map(Json) + 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, + 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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::CATCHUP_REWARD_ACCOUNTS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::CATCHUP_REWARD_ACCOUNT_V2_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::CATCHUP_REWARD_ACCOUNTS_V2_ROUTE, + 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.") + }), + ) + .api_route( + routes::v1::CATCHUP_REWARD_AMOUNTS_ROUTE, + get_with(catchup_reward_amounts, |op| { + op.summary("List reward amounts — deprecated") + .description("Deprecated: this endpoint always returns 404 Not Found.") + }), + ) + .api_route( + routes::v1::CATCHUP_REWARD_MERKLE_TREE_V2_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::CATCHUP_STATE_CERT_ROUTE, + 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) -> ApiRouter +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 { + let tx: ::Transaction = decode_body(&headers, &body)?; + let hash = state.submit(tx).await.map_err(ApiError::Internal)?; + encode_response(&headers, hash) + }; + + ApiRouter::new() + .api_route( + routes::v1::SUBMIT_ROUTE, + 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) -> ApiRouter +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(ApiJson) + .map_err(classify_availability_error) + }; + + ApiRouter::new() + .api_route( + routes::v1::STATE_SIGNATURE_BLOCK_ROUTE, + 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) -> ApiRouter +where + S: v1::HotShotEventsApi + Clone + Send + Sync + 'static, +{ + // HotShot events handlers + let hotshot_events_startup = |State(state): State| async move { + state + .startup_info() + .await + .map(ApiJson) + .map_err(ApiError::Internal) + }; + + let hotshot_events_stream = + |State(state): State, headers: HeaderMap, ws: WebSocketUpgrade| async move { + let format = ws_format(&headers); + match ::events(&state).await { + Ok(stream) => ws.on_upgrade(move |socket| async move { + drive_ws_stream(socket, stream, format).await + }), + Err(err) => ApiError::Internal(err).into_response(), + } + }; + + ApiRouter::new() + .api_route( + routes::v1::HOTSHOT_EVENTS_STARTUP_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::HOTSHOT_EVENTS_STREAM_ROUTE, + 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) -> ApiRouter +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 + .get_leaf_proof(v1::LeafQuery::Height(height), None) + .await + .map(ApiJson) + .map_err(classify_availability_error) + }; + + let lc_leaf_by_height_finalized = + |State(state): State, Path((height, finalized)): Path<(u64, u64)>| async move { + state + .get_leaf_proof(v1::LeafQuery::Height(height), Some(finalized)) + .await + .map(ApiJson) + .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) + .await + .map(ApiJson) .map_err(classify_availability_error) }; + let lc_leaf_by_hash_finalized = |State(state): State, Path((hash, finalized)): Path<(String, u64)>| async move { state .get_leaf_proof(v1::LeafQuery::Hash(hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .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 .get_leaf_proof(v1::LeafQuery::BlockHash(block_hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .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 .get_leaf_proof(v1::LeafQuery::PayloadHash(payload_hash), Some(finalized)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; @@ -1324,137 +2787,288 @@ where state .get_header_proof(root, v1::HeaderQuery::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let lc_header_by_payload_hash = |State(state): State, Path((root, payload_hash)): Path<(u64, String)>| async move { state .get_header_proof(root, v1::HeaderQuery::PayloadHash(payload_hash)) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let lc_stake_table = |State(state): State, Path(epoch): Path| async move { state .get_light_client_stake_table(epoch) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let lc_payload = |State(state): State, Path(height): Path| async move { state .get_payload_proof(height) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let lc_namespace_range = |State(state): State, Path((start, end, namespace)): Path<(u64, u64, u64)>| async move { state .get_lc_namespace_proof_range(start, end, namespace) .await - .map(Json) + .map(ApiJson) + .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(ApiJson) .map_err(classify_availability_error) }; + 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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::LC_LEAF_BY_BLOCK_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::LC_LEAF_BY_BLOCK_HASH_FINALIZED_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::LC_LEAF_BY_PAYLOAD_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::LC_LEAF_BY_PAYLOAD_HASH_FINALIZED_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::LC_HEADER_BY_HEIGHT_ROUTE, + 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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .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_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.", + ) + }), + ) + .api_route( + routes::v1::LC_NAMESPACES_RANGE_ROUTE, + 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) -> ApiRouter +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 .get_block_detail(v1::BlockIdent::Height(height)) .await - .map(Json) + .map(ApiJson) .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)) .await - .map(Json) + .map(ApiJson) .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) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let explorer_block_summaries_from = |State(state): State, Path((from, limit)): Path<(u64, u64)>| async move { state .get_block_summaries(v1::BlockIdent::Height(from), limit) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let explorer_tx_detail_by_position = |State(state): State, Path((height, offset)): Path<(u64, u64)>| async move { state .get_transaction_detail(v1::TxIdent::HeightAndOffset(height, offset)) .await - .map(Json) + .map(ApiJson) .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)) .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(ApiJson) .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 @@ -1464,9 +3078,10 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .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 { @@ -1477,9 +3092,10 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .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 @@ -1489,9 +3105,10 @@ where v1::TxSummaryFilter::Block(block), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let explorer_tx_summaries_latest_ns = |State(state): State, Path((limit, namespace)): Path<(u64, i64)>| async move { state @@ -1501,9 +3118,10 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) .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 { @@ -1514,9 +3132,10 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let explorer_tx_summaries_by_hash_ns = |State(state): State, Path((hash, limit, namespace)): Path<( String, @@ -1530,548 +3149,567 @@ where v1::TxSummaryFilter::Namespace(namespace), ) .await - .map(Json) + .map(ApiJson) + .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(ApiJson) .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(ApiJson) + .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(ApiJson) + .map_err(classify_availability_error) + }; + let explorer_summary = |State(state): State| async move { state .get_explorer_summary() .await - .map(Json) + .map(ApiJson) .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(ApiJson) .map_err(classify_availability_error) }; + ApiRouter::new() + .api_route( + routes::v1::EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, + get_with(explorer_block_detail_by_height, |op| { + op.summary("Get block detail") + .description("Get details for a block identified by height or hash.") + }), + ) + .api_route( + routes::v1::EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, + get_with(explorer_block_detail_by_hash, |op| { + op.summary("Get block detail") + .description("Get details for a block identified by height or hash.") + }), + ) + .api_route( + routes::v1::EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_DETAIL_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_LATEST_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_FROM_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, + 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.", + ) + }), + ) + .with_state(state) +} + +pub(crate) fn router_token(state: S) -> ApiRouter +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(ApiJson) .map_err(classify_availability_error) }; + let token_circulating = |State(state): State| async move { state .circulating_supply() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let token_circulating_eth = |State(state): State| async move { state .circulating_supply_ethereum() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let token_total_issued = |State(state): State| async move { state .total_issued_supply() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + let token_total_reward_distributed = |State(state): State| async move { state .total_reward_distributed() .await - .map(Json) + .map(ApiJson) .map_err(classify_availability_error) }; + ApiRouter::new() + .api_route( + routes::v1::TOKEN_TOTAL_MINTED_SUPPLY_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TOKEN_CIRCULATING_SUPPLY_ROUTE, + get_with(token_circulating, |op| { + op.summary("Get circulating supply").description( + "Circulating supply: initial_supply + reward_distributed - locked, following \ + the mainnet unlock schedule.", + ) + }), + ) + .api_route( + routes::v1::TOKEN_CIRCULATING_SUPPLY_ETHEREUM_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TOKEN_TOTAL_ISSUED_SUPPLY_ROUTE, + 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.", + ) + }), + ) + .api_route( + routes::v1::TOKEN_TOTAL_REWARD_DISTRIBUTED_ROUTE, + 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) -> ApiRouter +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(ApiJson) + .map_err(ApiError::Internal) + }; + let database_migration_status = |State(state): State| async move { + ::get_migration_status(&state) + .await + .map(ApiJson) .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), + ApiRouter::new() + .api_route( + routes::v1::DATABASE_TABLE_SIZES_ROUTE, + 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(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 - .route( - routes::v1::EXPLORER_BLOCK_DETAIL_BY_HEIGHT_ROUTE, - get(explorer_block_detail_by_height), - ) - .route( - routes::v1::EXPLORER_BLOCK_DETAIL_BY_HASH_ROUTE, - get(explorer_block_detail_by_hash), - ) - .route( - routes::v1::EXPLORER_BLOCK_SUMMARIES_LATEST_ROUTE, - get(explorer_block_summaries_latest), - ) - .route( - routes::v1::EXPLORER_BLOCK_SUMMARIES_FROM_ROUTE, - get(explorer_block_summaries_from), - ) - .route( - routes::v1::EXPLORER_TX_DETAIL_BY_POSITION_ROUTE, - get(explorer_tx_detail_by_position), - ) - .route( - routes::v1::EXPLORER_TX_DETAIL_BY_HASH_ROUTE, - get(explorer_tx_detail_by_hash), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_LATEST_BLOCK_ROUTE, - get(explorer_tx_summaries_latest_block), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_FROM_BLOCK_ROUTE, - get(explorer_tx_summaries_from_block), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_BLOCK_ROUTE, - get(explorer_tx_summaries_by_hash_block), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_LATEST_NS_ROUTE, - get(explorer_tx_summaries_latest_ns), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_FROM_NS_ROUTE, - get(explorer_tx_summaries_from_ns), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_NS_ROUTE, - get(explorer_tx_summaries_by_hash_ns), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_LATEST_ROUTE, - get(explorer_tx_summaries_latest), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_FROM_ROUTE, - get(explorer_tx_summaries_from), - ) - .route( - routes::v1::EXPLORER_TX_SUMMARIES_BY_HASH_ROUTE, - get(explorer_tx_summaries_by_hash), - ) - .route(routes::v1::EXPLORER_SUMMARY_ROUTE, get(explorer_summary)) - .route(routes::v1::EXPLORER_SEARCH_ROUTE, get(explorer_search)) - // Token - .route( - routes::v1::TOKEN_TOTAL_MINTED_SUPPLY_ROUTE, - get(token_total_minted), - ) - .route( - routes::v1::TOKEN_CIRCULATING_SUPPLY_ROUTE, - get(token_circulating), - ) - .route( - routes::v1::TOKEN_CIRCULATING_SUPPLY_ETHEREUM_ROUTE, - get(token_circulating_eth), + .api_route( + routes::v1::DATABASE_MIGRATION_STATUS_ROUTE, + 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 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 + + 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, +{ + // 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())) + .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)); + + finish_v1_docs(router) +} + +/// 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 { + title: "Espresso Node API v1".to_string(), + description: None, + version: "1.0.0".to_string(), + ..Default::default() + }, + ..Default::default() + }; + + 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). + 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::TOKEN_TOTAL_ISSUED_SUPPLY_ROUTE, - get(token_total_issued), + routes::v1::SWAGGER_ROUTE, + get(|| async { swagger_html(routes::v1::OPENAPI_SPEC_ROUTE) }), ) .route( - routes::v1::TOKEN_TOTAL_REWARD_DISTRIBUTED_ROUTE, - get(token_total_reward_distributed), + "/v1/", + get(|| async { swagger_html(routes::v1::OPENAPI_SPEC_ROUTE) }), ) - // Database (diagnostic) .route( - routes::v1::DATABASE_TABLE_SIZES_ROUTE, - get(database_table_sizes), + routes::v1::SCALAR_ROUTE, + get(Scalar::new(routes::v1::OPENAPI_SPEC_ROUTE) + .with_title("Espresso Node API v1") + .axum_handler()), ) - .with_state(state) + .layer(Extension(OpenApiV1(api))) +} + +/// Declare a path parameter for every `{name}` template segment of every operation. +/// +/// 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. +/// +/// 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. +/// 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; + }; + 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(); +} + +/// 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" + | "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; + }; + for (path, path_item_ref) in paths.paths.iter_mut() { + let ReferenceOr::Item(path_item) = path_item_ref else { + continue; + }; + let names: Vec<&str> = path + .split('/') + .filter_map(|seg| seg.strip_prefix('{').and_then(|s| s.strip_suffix('}'))) + .collect(); + if names.is_empty() { + continue; + } + 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() + { + for name in &names { + let already_declared = operation.parameters.iter().any(|p| { + matches!( + p, + ReferenceOr::Item(Parameter::Path { + parameter_data, + .. + }) if parameter_data.name == *name + ) + }); + if already_declared { + continue; + } + operation + .parameters + .push(ReferenceOr::Item(Parameter::Path { + parameter_data: ParameterData { + name: (*name).to_string(), + description: None, + required: true, + deprecated: None, + format: ParameterSchemaOrContent::Schema(SchemaObject { + json_schema: path_parameter_schema(name), + external_docs: None, + example: None, + }), + example: None, + examples: Default::default(), + explode: None, + extensions: Default::default(), + }, + style: PathStyle::Simple, + })); + } + } + } } /// Create v2 router with OpenAPI documentation (proto types) @@ -2269,8 +3907,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) @@ -2286,3 +3930,1031 @@ 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" + ); + } + + /// 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 + ); + } + + #[tokio::test] + 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(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + 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 /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(); + (sock, String::from_utf8_lossy(&buf[..n]).to_string()) + } + + 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 request must be limited: {status}" + ); + } + + /// 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 + /// layers as `serve_router`, and checks the docs are reachable and the spec reflects only + /// the mounted modules. + #[tokio::test] + async fn serve_mode_assembly_serves_v1_docs() { + let api_router = router_status(MockState).merge(router_state_signature(MockState)); + let router = with_top_level_routes(finish_v1_docs(api_router)); + let app = tower::Layer::layer( + &tower::util::MapRequestLayer::new(rewrite_legacy_uri), + router, + ); + + let get = |uri: &'static str| { + let app = app.clone(); + async move { + let req = Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(); + tower::ServiceExt::oneshot(app, req).await.unwrap() + } + }; + + let resp = get("/").await; + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + resp.headers().get(axum::http::header::LOCATION).unwrap(), + "/v1" + ); + + let resp = get("/v1").await; + assert_eq!(resp.status(), StatusCode::OK, "/v1 must serve the docs UI"); + + let resp = get(routes::v1::OPENAPI_SPEC_ROUTE).await; + assert_eq!(resp.status(), StatusCode::OK); + let spec: serde_json::Value = + serde_json::from_str(&body_string(resp).await).expect("valid JSON"); + let paths = spec["paths"].as_object().expect("spec has paths"); + assert!(paths.contains_key(routes::v1::STATUS_BLOCK_HEIGHT_ROUTE)); + assert!( + !paths.contains_key(routes::v1::LEAF_BY_HEIGHT_ROUTE), + "spec must only document the modules this mode mounts" + ); + + // Every `{name}` template segment must be declared as a path parameter, or Swagger's + // try-it-out cannot fill the URL. + let params = &paths[routes::v1::STATE_SIGNATURE_BLOCK_ROUTE]["get"]["parameters"]; + assert_eq!( + params[0]["name"], "height", + "template parameters must be declared: {params}" + ); + assert_eq!(params[0]["in"], "path"); + assert_eq!(params[0]["required"], true); + assert_eq!(params[0]["schema"]["type"], "integer"); + } + + /// Multi-segment templates declare one parameter per `{name}`, in template order. + #[tokio::test] + async fn v1_spec_declares_all_template_parameters() { + 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(); + let spec: serde_json::Value = + serde_json::from_str(&body_string(resp).await).expect("valid JSON"); + let paths = spec["paths"].as_object().expect("spec has paths"); + for (path, item) in paths { + let names: Vec<&str> = path + .split('/') + .filter_map(|s| s.strip_prefix('{').and_then(|s| s.strip_suffix('}'))) + .collect(); + for op in item.as_object().unwrap().values() { + let declared: Vec<&str> = op["parameters"] + .as_array() + .map(|ps| { + ps.iter() + .filter(|p| p["in"] == "path") + .map(|p| p["name"].as_str().unwrap()) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + declared, names, + "path {path} must declare its template params" + ); + } + } + + // 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"); + + // 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") + ); + } +} diff --git a/crates/espresso/api/src/axum/routes.rs b/crates/espresso/api/src/axum/routes.rs index 92b13cf0e0f..fada8a9e259 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, $($param:ident),+ $(,)?) => { + pub fn $name($($param: impl ::std::fmt::Display),+) -> String { + let mut path = ::std::string::String::from($route); + $( + path = path.replace( + concat!("{", stringify!($param), "}"), + &$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,677 @@ 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"; + + // 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 + // + // 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, + 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, + 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, + 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); + path_fn!(state_cert_v2, STATE_CERT_V2_ROUTE, epoch); + + // Availability — leaves + 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); + 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); + 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); + 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); + 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, + 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 + ); + 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); + + // Availability — streams + 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, + 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, + 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 + ); + + // 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 + ); + 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); + 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 + ); + 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 + ); + 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, + 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); + 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); + 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 + ); + path_fn!( + node_vote_participation_current, + NODE_VOTE_PARTICIPATION_CURRENT_ROUTE + ); + 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 + ); + 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, + 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); + + // 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); + 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 + ); + 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, + 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); + + // 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..c622dcf6fe7 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,24 +73,242 @@ where + Sync + 'static, { - tracing::info!("Starting Axum server on port {} with v1 and v2 APIs", port); + 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())) + .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())); + 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.clone())); + } + let router = axum::finish_v1_docs(router).merge(axum::create_router_v2(state)); + serve_router(listener, "v1 and v2", router, max_connections).await +} - let app = create_combined_router(state); - let addr = format!("0.0.0.0:{}", port); +/// 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 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())) + .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( + 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 +/// 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 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( + listener, + "status", + axum::finish_v1_docs(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 listener = bind_api(port).await?; + let router = axum::router_state_signature(state.clone()); + let router = merge_hotshot_modules(router, &state, modules); + serve_router( + listener, + "bare", + axum::finish_v1_docs(router), + max_connections, + ) + .await +} +fn merge_hotshot_modules( + mut router: aide::axum::ApiRouter, + state: &S, + modules: OptionalModules, +) -> aide::axum::ApiRouter +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. +/// 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); - let listener = tokio::net::TcpListener::bind(&addr).await?; + Ok(tokio::net::TcpListener::bind(&addr).await?) +} + +async fn serve_router( + listener: tokio::net::TcpListener, + mode: &str, + router: ::axum::Router, + max_connections: Option, +) -> anyhow::Result<()> { + let mut router = axum::with_top_level_routes(router); + if let Some(limit) = max_connections { + 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); tracing::info!( - "Axum API server listening on {} (v1 and v2 routes available)", - addr + "Axum API server listening on {:?} ({} mode)", + listener.local_addr()?, + mode ); - ::axum::serve(listener, app.into_make_service()).await?; + ::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_requests)) + .layer(::axum::Extension(axum::RequestLimit(semaphore))) +} + /// Start Tonic gRPC server pub async fn serve_tonic(port: u16, state: S) -> anyhow::Result<()> where 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/api/templates/swagger.html b/crates/espresso/api/templates/swagger.html index 67157f88f30..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 +
@@ -12,11 +65,12 @@ 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 063ee965d4d..07782ad1a8b 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::{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) @@ -8306,9 +8339,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 @@ -8318,14 +8353,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)) @@ -8499,6 +8532,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( @@ -8536,6 +8589,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( @@ -8552,6 +8623,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. @@ -9327,6 +9452,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?; @@ -9623,7 +9766,6 @@ mod test { .api_config(Options::from(options::Http { port, max_connections: None, - axum_port: None, tonic_port: None, })) .catchups(std::array::from_fn(|_| { @@ -9732,87 +9874,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 10eb47891dc..b10073e6cc9 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -30,6 +30,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; @@ -489,7 +490,7 @@ where .collect()) } -async fn get_namespaces_proof_range( +pub(crate) async fn get_namespaces_proof_range( state: &State, start: usize, end: usize, @@ -518,21 +519,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 c269d6ecb4f..ab40b34078b 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,12 +60,15 @@ use serialization_api::v2::{ reward_merkle_proof_v2::ProofType, }; use tagged_base64::TaggedBase64; +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 _, }, }; @@ -531,17 +547,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 +574,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 +593,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 +608,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 +637,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 +654,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 +677,80 @@ 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 { + 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 { + // `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 + } + + async fn get_reward_account_proof_v1( + &self, + height: u64, + address: String, + ) -> anyhow::Result { + let account: 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 +760,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 +768,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 +801,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 +809,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 +824,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 +848,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 +856,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 +872,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 +880,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 +897,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 +925,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 +945,64 @@ 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 { + 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: RewardAccountV1 = key + .parse() + .map_err(|_| bad_request("failed to parse Key param"))?; + let ds = &*self.data_source; + MerklizedStateDataSource::::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 { + 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::::get_path(ds, hs_snapshot, key) + .await + .map_err(classify_query_error) + } } // ============================================================================ @@ -1142,8 +1280,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 +1300,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 +1309,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 +1330,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( @@ -1979,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()), @@ -2010,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) => { @@ -2040,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 @@ -2075,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) => { @@ -2105,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 @@ -2118,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"))?; @@ -2180,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}")) } @@ -2201,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) } @@ -2267,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), @@ -2291,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), @@ -2342,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), @@ -2436,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 } @@ -2489,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 @@ -2507,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; @@ -2521,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; @@ -2531,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() @@ -2542,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 @@ -2550,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) @@ -2565,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 @@ -2583,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; @@ -2598,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 @@ -2615,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 @@ -2628,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 @@ -2650,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) @@ -2681,6 +2788,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 +2845,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 // ============================================================================ @@ -2753,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), @@ -2774,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"))?; @@ -2801,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) => { @@ -2825,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"))?; @@ -2867,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 @@ -2876,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() @@ -2921,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}")))?, ), @@ -2940,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 @@ -2951,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) }, }; @@ -2999,7 +3123,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(); @@ -3082,7 +3205,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; @@ -3131,7 +3253,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")); } @@ -3155,7 +3277,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) } } @@ -3167,6 +3311,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}"), + } +} + fn lc_leaf_proof_chain_limit() -> usize { crate::api::light_client::Options::default().leaf_proof_chain_limit } @@ -3186,13 +3340,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)) @@ -3213,7 +3365,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() @@ -3297,10 +3448,53 @@ 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 { + 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 3b64b4b46d6..8affd804855 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()) }) @@ -305,7 +306,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?; @@ -340,7 +341,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 @@ -362,7 +363,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!( @@ -387,7 +388,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) @@ -412,7 +413,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) @@ -445,7 +446,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 { @@ -456,9 +457,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? }, @@ -497,10 +496,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?; @@ -529,8 +525,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 5e6148876ae..7d8cb91a171 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -231,7 +231,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} @@ -295,7 +294,6 @@ processes: - ESPRESSO_NODE_TELEMETRY_METRICS_ENABLE=true - ESPRESSO_NODE_TELEMETRY_ENDPOINT=http://intentionally-unreachable-telemetry-endpoint - 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} @@ -357,7 +355,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} @@ -415,7 +412,6 @@ processes: - ESPRESSO_NODE_TELEMETRY_ENDPOINT=http://intentionally-unreachable-telemetry-endpoint - 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} @@ -471,7 +467,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} @@ -525,8 +520,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