mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-20 16:58:40 +02:00
feat: BTC/LTC wallet handlers (electrum proxy, authenticated)
First wallet-handler slice. Thin, non-custodial, JWT-gated proxies over the
ElectrumClient — the wallet derives addresses and signs locally; these
endpoints only relay reads and a finalized broadcast.
Endpoints (POST, under /api/v1/wallet/utxo, asset = btc|ltc in body):
- balance -> {confirmed, unconfirmed} (separate integers; client sums)
- utxos -> [{txid, vout, value, height}]
- history -> [{txid, height, fee}]
- tip -> {height}
- fee -> {sat_per_vb}
- broadcast-> {txid}
- JWT-gated for abuse control; the client-supplied address is NOT persisted
against the user (no address<->identity graph stored here).
- Feature-gated dispatch: unknown/disabled asset -> 400; the Electrum client
validates the address + network before any network call.
- broadcast validates tx_hex (non-empty, even-length, hex, <=200 KB) before
reaching the node.
- Registered in build_router + openapi.json (6 paths, btc_ltc tag); spec
regenerated, drift-clean.
5 L1 integration tests (auth gate on balance/broadcast/fee; unknown-asset and
malformed-tx-hex 400s — all deterministic, no network) + tx_hex unit test.
This commit is contained in:
@@ -6,4 +6,5 @@ pub mod middleware;
|
||||
pub mod nip05;
|
||||
pub mod openapi;
|
||||
pub mod users;
|
||||
pub mod wallet;
|
||||
pub mod website;
|
||||
|
||||
+20
-1
@@ -32,6 +32,12 @@ use utoipa::OpenApi;
|
||||
crate::api::users::register_key,
|
||||
crate::api::users::get_user_keys,
|
||||
crate::api::users::get_user_key_for_asset,
|
||||
crate::api::wallet::btc_ltc::balance,
|
||||
crate::api::wallet::btc_ltc::utxos,
|
||||
crate::api::wallet::btc_ltc::history,
|
||||
crate::api::wallet::btc_ltc::tip,
|
||||
crate::api::wallet::btc_ltc::fee,
|
||||
crate::api::wallet::btc_ltc::broadcast,
|
||||
crate::api::nip05::well_known_nostr,
|
||||
),
|
||||
components(schemas(
|
||||
@@ -59,11 +65,24 @@ use utoipa::OpenApi;
|
||||
crate::api::users::RegisterKeyRequest,
|
||||
crate::api::users::UserKeyInfo,
|
||||
crate::api::users::UserKeysResponse,
|
||||
crate::api::wallet::btc_ltc::AddressRequest,
|
||||
crate::api::wallet::btc_ltc::BalanceResponse,
|
||||
crate::api::wallet::btc_ltc::Utxo,
|
||||
crate::api::wallet::btc_ltc::UtxosResponse,
|
||||
crate::api::wallet::btc_ltc::HistoryEntry,
|
||||
crate::api::wallet::btc_ltc::HistoryResponse,
|
||||
crate::api::wallet::btc_ltc::AssetRequest,
|
||||
crate::api::wallet::btc_ltc::TipResponse,
|
||||
crate::api::wallet::btc_ltc::FeeRequest,
|
||||
crate::api::wallet::btc_ltc::FeeResponse,
|
||||
crate::api::wallet::btc_ltc::BroadcastRequest,
|
||||
crate::api::wallet::btc_ltc::BroadcastResponse,
|
||||
crate::api::nip05::WellKnownResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "system", description = "Service health and metadata."),
|
||||
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity.")
|
||||
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."),
|
||||
(name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast).")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Bitcoin/Litecoin wallet handlers — thin authenticated proxies over the
|
||||
//! [`ElectrumClient`](crate::infra::electrum::ElectrumClient).
|
||||
//!
|
||||
//! The backend is non-custodial: it holds no keys. The wallet derives addresses
|
||||
//! and signs transactions locally; these endpoints only relay reads
|
||||
//! (balance/UTXOs/history/tip/fee) and a finalized broadcast to Electrum/Fulcrum.
|
||||
//!
|
||||
//! Conventions (matching [`crate::api::users`]):
|
||||
//! * JWT-gated: every endpoint resolves the caller via
|
||||
//! [`extract_user_id_from_token`] purely to gate abuse — the queried address is
|
||||
//! client-supplied and is NOT persisted against the user (no address↔identity
|
||||
//! graph is stored here).
|
||||
//! * `asset` crosses the wire as a lowercase string (`"btc"`/`"ltc"`); a disabled
|
||||
//! or unknown asset is a 400. The Electrum client validates the address (and its
|
||||
//! network) before any network call.
|
||||
//! * snake_case wire fields; every DTO derives `utoipa::ToSchema`.
|
||||
//! * Routes are RELATIVE to the `/api/v1` mount point; see [`routes`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, http::HeaderMap, routing::post, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::api::middleware::extract_user_id_from_token;
|
||||
use crate::error::AppError;
|
||||
use crate::infra::electrum::ElectrumClient;
|
||||
use crate::AppState;
|
||||
|
||||
/// Generous cap on a raw transaction hex (~100 KB of tx). Bounds the broadcast
|
||||
/// body before it reaches the node; the axum body limit is a second backstop.
|
||||
const MAX_TX_HEX_LEN: usize = 200_000;
|
||||
|
||||
/// Resolve the Electrum client for a UTXO asset, or a 400 (unknown / disabled).
|
||||
fn electrum_for<'a>(state: &'a AppState, asset: &str) -> Result<&'a ElectrumClient, AppError> {
|
||||
let client = match asset {
|
||||
"btc" => state.chains.btc.as_ref(),
|
||||
"ltc" => state.chains.ltc.as_ref(),
|
||||
other => {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"Invalid UTXO asset: {other} (expected btc or ltc)"
|
||||
)))
|
||||
}
|
||||
};
|
||||
client.ok_or_else(|| {
|
||||
AppError::ValidationError(format!("{asset} support is not enabled on this server"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a broadcast payload: non-empty, even-length, hex, within the cap.
|
||||
fn validate_tx_hex(tx_hex: &str) -> Result<(), AppError> {
|
||||
if tx_hex.is_empty() || tx_hex.len() > MAX_TX_HEX_LEN {
|
||||
return Err(AppError::ValidationError(
|
||||
"Transaction hex has invalid length".into(),
|
||||
));
|
||||
}
|
||||
if !tx_hex.len().is_multiple_of(2) || !tx_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(AppError::ValidationError(
|
||||
"Transaction must be even-length hexadecimal".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── DTOs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An address query for a UTXO asset.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AddressRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
/// The address to query (validated by the Electrum client).
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
/// Confirmed/unconfirmed balance in satoshis. The client computes any total
|
||||
/// (kept as separate integer fields — no lossy server-side sum).
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct BalanceResponse {
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
pub confirmed: u64,
|
||||
pub unconfirmed: i64,
|
||||
}
|
||||
|
||||
/// A single unspent output.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct Utxo {
|
||||
pub txid: String,
|
||||
pub vout: u64,
|
||||
pub value: u64,
|
||||
/// Block height; `0` if unconfirmed.
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct UtxosResponse {
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
pub utxos: Vec<Utxo>,
|
||||
}
|
||||
|
||||
/// A transaction-history entry.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct HistoryEntry {
|
||||
pub txid: String,
|
||||
/// Block height (`0`/negative for unconfirmed).
|
||||
pub height: i64,
|
||||
/// Fee in satoshis (mempool entries only).
|
||||
pub fee: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct HistoryResponse {
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
pub transactions: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// An asset-only query.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AssetRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TipResponse {
|
||||
pub asset: String,
|
||||
/// Best-chain tip height.
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
/// A fee-estimate request.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct FeeRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
/// Target confirmation within this many blocks.
|
||||
pub blocks: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct FeeResponse {
|
||||
pub asset: String,
|
||||
/// Estimated fee rate in sat/vB, or `null` if the server can't estimate.
|
||||
pub sat_per_vb: Option<f64>,
|
||||
}
|
||||
|
||||
/// A broadcast request: a finalized, signed raw transaction.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct BroadcastRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
/// Hex-encoded signed transaction.
|
||||
pub tx_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct BroadcastResponse {
|
||||
/// The broadcast transaction id.
|
||||
pub txid: String,
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Confirmed/unconfirmed balance for a BTC/LTC address.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/balance",
|
||||
request_body = AddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Address balance in satoshis", body = BalanceResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or address"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn balance(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AddressRequest>,
|
||||
) -> Result<Json<BalanceResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let bal = electrum_for(&state, &asset)?
|
||||
.get_balance(&req.address)
|
||||
.await?;
|
||||
Ok(Json(BalanceResponse {
|
||||
asset,
|
||||
address: req.address,
|
||||
confirmed: bal.confirmed,
|
||||
unconfirmed: bal.unconfirmed,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unspent outputs for a BTC/LTC address.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/utxos",
|
||||
request_body = AddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Unspent outputs", body = UtxosResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or address"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn utxos(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AddressRequest>,
|
||||
) -> Result<Json<UtxosResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let utxos = electrum_for(&state, &asset)?
|
||||
.get_utxos(&req.address)
|
||||
.await?;
|
||||
Ok(Json(UtxosResponse {
|
||||
asset,
|
||||
address: req.address,
|
||||
utxos: utxos
|
||||
.into_iter()
|
||||
.map(|u| Utxo {
|
||||
txid: u.tx_hash,
|
||||
vout: u.tx_pos,
|
||||
value: u.value,
|
||||
height: u.height,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Confirmed + mempool transaction history for a BTC/LTC address.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/history",
|
||||
request_body = AddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Transaction history", body = HistoryResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or address"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn history(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AddressRequest>,
|
||||
) -> Result<Json<HistoryResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let entries = electrum_for(&state, &asset)?
|
||||
.get_history(&req.address)
|
||||
.await?;
|
||||
Ok(Json(HistoryResponse {
|
||||
asset,
|
||||
address: req.address,
|
||||
transactions: entries
|
||||
.into_iter()
|
||||
.map(|e| HistoryEntry {
|
||||
txid: e.tx_hash,
|
||||
height: e.height,
|
||||
fee: e.fee,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Best-chain tip height (for client-side confirmation counting).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/tip",
|
||||
request_body = AssetRequest,
|
||||
responses(
|
||||
(status = 200, description = "Chain tip height", body = TipResponse),
|
||||
(status = 400, description = "Invalid/disabled asset"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn tip(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AssetRequest>,
|
||||
) -> Result<Json<TipResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let height = electrum_for(&state, &asset)?.get_tip_height().await?;
|
||||
Ok(Json(TipResponse { asset, height }))
|
||||
}
|
||||
|
||||
/// Fee-rate estimate (sat/vB) for confirmation within `blocks` blocks.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/fee",
|
||||
request_body = FeeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fee estimate", body = FeeResponse),
|
||||
(status = 400, description = "Invalid/disabled asset"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn fee(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<FeeRequest>,
|
||||
) -> Result<Json<FeeResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let sat_per_vb = electrum_for(&state, &asset)?
|
||||
.estimate_fee(req.blocks)
|
||||
.await?;
|
||||
Ok(Json(FeeResponse { asset, sat_per_vb }))
|
||||
}
|
||||
|
||||
/// Broadcast a finalized, signed BTC/LTC transaction.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/utxo/broadcast",
|
||||
request_body = BroadcastRequest,
|
||||
responses(
|
||||
(status = 200, description = "Broadcast accepted; returns the txid", body = BroadcastResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or malformed tx hex"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn broadcast(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<BroadcastRequest>,
|
||||
) -> Result<Json<BroadcastResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = electrum_for(&state, &asset)?;
|
||||
validate_tx_hex(&req.tx_hex)?;
|
||||
let txid = client.broadcast_transaction(&req.tx_hex).await?;
|
||||
Ok(Json(BroadcastResponse { txid }))
|
||||
}
|
||||
|
||||
// ── router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BTC/LTC routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/wallet/utxo/balance", post(balance))
|
||||
.route("/wallet/utxo/utxos", post(utxos))
|
||||
.route("/wallet/utxo/history", post(history))
|
||||
.route("/wallet/utxo/tip", post(tip))
|
||||
.route("/wallet/utxo/fee", post(fee))
|
||||
.route("/wallet/utxo/broadcast", post(broadcast))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tx_hex_validation() {
|
||||
assert!(validate_tx_hex("deadbeef").is_ok());
|
||||
assert!(validate_tx_hex("DEADBEEF01").is_ok());
|
||||
assert!(validate_tx_hex("").is_err()); // empty
|
||||
assert!(validate_tx_hex("abc").is_err()); // odd length
|
||||
assert!(validate_tx_hex("xyz!").is_err()); // non-hex
|
||||
assert!(validate_tx_hex(&"a".repeat(MAX_TX_HEX_LEN + 1)).is_err()); // too long
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Wallet (chain-access) handlers: thin authenticated proxies over the per-chain
|
||||
//! infra clients. The backend is non-custodial — these endpoints relay reads and
|
||||
//! finalized broadcasts; all key handling and signing happen in the wallet.
|
||||
//!
|
||||
//! Each chain family lives in its own submodule and contributes its routes here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub mod btc_ltc;
|
||||
|
||||
/// All wallet routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
btc_ltc::routes()
|
||||
}
|
||||
Reference in New Issue
Block a user