From ff89ca65842ef95dd183aaacd81b1c1b83959ace Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 28 Jun 2026 22:01:14 -0400 Subject: [PATCH] feat(wallet): unified GET /wallet/heights (tip per chain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet's ChainProvider seam fetches all chain tips in one call (getBlockchainHeights) and selects per asset; the backend exposed per-chain height endpoints but no aggregate, so the client's call 404'd. Add a best-effort /wallet/heights returning {btc,ltc,xmr,wow,grin} concurrently — a disabled or unreachable chain is null, never failing the others. JWT-gated, OpenAPI-annotated (drift clean). --- openapi.json | 71 ++++++++++++++++++++++++++++ src/api/openapi.rs | 2 + src/api/wallet/heights.rs | 98 +++++++++++++++++++++++++++++++++++++++ src/api/wallet/mod.rs | 2 + 4 files changed, 173 insertions(+) create mode 100644 src/api/wallet/heights.rs diff --git a/openapi.json b/openapi.json index 5b8db6b..e123894 100644 --- a/openapi.json +++ b/openapi.json @@ -1040,6 +1040,35 @@ ] } }, + "/wallet/heights": { + "get": { + "tags": [ + "system" + ], + "summary": "Current tip height for every enabled chain (one round-trip).", + "operationId": "heights", + "responses": { + "200": { + "description": "Tip height per chain (null if disabled/unreachable)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeightsResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/wallet/lws/balance": { "post": { "tags": [ @@ -2366,6 +2395,48 @@ } } }, + "HeightsResponse": { + "type": "object", + "description": "Current tip height per chain. `null` = the chain is disabled on this instance\nor its source was unreachable for this request.", + "properties": { + "btc": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "example": 955860 + }, + "grin": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "ltc": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "wow": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "xmr": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, "HistoryEntry": { "type": "object", "description": "A transaction-history entry.", diff --git a/src/api/openapi.rs b/src/api/openapi.rs index dfa412d..a294a98 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -58,6 +58,7 @@ use utoipa::OpenApi; crate::api::wallet::grin_relay::respond, crate::api::wallet::grin_relay::finalize, crate::api::wallet::grin_relay::cancel, + crate::api::wallet::heights::heights, crate::api::nip05::well_known_nostr, ), components(schemas( @@ -132,6 +133,7 @@ use utoipa::OpenApi; crate::api::wallet::grin_relay::SlateIdRequest, crate::api::wallet::grin_relay::RelayEntry, crate::api::wallet::grin_relay::PendingRelaysResponse, + crate::api::wallet::heights::HeightsResponse, crate::api::nip05::WellKnownResponse, )), tags( diff --git a/src/api/wallet/heights.rs b/src/api/wallet/heights.rs new file mode 100644 index 0000000..32051fb --- /dev/null +++ b/src/api/wallet/heights.rs @@ -0,0 +1,98 @@ +//! Unified blockchain-tip heights across all enabled chains. +//! +//! The wallet's ChainProvider seam fetches every chain's tip in a single call +//! and selects per asset, so one `GET /wallet/heights` returns them together. +//! Best-effort: a disabled chain (or a momentarily-unreachable source) is +//! reported `null` rather than failing the whole response. +//! +//! JWT-gated like the rest of the wallet surface (abuse-gating only; tip height +//! is public chain data and is not user-specific). + +use std::sync::Arc; + +use axum::{extract::State, http::HeaderMap, routing::get, Json, Router}; +use serde::Serialize; +use tracing::instrument; +use utoipa::ToSchema; + +use crate::api::middleware::extract_user_id_from_token; +use crate::error::AppError; +use crate::AppState; + +/// Current tip height per chain. `null` = the chain is disabled on this instance +/// or its source was unreachable for this request. +#[derive(Debug, Serialize, ToSchema)] +pub struct HeightsResponse { + #[schema(example = 955860)] + pub btc: Option, + pub ltc: Option, + pub xmr: Option, + pub wow: Option, + pub grin: Option, +} + +/// Current tip height for every enabled chain (one round-trip). +#[utoipa::path( + security(("bearer_auth" = [])), + get, + path = "/wallet/heights", + responses( + (status = 200, description = "Tip height per chain (null if disabled/unreachable)", body = HeightsResponse), + (status = 401, description = "Missing or invalid token") + ), + tag = "system" +)] +#[instrument(skip(state, headers))] +pub async fn heights( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + extract_user_id_from_token(&state, &headers).await?; + let c = &state.chains; + // Concurrent best-effort: each chain's failure collapses to `None`, never + // failing the others. Disabled chains short-circuit without a network call. + let (btc, ltc, xmr, wow, grin) = tokio::join!( + async { + match &c.btc { + Some(e) => e.get_tip_height().await.ok(), + None => None, + } + }, + async { + match &c.ltc { + Some(e) => e.get_tip_height().await.ok(), + None => None, + } + }, + async { + match &c.xmr { + Some(l) => l.get_blockchain_height().await.ok().map(|h| h as i64), + None => None, + } + }, + async { + match &c.wow { + Some(l) => l.get_blockchain_height().await.ok().map(|h| h as i64), + None => None, + } + }, + async { + match &c.grin { + Some(g) => g.get_height().await.ok().map(|h| h as i64), + None => None, + } + }, + ); + Ok(Json(HeightsResponse { + btc, + ltc, + xmr, + wow, + grin, + })) +} + +/// `GET /wallet/heights`, RELATIVE to the `/api/v1` mount point. +pub fn routes() -> Router> { + Router::new().route("/wallet/heights", get(heights)) +} diff --git a/src/api/wallet/mod.rs b/src/api/wallet/mod.rs index 61484c8..000e6f7 100644 --- a/src/api/wallet/mod.rs +++ b/src/api/wallet/mod.rs @@ -14,6 +14,7 @@ use crate::AppState; pub mod btc_ltc; pub mod grin; pub mod grin_relay; +pub mod heights; pub mod xmr_wow; /// All wallet routes, RELATIVE to the `/api/v1` mount point. @@ -22,6 +23,7 @@ pub fn routes() -> Router> { .merge(xmr_wow::routes()) .merge(grin::routes()) .merge(grin_relay::routes()) + .merge(heights::routes()) } // ── shared input validators ───────────────────────────────────────────────────