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:
jwinterm
2026-06-23 11:57:43 -04:00
parent b7e3199200
commit 1ec4639652
7 changed files with 1000 additions and 2 deletions
+490
View File
@@ -583,10 +583,268 @@
}
}
}
},
"/wallet/utxo/balance": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Confirmed/unconfirmed balance for a BTC/LTC address.",
"operationId": "balance",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Address balance in satoshis",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BalanceResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or address"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/broadcast": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Broadcast a finalized, signed BTC/LTC transaction.",
"operationId": "broadcast",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BroadcastRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Broadcast accepted; returns the txid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BroadcastResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or malformed tx hex"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/fee": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Fee-rate estimate (sat/vB) for confirmation within `blocks` blocks.",
"operationId": "fee",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FeeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Fee estimate",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FeeResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/history": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Confirmed + mempool transaction history for a BTC/LTC address.",
"operationId": "history",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Transaction history",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HistoryResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or address"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/tip": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Best-chain tip height (for client-side confirmation counting).",
"operationId": "tip",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Chain tip height",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TipResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/utxos": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Unspent outputs for a BTC/LTC address.",
"operationId": "utxos",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Unspent outputs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UtxosResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or address"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
}
},
"components": {
"schemas": {
"AddressRequest": {
"type": "object",
"description": "An address query for a UTXO asset.",
"required": [
"asset",
"address"
],
"properties": {
"address": {
"type": "string",
"description": "The address to query (validated by the Electrum client)."
},
"asset": {
"type": "string",
"description": "`btc` or `ltc`."
}
}
},
"AssetPublicKey": {
"type": "object",
"description": "A per-asset public key submitted by the wallet.",
@@ -610,6 +868,19 @@
}
}
},
"AssetRequest": {
"type": "object",
"description": "An asset-only query.",
"required": [
"asset"
],
"properties": {
"asset": {
"type": "string",
"description": "`btc` or `ltc`."
}
}
},
"AssetSignature": {
"type": "object",
"description": "One asset's signature over the challenge message.",
@@ -664,6 +935,63 @@
}
}
},
"BalanceResponse": {
"type": "object",
"description": "Confirmed/unconfirmed balance in satoshis. The client computes any total\n(kept as separate integer fields — no lossy server-side sum).",
"required": [
"asset",
"address",
"confirmed",
"unconfirmed"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string"
},
"confirmed": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"unconfirmed": {
"type": "integer",
"format": "int64"
}
}
},
"BroadcastRequest": {
"type": "object",
"description": "A broadcast request: a finalized, signed raw transaction.",
"required": [
"asset",
"tx_hex"
],
"properties": {
"asset": {
"type": "string",
"description": "`btc` or `ltc`."
},
"tx_hex": {
"type": "string",
"description": "Hex-encoded signed transaction."
}
}
},
"BroadcastResponse": {
"type": "object",
"required": [
"txid"
],
"properties": {
"txid": {
"type": "string",
"description": "The broadcast transaction id."
}
}
},
"CheckRestoreRequest": {
"type": "object",
"description": "Ask whether a wallet (by seed fingerprint) was created on this backend, and\nwhether the submitted keys match.",
@@ -795,6 +1123,45 @@
}
}
},
"FeeRequest": {
"type": "object",
"description": "A fee-estimate request.",
"required": [
"asset",
"blocks"
],
"properties": {
"asset": {
"type": "string",
"description": "`btc` or `ltc`."
},
"blocks": {
"type": "integer",
"format": "int32",
"description": "Target confirmation within this many blocks.",
"minimum": 0
}
}
},
"FeeResponse": {
"type": "object",
"required": [
"asset"
],
"properties": {
"asset": {
"type": "string"
},
"sat_per_vb": {
"type": [
"number",
"null"
],
"format": "double",
"description": "Estimated fee rate in sat/vB, or `null` if the server can't estimate."
}
}
},
"HealthResponse": {
"type": "object",
"description": "Liveness response.",
@@ -808,6 +1175,55 @@
}
}
},
"HistoryEntry": {
"type": "object",
"description": "A transaction-history entry.",
"required": [
"txid",
"height"
],
"properties": {
"fee": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Fee in satoshis (mempool entries only).",
"minimum": 0
},
"height": {
"type": "integer",
"format": "int64",
"description": "Block height (`0`/negative for unconfirmed)."
},
"txid": {
"type": "string"
}
}
},
"HistoryResponse": {
"type": "object",
"required": [
"asset",
"address",
"transactions"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string"
},
"transactions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HistoryEntry"
}
}
}
},
"LogoutRequest": {
"type": "object",
"required": [
@@ -1006,6 +1422,23 @@
}
}
},
"TipResponse": {
"type": "object",
"required": [
"asset",
"height"
],
"properties": {
"asset": {
"type": "string"
},
"height": {
"type": "integer",
"format": "int64",
"description": "Best-chain tip height."
}
}
},
"UserInfo": {
"type": "object",
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
@@ -1070,6 +1503,59 @@
}
}
},
"Utxo": {
"type": "object",
"description": "A single unspent output.",
"required": [
"txid",
"vout",
"value",
"height"
],
"properties": {
"height": {
"type": "integer",
"format": "int64",
"description": "Block height; `0` if unconfirmed.",
"minimum": 0
},
"txid": {
"type": "string"
},
"value": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"vout": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"UtxosResponse": {
"type": "object",
"required": [
"asset",
"address",
"utxos"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string"
},
"utxos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Utxo"
}
}
}
},
"WebsiteChallengeRequest": {
"type": "object",
"description": "Request a website-auth challenge for a calling origin.",
@@ -1167,6 +1653,10 @@
{
"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)."
}
]
}
+1
View File
@@ -6,4 +6,5 @@ pub mod middleware;
pub mod nip05;
pub mod openapi;
pub mod users;
pub mod wallet;
pub mod website;
+20 -1
View File
@@ -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;
+381
View File
@@ -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
}
}
+18
View File
@@ -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()
}
+2 -1
View File
@@ -46,7 +46,8 @@ pub struct AppState {
pub fn build_router(state: Arc<AppState>) -> Router {
let api_v1 = api::auth::routes()
.merge(api::website::routes())
.merge(api::users::routes());
.merge(api::users::routes())
.merge(api::wallet::routes());
Router::new()
.route("/health", get(api::health::health))
+88
View File
@@ -0,0 +1,88 @@
//! L1 integration: BTC/LTC wallet endpoints through the real router.
//!
//! These assert the deterministic, no-network paths: the JWT gate, asset
//! validation, and broadcast tx-hex validation — all of which resolve before any
//! Electrum call. The funded/networked happy paths (real balance/broadcast) live
//! in the separate gitignored L3 harness.
mod common;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn balance_requires_a_token() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/api/v1/wallet/utxo/balance",
None,
Some(
json!({ "asset": "btc", "address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" }),
),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn balance_rejects_unknown_asset() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/utxo/balance",
Some(&access),
Some(json!({ "asset": "doge", "address": "x" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}
#[tokio::test]
async fn broadcast_requires_a_token() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/api/v1/wallet/utxo/broadcast",
None,
Some(json!({ "asset": "btc", "tx_hex": "deadbeef" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn broadcast_rejects_malformed_tx_hex() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
// Authed + valid asset, but the tx hex is non-hex -> 400 before any network.
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/utxo/broadcast",
Some(&access),
Some(json!({ "asset": "btc", "tx_hex": "nothex!!" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}
#[tokio::test]
async fn fee_requires_a_token() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/api/v1/wallet/utxo/fee",
None,
Some(json!({ "asset": "btc", "blocks": 6 })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}