mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat: XMR/WOW wallet handlers (LWS proxy, stateless-forward)
Second wallet-handler slice. Authenticated, non-custodial, stateless-forward proxies over the LwsClient: the wallet sends its private view key per request, the backend forwards it (never stores it; the LWS holds it for scanning), and the wallet constructs + signs spends locally. Endpoints (POST, under /api/v1/wallet/lws, asset = xmr|wow in body): - register -> add/import account (view key) for LWS scanning (+start_height) - balance -> total_received / unlocked / locked + scan state - history -> txs incl. mempool pending + candidate spent_outputs - unspent_outs -> outputs + fee params for client-side spend construction - random_outs -> ring decoys (count clamped + fan-out capped in the client) - submit_tx -> broadcast a finalized signed tx - height -> daemon chain height - confirmations-> tx confirmation count - JWT-gated; the view key is skipped from every tracing span (never logged) and never stored. Feature-gated dispatch; unknown/disabled asset -> 400. - Boundary validation: view_key (64 hex), address (bounded), txid (64 hex), tx_hex (even hex, <=2 MiB) — all before any LWS call. - Shared validators in wallet/mod.rs (validate_hex/view_key/cn_address). - Colliding DTO names disambiguated (Lws-prefixed) so the openapi schema registry stays correct; 8 paths + schemas + xmr_wow tag registered; spec regenerated, drift-clean. 6 L1 integration tests (auth gate on balance/register; unknown-asset, bad-view-key, malformed-tx-hex, malformed-txid 400s) + validator unit tests.
This commit is contained in:
+810
@@ -584,6 +584,326 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/balance": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Balance + scan state for an account.",
|
||||
"operationId": "balance",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ViewRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account balance and scan state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LwsBalanceResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset, address, or view key"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/confirmations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Confirmation count for a transaction (daemon).",
|
||||
"operationId": "confirmations",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfirmationsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Confirmation count",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfirmationsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset or txid"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/height": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Current chain height (daemon) for confirmation counting.",
|
||||
"operationId": "height",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LwsAssetRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Chain height",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HeightResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/history": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Transaction history (confirmed + mempool) for an account.",
|
||||
"operationId": "history",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ViewRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account transaction history",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LwsHistoryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset, address, or view key"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/random_outs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Random decoy outputs for ring construction.",
|
||||
"operationId": "random_outs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RandomOutsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Decoy outputs",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RandomOutsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset or request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/register": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Register an account (its view key) with the LWS so it begins scanning.\nIdempotent at the LWS; pass `start_height` to scan from a wallet birthday.",
|
||||
"operationId": "register",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account registered for scanning",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset, address, or view key"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/submit_tx": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Broadcast a finalized, signed Monero/Wownero transaction.",
|
||||
"operationId": "submit_tx",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SubmitRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Transaction submitted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset or malformed tx hex"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/lws/unspent_outs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"xmr_wow"
|
||||
],
|
||||
"summary": "Unspent outputs for an account (for client-side spend construction).",
|
||||
"operationId": "unspent_outs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ViewRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unspent outputs + fee parameters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnspentOutsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid/disabled asset, address, or view key"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"503": {
|
||||
"description": "Upstream node unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallet/utxo/balance": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -845,6 +1165,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AmountOutsDto": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"amount",
|
||||
"outputs"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RandomOutputDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AssetPublicKey": {
|
||||
"type": "object",
|
||||
"description": "A per-asset public key submitted by the wallet.",
|
||||
@@ -1049,6 +1387,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmationsRequest": {
|
||||
"type": "object",
|
||||
"description": "Confirmation-count query for a tx.",
|
||||
"required": [
|
||||
"asset",
|
||||
"txid"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"txid": {
|
||||
"type": "string",
|
||||
"description": "Transaction id (64 hex)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmationsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"asset"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"confirmations": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Confirmations, or `null` if the tx is unknown / not yet in a block.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExtensionRegisterRequest": {
|
||||
"type": "object",
|
||||
"description": "Register a new extension wallet or re-authenticate an existing one.",
|
||||
@@ -1175,6 +1550,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HeightResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"asset",
|
||||
"height"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"HistoryEntry": {
|
||||
"type": "object",
|
||||
"description": "A transaction-history entry.",
|
||||
@@ -1284,6 +1676,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LwsAssetRequest": {
|
||||
"type": "object",
|
||||
"description": "Asset-only query.",
|
||||
"required": [
|
||||
"asset"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string",
|
||||
"description": "`xmr` or `wow`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LwsBalanceResponse": {
|
||||
"type": "object",
|
||||
"description": "Balance + scan state. `total_received` is the balance (an LWS cannot confirm\nspends without the spend key; the wallet subtracts its own known spends).",
|
||||
"required": [
|
||||
"asset",
|
||||
"total_received",
|
||||
"unlocked",
|
||||
"locked",
|
||||
"scanned_height",
|
||||
"blockchain_height",
|
||||
"transaction_count"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"blockchain_height": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"locked": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"scanned_height": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"total_received": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"transaction_count": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"unlocked": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"LwsHistoryResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"asset",
|
||||
"transactions"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"transactions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TxDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MyUsernameResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1326,6 +1797,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OkResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ok"
|
||||
],
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicKeysInfo": {
|
||||
"type": "object",
|
||||
"description": "A user's per-asset receiving public keys, by asset.",
|
||||
@@ -1362,6 +1844,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomOutputDto": {
|
||||
"type": "object",
|
||||
"description": "A decoy output.",
|
||||
"required": [
|
||||
"global_index",
|
||||
"public_key",
|
||||
"rct"
|
||||
],
|
||||
"properties": {
|
||||
"global_index": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"rct": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomOutsRequest": {
|
||||
"type": "object",
|
||||
"description": "Request decoy outputs for ring construction.",
|
||||
"required": [
|
||||
"asset",
|
||||
"count",
|
||||
"amounts"
|
||||
],
|
||||
"properties": {
|
||||
"amounts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Amounts to request decoys for (`[\"0\"]` for RingCT)."
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Decoys per real output (protocol ring size; clamped server-side).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomOutsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"asset",
|
||||
"amount_outs"
|
||||
],
|
||||
"properties": {
|
||||
"amount_outs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AmountOutsDto"
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RefreshTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1398,6 +1947,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"type": "object",
|
||||
"description": "Register (or import-with-height) an account for LWS scanning.",
|
||||
"required": [
|
||||
"asset",
|
||||
"address",
|
||||
"view_key"
|
||||
],
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"start_height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Scan from this block height (wallet birthday). Omit to scan from now.",
|
||||
"minimum": 0
|
||||
},
|
||||
"view_key": {
|
||||
"type": "string",
|
||||
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetUsernameRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1422,6 +2001,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SpentOutputDto": {
|
||||
"type": "object",
|
||||
"description": "A candidate spent output (verify with the spend key before trusting).",
|
||||
"required": [
|
||||
"amount",
|
||||
"key_image",
|
||||
"tx_pub_key",
|
||||
"out_index",
|
||||
"mixin"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"key_image": {
|
||||
"type": "string"
|
||||
},
|
||||
"mixin": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"out_index": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"tx_pub_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubmitRequest": {
|
||||
"type": "object",
|
||||
"description": "Submit a finalized, signed transaction.",
|
||||
"required": [
|
||||
"asset",
|
||||
"tx_hex"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"tx_hex": {
|
||||
"type": "string",
|
||||
"description": "Hex-encoded signed transaction blob."
|
||||
}
|
||||
}
|
||||
},
|
||||
"TipResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1439,6 +2069,160 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TxDto": {
|
||||
"type": "object",
|
||||
"description": "A transaction in the account's history.",
|
||||
"required": [
|
||||
"hash",
|
||||
"height",
|
||||
"timestamp",
|
||||
"total_received",
|
||||
"total_sent",
|
||||
"mempool",
|
||||
"unlock_time",
|
||||
"spent_outputs"
|
||||
],
|
||||
"properties": {
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"mempool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"payment_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spent_outputs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SpentOutputDto"
|
||||
}
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_received": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"total_sent": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "\"Possible\" sent — candidate spends, not authoritative.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unlock_time": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnspentOutputDto": {
|
||||
"type": "object",
|
||||
"description": "An unspent output for spend construction.",
|
||||
"required": [
|
||||
"amount",
|
||||
"public_key",
|
||||
"tx_pub_key",
|
||||
"index",
|
||||
"global_index",
|
||||
"height",
|
||||
"timestamp",
|
||||
"tx_hash",
|
||||
"rct",
|
||||
"spend_key_images"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"global_index": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"rct": {
|
||||
"type": "string"
|
||||
},
|
||||
"spend_key_images": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Key images seen on-chain that may correspond to this output being spent."
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"tx_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"tx_pub_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnspentOutsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"asset",
|
||||
"outputs",
|
||||
"per_byte_fee",
|
||||
"fee_mask",
|
||||
"fork_version"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"fee_mask": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"fork_version": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UnspentOutputDto"
|
||||
}
|
||||
},
|
||||
"per_byte_fee": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserInfo": {
|
||||
"type": "object",
|
||||
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
|
||||
@@ -1556,6 +2340,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ViewRequest": {
|
||||
"type": "object",
|
||||
"description": "Address + private view key for a per-account query.",
|
||||
"required": [
|
||||
"asset",
|
||||
"address",
|
||||
"view_key"
|
||||
],
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"asset": {
|
||||
"type": "string",
|
||||
"description": "`xmr` or `wow`."
|
||||
},
|
||||
"view_key": {
|
||||
"type": "string",
|
||||
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebsiteChallengeRequest": {
|
||||
"type": "object",
|
||||
"description": "Request a website-auth challenge for a calling origin.",
|
||||
@@ -1657,6 +2463,10 @@
|
||||
{
|
||||
"name": "btc_ltc",
|
||||
"description": "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."
|
||||
},
|
||||
{
|
||||
"name": "xmr_wow",
|
||||
"description": "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+28
-1
@@ -38,6 +38,14 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::tip,
|
||||
crate::api::wallet::btc_ltc::fee,
|
||||
crate::api::wallet::btc_ltc::broadcast,
|
||||
crate::api::wallet::xmr_wow::register,
|
||||
crate::api::wallet::xmr_wow::balance,
|
||||
crate::api::wallet::xmr_wow::history,
|
||||
crate::api::wallet::xmr_wow::unspent_outs,
|
||||
crate::api::wallet::xmr_wow::random_outs,
|
||||
crate::api::wallet::xmr_wow::submit_tx,
|
||||
crate::api::wallet::xmr_wow::height,
|
||||
crate::api::wallet::xmr_wow::confirmations,
|
||||
crate::api::nip05::well_known_nostr,
|
||||
),
|
||||
components(schemas(
|
||||
@@ -77,12 +85,31 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::FeeResponse,
|
||||
crate::api::wallet::btc_ltc::BroadcastRequest,
|
||||
crate::api::wallet::btc_ltc::BroadcastResponse,
|
||||
crate::api::wallet::xmr_wow::ViewRequest,
|
||||
crate::api::wallet::xmr_wow::RegisterRequest,
|
||||
crate::api::wallet::xmr_wow::RandomOutsRequest,
|
||||
crate::api::wallet::xmr_wow::SubmitRequest,
|
||||
crate::api::wallet::xmr_wow::LwsAssetRequest,
|
||||
crate::api::wallet::xmr_wow::ConfirmationsRequest,
|
||||
crate::api::wallet::xmr_wow::LwsBalanceResponse,
|
||||
crate::api::wallet::xmr_wow::SpentOutputDto,
|
||||
crate::api::wallet::xmr_wow::TxDto,
|
||||
crate::api::wallet::xmr_wow::LwsHistoryResponse,
|
||||
crate::api::wallet::xmr_wow::UnspentOutputDto,
|
||||
crate::api::wallet::xmr_wow::UnspentOutsResponse,
|
||||
crate::api::wallet::xmr_wow::RandomOutputDto,
|
||||
crate::api::wallet::xmr_wow::AmountOutsDto,
|
||||
crate::api::wallet::xmr_wow::RandomOutsResponse,
|
||||
crate::api::wallet::xmr_wow::HeightResponse,
|
||||
crate::api::wallet::xmr_wow::ConfirmationsResponse,
|
||||
crate::api::wallet::xmr_wow::OkResponse,
|
||||
crate::api::nip05::WellKnownResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "system", description = "Service health and metadata."),
|
||||
(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).")
|
||||
(name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."),
|
||||
(name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations).")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
+76
-1
@@ -8,11 +8,86 @@ use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::AppState;
|
||||
|
||||
pub mod btc_ltc;
|
||||
pub mod xmr_wow;
|
||||
|
||||
/// All wallet routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
btc_ltc::routes()
|
||||
btc_ltc::routes().merge(xmr_wow::routes())
|
||||
}
|
||||
|
||||
// ── shared input validators ───────────────────────────────────────────────────
|
||||
//
|
||||
// These bound client-supplied values at the API boundary before they reach a
|
||||
// chain client. They never reveal secret content (length/charset only).
|
||||
|
||||
/// Even-length hex within `max` bytes, non-empty. `field` names the value for the
|
||||
/// error message (never the value itself).
|
||||
pub(crate) fn validate_hex(value: &str, field: &str, max: usize) -> Result<(), AppError> {
|
||||
if value.is_empty() || value.len() > max {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"{field} has invalid length"
|
||||
)));
|
||||
}
|
||||
if !value.len().is_multiple_of(2) || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"{field} must be even-length hexadecimal"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Monero/Wownero private view key: 32 bytes = 64 hex chars. Validated only by
|
||||
/// shape; never logged.
|
||||
pub(crate) fn validate_view_key(view_key: &str) -> Result<(), AppError> {
|
||||
if view_key.len() != 64 || !view_key.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(AppError::ValidationError(
|
||||
"view_key must be 64 hexadecimal characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A CryptoNote address — base58, ~95 (standard) to ~106 (integrated) chars.
|
||||
/// Bounded generously; the LWS does full validation.
|
||||
pub(crate) fn validate_cn_address(address: &str) -> Result<(), AppError> {
|
||||
if address.is_empty() || address.len() > 256 {
|
||||
return Err(AppError::ValidationError(
|
||||
"address has invalid length".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_validation() {
|
||||
assert!(validate_hex("deadbeef", "tx", 100).is_ok());
|
||||
assert!(validate_hex("", "tx", 100).is_err());
|
||||
assert!(validate_hex("abc", "tx", 100).is_err()); // odd
|
||||
assert!(validate_hex("zz", "tx", 100).is_err()); // non-hex
|
||||
assert!(validate_hex("aaaa", "tx", 2).is_err()); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_key_validation() {
|
||||
assert!(validate_view_key(&"a".repeat(64)).is_ok());
|
||||
assert!(validate_view_key(&"a".repeat(63)).is_err());
|
||||
assert!(validate_view_key(&"z".repeat(64)).is_err()); // non-hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cn_address_validation() {
|
||||
assert!(
|
||||
validate_cn_address("4AdkPJoxn7JCvAby9szgnt93MSEwdnxdhaASxbTBm6x5dCwmsDep").is_ok()
|
||||
);
|
||||
assert!(validate_cn_address("").is_err());
|
||||
assert!(validate_cn_address(&"x".repeat(257)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Monero/Wownero wallet handlers — authenticated, stateless-forward proxies
|
||||
//! over the [`LwsClient`](crate::infra::lws::LwsClient).
|
||||
//!
|
||||
//! The backend is non-custodial and holds no view secret: the wallet sends its
|
||||
//! private view key per request, the backend forwards it to the LWS (which holds
|
||||
//! it only for scanning), and the wallet constructs + signs spends locally. The
|
||||
//! one-time `register` call forwards the view key to the LWS `add_account` so it
|
||||
//! begins scanning.
|
||||
//!
|
||||
//! Conventions (matching [`crate::api::users`] / [`super::btc_ltc`]):
|
||||
//! * JWT-gated; the view key is `skip`-ped from every span so it is never logged.
|
||||
//! * `asset` is a lowercase string (`"xmr"`/`"wow"`); disabled/unknown is a 400.
|
||||
//! * Amounts are atomic units (piconero / wownoshi) as `u64`.
|
||||
//! * 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 super::{validate_cn_address, validate_hex, validate_view_key};
|
||||
use crate::api::middleware::extract_user_id_from_token;
|
||||
use crate::error::AppError;
|
||||
use crate::infra::lws::{AddressTx, LwsClient, RandomOutput, SpentOutput, UnspentOutput};
|
||||
use crate::AppState;
|
||||
|
||||
/// Cap on a submitted Monero/Wownero tx hex. CryptoNote txs (rings, bulletproofs)
|
||||
/// are larger than UTXO txs; 2 MiB of hex is well above any real single tx.
|
||||
const MAX_CN_TX_HEX_LEN: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Resolve the LWS client for a CryptoNote asset, or a 400 (unknown / disabled).
|
||||
fn lws_for<'a>(state: &'a AppState, asset: &str) -> Result<&'a LwsClient, AppError> {
|
||||
let client = match asset {
|
||||
"xmr" => state.chains.xmr.as_ref(),
|
||||
"wow" => state.chains.wow.as_ref(),
|
||||
other => {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"Invalid CryptoNote asset: {other} (expected xmr or wow)"
|
||||
)))
|
||||
}
|
||||
};
|
||||
client.ok_or_else(|| {
|
||||
AppError::ValidationError(format!("{asset} support is not enabled on this server"))
|
||||
})
|
||||
}
|
||||
|
||||
// ── request DTOs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Address + private view key for a per-account query.
|
||||
// Deliberately omits `Debug`: it carries the private `view_key`, matching the
|
||||
// crate convention for secret-bearing request structs — so it can't be dumped in
|
||||
// cleartext via a stray `{:?}`, panic message, or added log line.
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ViewRequest {
|
||||
/// `xmr` or `wow`.
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
|
||||
pub view_key: String,
|
||||
}
|
||||
|
||||
/// Register (or import-with-height) an account for LWS scanning.
|
||||
// Omits `Debug` (carries the private `view_key`) — see `ViewRequest`.
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct RegisterRequest {
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
|
||||
pub view_key: String,
|
||||
/// Scan from this block height (wallet birthday). Omit to scan from now.
|
||||
pub start_height: Option<u64>,
|
||||
}
|
||||
|
||||
/// Request decoy outputs for ring construction.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct RandomOutsRequest {
|
||||
pub asset: String,
|
||||
/// Decoys per real output (protocol ring size; clamped server-side).
|
||||
pub count: u32,
|
||||
/// Amounts to request decoys for (`["0"]` for RingCT).
|
||||
pub amounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Submit a finalized, signed transaction.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct SubmitRequest {
|
||||
pub asset: String,
|
||||
/// Hex-encoded signed transaction blob.
|
||||
pub tx_hex: String,
|
||||
}
|
||||
|
||||
/// Asset-only query.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct LwsAssetRequest {
|
||||
/// `xmr` or `wow`.
|
||||
pub asset: String,
|
||||
}
|
||||
|
||||
/// Confirmation-count query for a tx.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ConfirmationsRequest {
|
||||
pub asset: String,
|
||||
/// Transaction id (64 hex).
|
||||
pub txid: String,
|
||||
}
|
||||
|
||||
// ── response DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Balance + scan state. `total_received` is the balance (an LWS cannot confirm
|
||||
/// spends without the spend key; the wallet subtracts its own known spends).
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct LwsBalanceResponse {
|
||||
pub asset: String,
|
||||
pub total_received: u64,
|
||||
pub unlocked: u64,
|
||||
pub locked: u64,
|
||||
pub scanned_height: u64,
|
||||
pub blockchain_height: u64,
|
||||
pub transaction_count: u64,
|
||||
}
|
||||
|
||||
/// A candidate spent output (verify with the spend key before trusting).
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SpentOutputDto {
|
||||
pub amount: u64,
|
||||
pub key_image: String,
|
||||
pub tx_pub_key: String,
|
||||
pub out_index: u64,
|
||||
pub mixin: u64,
|
||||
}
|
||||
|
||||
/// A transaction in the account's history.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TxDto {
|
||||
pub hash: String,
|
||||
pub height: u64,
|
||||
pub timestamp: String,
|
||||
pub total_received: u64,
|
||||
/// "Possible" sent — candidate spends, not authoritative.
|
||||
pub total_sent: u64,
|
||||
pub mempool: bool,
|
||||
pub unlock_time: u64,
|
||||
pub payment_id: Option<String>,
|
||||
pub spent_outputs: Vec<SpentOutputDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct LwsHistoryResponse {
|
||||
pub asset: String,
|
||||
pub transactions: Vec<TxDto>,
|
||||
}
|
||||
|
||||
/// An unspent output for spend construction.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct UnspentOutputDto {
|
||||
pub amount: u64,
|
||||
pub public_key: String,
|
||||
pub tx_pub_key: String,
|
||||
pub index: u32,
|
||||
pub global_index: u64,
|
||||
pub height: u64,
|
||||
pub timestamp: String,
|
||||
pub tx_hash: String,
|
||||
pub rct: String,
|
||||
/// Key images seen on-chain that may correspond to this output being spent.
|
||||
pub spend_key_images: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct UnspentOutsResponse {
|
||||
pub asset: String,
|
||||
pub outputs: Vec<UnspentOutputDto>,
|
||||
pub per_byte_fee: u64,
|
||||
pub fee_mask: u64,
|
||||
pub fork_version: u8,
|
||||
}
|
||||
|
||||
/// A decoy output.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct RandomOutputDto {
|
||||
pub global_index: u64,
|
||||
pub public_key: String,
|
||||
pub rct: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct AmountOutsDto {
|
||||
pub amount: String,
|
||||
pub outputs: Vec<RandomOutputDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct RandomOutsResponse {
|
||||
pub asset: String,
|
||||
pub amount_outs: Vec<AmountOutsDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct HeightResponse {
|
||||
pub asset: String,
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ConfirmationsResponse {
|
||||
pub asset: String,
|
||||
/// Confirmations, or `null` if the tx is unknown / not yet in a block.
|
||||
pub confirmations: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
// ── mappers (infra type -> wire DTO) ──────────────────────────────────────────
|
||||
|
||||
fn spent_dto(s: SpentOutput) -> SpentOutputDto {
|
||||
SpentOutputDto {
|
||||
amount: s.amount,
|
||||
key_image: s.key_image,
|
||||
tx_pub_key: s.tx_pub_key,
|
||||
out_index: s.out_index,
|
||||
mixin: s.mixin,
|
||||
}
|
||||
}
|
||||
|
||||
fn tx_dto(t: AddressTx) -> TxDto {
|
||||
TxDto {
|
||||
hash: t.hash,
|
||||
height: t.height,
|
||||
timestamp: t.timestamp,
|
||||
total_received: t.total_received,
|
||||
total_sent: t.total_sent,
|
||||
mempool: t.mempool,
|
||||
unlock_time: t.unlock_time,
|
||||
payment_id: t.payment_id,
|
||||
spent_outputs: t.spent_outputs.into_iter().map(spent_dto).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unspent_dto(u: UnspentOutput) -> UnspentOutputDto {
|
||||
UnspentOutputDto {
|
||||
amount: u.amount,
|
||||
public_key: u.public_key,
|
||||
tx_pub_key: u.tx_pub_key,
|
||||
index: u.index,
|
||||
global_index: u.global_index,
|
||||
height: u.height,
|
||||
timestamp: u.timestamp,
|
||||
tx_hash: u.tx_hash,
|
||||
rct: u.rct,
|
||||
spend_key_images: u.spend_key_images,
|
||||
}
|
||||
}
|
||||
|
||||
fn random_dto(r: RandomOutput) -> RandomOutputDto {
|
||||
RandomOutputDto {
|
||||
global_index: r.global_index,
|
||||
public_key: r.public_key,
|
||||
rct: r.rct,
|
||||
}
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register an account (its view key) with the LWS so it begins scanning.
|
||||
/// Idempotent at the LWS; pass `start_height` to scan from a wallet birthday.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 200, description = "Account registered for scanning", body = OkResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<OkResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
match req.start_height {
|
||||
Some(h) => {
|
||||
client
|
||||
.import_account(&req.address, &req.view_key, h)
|
||||
.await?
|
||||
}
|
||||
None => client.register_account(&req.address, &req.view_key).await?,
|
||||
}
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
/// Balance + scan state for an account.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/balance",
|
||||
request_body = ViewRequest,
|
||||
responses(
|
||||
(status = 200, description = "Account balance and scan state", body = LwsBalanceResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn balance(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ViewRequest>,
|
||||
) -> Result<Json<LwsBalanceResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
let info = client.get_address_info(&req.address, &req.view_key).await?;
|
||||
Ok(Json(LwsBalanceResponse {
|
||||
asset,
|
||||
total_received: info.balance(),
|
||||
unlocked: info.unlocked_balance(),
|
||||
locked: info.locked_funds,
|
||||
scanned_height: info.scanned_height,
|
||||
blockchain_height: info.blockchain_height,
|
||||
transaction_count: info.transaction_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Transaction history (confirmed + mempool) for an account.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/history",
|
||||
request_body = ViewRequest,
|
||||
responses(
|
||||
(status = 200, description = "Account transaction history", body = LwsHistoryResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn history(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ViewRequest>,
|
||||
) -> Result<Json<LwsHistoryResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
let txs = client.get_address_txs(&req.address, &req.view_key).await?;
|
||||
Ok(Json(LwsHistoryResponse {
|
||||
asset,
|
||||
transactions: txs.transactions.into_iter().map(tx_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unspent outputs for an account (for client-side spend construction).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/unspent_outs",
|
||||
request_body = ViewRequest,
|
||||
responses(
|
||||
(status = 200, description = "Unspent outputs + fee parameters", body = UnspentOutsResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn unspent_outs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ViewRequest>,
|
||||
) -> Result<Json<UnspentOutsResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
let outs = client.get_unspent_outs(&req.address, &req.view_key).await?;
|
||||
Ok(Json(UnspentOutsResponse {
|
||||
asset,
|
||||
outputs: outs.outputs.into_iter().map(unspent_dto).collect(),
|
||||
per_byte_fee: outs.per_byte_fee,
|
||||
fee_mask: outs.fee_mask,
|
||||
fork_version: outs.fork_version,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Random decoy outputs for ring construction.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/random_outs",
|
||||
request_body = RandomOutsRequest,
|
||||
responses(
|
||||
(status = 200, description = "Decoy outputs", body = RandomOutsResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or request"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn random_outs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RandomOutsRequest>,
|
||||
) -> Result<Json<RandomOutsResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
|
||||
// The client clamps `count` and caps `amounts`/fan-out defensively.
|
||||
let outs = client.get_random_outs(req.count, req.amounts).await?;
|
||||
Ok(Json(RandomOutsResponse {
|
||||
asset,
|
||||
amount_outs: outs
|
||||
.amount_outs
|
||||
.into_iter()
|
||||
.map(|a| AmountOutsDto {
|
||||
amount: a.amount,
|
||||
outputs: a.outputs.into_iter().map(random_dto).collect(),
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Broadcast a finalized, signed Monero/Wownero transaction.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/submit_tx",
|
||||
request_body = SubmitRequest,
|
||||
responses(
|
||||
(status = 200, description = "Transaction submitted", body = OkResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or malformed tx hex"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn submit_tx(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<SubmitRequest>,
|
||||
) -> Result<Json<OkResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_hex(&req.tx_hex, "tx_hex", MAX_CN_TX_HEX_LEN)?;
|
||||
|
||||
client.submit_raw_tx(&req.tx_hex).await?;
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
/// Current chain height (daemon) for confirmation counting.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/height",
|
||||
request_body = LwsAssetRequest,
|
||||
responses(
|
||||
(status = 200, description = "Chain height", body = HeightResponse),
|
||||
(status = 400, description = "Invalid/disabled asset"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn height(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LwsAssetRequest>,
|
||||
) -> Result<Json<HeightResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let height = lws_for(&state, &asset)?.get_blockchain_height().await?;
|
||||
Ok(Json(HeightResponse { asset, height }))
|
||||
}
|
||||
|
||||
/// Confirmation count for a transaction (daemon).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/wallet/lws/confirmations",
|
||||
request_body = ConfirmationsRequest,
|
||||
responses(
|
||||
(status = 200, description = "Confirmation count", body = ConfirmationsResponse),
|
||||
(status = 400, description = "Invalid/disabled asset or txid"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn confirmations(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ConfirmationsRequest>,
|
||||
) -> Result<Json<ConfirmationsResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_hex(&req.txid, "txid", 64)?;
|
||||
|
||||
let confirmations = client.get_transaction_confirmations(&req.txid).await?;
|
||||
Ok(Json(ConfirmationsResponse {
|
||||
asset,
|
||||
confirmations,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Monero/Wownero routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/wallet/lws/register", post(register))
|
||||
.route("/wallet/lws/balance", post(balance))
|
||||
.route("/wallet/lws/history", post(history))
|
||||
.route("/wallet/lws/unspent_outs", post(unspent_outs))
|
||||
.route("/wallet/lws/random_outs", post(random_outs))
|
||||
.route("/wallet/lws/submit_tx", post(submit_tx))
|
||||
.route("/wallet/lws/height", post(height))
|
||||
.route("/wallet/lws/confirmations", post(confirmations))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! L1 integration: Monero/Wownero wallet endpoints through the real router.
|
||||
//!
|
||||
//! Deterministic, no-network paths only: the JWT gate, asset validation, and
|
||||
//! view-key / tx-hex / txid validation — all resolved before any LWS call. The
|
||||
//! funded/networked happy paths 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/lws/balance",
|
||||
None,
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.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/lws/balance",
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn balance_rejects_malformed_view_key() {
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
// Authed + valid asset + address, but the view key is not 64 hex -> 400
|
||||
// before any LWS call.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/wallet/lws/balance",
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "address": "4SomeAddress", "view_key": "tooshort" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_requires_a_token() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/wallet/lws/register",
|
||||
None,
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_rejects_malformed_tx_hex() {
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/wallet/lws/submit_tx",
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "tx_hex": "nothex!!" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirmations_rejects_malformed_txid() {
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/wallet/lws/confirmations",
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "txid": "not-a-txid" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
Reference in New Issue
Block a user