feat(wallet): unified GET /wallet/heights (tip per chain)

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).
This commit is contained in:
jwinterm
2026-06-28 22:01:14 -04:00
parent 56c8dd463a
commit ff89ca6584
4 changed files with 173 additions and 0 deletions
+71
View File
@@ -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.",
+2
View File
@@ -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(
+98
View File
@@ -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<i64>,
pub ltc: Option<i64>,
pub xmr: Option<i64>,
pub wow: Option<i64>,
pub grin: Option<i64>,
}
/// 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<HeightsResponse>, 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<Arc<AppState>> {
Router::new().route("/wallet/heights", get(heights))
}
+2
View File
@@ -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<Arc<AppState>> {
.merge(xmr_wow::routes())
.merge(grin::routes())
.merge(grin_relay::routes())
.merge(heights::routes())
}
// ── shared input validators ───────────────────────────────────────────────────