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)."
}
]
}