From 1ec4639652687766cb55a5c4dbe21b9679d78c85 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 23 Jun 2026 11:57:43 -0400 Subject: [PATCH] feat: BTC/LTC wallet handlers (electrum proxy, authenticated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- openapi.json | 490 ++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/api/openapi.rs | 21 +- src/api/wallet/btc_ltc.rs | 381 +++++++++++++++++++++++++++++ src/api/wallet/mod.rs | 18 ++ src/lib.rs | 3 +- tests/wallet_btc_ltc.rs | 88 +++++++ 7 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 src/api/wallet/btc_ltc.rs create mode 100644 src/api/wallet/mod.rs create mode 100644 tests/wallet_btc_ltc.rs diff --git a/openapi.json b/openapi.json index f068881..8feb927 100644 --- a/openapi.json +++ b/openapi.json @@ -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)." } ] } diff --git a/src/api/mod.rs b/src/api/mod.rs index a625862..69e434f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,4 +6,5 @@ pub mod middleware; pub mod nip05; pub mod openapi; pub mod users; +pub mod wallet; pub mod website; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 1770e95..e89b8ed 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -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; diff --git a/src/api/wallet/btc_ltc.rs b/src/api/wallet/btc_ltc.rs new file mode 100644 index 0000000..ba353b7 --- /dev/null +++ b/src/api/wallet/btc_ltc.rs @@ -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, +} + +/// 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, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct HistoryResponse { + pub asset: String, + pub address: String, + pub transactions: Vec, +} + +/// 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, +} + +/// 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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> { + 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 + } +} diff --git a/src/api/wallet/mod.rs b/src/api/wallet/mod.rs new file mode 100644 index 0000000..cd2bfdb --- /dev/null +++ b/src/api/wallet/mod.rs @@ -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> { + btc_ltc::routes() +} diff --git a/src/lib.rs b/src/lib.rs index b6ec84c..4b45905 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,7 +46,8 @@ pub struct AppState { pub fn build_router(state: Arc) -> 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)) diff --git a/tests/wallet_btc_ltc.rs b/tests/wallet_btc_ltc.rs new file mode 100644 index 0000000..116767c --- /dev/null +++ b/tests/wallet_btc_ltc.rs @@ -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); +}