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
+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);
}