feat(wallet): subaddress provisioning and batched UTXO endpoints (off by default)

Backend support for two address-privacy features. Both are behind env flags
that default to off, so an unconfigured instance behaves exactly as before:
the routes are not mounted and no provisioning is attempted.

FEATURE_XMR_SUBADDR_PROVISIONING
  Adds POST /wallet/lws/provision_subaddrs and provisions account-0 subaddress
  indices on the LWS at register/import.

  Three money-safety properties worth calling out:

  * The provisioned ceiling is read from the LWS response, never echoed back
    from the request. It is the contiguous run starting at minor 0, not
    max(hi): given ranges [[0,99],[200,299]], reporting 299 would let a wallet
    hand out indices 100..199 that the LWS never scans, so funds sent there
    would be invisible. No usable run is a hard error, not a guess.
  * import_account now always runs the backward rescan even when provisioning
    fails. Previously a provisioning error aborted first, leaving the account
    registered at the chain tip while every retry returned 200 OK, so a
    restoring wallet would show a zero balance permanently.
  * A capacity probe reads max_subaddresses from the LWS and refuses up front
    with a message naming the required --max-subaddresses value, rather than
    failing per-account later. The option defaults to 0 upstream.

  Spent outputs now carry their subaddress index. monero-lws emits this as
  `sender` (the index the spent output was received at), not `recipient`;
  without it a client recomputes a primary key image for a subaddress spend,
  treats the mismatch as a decoy, and never subtracts the amount.

FEATURE_UTXO_MULTI_ADDRESS
  Adds balance_multi / utxos_multi / history_multi for HD gap-limit wallets.
  Capped at 32 addresses, summed with checked_add, per-UTXO address tag
  preserved so the client never has to re-guess which key owns an output.
  Bounded concurrency with a per-batch deadline returning 504, so a slow
  Electrum server cannot stack n * IO_TIMEOUT.

Both flags are advertised in /capabilities so a client can negotiate instead
of calling routes that 404.
This commit is contained in:
jwinterm
2026-07-28 16:01:25 -04:00
parent cf85dce89f
commit fb48e04ea6
12 changed files with 2379 additions and 44 deletions
+447 -4
View File
@@ -1949,6 +1949,55 @@
]
}
},
"/wallet/lws/provision_subaddrs": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Provision account-0 subaddress indices at the LWS for an already-registered\naccount, and report the ceiling the LWS confirmed.",
"description": "Mounted only when `FEATURE_XMR_SUBADDR_PROVISIONING` is on (see\n`/capabilities` → `features.xmr_subaddr_provisioning`); otherwise the route\ndoes not exist and the request 404s.",
"operationId": "provision_subaddrs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProvisionRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "LWS-confirmed subaddress ceiling for account 0",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProvisionResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or view key"
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Subaddress provisioning is not enabled on this instance"
},
"503": {
"description": "Upstream node unavailable, or it cannot provision subaddresses"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/wallet/lws/random_outs": {
"post": {
"tags": [
@@ -2174,6 +2223,54 @@
]
}
},
"/wallet/utxo/balance_multi": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Confirmed/unconfirmed balance summed across a batch of BTC/LTC addresses.",
"operationId": "balance_multi",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiAddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Summed balance in satoshis",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiBalanceResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or oversized batch"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
},
"504": {
"description": "The batch exceeded its deadline; retry with a smaller batch"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/wallet/utxo/broadcast": {
"post": {
"tags": [
@@ -2309,6 +2406,54 @@
]
}
},
"/wallet/utxo/history_multi": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Confirmed + mempool history across a batch of BTC/LTC addresses, each entry\ntagged with its owning address.",
"operationId": "history_multi",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiAddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Address-tagged transaction history",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiHistoryResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or oversized batch"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
},
"504": {
"description": "The batch exceeded its deadline; retry with a smaller batch"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/wallet/utxo/tip": {
"post": {
"tags": [
@@ -2398,6 +2543,54 @@
}
]
}
},
"/wallet/utxo/utxos_multi": {
"post": {
"tags": [
"btc_ltc"
],
"summary": "Unspent outputs across a batch of BTC/LTC addresses, each tagged with its\nowning address (the wallet never re-guesses UTXO ownership).",
"operationId": "utxos_multi",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiAddressRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Address-tagged unspent outputs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiUtxosResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or oversized batch"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
},
"504": {
"description": "The batch exceeded its deadline; retry with a smaller batch"
}
},
"security": [
{
"bearer_auth": []
}
]
}
}
},
"components": {
@@ -3123,7 +3316,9 @@
"nostr_relay",
"premium_relay",
"feed",
"tips"
"tips",
"xmr_subaddr_provisioning",
"utxo_multi_address"
],
"properties": {
"feed": {
@@ -3161,6 +3356,14 @@
"tips": {
"type": "boolean",
"description": "Tipping (parked)."
},
"utxo_multi_address": {
"type": "boolean",
"description": "Batch (multi-address) BTC/LTC queries: this instance serves\n`POST /wallet/utxo/{balance,utxos,history}_multi`. Off ⇒ those routes are\nnot mounted (404) and the wallet must query one address at a time."
},
"xmr_subaddr_provisioning": {
"type": "boolean",
"description": "Monero/Wownero subaddress provisioning: this instance registers a batch\nof account-0 subaddress indices with its LWS and serves\n`POST /wallet/lws/provision_subaddrs`. Off ⇒ that route is not mounted\n(404) and the wallet must keep receiving on the primary address only."
}
}
},
@@ -3815,6 +4018,86 @@
}
}
},
"MultiAddressRequest": {
"type": "object",
"description": "A batch query for several addresses of one UTXO asset. The list is bounded\nserver-side (see [`MAX_MULTI_ADDRESSES`]).",
"required": [
"asset",
"addresses"
],
"properties": {
"addresses": {
"type": "array",
"items": {
"type": "string"
},
"description": "The addresses to query (each validated by the Electrum client)."
},
"asset": {
"type": "string",
"description": "`btc` or `ltc`."
}
}
},
"MultiBalanceResponse": {
"type": "object",
"description": "Confirmed/unconfirmed balance summed across the requested addresses. Summed\nwith checked arithmetic server-side (a hostile value errors, never wraps);\nthe wallet computes any grand total from these integer fields.",
"required": [
"asset",
"confirmed",
"unconfirmed"
],
"properties": {
"asset": {
"type": "string"
},
"confirmed": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"unconfirmed": {
"type": "integer",
"format": "int64"
}
}
},
"MultiHistoryResponse": {
"type": "object",
"required": [
"asset",
"transactions"
],
"properties": {
"asset": {
"type": "string"
},
"transactions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TaggedHistoryEntry"
}
}
}
},
"MultiUtxosResponse": {
"type": "object",
"required": [
"asset",
"utxos"
],
"properties": {
"asset": {
"type": "string"
},
"utxos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TaggedUtxo"
}
}
}
},
"MyUsernameResponse": {
"type": "object",
"properties": {
@@ -4086,6 +4369,56 @@
}
}
},
"ProvisionRequest": {
"type": "object",
"description": "Provision a batch of account-0 subaddress indices for an account. Additive to\n`register`; the identity comes from the bearer token, so any `user_id` on the\nwire is ignored.",
"required": [
"asset",
"address",
"view_key"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string",
"description": "`xmr` or `wow`."
},
"max_minor": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Highest minor index wanted for account 0. Clamped to the server ceiling.\nOmit for the instance default.",
"minimum": 0
},
"view_key": {
"type": "string",
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
}
}
},
"ProvisionResponse": {
"type": "object",
"description": "The subaddress ceiling the LWS CONFIRMED for account 0.",
"required": [
"asset",
"provisioned_minor_max"
],
"properties": {
"asset": {
"type": "string"
},
"provisioned_minor_max": {
"type": "integer",
"format": "int32",
"description": "Highest minor index the LWS confirmed it is scanning for account 0, read\nback from its response - never an echo of the request. Every index in\n`0..=provisioned_minor_max` is provisioned; the wallet must not hand out\nan index above it.",
"minimum": 0
}
}
},
"PublicKeysInfo": {
"type": "object",
"description": "A user's per-asset receiving public keys, by asset.",
@@ -4317,6 +4650,15 @@
"description": "Scan from this block height (wallet birthday). Omit to scan from now.",
"minimum": 0
},
"subaddr_count": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "How many account-0 minor subaddress indices to provision at the LWS.\nClamped to the server ceiling, and may only RAISE this instance's\ndefault; it can never enable provisioning on an instance that has it off.\nOmit to use the instance default.",
"minimum": 0
},
"view_key": {
"type": "string",
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
@@ -4617,7 +4959,8 @@
"key_image",
"tx_pub_key",
"out_index",
"mixin"
"mixin",
"subaddr_index"
],
"properties": {
"amount": {
@@ -4636,6 +4979,10 @@
"format": "int64",
"minimum": 0
},
"subaddr_index": {
"$ref": "#/components/schemas/SubaddrIndexDto",
"description": "Subaddress index of the output BEING SPENT (`(0, 0)` = primary address),\ntaken from the spend record itself.\n\nLoad-bearing for the balance: the wallet recomputes this output's key\nimage to tell a real spend from a ring decoy, and the key image depends\non the subaddress index. Without it a subaddress spend is recomputed\nagainst the primary index, the key images never match, the spend is\ndismissed as a decoy, and the amount is never subtracted - the balance\nover-reports forever. It is deliberately NOT the enclosing transaction's\n`subaddr_index`, which is the change index and would be just as wrong."
},
"tx_pub_key": {
"type": "string"
}
@@ -4660,6 +5007,26 @@
}
}
},
"SubaddrIndexDto": {
"type": "object",
"description": "A subaddress index `(major, minor)` an output/tx was received at. Nested to\nmatch the wallet's wasm `LwsOutput` shape (`subaddr_index: {major, minor}`);\nmapped from the LWS `recipient` field. `(0, 0)` is the primary address.",
"required": [
"major",
"minor"
],
"properties": {
"major": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"minor": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"SubmitRequest": {
"type": "object",
"description": "Submit a finalized, signed transaction.",
@@ -4677,6 +5044,72 @@
}
}
},
"TaggedHistoryEntry": {
"type": "object",
"description": "A transaction-history entry TAGGED with its owning address.",
"required": [
"address",
"txid",
"height"
],
"properties": {
"address": {
"type": "string"
},
"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"
}
}
},
"TaggedUtxo": {
"type": "object",
"description": "A single unspent output TAGGED with the address it belongs to. The tag is\nload-bearing: the wallet never re-derives which address owns a UTXO.",
"required": [
"address",
"txid",
"vout",
"value",
"height"
],
"properties": {
"address": {
"type": "string"
},
"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
}
}
},
"TipResponse": {
"type": "object",
"required": [
@@ -4705,7 +5138,8 @@
"total_sent",
"mempool",
"unlock_time",
"spent_outputs"
"spent_outputs",
"subaddr_index"
],
"properties": {
"hash": {
@@ -4731,6 +5165,10 @@
"$ref": "#/components/schemas/SpentOutputDto"
}
},
"subaddr_index": {
"$ref": "#/components/schemas/SubaddrIndexDto",
"description": "Subaddress index this tx was received at (`(0, 0)` = primary address)."
},
"timestamp": {
"type": "string"
},
@@ -4761,7 +5199,8 @@
"timestamp",
"tx_hash",
"rct",
"spend_key_images"
"spend_key_images",
"subaddr_index"
],
"properties": {
"amount": {
@@ -4795,6 +5234,10 @@
},
"description": "Key images seen on-chain that may correspond to this output being spent."
},
"subaddr_index": {
"$ref": "#/components/schemas/SubaddrIndexDto",
"description": "Subaddress index this output was received at (`(0, 0)` = primary address)."
},
"timestamp": {
"type": "string"
},