mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +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:
+490
@@ -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": {
|
"components": {
|
||||||
"schemas": {
|
"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": {
|
"AssetPublicKey": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A per-asset public key submitted by the wallet.",
|
"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": {
|
"AssetSignature": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One asset's signature over the challenge message.",
|
"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": {
|
"CheckRestoreRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Ask whether a wallet (by seed fingerprint) was created on this backend, and\nwhether the submitted keys match.",
|
"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": {
|
"HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Liveness response.",
|
"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": {
|
"LogoutRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"UserInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
|
"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": {
|
"WebsiteChallengeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request a website-auth challenge for a calling origin.",
|
"description": "Request a website-auth challenge for a calling origin.",
|
||||||
@@ -1167,6 +1653,10 @@
|
|||||||
{
|
{
|
||||||
"name": "auth",
|
"name": "auth",
|
||||||
"description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."
|
"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)."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ pub mod middleware;
|
|||||||
pub mod nip05;
|
pub mod nip05;
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod wallet;
|
||||||
pub mod website;
|
pub mod website;
|
||||||
|
|||||||
+20
-1
@@ -32,6 +32,12 @@ use utoipa::OpenApi;
|
|||||||
crate::api::users::register_key,
|
crate::api::users::register_key,
|
||||||
crate::api::users::get_user_keys,
|
crate::api::users::get_user_keys,
|
||||||
crate::api::users::get_user_key_for_asset,
|
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,
|
crate::api::nip05::well_known_nostr,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
@@ -59,11 +65,24 @@ use utoipa::OpenApi;
|
|||||||
crate::api::users::RegisterKeyRequest,
|
crate::api::users::RegisterKeyRequest,
|
||||||
crate::api::users::UserKeyInfo,
|
crate::api::users::UserKeyInfo,
|
||||||
crate::api::users::UserKeysResponse,
|
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,
|
crate::api::nip05::WellKnownResponse,
|
||||||
)),
|
)),
|
||||||
tags(
|
tags(
|
||||||
(name = "system", description = "Service health and metadata."),
|
(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;
|
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()
|
||||||
|
}
|
||||||
+2
-1
@@ -46,7 +46,8 @@ pub struct AppState {
|
|||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
let api_v1 = api::auth::routes()
|
let api_v1 = api::auth::routes()
|
||||||
.merge(api::website::routes())
|
.merge(api::website::routes())
|
||||||
.merge(api::users::routes());
|
.merge(api::users::routes())
|
||||||
|
.merge(api::wallet::routes());
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(api::health::health))
|
.route("/health", get(api::health::health))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user