mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -107,6 +107,23 @@ FEATURE_XMR=true
|
||||
FEATURE_WOW=true
|
||||
FEATURE_GRIN=true
|
||||
|
||||
# ── Address-privacy features (opt-in) ─────────────────────────────────────
|
||||
# Per-payment receive addresses. Off by default; enable once your node stack
|
||||
# meets the requirement noted below.
|
||||
#
|
||||
# FEATURE_XMR_SUBADDR_PROVISIONING: provisions account-0 subaddress indices on
|
||||
# the LWS at register/import so per-payment XMR/WOW receive addresses are
|
||||
# scanned. REQUIRES your monero-lws / wownero-lws to run with
|
||||
# `--max-subaddresses >= 200` (it defaults to 0, which disables subaddress
|
||||
# support entirely). Check yours before enabling.
|
||||
FEATURE_XMR_SUBADDR_PROVISIONING=false
|
||||
#
|
||||
# FEATURE_UTXO_MULTI_ADDRESS: mounts the batched BTC/LTC endpoints
|
||||
# (/wallet/utxo/{balance,utxos,history}_multi) used by HD gap-limit wallets,
|
||||
# capped at 32 addresses per request. When off, the routes are not mounted and
|
||||
# the single-address endpoints are unchanged.
|
||||
FEATURE_UTXO_MULTI_ADDRESS=false
|
||||
|
||||
# ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ─────
|
||||
BTC_NETWORK=mainnet
|
||||
BTC_ELECTRUM_URL=
|
||||
|
||||
+447
-4
@@ -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"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,20 @@ use crate::AppState;
|
||||
/// soft-notice a higher value and ignore unknown (additive) keys.
|
||||
pub const CAPABILITIES_CONTRACT_VERSION: u32 = 1;
|
||||
|
||||
/// The one parser for the environment-held feature flags, so every reader of a
|
||||
/// flag agrees on what "on" means. Accepts `1` / `true` / `on` / `yes`, trimmed
|
||||
/// and ASCII-case-insensitive; anything else (including an unset variable) is
|
||||
/// off. A flag that gates a money path must never mean one thing where it is
|
||||
/// enforced and another where it is advertised.
|
||||
pub(crate) fn env_flag_enabled(name: &str) -> bool {
|
||||
std::env::var(name).is_ok_and(|raw| {
|
||||
let v = raw.trim();
|
||||
["1", "true", "on", "yes"]
|
||||
.iter()
|
||||
.any(|truthy| v.eq_ignore_ascii_case(truthy))
|
||||
})
|
||||
}
|
||||
|
||||
/// Per-chain availability. `network` is the configured network for UTXO chains
|
||||
/// (so the wallet derives addresses for the right one); `null` for chains whose
|
||||
/// network isn't a backend setting.
|
||||
@@ -60,6 +74,15 @@ pub struct FeatureCapabilities {
|
||||
pub feed: bool,
|
||||
/// Tipping (parked).
|
||||
pub tips: bool,
|
||||
/// Monero/Wownero subaddress provisioning: this instance registers a batch
|
||||
/// of account-0 subaddress indices with its LWS and serves
|
||||
/// `POST /wallet/lws/provision_subaddrs`. Off ⇒ that route is not mounted
|
||||
/// (404) and the wallet must keep receiving on the primary address only.
|
||||
pub xmr_subaddr_provisioning: bool,
|
||||
/// Batch (multi-address) BTC/LTC queries: this instance serves
|
||||
/// `POST /wallet/utxo/{balance,utxos,history}_multi`. Off ⇒ those routes are
|
||||
/// not mounted (404) and the wallet must query one address at a time.
|
||||
pub utxo_multi_address: bool,
|
||||
}
|
||||
|
||||
/// First-party Nostr relay details (present only when `features.nostr_relay`).
|
||||
@@ -251,6 +274,15 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
premium_relay: premium_advertised(config),
|
||||
feed: feed_advertised(config),
|
||||
tips: tips_advertised(config),
|
||||
// Advertised through the SAME predicates the routers gate on, so a
|
||||
// client can never be told a route exists that would 404 (or the
|
||||
// reverse). Both are environment flags, and both are chain-gated:
|
||||
// never advertise subaddress provisioning on an instance with no
|
||||
// CryptoNote chain, or batch UTXO queries with no UTXO chain.
|
||||
xmr_subaddr_provisioning: crate::api::wallet::xmr_wow::subaddr_provisioning_enabled()
|
||||
&& (chain_serviceable(config, "xmr") || chain_serviceable(config, "wow")),
|
||||
utxo_multi_address: crate::api::wallet::btc_ltc::utxo_multi_enabled()
|
||||
&& (chain_serviceable(config, "btc") || chain_serviceable(config, "ltc")),
|
||||
},
|
||||
restore: RestoreCapability {
|
||||
policy: config.restore.policy.as_str().to_string(),
|
||||
|
||||
@@ -42,10 +42,14 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::balance,
|
||||
crate::api::wallet::btc_ltc::utxos,
|
||||
crate::api::wallet::btc_ltc::history,
|
||||
crate::api::wallet::btc_ltc::balance_multi,
|
||||
crate::api::wallet::btc_ltc::utxos_multi,
|
||||
crate::api::wallet::btc_ltc::history_multi,
|
||||
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::provision_subaddrs,
|
||||
crate::api::wallet::xmr_wow::balance,
|
||||
crate::api::wallet::xmr_wow::history,
|
||||
crate::api::wallet::xmr_wow::unspent_outs,
|
||||
@@ -125,6 +129,12 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::UtxosResponse,
|
||||
crate::api::wallet::btc_ltc::HistoryEntry,
|
||||
crate::api::wallet::btc_ltc::HistoryResponse,
|
||||
crate::api::wallet::btc_ltc::MultiAddressRequest,
|
||||
crate::api::wallet::btc_ltc::MultiBalanceResponse,
|
||||
crate::api::wallet::btc_ltc::TaggedUtxo,
|
||||
crate::api::wallet::btc_ltc::MultiUtxosResponse,
|
||||
crate::api::wallet::btc_ltc::TaggedHistoryEntry,
|
||||
crate::api::wallet::btc_ltc::MultiHistoryResponse,
|
||||
crate::api::wallet::btc_ltc::AssetRequest,
|
||||
crate::api::wallet::btc_ltc::TipResponse,
|
||||
crate::api::wallet::btc_ltc::FeeRequest,
|
||||
@@ -133,11 +143,14 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::BroadcastResponse,
|
||||
crate::api::wallet::xmr_wow::ViewRequest,
|
||||
crate::api::wallet::xmr_wow::RegisterRequest,
|
||||
crate::api::wallet::xmr_wow::ProvisionRequest,
|
||||
crate::api::wallet::xmr_wow::ProvisionResponse,
|
||||
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::SubaddrIndexDto,
|
||||
crate::api::wallet::xmr_wow::SpentOutputDto,
|
||||
crate::api::wallet::xmr_wow::TxDto,
|
||||
crate::api::wallet::xmr_wow::LwsHistoryResponse,
|
||||
|
||||
+268
-3
@@ -24,13 +24,69 @@ use tracing::instrument;
|
||||
|
||||
use crate::api::middleware::extract_user_id_from_token;
|
||||
use crate::error::AppError;
|
||||
use crate::infra::electrum::ElectrumClient;
|
||||
use crate::infra::electrum::{BatchError, 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;
|
||||
|
||||
/// HARD cap on the number of addresses in one batch (`*_multi`) request. Mirrors
|
||||
/// the fixed-ceiling discipline of [`MAX_TX_HEX_LEN`]: a client-supplied length
|
||||
/// is bounded before any network fan-out so one request cannot fan out
|
||||
/// unboundedly. A wallet with more addresses batches in groups of this size.
|
||||
const MAX_MULTI_ADDRESSES: usize = 32;
|
||||
|
||||
/// Dark feature flag (default OFF): the additive batch (`*_multi`) endpoints are
|
||||
/// only mounted when `FEATURE_UTXO_MULTI_ADDRESS` is set truthy in the
|
||||
/// environment. OFF ⇒ the routes do not exist (404) and every existing
|
||||
/// single-address endpoint is byte-for-byte unchanged. Kept as an environment
|
||||
/// read (not a new `Config` field) to stay within this change's file lane.
|
||||
pub(crate) fn utxo_multi_enabled() -> bool {
|
||||
crate::api::capabilities::env_flag_enabled("FEATURE_UTXO_MULTI_ADDRESS")
|
||||
}
|
||||
|
||||
/// HTTP mapping for a batch query outcome.
|
||||
///
|
||||
/// [`BatchError::Deadline`] is its own status (504) rather than the generic
|
||||
/// upstream-unavailable 503: a batch that ran out of time is not a broken node,
|
||||
/// and a wallet should back off and re-batch smaller rather than mark the chain
|
||||
/// down. Everything else delegates to the shared [`AppError`] mapping, so the
|
||||
/// error envelope (`{error, code}`) and the CWE-209 redaction rules are
|
||||
/// unchanged.
|
||||
impl axum::response::IntoResponse for BatchError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
BatchError::App(e) => e.into_response(),
|
||||
BatchError::Deadline => (
|
||||
axum::http::StatusCode::GATEWAY_TIMEOUT,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "Batch address query timed out",
|
||||
"code": "BATCH_TIMEOUT",
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound a batch address list: non-empty and within [`MAX_MULTI_ADDRESSES`].
|
||||
/// Rejects (never truncates) an oversized list so the caller's intent is never
|
||||
/// silently narrowed.
|
||||
fn validate_address_batch(addresses: &[String]) -> Result<(), AppError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(AppError::ValidationError(
|
||||
"addresses must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if addresses.len() > MAX_MULTI_ADDRESSES {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"too many addresses (max {MAX_MULTI_ADDRESSES})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -117,6 +173,61 @@ pub struct HistoryResponse {
|
||||
pub transactions: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// A batch query for several addresses of one UTXO asset. The list is bounded
|
||||
/// server-side (see [`MAX_MULTI_ADDRESSES`]).
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct MultiAddressRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
/// The addresses to query (each validated by the Electrum client).
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Confirmed/unconfirmed balance summed across the requested addresses. Summed
|
||||
/// with checked arithmetic server-side (a hostile value errors, never wraps);
|
||||
/// the wallet computes any grand total from these integer fields.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MultiBalanceResponse {
|
||||
pub asset: String,
|
||||
pub confirmed: u64,
|
||||
pub unconfirmed: i64,
|
||||
}
|
||||
|
||||
/// A single unspent output TAGGED with the address it belongs to. The tag is
|
||||
/// load-bearing: the wallet never re-derives which address owns a UTXO.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TaggedUtxo {
|
||||
pub address: String,
|
||||
pub txid: String,
|
||||
pub vout: u64,
|
||||
pub value: u64,
|
||||
/// Block height; `0` if unconfirmed.
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MultiUtxosResponse {
|
||||
pub asset: String,
|
||||
pub utxos: Vec<TaggedUtxo>,
|
||||
}
|
||||
|
||||
/// A transaction-history entry TAGGED with its owning address.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TaggedHistoryEntry {
|
||||
pub address: String,
|
||||
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 MultiHistoryResponse {
|
||||
pub asset: String,
|
||||
pub transactions: Vec<TaggedHistoryEntry>,
|
||||
}
|
||||
|
||||
/// An asset-only query.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AssetRequest {
|
||||
@@ -358,17 +469,147 @@ pub async fn broadcast(
|
||||
Ok(Json(BroadcastResponse { txid }))
|
||||
}
|
||||
|
||||
// ── batch (multi-address) handlers ────────────────────────────────────────────
|
||||
|
||||
/// Confirmed/unconfirmed balance summed across a batch of BTC/LTC addresses.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/balance_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Summed balance in satoshis", body = MultiBalanceResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn balance_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiBalanceResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let bal = electrum_for(&state, &asset)?
|
||||
.get_balance_sum(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiBalanceResponse {
|
||||
asset,
|
||||
confirmed: bal.confirmed,
|
||||
unconfirmed: bal.unconfirmed,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unspent outputs across a batch of BTC/LTC addresses, each tagged with its
|
||||
/// owning address (the wallet never re-guesses UTXO ownership).
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/utxos_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Address-tagged unspent outputs", body = MultiUtxosResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn utxos_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiUtxosResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let tagged = electrum_for(&state, &asset)?
|
||||
.get_utxos_tagged(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiUtxosResponse {
|
||||
asset,
|
||||
utxos: tagged
|
||||
.into_iter()
|
||||
.map(|(address, u)| TaggedUtxo {
|
||||
address,
|
||||
txid: u.tx_hash,
|
||||
vout: u.tx_pos,
|
||||
value: u.value,
|
||||
height: u.height,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Confirmed + mempool history across a batch of BTC/LTC addresses, each entry
|
||||
/// tagged with its owning address.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/history_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Address-tagged transaction history", body = MultiHistoryResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn history_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiHistoryResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let tagged = electrum_for(&state, &asset)?
|
||||
.get_history_tagged(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiHistoryResponse {
|
||||
asset,
|
||||
transactions: tagged
|
||||
.into_iter()
|
||||
.map(|(address, e)| TaggedHistoryEntry {
|
||||
address,
|
||||
txid: e.tx_hash,
|
||||
height: e.height,
|
||||
fee: e.fee,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BTC/LTC routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
let mut router = 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))
|
||||
.route("/wallet/utxo/broadcast", post(broadcast));
|
||||
|
||||
// Dark by default: the batch endpoints exist only when explicitly enabled.
|
||||
if utxo_multi_enabled() {
|
||||
router = router
|
||||
.route("/wallet/utxo/balance_multi", post(balance_multi))
|
||||
.route("/wallet/utxo/utxos_multi", post(utxos_multi))
|
||||
.route("/wallet/utxo/history_multi", post(history_multi));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -384,4 +625,28 @@ mod tests {
|
||||
assert!(validate_tx_hex("xyz!").is_err()); // non-hex
|
||||
assert!(validate_tx_hex(&"a".repeat(MAX_TX_HEX_LEN + 1)).is_err()); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_address_request_deserializes() {
|
||||
let req: MultiAddressRequest =
|
||||
serde_json::from_str(r#"{"asset":"btc","addresses":["a1","a2","a3"]}"#).unwrap();
|
||||
assert_eq!(req.asset, "btc");
|
||||
assert_eq!(req.addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_batch_bounds() {
|
||||
// A normal batch passes.
|
||||
assert!(validate_address_batch(&["a".into(), "b".into()]).is_ok());
|
||||
// Exactly at the cap passes.
|
||||
let at_cap: Vec<String> = (0..MAX_MULTI_ADDRESSES).map(|i| i.to_string()).collect();
|
||||
assert!(validate_address_batch(&at_cap).is_ok());
|
||||
// Empty is rejected (never a silent no-op).
|
||||
assert!(validate_address_batch(&[]).is_err());
|
||||
// Oversized is rejected (never silently truncated) — money gate G13.
|
||||
let over: Vec<String> = (0..MAX_MULTI_ADDRESSES + 1)
|
||||
.map(|i| i.to_string())
|
||||
.collect();
|
||||
assert!(validate_address_batch(&over).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+366
-4
@@ -37,6 +37,67 @@ use crate::AppState;
|
||||
/// 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;
|
||||
|
||||
/// Hard cap on the batch of minor subaddress indices provisioned for account 0
|
||||
/// at register time. Bounds one registration's ask on the LWS; the LWS's own
|
||||
/// `--max-subaddresses` is the ultimate bound (and must be `>=` this to enable
|
||||
/// the feature at all).
|
||||
const MAX_PROVISION_MINORS: u32 = 200;
|
||||
|
||||
/// Dark feature flag (default OFF): whether this instance provisions account-0
|
||||
/// subaddress ranges at the LWS. Also advertised on `/capabilities` so a client
|
||||
/// can negotiate instead of calling a route that is not mounted.
|
||||
///
|
||||
/// Kept as an environment read (rather than a new `Config` field) to stay within
|
||||
/// this change's file lane. ENABLING IT ALSO REQUIRES the LWS to run with
|
||||
/// `--max-subaddresses >= MAX_PROVISION_MINORS` (it defaults to `0` =
|
||||
/// subaddresses disabled); the client probes that ceiling and fails closed with
|
||||
/// an operator-legible error rather than half-provisioning.
|
||||
pub(crate) fn subaddr_provisioning_enabled() -> bool {
|
||||
crate::api::capabilities::env_flag_enabled("FEATURE_XMR_SUBADDR_PROVISIONING")
|
||||
}
|
||||
|
||||
/// The number of account-0 minor subaddress indices to provision at the LWS.
|
||||
/// `0` (no provisioning; behavior identical to before) unless the flag is on.
|
||||
fn subaddr_provisioning_minors() -> u32 {
|
||||
if subaddr_provisioning_enabled() {
|
||||
MAX_PROVISION_MINORS
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch width to provision for one register call.
|
||||
///
|
||||
/// A client-supplied `subaddr_count` may RAISE the instance default (clamped to
|
||||
/// [`MAX_PROVISION_MINORS`]) but can never enable provisioning: with the flag
|
||||
/// off this returns `0` for every input, so the dark path stays byte-identical.
|
||||
/// A money-gating field is never silently dropped, which is what an absent
|
||||
/// `deny_unknown_fields` used to do to it.
|
||||
fn effective_provision_minors(requested: Option<u32>) -> u32 {
|
||||
let base = subaddr_provisioning_minors();
|
||||
if base == 0 {
|
||||
return 0;
|
||||
}
|
||||
base.max(requested.unwrap_or(0).min(MAX_PROVISION_MINORS))
|
||||
}
|
||||
|
||||
/// The LWS batch width for an on-demand `max_minor` ask.
|
||||
///
|
||||
/// `max_minor` is the highest minor INDEX the caller wants, so the width is
|
||||
/// `max_minor + 1`, clamped so one call never provisions more than
|
||||
/// [`MAX_PROVISION_MINORS`] indices (i.e. an ask above `MAX_PROVISION_MINORS - 1`
|
||||
/// is clamped down, never rejected). An absent ask uses the instance default.
|
||||
/// `0` whenever provisioning is off, so this can never turn the feature on.
|
||||
fn provision_width_for(max_minor: Option<u32>) -> u32 {
|
||||
if !subaddr_provisioning_enabled() {
|
||||
return 0;
|
||||
}
|
||||
match max_minor {
|
||||
Some(m) => m.saturating_add(1).min(MAX_PROVISION_MINORS),
|
||||
None => subaddr_provisioning_minors(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -83,6 +144,40 @@ pub struct RegisterRequest {
|
||||
/// otherwise. Bound to `(asset, address, start_height)` (see `restore_pow`).
|
||||
#[serde(default)]
|
||||
pub restore_pow_nonce: Option<u64>,
|
||||
/// How many account-0 minor subaddress indices to provision at the LWS.
|
||||
/// Clamped to the server ceiling, and may only RAISE this instance's
|
||||
/// default; it can never enable provisioning on an instance that has it off.
|
||||
/// Omit to use the instance default.
|
||||
#[serde(default)]
|
||||
pub subaddr_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Provision a batch of account-0 subaddress indices for an account. Additive to
|
||||
/// `register`; the identity comes from the bearer token, so any `user_id` on the
|
||||
/// wire is ignored.
|
||||
// Omits `Debug` (carries the private `view_key`) - see `ViewRequest`.
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProvisionRequest {
|
||||
/// `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,
|
||||
/// Highest minor index wanted for account 0. Clamped to the server ceiling.
|
||||
/// Omit for the instance default.
|
||||
#[serde(default)]
|
||||
pub max_minor: Option<u32>,
|
||||
}
|
||||
|
||||
/// The subaddress ceiling the LWS CONFIRMED for account 0.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ProvisionResponse {
|
||||
pub asset: String,
|
||||
/// Highest minor index the LWS confirmed it is scanning for account 0, read
|
||||
/// back from its response - never an echo of the request. Every index in
|
||||
/// `0..=provisioned_minor_max` is provisioned; the wallet must not hand out
|
||||
/// an index above it.
|
||||
pub provisioned_minor_max: u32,
|
||||
}
|
||||
|
||||
/// Request decoy outputs for ring construction.
|
||||
@@ -157,6 +252,15 @@ pub struct LwsBalanceResponse {
|
||||
pub spent_outputs: Vec<SpentOutputDto>,
|
||||
}
|
||||
|
||||
/// A subaddress index `(major, minor)` an output/tx was received at. Nested to
|
||||
/// match the wallet's wasm `LwsOutput` shape (`subaddr_index: {major, minor}`);
|
||||
/// mapped from the LWS `recipient` field. `(0, 0)` is the primary address.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SubaddrIndexDto {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
}
|
||||
|
||||
/// A candidate spent output (verify with the spend key before trusting).
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SpentOutputDto {
|
||||
@@ -167,6 +271,17 @@ pub struct SpentOutputDto {
|
||||
pub tx_pub_key: String,
|
||||
pub out_index: u64,
|
||||
pub mixin: u64,
|
||||
/// Subaddress index of the output BEING SPENT (`(0, 0)` = primary address),
|
||||
/// taken from the spend record itself.
|
||||
///
|
||||
/// Load-bearing for the balance: the wallet recomputes this output's key
|
||||
/// image to tell a real spend from a ring decoy, and the key image depends
|
||||
/// on the subaddress index. Without it a subaddress spend is recomputed
|
||||
/// against the primary index, the key images never match, the spend is
|
||||
/// dismissed as a decoy, and the amount is never subtracted - the balance
|
||||
/// over-reports forever. It is deliberately NOT the enclosing transaction's
|
||||
/// `subaddr_index`, which is the change index and would be just as wrong.
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
/// A transaction in the account's history.
|
||||
@@ -186,6 +301,8 @@ pub struct TxDto {
|
||||
pub unlock_time: u64,
|
||||
pub payment_id: Option<String>,
|
||||
pub spent_outputs: Vec<SpentOutputDto>,
|
||||
/// Subaddress index this tx was received at (`(0, 0)` = primary address).
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
@@ -210,6 +327,8 @@ pub struct UnspentOutputDto {
|
||||
pub rct: String,
|
||||
/// Key images seen on-chain that may correspond to this output being spent.
|
||||
pub spend_key_images: Vec<String>,
|
||||
/// Subaddress index this output was received at (`(0, 0)` = primary address).
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
@@ -268,6 +387,12 @@ fn spent_dto(s: SpentOutput) -> SpentOutputDto {
|
||||
tx_pub_key: s.tx_pub_key,
|
||||
out_index: s.out_index,
|
||||
mixin: s.mixin,
|
||||
// From the SPEND RECORD (monero-lws `sender`), never from the enclosing
|
||||
// tx's `recipient` - that is the change index.
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: s.sender.maj_i,
|
||||
minor: s.sender.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +407,10 @@ fn tx_dto(t: AddressTx) -> TxDto {
|
||||
unlock_time: t.unlock_time,
|
||||
payment_id: t.payment_id,
|
||||
spent_outputs: t.spent_outputs.into_iter().map(spent_dto).collect(),
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: t.recipient.maj_i,
|
||||
minor: t.recipient.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +426,10 @@ fn unspent_dto(u: UnspentOutput) -> UnspentOutputDto {
|
||||
tx_hash: u.tx_hash,
|
||||
rct: u.rct,
|
||||
spend_key_images: u.spend_key_images,
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: u.recipient.maj_i,
|
||||
minor: u.recipient.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +470,14 @@ pub async fn register(
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
// Dark by default: 0 ⇒ no subaddress provisioning (register behaves exactly
|
||||
// as before). When the flag is on, both paths provision a bounded batch of
|
||||
// account-0 minor indices and HARD-error on provision failure (the `?`
|
||||
// propagates) — never silently skipping so a wallet is not told scanning is
|
||||
// ready when the subaddresses were not registered. A client-supplied
|
||||
// `subaddr_count` may raise the batch within the server ceiling.
|
||||
let provision_minors = effective_provision_minors(req.subaddr_count);
|
||||
|
||||
match req.start_height {
|
||||
Some(h) => {
|
||||
// Restore: gate the scan depth against this instance's policy (the
|
||||
@@ -351,14 +492,65 @@ pub async fn register(
|
||||
req.restore_pow_nonce,
|
||||
)?;
|
||||
client
|
||||
.import_account(&req.address, &req.view_key, h)
|
||||
.import_account(&req.address, &req.view_key, h, provision_minors)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
client
|
||||
.register_account(&req.address, &req.view_key, provision_minors)
|
||||
.await?
|
||||
}
|
||||
None => client.register_account(&req.address, &req.view_key).await?,
|
||||
}
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
/// Provision account-0 subaddress indices at the LWS for an already-registered
|
||||
/// account, and report the ceiling the LWS confirmed.
|
||||
///
|
||||
/// Mounted only when `FEATURE_XMR_SUBADDR_PROVISIONING` is on (see
|
||||
/// `/capabilities` → `features.xmr_subaddr_provisioning`); otherwise the route
|
||||
/// does not exist and the request 404s.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/lws/provision_subaddrs",
|
||||
request_body = ProvisionRequest,
|
||||
responses(
|
||||
(status = 200, description = "LWS-confirmed subaddress ceiling for account 0", body = ProvisionResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 404, description = "Subaddress provisioning is not enabled on this instance"),
|
||||
(status = 503, description = "Upstream node unavailable, or it cannot provision subaddresses")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn provision_subaddrs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ProvisionRequest>,
|
||||
) -> Result<Json<ProvisionResponse>, AppError> {
|
||||
// Identity comes from the bearer token alone. Any `user_id` on the wire is
|
||||
// ignored (unknown fields are dropped), so it can never select an account.
|
||||
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)?;
|
||||
|
||||
// Clamped to the hard server ceiling; an absent ask uses the instance
|
||||
// default. The route is only mounted with the flag on, so the width is
|
||||
// never 0 here, but `provision_account0` rejects a 0 ask regardless.
|
||||
let n_min = provision_width_for(req.max_minor);
|
||||
let provisioned_minor_max = client
|
||||
.provision_account0(&req.address, &req.view_key, n_min)
|
||||
.await?;
|
||||
Ok(Json(ProvisionResponse {
|
||||
asset,
|
||||
provisioned_minor_max,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Balance + scan state for an account.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
@@ -616,7 +808,7 @@ pub async fn confirmations(
|
||||
|
||||
/// Monero/Wownero routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
let mut router = Router::new()
|
||||
.route("/wallet/lws/register", post(register))
|
||||
.route("/wallet/lws/balance", post(balance))
|
||||
.route("/wallet/lws/history", post(history))
|
||||
@@ -629,7 +821,14 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
post(submit_tx).layer(DefaultBodyLimit::max(MAX_CN_TX_HEX_LEN + 64 * 1024)),
|
||||
)
|
||||
.route("/wallet/lws/height", post(height))
|
||||
.route("/wallet/lws/confirmations", post(confirmations))
|
||||
.route("/wallet/lws/confirmations", post(confirmations));
|
||||
|
||||
// Dark by default: the provisioning endpoint exists only when enabled, so a
|
||||
// client that has not negotiated the capability gets a clean 404.
|
||||
if subaddr_provisioning_enabled() {
|
||||
router = router.route("/wallet/lws/provision_subaddrs", post(provision_subaddrs));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -653,12 +852,39 @@ mod amount_wire_tests {
|
||||
tx_hash: "h".into(),
|
||||
rct: String::new(),
|
||||
spend_key_images: vec![],
|
||||
subaddr_index: SubaddrIndexDto { major: 0, minor: 7 },
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["amount"], serde_json::json!("9007199254740993"));
|
||||
assert!(v["amount"].is_string(), "amount must be a JSON string");
|
||||
// A non-amount field stays a number.
|
||||
assert!(v["global_index"].is_number());
|
||||
// The subaddress index is nested `{major, minor}` (wasm LwsOutput shape).
|
||||
assert_eq!(v["subaddr_index"]["major"], serde_json::json!(0));
|
||||
assert_eq!(v["subaddr_index"]["minor"], serde_json::json!(7));
|
||||
}
|
||||
|
||||
// The LWS `recipient` field maps straight into the nested `subaddr_index`
|
||||
// DTO — including the fail-open `(0, 0)` for a primary-address receive.
|
||||
#[test]
|
||||
fn unspent_recipient_maps_to_nested_subaddr_index() {
|
||||
use crate::infra::lws::{SubaddrIndex, UnspentOutput};
|
||||
let infra = UnspentOutput {
|
||||
amount: 1,
|
||||
public_key: "aa".into(),
|
||||
tx_pub_key: "bb".into(),
|
||||
index: 0,
|
||||
global_index: 1,
|
||||
height: 2,
|
||||
timestamp: String::new(),
|
||||
tx_hash: "h".into(),
|
||||
rct: String::new(),
|
||||
spend_key_images: vec![],
|
||||
recipient: SubaddrIndex { maj_i: 1, min_i: 9 },
|
||||
};
|
||||
let dto = unspent_dto(infra);
|
||||
assert_eq!(dto.subaddr_index.major, 1);
|
||||
assert_eq!(dto.subaddr_index.minor, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -679,4 +905,140 @@ mod amount_wire_tests {
|
||||
assert!(v["locked_balance"].is_string() && v["pending_balance"].is_string());
|
||||
assert!(v["blockchain_height"].is_number(), "heights stay numbers");
|
||||
}
|
||||
|
||||
// A spend's subaddress index comes from the SPEND RECORD, so a subaddress
|
||||
// spend's key image can be recomputed. Taking it from the enclosing tx (the
|
||||
// change index) would mislabel it, the key image would never match, and the
|
||||
// spend would be dismissed as a decoy - the balance would over-report.
|
||||
#[test]
|
||||
fn spent_output_index_comes_from_the_spend_record_not_the_tx() {
|
||||
use crate::infra::lws::{SpentOutput as InfraSpent, SubaddrIndex};
|
||||
let tx = AddressTx {
|
||||
hash: "h".into(),
|
||||
height: 5,
|
||||
timestamp: String::new(),
|
||||
total_received: 1,
|
||||
total_sent: 9,
|
||||
mempool: false,
|
||||
unlock_time: 0,
|
||||
payment_id: None,
|
||||
spent_outputs: vec![InfraSpent {
|
||||
amount: 9,
|
||||
key_image: "ki".into(),
|
||||
tx_pub_key: "tp".into(),
|
||||
out_index: 2,
|
||||
mixin: 15,
|
||||
sender: SubaddrIndex { maj_i: 0, min_i: 7 },
|
||||
}],
|
||||
// The tx-level index is the CHANGE index and must not leak into the
|
||||
// spend's index.
|
||||
recipient: SubaddrIndex { maj_i: 0, min_i: 1 },
|
||||
};
|
||||
let dto = tx_dto(tx);
|
||||
assert_eq!(dto.subaddr_index.minor, 1, "tx keeps its own index");
|
||||
assert_eq!(dto.spent_outputs[0].subaddr_index.major, 0);
|
||||
assert_eq!(
|
||||
dto.spent_outputs[0].subaddr_index.minor, 7,
|
||||
"the spend's index must be the spent output's own"
|
||||
);
|
||||
// It also crosses the wire under the nested `{major, minor}` shape.
|
||||
let v = serde_json::to_value(&dto.spent_outputs[0]).unwrap();
|
||||
assert_eq!(v["subaddr_index"]["minor"], serde_json::json!(7));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provision_gate_tests {
|
||||
use super::*;
|
||||
|
||||
// These read a process-wide env var, so they run under one lock and always
|
||||
// restore the previous value.
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn with_flag<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var("FEATURE_XMR_SUBADDR_PROVISIONING").ok();
|
||||
match value {
|
||||
Some(v) => std::env::set_var("FEATURE_XMR_SUBADDR_PROVISIONING", v),
|
||||
None => std::env::remove_var("FEATURE_XMR_SUBADDR_PROVISIONING"),
|
||||
}
|
||||
let out = f();
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("FEATURE_XMR_SUBADDR_PROVISIONING", v),
|
||||
None => std::env::remove_var("FEATURE_XMR_SUBADDR_PROVISIONING"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_parsing_is_case_insensitive_and_trimmed() {
|
||||
for on in ["1", "true", "TRUE", "True", "on", "ON", "Yes", " yes "] {
|
||||
assert!(with_flag(Some(on), subaddr_provisioning_enabled), "{on}");
|
||||
}
|
||||
for off in ["0", "false", "no", "off", "", "maybe"] {
|
||||
assert!(!with_flag(Some(off), subaddr_provisioning_enabled), "{off}");
|
||||
}
|
||||
assert!(!with_flag(None, subaddr_provisioning_enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subaddr_count_is_ignored_while_the_flag_is_off() {
|
||||
// Flag OFF must stay byte-identical: no client-supplied count can turn
|
||||
// provisioning on.
|
||||
with_flag(None, || {
|
||||
assert_eq!(effective_provision_minors(None), 0);
|
||||
assert_eq!(effective_provision_minors(Some(200)), 0);
|
||||
assert_eq!(effective_provision_minors(Some(u32::MAX)), 0);
|
||||
assert_eq!(provision_width_for(Some(50)), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subaddr_count_is_accepted_and_clamped_when_enabled() {
|
||||
with_flag(Some("1"), || {
|
||||
// Absent => the instance default.
|
||||
assert_eq!(effective_provision_minors(None), MAX_PROVISION_MINORS);
|
||||
// Never below the instance default (a client cannot narrow it).
|
||||
assert_eq!(effective_provision_minors(Some(1)), MAX_PROVISION_MINORS);
|
||||
// Never above the hard server ceiling.
|
||||
assert_eq!(
|
||||
effective_provision_minors(Some(u32::MAX)),
|
||||
MAX_PROVISION_MINORS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_width_treats_max_minor_as_an_index() {
|
||||
with_flag(Some("yes"), || {
|
||||
// `max_minor` is the highest wanted INDEX, so the width is +1.
|
||||
assert_eq!(provision_width_for(Some(0)), 1);
|
||||
assert_eq!(provision_width_for(Some(49)), 50);
|
||||
// Clamped to the ceiling, never rejected, and never overflowing.
|
||||
assert_eq!(provision_width_for(Some(10_000)), MAX_PROVISION_MINORS);
|
||||
assert_eq!(provision_width_for(Some(u32::MAX)), MAX_PROVISION_MINORS);
|
||||
// Absent => the instance default.
|
||||
assert_eq!(provision_width_for(None), MAX_PROVISION_MINORS);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_accepts_and_ignores_extra_wire_fields() {
|
||||
// `subaddr_count` is read (never silently dropped) and an unknown
|
||||
// `user_id` on the provision request is ignored - identity comes from
|
||||
// the bearer token.
|
||||
let r: RegisterRequest = serde_json::from_str(
|
||||
r#"{"asset":"xmr","address":"9a","view_key":"vk","subaddr_count":64}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.subaddr_count, Some(64));
|
||||
let r2: RegisterRequest =
|
||||
serde_json::from_str(r#"{"asset":"xmr","address":"9a","view_key":"vk"}"#).unwrap();
|
||||
assert_eq!(r2.subaddr_count, None);
|
||||
let p: ProvisionRequest = serde_json::from_str(
|
||||
r#"{"asset":"xmr","address":"9a","view_key":"vk","max_minor":31,"user_id":"attacker"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.max_minor, Some(31));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,67 @@ const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// busy address's full history sits well under this; the cap turns a hostile
|
||||
/// unbounded stream into a clean error instead of unbounded memory growth.
|
||||
const MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// In-flight per-address queries within one batch (`*_multi`). Bounded so a
|
||||
/// 32-address batch is a handful of connections to the upstream server rather
|
||||
/// than 32 at once (which public fallbacks answer by throttling or dropping us),
|
||||
/// while still finishing in a fraction of the sequential time.
|
||||
const BATCH_CONCURRENCY: usize = 6;
|
||||
/// Whole-batch deadline. Deliberately below the per-exchange [`IO_TIMEOUT`], so
|
||||
/// one stalled address cannot hold a request open for `n * IO_TIMEOUT`; the
|
||||
/// batch fails fast and the caller re-batches instead of hanging.
|
||||
const BATCH_DEADLINE: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The outcome of a bounded batch query.
|
||||
///
|
||||
/// Kept distinct from a bare [`AppError`] so the API layer can answer a
|
||||
/// deadline breach with its own status (504) instead of folding it into the
|
||||
/// generic upstream-unavailable 503.
|
||||
#[derive(Debug)]
|
||||
pub enum BatchError {
|
||||
/// A validation or upstream failure from one of the addresses.
|
||||
App(AppError),
|
||||
/// The batch as a whole exceeded [`BATCH_DEADLINE`].
|
||||
Deadline,
|
||||
}
|
||||
|
||||
impl From<AppError> for BatchError {
|
||||
fn from(e: AppError) -> Self {
|
||||
BatchError::App(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BatchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::App(e) => write!(f, "{e}"),
|
||||
Self::Deadline => f.write_str("batch address query deadline exceeded"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive per-address queries with bounded concurrency under one whole-batch
|
||||
/// deadline, yielding results in INPUT order.
|
||||
///
|
||||
/// Order preservation is not cosmetic: it keeps every batch response, and the
|
||||
/// order the checked balance sum folds in, identical to the sequential
|
||||
/// implementation, so concurrency cannot make a response depend on which server
|
||||
/// answered first. (`buffered`, not `buffer_unordered`, for exactly that.)
|
||||
async fn run_batch<T, I>(queries: I) -> Result<Vec<T>, BatchError>
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
|
||||
let work = stream::iter(queries)
|
||||
.buffered(BATCH_CONCURRENCY)
|
||||
.try_collect::<Vec<T>>();
|
||||
|
||||
match timeout(BATCH_DEADLINE, work).await {
|
||||
Ok(res) => res.map_err(BatchError::App),
|
||||
Err(_) => Err(BatchError::Deadline),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix time in milliseconds. Used only as the primary circuit-breaker clock, so
|
||||
/// a clock glitch at worst re-probes the primary a little early/late.
|
||||
@@ -322,6 +383,30 @@ fn checked_btc_to_sat(value: f64) -> Result<u64, AppError> {
|
||||
Ok(sats as u64)
|
||||
}
|
||||
|
||||
/// Sum confirmed/unconfirmed balances with CHECKED arithmetic, so a hostile or
|
||||
/// corrupt per-address value cannot wrap the total to a wrong number: the sum
|
||||
/// errors instead (money gate G13). Mirrors the `checked_add` discipline of the
|
||||
/// per-tx amount sums above. Pure over already-fetched balances so it is unit
|
||||
/// testable without a live server.
|
||||
pub fn sum_balances<'a>(
|
||||
balances: impl IntoIterator<Item = &'a ElectrumBalance>,
|
||||
) -> Result<ElectrumBalance, AppError> {
|
||||
let mut confirmed: u64 = 0;
|
||||
let mut unconfirmed: i64 = 0;
|
||||
for b in balances {
|
||||
confirmed = confirmed
|
||||
.checked_add(b.confirmed)
|
||||
.ok_or_else(|| AppError::NodeError("confirmed balance sum overflow".into()))?;
|
||||
unconfirmed = unconfirmed
|
||||
.checked_add(b.unconfirmed)
|
||||
.ok_or_else(|| AppError::NodeError("unconfirmed balance sum overflow".into()))?;
|
||||
}
|
||||
Ok(ElectrumBalance {
|
||||
confirmed,
|
||||
unconfirmed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render untrusted upstream text safe for a private (redacted-publicly) log or
|
||||
/// error string: drop control characters that could forge log lines, and cap
|
||||
/// the length to bound volume. Operates on `chars`, never byte-indexing.
|
||||
@@ -421,6 +506,74 @@ impl ElectrumClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Confirmed/unconfirmed balance summed across several addresses, using the
|
||||
/// same checked-arithmetic discipline as the single-address paths: a hostile
|
||||
/// or corrupt per-address value cannot overflow the total into a wrong number
|
||||
/// (the sum errors instead: money gate G13). Addresses are queried with
|
||||
/// bounded concurrency under one batch deadline and folded in INPUT order;
|
||||
/// the caller bounds the list length. Persists nothing.
|
||||
#[instrument(skip_all, fields(net = ?self.network, n = addresses.len()))]
|
||||
pub async fn get_balance_sum(
|
||||
&self,
|
||||
addresses: &[String],
|
||||
) -> Result<ElectrumBalance, BatchError> {
|
||||
// Built as a plain loop rather than `.map(|a| ...)`: a closure returning
|
||||
// a future that borrows its argument cannot be inferred as higher-ranked.
|
||||
let mut queries = Vec::with_capacity(addresses.len());
|
||||
for address in addresses {
|
||||
queries.push(self.get_balance(address));
|
||||
}
|
||||
let acc = run_batch(queries).await?;
|
||||
sum_balances(&acc).map_err(BatchError::App)
|
||||
}
|
||||
|
||||
/// Unspent outputs across several addresses, each tagged with the address it
|
||||
/// belongs to. The tag is load-bearing: the wallet never has to re-derive
|
||||
/// which address owns a UTXO (a wrong guess mis-signs a spend). Queried with
|
||||
/// bounded concurrency under one batch deadline, emitted in input order.
|
||||
/// Persists nothing; the caller bounds the list length.
|
||||
#[instrument(skip_all, fields(net = ?self.network, n = addresses.len()))]
|
||||
pub async fn get_utxos_tagged(
|
||||
&self,
|
||||
addresses: &[String],
|
||||
) -> Result<Vec<(String, ElectrumUtxo)>, BatchError> {
|
||||
let mut queries = Vec::with_capacity(addresses.len());
|
||||
for address in addresses {
|
||||
queries.push(self.get_utxos(address));
|
||||
}
|
||||
let results = run_batch(queries).await?;
|
||||
let mut out = Vec::new();
|
||||
for (address, utxos) in addresses.iter().zip(results) {
|
||||
for utxo in utxos {
|
||||
out.push((address.clone(), utxo));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Confirmed + mempool history across several addresses, each entry tagged
|
||||
/// with its owning address (so the wallet keeps per-address provenance).
|
||||
/// Queried with bounded concurrency under one batch deadline, emitted in
|
||||
/// input order. Persists nothing; the caller bounds the list length.
|
||||
#[instrument(skip_all, fields(net = ?self.network, n = addresses.len()))]
|
||||
pub async fn get_history_tagged(
|
||||
&self,
|
||||
addresses: &[String],
|
||||
) -> Result<Vec<(String, ElectrumHistoryEntry)>, BatchError> {
|
||||
let mut queries = Vec::with_capacity(addresses.len());
|
||||
for address in addresses {
|
||||
queries.push(self.get_history(address));
|
||||
}
|
||||
let results = run_batch(queries).await?;
|
||||
let mut out = Vec::new();
|
||||
for (address, entries) in addresses.iter().zip(results) {
|
||||
for entry in entries {
|
||||
out.push((address.clone(), entry));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Current best-chain tip height via `blockchain.headers.subscribe`.
|
||||
#[instrument(skip_all, fields(net = ?self.network))]
|
||||
pub async fn get_tip_height(&self) -> Result<i64, AppError> {
|
||||
@@ -1147,6 +1300,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_balances_checked_add() {
|
||||
// A normal set sums exactly.
|
||||
let ok = vec![
|
||||
ElectrumBalance {
|
||||
confirmed: 1000,
|
||||
unconfirmed: -50,
|
||||
},
|
||||
ElectrumBalance {
|
||||
confirmed: 250,
|
||||
unconfirmed: 75,
|
||||
},
|
||||
];
|
||||
let s = sum_balances(&ok).unwrap();
|
||||
assert_eq!(s.confirmed, 1250);
|
||||
assert_eq!(s.unconfirmed, 25);
|
||||
|
||||
// Confirmed overflow errors instead of wrapping (money gate G13).
|
||||
let conf_overflow = vec![
|
||||
ElectrumBalance {
|
||||
confirmed: u64::MAX,
|
||||
unconfirmed: 0,
|
||||
},
|
||||
ElectrumBalance {
|
||||
confirmed: 1,
|
||||
unconfirmed: 0,
|
||||
},
|
||||
];
|
||||
assert!(sum_balances(&conf_overflow).is_err());
|
||||
|
||||
// Unconfirmed (i64) overflow errors too.
|
||||
let unconf_overflow = vec![
|
||||
ElectrumBalance {
|
||||
confirmed: 0,
|
||||
unconfirmed: i64::MAX,
|
||||
},
|
||||
ElectrumBalance {
|
||||
confirmed: 0,
|
||||
unconfirmed: 1,
|
||||
},
|
||||
];
|
||||
assert!(sum_balances(&unconf_overflow).is_err());
|
||||
|
||||
// Empty set is a clean zero.
|
||||
let zero = sum_balances(&[]).unwrap();
|
||||
assert_eq!(zero.confirmed, 0);
|
||||
assert_eq!(zero.unconfirmed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_btc_to_sat_rejects_hostile() {
|
||||
assert!(checked_btc_to_sat(f64::NAN).is_err());
|
||||
|
||||
+325
-31
@@ -48,6 +48,11 @@ pub struct LwsClient {
|
||||
admin_key: Secret,
|
||||
daemon_url: String,
|
||||
http: reqwest::Client,
|
||||
/// First-use probe of the LWS `--max-subaddresses` option, cached for the
|
||||
/// process. Shared across clones (an `AppState` hands out `LwsClient`
|
||||
/// clones), so the probe runs once per network, not once per request. A
|
||||
/// FAILED probe is not cached, so a temporarily unreachable LWS is retried.
|
||||
subaddr_capacity: std::sync::Arc<tokio::sync::OnceCell<u32>>,
|
||||
}
|
||||
|
||||
impl LwsClient {
|
||||
@@ -75,6 +80,7 @@ impl LwsClient {
|
||||
admin_key: Secret::new(cfg.lws_admin_key.clone()),
|
||||
daemon_url: cfg.daemon_url.clone(),
|
||||
http,
|
||||
subaddr_capacity: std::sync::Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,10 +212,219 @@ impl LwsClient {
|
||||
// ── admin API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register + activate an account (admin `add_account`), scanning from the
|
||||
/// current height.
|
||||
#[instrument(skip(self, view_key), fields(net = %self.network))]
|
||||
pub async fn register_account(&self, address: &str, view_key: &str) -> Result<(), AppError> {
|
||||
self.admin_add_account(address, view_key).await
|
||||
/// current height, then provision `provision_minors` subaddress indices for
|
||||
/// account 0 (`0` = provisioning disabled — the dark default).
|
||||
///
|
||||
/// Provisioning runs AFTER the add so the account exists first. A provision
|
||||
/// failure is a HARD error (money gate G3: never silently skip — a wallet
|
||||
/// that expects subaddress receipts must not be told scanning is set up when
|
||||
/// the subaddresses were not registered).
|
||||
#[instrument(skip(self, view_key), fields(net = %self.network, provision_minors))]
|
||||
pub async fn register_account(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
provision_minors: u32,
|
||||
) -> Result<(), AppError> {
|
||||
if provision_minors == 0 {
|
||||
// Provisioning off (the dark default): exactly the call this made
|
||||
// before, including monero-lws answering a duplicate `add_account`
|
||||
// with `account_exists` (an HTTP 500 it maps to a node error).
|
||||
return self.admin_add_account(address, view_key).await;
|
||||
}
|
||||
// Provisioning on: an account that ALREADY exists must still be brought
|
||||
// up to the required ceiling. monero-lws fails a duplicate `add_account`
|
||||
// (`db::storage::do_add_account` returns `lws::error::account_exists`,
|
||||
// served as a 500), so calling it unconditionally would abort before the
|
||||
// provisioning below and make the account permanently unprovisionable
|
||||
// through this path. Existence is checked first instead; nothing about
|
||||
// the account's scan state is touched.
|
||||
if self.account_scan_height(address).await?.is_none() {
|
||||
self.admin_add_account(address, view_key).await?;
|
||||
}
|
||||
self.provision_account0_covering(address, view_key, provision_minors)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The LWS `--max-subaddresses` ceiling, probed once and cached.
|
||||
///
|
||||
/// monero-lws exposes it on the USER endpoint `/get_version`
|
||||
/// (`rest_server.cpp` `endpoints[]`, `is_admin = false`), and rejects any
|
||||
/// `provision_subaddrs` whose `n_maj * n_min` exceeds it. It defaults to `0`
|
||||
/// (subaddresses DISABLED), which is exactly the misconfiguration that made
|
||||
/// provisioning fail late and half-way.
|
||||
pub async fn max_subaddresses(&self) -> Result<u32, AppError> {
|
||||
let cached = self
|
||||
.subaddr_capacity
|
||||
.get_or_try_init(|| async {
|
||||
let url = format!("{}/get_version", self.user_url);
|
||||
let resp: LwsVersionResponse = self
|
||||
.post_json(url, "get_version", &serde_json::json!({}))
|
||||
.await?;
|
||||
Ok::<u32, AppError>(resp.max_subaddresses)
|
||||
})
|
||||
.await?;
|
||||
Ok(*cached)
|
||||
}
|
||||
|
||||
/// Fail CLOSED unless this LWS can satisfy a `n_min`-wide provision for one
|
||||
/// major index: refuse up front with an operator-legible error rather than
|
||||
/// registering an account that only half-works (scanning set up, subaddress
|
||||
/// receipts silently unattributed).
|
||||
async fn ensure_subaddr_capacity(&self, n_min: u32) -> Result<(), AppError> {
|
||||
let max = self.max_subaddresses().await?;
|
||||
if max < n_min {
|
||||
return Err(AppError::NodeError(format!(
|
||||
"{} LWS cannot provision subaddresses: it allows max_subaddresses={max} \
|
||||
but {n_min} are required. Start monero-lws with \
|
||||
--max-subaddresses {n_min} (or higher), or disable \
|
||||
FEATURE_XMR_SUBADDR_PROVISIONING.",
|
||||
self.network
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Provision (`upsert`) subaddress ranges at the LWS so it attributes
|
||||
/// subaddress receipts, via the USER endpoint `/provision_subaddrs`.
|
||||
///
|
||||
/// CONFIRMED a USER endpoint (keyed by `address` + `view_key`, NOT admin):
|
||||
/// monero-lws registers `/provision_subaddrs` in `src/rest_server.cpp`
|
||||
/// `endpoints[]` with `is_admin = false`, and its wire reader
|
||||
/// (`src/rpc/light_wallet.cpp` `read_bytes(provision_subaddrs_request&)`)
|
||||
/// reads `address` + `view_key` + optional `maj_i/min_i/n_maj/n_min/get_all`.
|
||||
///
|
||||
/// Provisions the `[min_i .. min_i + n_min)` minor range for ONE major index
|
||||
/// (`maj_i`; `n_maj = 1`). SAFETY/LIVE-GATE: the LWS must be started with
|
||||
/// `--max-subaddresses >= n_min` — it defaults to `0` (subaddresses
|
||||
/// DISABLED), in which case the LWS returns a `max_subaddresses` error.
|
||||
/// Returns the CONFIRMED contiguous minor ceiling for `maj_i` as the LWS
|
||||
/// reports it back, never the ask. `get_all` is sent as `true` because
|
||||
/// `new_subaddrs` carries only the ranges a call newly added (an idempotent
|
||||
/// repeat returns it empty), so `all_subaddrs` is the only field that can
|
||||
/// state the ceiling actually in force. A body from which the ceiling cannot
|
||||
/// be established is an ERROR, never a guess: a ceiling the wallet trusts
|
||||
/// but the LWS is not scanning turns a receive into invisible funds.
|
||||
#[instrument(skip(self, view_key), fields(net = %self.network, maj_i, min_i, n_min))]
|
||||
pub async fn provision_subaddrs(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
maj_i: u32,
|
||||
min_i: u32,
|
||||
n_min: u32,
|
||||
) -> Result<u32, AppError> {
|
||||
let url = format!("{}/provision_subaddrs", self.user_url);
|
||||
let body = ProvisionSubaddrsRequest {
|
||||
address: address.to_string(),
|
||||
view_key: view_key.to_string(),
|
||||
maj_i,
|
||||
min_i,
|
||||
n_maj: 1,
|
||||
n_min,
|
||||
get_all: true,
|
||||
};
|
||||
// HTTP success = provisioned: the endpoint returns a non-2xx (mapped to
|
||||
// NodeError) on any failure, incl. `max_subaddresses`, so a 2xx cannot be
|
||||
// a silent no-op.
|
||||
let resp: ProvisionSubaddrsResponse =
|
||||
self.post_json(url, "provision_subaddrs", &body).await?;
|
||||
// Only major 0 has a defined contiguous-from-zero reading today, which is
|
||||
// the only major this client provisions.
|
||||
if maj_i != 0 {
|
||||
return Err(self.node_err(
|
||||
"provision_subaddrs",
|
||||
"only major account 0 can be provisioned",
|
||||
));
|
||||
}
|
||||
resp.confirmed_minor_max().ok_or_else(|| {
|
||||
self.node_err(
|
||||
"provision_subaddrs",
|
||||
"response did not confirm a contiguous minor range for account 0",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Provision `n_min` minor indices for major account 0, retrying a transient
|
||||
/// failure with the same backoff as the import rescan. `None` when
|
||||
/// `n_min == 0` (provisioning off: the dark default); otherwise the
|
||||
/// LWS-confirmed minor ceiling. The upsert is idempotent, so a retry is safe.
|
||||
///
|
||||
/// Capacity is probed BEFORE the first attempt so an LWS that cannot satisfy
|
||||
/// the ask fails closed with one legible error instead of three timed-out
|
||||
/// retries of a request that can never succeed.
|
||||
async fn provision_account0_with_retry(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
n_min: u32,
|
||||
) -> Result<Option<u32>, AppError> {
|
||||
if n_min == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.ensure_subaddr_capacity(n_min).await?;
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
match self
|
||||
.provision_subaddrs(address, view_key, 0, 0, n_min)
|
||||
.await
|
||||
{
|
||||
Ok(confirmed) => return Ok(Some(confirmed)),
|
||||
Err(e) => {
|
||||
attempt += 1;
|
||||
if attempt >= 3 {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300 * u64::from(attempt)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision account 0 and REQUIRE the confirmed ceiling to cover the ask.
|
||||
///
|
||||
/// `n_min` indices starting at minor 0 means the ceiling must be at least
|
||||
/// `n_min - 1`. Registration promises the wallet that the whole batch is
|
||||
/// being scanned, so a short ceiling is a hard error rather than a quiet
|
||||
/// downgrade the wallet would never learn about. `None` when provisioning is
|
||||
/// off.
|
||||
pub(crate) async fn provision_account0_covering(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
n_min: u32,
|
||||
) -> Result<Option<u32>, AppError> {
|
||||
let Some(confirmed) = self
|
||||
.provision_account0_with_retry(address, view_key, n_min)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if confirmed.saturating_add(1) < n_min {
|
||||
return Err(self.node_err(
|
||||
"provision_subaddrs",
|
||||
"confirmed subaddress range is narrower than requested",
|
||||
));
|
||||
}
|
||||
Ok(Some(confirmed))
|
||||
}
|
||||
|
||||
/// Provision account 0 for an on-demand request, returning the LWS-confirmed
|
||||
/// minor ceiling verbatim. Unlike [`Self::provision_account0_covering`] a
|
||||
/// ceiling wider than the ask is reported as-is (an account provisioned
|
||||
/// earlier for a larger batch keeps its larger range; the upsert never
|
||||
/// shrinks anything), so the caller always learns the truth.
|
||||
pub async fn provision_account0(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
n_min: u32,
|
||||
) -> Result<u32, AppError> {
|
||||
self.provision_account0_with_retry(address, view_key, n_min)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::ValidationError("subaddress count must be at least 1".into()))
|
||||
}
|
||||
|
||||
/// Register an account and scan it from `start_height` (a wallet birthday).
|
||||
@@ -219,12 +434,13 @@ impl LwsClient {
|
||||
/// rescan. Read the post-add scan height and only rescan when strictly
|
||||
/// lowering it — honoring the `rescan` backwards-only invariant (a rescan to
|
||||
/// `height >= current` is undefined behavior in monero-lws).
|
||||
#[instrument(skip(self, view_key), fields(net = %self.network, start_height))]
|
||||
#[instrument(skip(self, view_key), fields(net = %self.network, start_height, provision_minors))]
|
||||
pub async fn import_account(
|
||||
&self,
|
||||
address: &str,
|
||||
view_key: &str,
|
||||
start_height: u64,
|
||||
provision_minors: u32,
|
||||
) -> Result<(), AppError> {
|
||||
// Only a NEWLY added account needs the backwards rescan to its birthday
|
||||
// (monero-lws `add_account` starts every account at the chain tip). An
|
||||
@@ -239,39 +455,76 @@ impl LwsClient {
|
||||
// birthday goes through the explicit admin `rescan` path, not this one.
|
||||
match self.account_scan_height(address).await? {
|
||||
// Already registered: never reset an existing account from here.
|
||||
Some(_) => Ok(()),
|
||||
//
|
||||
// It is still brought up to the required subaddress ceiling. The
|
||||
// upsert is idempotent and touches no scan state, so the
|
||||
// anti-reset-loop invariant is untouched, while an account that was
|
||||
// registered before provisioning was enabled (or before the ceiling
|
||||
// was raised) can finally be provisioned instead of being locked out
|
||||
// of the feature forever by this short-circuit.
|
||||
Some(_) => {
|
||||
self.provision_account0_covering(address, view_key, provision_minors)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.admin_add_account(address, view_key).await?;
|
||||
let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
||||
if start_height < current {
|
||||
// The account is now added at the chain tip. If this backfill
|
||||
// rescan fails, the account is stranded at the tip: it reads a
|
||||
// 0 balance and the `Some(_)` short-circuit above (which exists
|
||||
// to prevent the reset loop) means a later re-registration will
|
||||
// NOT retry it. So retry a transient failure here, where we
|
||||
// still know this is a fresh account that owes a backwards scan.
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
match self.rescan(vec![address.to_string()], start_height).await {
|
||||
Ok(()) => break,
|
||||
Err(e) => {
|
||||
attempt += 1;
|
||||
if attempt >= 3 {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
300 * u64::from(attempt),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Provision subaddress ranges BEFORE the backwards rescan (money
|
||||
// gate G3): the rescan re-scans forward from the birthday, so the
|
||||
// subaddresses must ALREADY be registered for the LWS to attribute
|
||||
// historical subaddress receipts in that backfill.
|
||||
//
|
||||
// The result is HELD, not propagated with `?`. The account now
|
||||
// exists at the LWS, so returning early here would strand it at
|
||||
// the chain tip: every later re-registration takes the `Some(_)`
|
||||
// branch above, answers 200, and never runs the backwards scan,
|
||||
// leaving the wallet reading a zero balance forever. The rescan
|
||||
// therefore always runs, and the provisioning failure is surfaced
|
||||
// afterwards.
|
||||
let provisioned = self
|
||||
.provision_account0_covering(address, view_key, provision_minors)
|
||||
.await;
|
||||
let rescanned = self.rescan_back_to(address, start_height).await;
|
||||
// A failed backfill is the graver of the two (it is the one a
|
||||
// retry can no longer reach), so it is reported first; otherwise
|
||||
// the provisioning failure is surfaced, never swallowed.
|
||||
rescanned?;
|
||||
provisioned?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower a freshly added account's scan cursor to `start_height`, retrying a
|
||||
/// transient failure. A no-op when the account is already at or below it
|
||||
/// (the `rescan` backwards-only invariant).
|
||||
async fn rescan_back_to(&self, address: &str, start_height: u64) -> Result<(), AppError> {
|
||||
let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
||||
if start_height >= current {
|
||||
return Ok(());
|
||||
}
|
||||
// The account is added at the chain tip. If this backfill rescan fails,
|
||||
// the account is stranded at the tip: it reads a 0 balance and the
|
||||
// `Some(_)` short-circuit in `import_account` (which exists to prevent
|
||||
// the reset loop) means a later re-registration will NOT retry it. So
|
||||
// retry a transient failure here, where we still know this is a fresh
|
||||
// account that owes a backwards scan.
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
match self.rescan(vec![address.to_string()], start_height).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
attempt += 1;
|
||||
if attempt >= 3 {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300 * u64::from(attempt)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn admin_add_account(&self, address: &str, view_key: &str) -> Result<(), AppError> {
|
||||
let url = format!("{}/add_account", self.admin_url);
|
||||
let body = AdminAddAccountRequest {
|
||||
@@ -589,6 +842,7 @@ mod tests {
|
||||
unlock_time: 0,
|
||||
payment_id: None,
|
||||
spent_outputs: vec![],
|
||||
recipient: SubaddrIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +883,46 @@ mod tests {
|
||||
assert_eq!(confirmations_from(None, None, 110), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provisioning_off_makes_no_network_call() {
|
||||
// The dark default must stay byte-identical: `n_min == 0` short-circuits
|
||||
// before the capability probe and before any request. The configured URL
|
||||
// points at a closed port, so any attempted call would surface as an
|
||||
// error rather than silently succeed.
|
||||
let client = LwsClient::monero(&cfg()).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.provision_account0_covering("9addr", "vk", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_demand_provisioning_rejects_a_zero_count() {
|
||||
// A zero ask can never yield a confirmed ceiling, so it is a request
|
||||
// error rather than a silently successful no-op.
|
||||
let client = LwsClient::monero(&cfg()).unwrap();
|
||||
let err = client
|
||||
.provision_account0("9addr", "vk", 0)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::ValidationError(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_ceiling_is_read_from_the_response_not_the_ask() {
|
||||
// Regression guard for the echo bug: the value handed back to a client
|
||||
// must come from `all_subaddrs`, so asking for 200 against an LWS that
|
||||
// confirms only [0, 49] can never report 199.
|
||||
let resp: ProvisionSubaddrsResponse = serde_json::from_str(
|
||||
r#"{"new_subaddrs":[],"all_subaddrs":[{"key":0,"value":[[0,49]]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resp.confirmed_minor_max(), Some(49));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daemon_height_requires_daemon_url() {
|
||||
let client = LwsClient::monero(&cfg()).unwrap(); // daemon_url empty
|
||||
|
||||
@@ -14,5 +14,5 @@ pub use client::LwsClient;
|
||||
pub use types::{
|
||||
AccountEntry, AccountStatus, AddressInfo, AddressTx, AddressTxsResponse, AmountOuts,
|
||||
CryptoNoteNetwork, ListAccountsResponse, RandomOutput, RandomOutsResponse, SpentOutput,
|
||||
UnspentOutput, UnspentOutsResponse,
|
||||
SubaddrIndex, UnspentOutput, UnspentOutsResponse,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,53 @@ pub mod string_or_u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a `u32` that may arrive as a string, integer, or null/missing
|
||||
/// (null/missing → 0). Mirrors [`string_or_u64_default`] for the subaddress
|
||||
/// index fields, which fail OPEN to 0 (the primary index) so an unexpected or
|
||||
/// absent `recipient` encoding never breaks response parsing (money gate G2).
|
||||
///
|
||||
/// "Fail open" here is total, and deliberately so: **every** shape that is not a
|
||||
/// parseable in-range unsigned integer degrades to 0, including a negative
|
||||
/// number, a float, a value above `u32::MAX`, an unparseable string, or a
|
||||
/// structurally unexpected value. Anything less would let one malformed index
|
||||
/// abort the deserialization of the WHOLE `get_unspent_outs` /
|
||||
/// `get_address_txs` response, which would hide a user's entire balance rather
|
||||
/// than mislabel one output's index.
|
||||
///
|
||||
/// The safety of defaulting to the primary index rests on this holding only
|
||||
/// while subaddress provisioning is off. Once an account is knowingly
|
||||
/// provisioned for subaddresses, an unlabelled output must NOT be silently
|
||||
/// treated as primary (it would produce the wrong key image); that is enforced
|
||||
/// above this layer, not here.
|
||||
pub mod string_or_u32_default {
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrIntOrNull {
|
||||
String(String),
|
||||
Int(u32),
|
||||
Null,
|
||||
/// Catch-all so a negative / float / out-of-range / structurally
|
||||
/// unexpected value degrades to 0 instead of failing the parse.
|
||||
/// `IgnoredAny` consumes any shape without allocating and without
|
||||
/// leaving an unread field behind.
|
||||
Other(serde::de::IgnoredAny),
|
||||
}
|
||||
match Option::<StringOrIntOrNull>::deserialize(deserializer)? {
|
||||
// An unparseable string degrades to the primary index rather than
|
||||
// erroring, for the same whole-response reason documented above.
|
||||
Some(StringOrIntOrNull::String(s)) => Ok(s.parse().unwrap_or(0)),
|
||||
Some(StringOrIntOrNull::Int(n)) => Ok(n),
|
||||
Some(StringOrIntOrNull::Null) | Some(StringOrIntOrNull::Other(_)) | None => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a `u8` that may arrive as a string, integer, or null/missing
|
||||
/// (null/missing → 0).
|
||||
pub mod string_or_u8_default {
|
||||
@@ -151,6 +198,37 @@ pub(crate) struct GetAddressInfoRequest {
|
||||
pub view_key: String,
|
||||
}
|
||||
|
||||
/// USER-endpoint `provision_subaddrs` request (monero-lws `/provision_subaddrs`,
|
||||
/// confirmed a USER endpoint keyed by `address` + `view_key` — not admin — in
|
||||
/// `monero-lws/src/rest_server.cpp` `endpoints[]` (`is_admin = false`) and its
|
||||
/// `provision_subaddrs_request` wire reader in `src/rpc/light_wallet.cpp`).
|
||||
///
|
||||
/// Provisions the `[min_i .. min_i + n_min)` minor range for `n_maj` major
|
||||
/// indices starting at `maj_i`, so the LWS attributes subaddress receipts.
|
||||
///
|
||||
/// Carries the private `view_key`, so it deliberately omits `Debug` (crate
|
||||
/// convention for secret-bearing request structs).
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ProvisionSubaddrsRequest {
|
||||
pub address: String,
|
||||
pub view_key: String,
|
||||
/// First major (account) index to provision.
|
||||
pub maj_i: u32,
|
||||
/// First minor index within each major to provision.
|
||||
pub min_i: u32,
|
||||
/// Number of major indices to cover (1 = account 0 only).
|
||||
pub n_maj: u32,
|
||||
/// Number of minor indices per major. `n_maj * n_min` must not exceed the
|
||||
/// LWS `--max-subaddresses` (defaults to 0 = subaddresses DISABLED), else
|
||||
/// the LWS returns a `max_subaddresses` error.
|
||||
pub n_min: u32,
|
||||
/// Whether the LWS should echo back the full subaddress set. Sent as `true`:
|
||||
/// `new_subaddrs` lists only the ranges a call NEWLY added (empty on an
|
||||
/// idempotent repeat), so `all_subaddrs` is the only field that can confirm
|
||||
/// the ceiling actually in force for the account.
|
||||
pub get_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct GetAddressTxsRequest {
|
||||
pub address: String,
|
||||
@@ -216,6 +294,130 @@ pub(crate) struct RescanParams {
|
||||
// Response types (public)
|
||||
// ============================================================================
|
||||
|
||||
/// A subaddress index `(major, minor)` as delivered by monero-lws in the
|
||||
/// `recipient` field of an output/tx (wire shape `{"maj_i":u32,"min_i":u32}`,
|
||||
/// per `db::address_index` in `monero-lws/src/db/data.cpp`).
|
||||
///
|
||||
/// Both fields fail OPEN to `0` (the primary index) when absent, null, or
|
||||
/// otherwise unexpectedly encoded, and `#[serde(default)]` on the carrying field
|
||||
/// means an entirely absent `recipient` yields `(0, 0)` while the surrounding
|
||||
/// response STILL parses (money gate G2: a receive is never dropped because of an
|
||||
/// unexpected recipient encoding). A pre-subaddress LWS that omits the field, or
|
||||
/// a primary-address receive, is therefore attributed to account 0 / index 0.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
pub struct SubaddrIndex {
|
||||
#[serde(default, deserialize_with = "string_or_u32_default::deserialize")]
|
||||
pub maj_i: u32,
|
||||
#[serde(default, deserialize_with = "string_or_u32_default::deserialize")]
|
||||
pub min_i: u32,
|
||||
}
|
||||
|
||||
/// One entry of a monero-lws subaddress range list.
|
||||
///
|
||||
/// Wire shape (`db::subaddress_dict` = `std::pair<major_index, index_ranges>`,
|
||||
/// written by `map_subaddress_dict` in `monero-lws/src/db/data.cpp`):
|
||||
///
|
||||
/// ```json
|
||||
/// {"key": 0, "value": [[0, 199]]}
|
||||
/// ```
|
||||
///
|
||||
/// `key` is the major (account) index; `value` is a list of INCLUSIVE
|
||||
/// `[first_minor, last_minor]` ranges. `value` is written with
|
||||
/// `wire::optional_field`, so it is absent when the entry carries no ranges.
|
||||
///
|
||||
/// Parsing is deliberately STRICT here (unlike the fail-open `recipient`
|
||||
/// index): this type is only used to read back the ceiling the LWS actually
|
||||
/// confirmed, and a ceiling that is guessed from a malformed body would let the
|
||||
/// wallet hand out subaddresses the LWS is not scanning; funds would arrive
|
||||
/// invisibly. A malformed entry must be an error, never a default.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubaddrRangeEntry {
|
||||
/// Major (account) index this range list belongs to.
|
||||
#[serde(deserialize_with = "string_or_u32::deserialize")]
|
||||
pub key: u32,
|
||||
/// Inclusive `[first, last]` minor ranges. Absent ⇒ no ranges.
|
||||
#[serde(default)]
|
||||
pub value: Vec<[u32; 2]>,
|
||||
}
|
||||
|
||||
/// Response from monero-lws `provision_subaddrs` (`new_subaddrs_response`:
|
||||
/// `{"new_subaddrs":[...],"all_subaddrs":[...]}`, see
|
||||
/// `rpc::write_bytes(wire::json_writer&, const new_subaddrs_response&)` in
|
||||
/// `monero-lws/src/rpc/light_wallet.cpp`).
|
||||
///
|
||||
/// `new_subaddrs` holds ONLY the ranges this call newly added (an idempotent
|
||||
/// re-provision of an existing range returns an empty list), so it can never be
|
||||
/// used to learn the account's actual ceiling. `all_subaddrs` holds the full
|
||||
/// current set, and is populated only when the request sets `get_all: true`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ProvisionSubaddrsResponse {
|
||||
/// Ranges newly added by this call (empty on an idempotent repeat).
|
||||
///
|
||||
/// Documents the wire contract and is asserted in unit tests; deliberately
|
||||
/// NOT consulted in production, since an empty list means "nothing new",
|
||||
/// not "nothing provisioned".
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
pub new_subaddrs: Vec<SubaddrRangeEntry>,
|
||||
/// The account's complete current range set (requires `get_all: true`).
|
||||
#[serde(default)]
|
||||
pub all_subaddrs: Vec<SubaddrRangeEntry>,
|
||||
}
|
||||
|
||||
impl ProvisionSubaddrsResponse {
|
||||
/// The CONFIRMED contiguous minor ceiling for major account 0: the highest
|
||||
/// minor index `m` such that every index in `0..=m` is provisioned at the
|
||||
/// LWS. `None` when that cannot be established (major 0 absent, or its
|
||||
/// ranges do not start at minor 0).
|
||||
///
|
||||
/// Contiguity is the whole point: monero-lws stores an account's minors as a
|
||||
/// list of disjoint ranges, so `[[0,99],[200,299]]` has a maximum of `299`
|
||||
/// while indices `100..=199` are NOT scanned. Reporting `299` as the ceiling
|
||||
/// would let the wallet hand out an unscanned subaddress and receive funds
|
||||
/// it can never see. Only the run starting at `0` is reported.
|
||||
pub fn confirmed_minor_max(&self) -> Option<u32> {
|
||||
let entry = self.all_subaddrs.iter().find(|e| e.key == 0)?;
|
||||
let mut ranges: Vec<[u32; 2]> = entry
|
||||
.value
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|r| r[0] <= r[1]) // drop an inverted range rather than trust it
|
||||
.collect();
|
||||
ranges.sort_unstable();
|
||||
let first = ranges.first()?;
|
||||
if first[0] != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut ceiling = first[1];
|
||||
for r in ranges.iter().skip(1) {
|
||||
// Extend only across a range that starts at or before the next
|
||||
// index; anything beyond that is a gap and ends the run.
|
||||
if r[0] > ceiling.saturating_add(1) {
|
||||
break;
|
||||
}
|
||||
ceiling = ceiling.max(r[1]);
|
||||
}
|
||||
Some(ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from the monero-lws USER endpoint `/get_version`
|
||||
/// (`rpc::get_version_response`, `src/rpc/light_wallet.cpp`). Only
|
||||
/// `max_subaddresses` is consumed: it is the LWS's `--max-subaddresses` option
|
||||
/// (default `0` = subaddresses DISABLED) and is the hard ceiling every
|
||||
/// `provision_subaddrs` call is checked against server-side
|
||||
/// (`rest_server.cpp`: `if (options.max_subaddresses < n_major * n_minor)
|
||||
/// return {lws::error::max_subaddresses};`).
|
||||
///
|
||||
/// Parsed strictly: an absent or malformed `max_subaddresses` must not be read
|
||||
/// as "capable", so the field has no default.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LwsVersionResponse {
|
||||
/// Maximum `n_maj * n_min` this LWS will provision for one account.
|
||||
#[serde(deserialize_with = "string_or_u32::deserialize")]
|
||||
pub max_subaddresses: u32,
|
||||
}
|
||||
|
||||
/// Response from `get_address_txs`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AddressTxsResponse {
|
||||
@@ -252,6 +454,10 @@ pub struct AddressTx {
|
||||
/// Candidate spends — must be verified client-side with the spend key.
|
||||
#[serde(default)]
|
||||
pub spent_outputs: Vec<SpentOutput>,
|
||||
/// Subaddress index this tx was received at. Absent/garbage → `(0, 0)`
|
||||
/// (fails OPEN; the response still parses — money gate G2).
|
||||
#[serde(default)]
|
||||
pub recipient: SubaddrIndex,
|
||||
}
|
||||
|
||||
/// A candidate spent output from LWS (verify with the spend key before trusting).
|
||||
@@ -265,6 +471,23 @@ pub struct SpentOutput {
|
||||
pub out_index: u64,
|
||||
#[serde(default, deserialize_with = "string_or_u64_default::deserialize")]
|
||||
pub mixin: u64,
|
||||
/// Subaddress index of the output BEING SPENT, so a client can recompute the
|
||||
/// right key image for it.
|
||||
///
|
||||
/// monero-lws names this field `sender` on the wire:
|
||||
/// `rpc::write_bytes(wire::json_writer&, const transaction_spend&)` in
|
||||
/// `src/rpc/light_wallet.cpp` writes
|
||||
/// `wire::field("sender", std::cref(self.possible_spend.sender))`, and
|
||||
/// `db::spend::sender` is filled from `user.get_spendable(output_id)` in
|
||||
/// `src/util/ownership_test.cpp`, i.e. the index the SPENT output was
|
||||
/// received at, not the enclosing transaction's change index.
|
||||
///
|
||||
/// `recipient` is accepted as an alias for forward compatibility. Absent
|
||||
/// (a pre-subaddress LWS) → `(0, 0)`, so the surrounding response still
|
||||
/// parses; the client-side contract decides whether an unlabelled spend is
|
||||
/// usable, since a wrong index yields a key image that never matches.
|
||||
#[serde(default, alias = "recipient")]
|
||||
pub sender: SubaddrIndex,
|
||||
}
|
||||
|
||||
/// Address info (balance and scan state) from `get_address_info`.
|
||||
@@ -327,6 +550,10 @@ pub struct UnspentOutput {
|
||||
/// spent. Non-empty ⇒ likely spent.
|
||||
#[serde(default)]
|
||||
pub spend_key_images: Vec<String>,
|
||||
/// Subaddress index this output was received at. Absent/garbage → `(0, 0)`
|
||||
/// (fails OPEN; the response still parses — money gate G2).
|
||||
#[serde(default)]
|
||||
pub recipient: SubaddrIndex,
|
||||
}
|
||||
|
||||
/// Response from `get_unspent_outs`.
|
||||
@@ -475,4 +702,302 @@ mod tests {
|
||||
assert_eq!(info.balance(), 10_000);
|
||||
assert_eq!(info.unlocked_balance(), 9000);
|
||||
}
|
||||
|
||||
// ── subaddress recipient: fail-OPEN to (0,0) (money gate G2) ──────────────
|
||||
|
||||
#[test]
|
||||
fn recipient_absent_defaults_to_zero_and_output_still_parses() {
|
||||
// A `get_unspent_outs` output with NO `recipient` field (pre-subaddress
|
||||
// LWS) must parse, attributing the output to account 0 / index 0.
|
||||
let json = r#"{
|
||||
"amount": "1000000000000",
|
||||
"public_key": "aa",
|
||||
"tx_pub_key": "bb",
|
||||
"index": 3,
|
||||
"global_index": 42,
|
||||
"height": 100
|
||||
}"#;
|
||||
let out: UnspentOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(out.recipient, SubaddrIndex { maj_i: 0, min_i: 0 });
|
||||
assert_eq!(out.amount, 1_000_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_absent_defaults_to_zero_and_full_txs_response_still_parses() {
|
||||
// A full `get_address_txs` response whose tx omits `recipient` must
|
||||
// still parse end-to-end (the whole response is not rejected — G2).
|
||||
let json = r#"{
|
||||
"transactions": [
|
||||
{"hash": "h1", "height": 10, "total_received": "5"},
|
||||
{"hash": "h2", "height": 11, "total_received": "7", "recipient": {"maj_i": 0, "min_i": 4}}
|
||||
],
|
||||
"total_received": "12",
|
||||
"scanned_height": 11,
|
||||
"blockchain_height": 11
|
||||
}"#;
|
||||
let resp: AddressTxsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.transactions.len(), 2);
|
||||
assert_eq!(resp.transactions[0].recipient, SubaddrIndex::default());
|
||||
assert_eq!(
|
||||
resp.transactions[1].recipient,
|
||||
SubaddrIndex { maj_i: 0, min_i: 4 }
|
||||
);
|
||||
assert_eq!(resp.total_received, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_accepts_string_or_int_and_null_fails_open() {
|
||||
// String-encoded, null, and integer indices all fail open cleanly.
|
||||
let s: SubaddrIndex = serde_json::from_str(r#"{"maj_i":"1","min_i":"9"}"#).unwrap();
|
||||
assert_eq!(s, SubaddrIndex { maj_i: 1, min_i: 9 });
|
||||
let n: SubaddrIndex = serde_json::from_str(r#"{"maj_i":null,"min_i":9}"#).unwrap();
|
||||
assert_eq!(n, SubaddrIndex { maj_i: 0, min_i: 9 });
|
||||
let e: SubaddrIndex = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert_eq!(e, SubaddrIndex::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_malformed_index_degrades_to_primary_and_never_breaks_the_response() {
|
||||
// Every shape that is not a parseable in-range unsigned integer must
|
||||
// degrade to 0 rather than abort the parse. Before this was total, a
|
||||
// single negative / float / oversized index would fail the WHOLE
|
||||
// `get_unspent_outs` response, hiding the user's entire balance.
|
||||
for bad in [
|
||||
r#"{"maj_i":-1,"min_i":2}"#, // negative
|
||||
r#"{"maj_i":1.5,"min_i":2}"#, // float
|
||||
r#"{"maj_i":4294967296,"min_i":2}"#, // > u32::MAX
|
||||
r#"{"maj_i":"not-a-number","min_i":2}"#, // unparseable string
|
||||
r#"{"maj_i":{"nested":1},"min_i":2}"#, // structurally unexpected
|
||||
r#"{"maj_i":[1],"min_i":2}"#, // array
|
||||
r#"{"maj_i":true,"min_i":2}"#, // bool
|
||||
] {
|
||||
let v: SubaddrIndex =
|
||||
serde_json::from_str(bad).unwrap_or_else(|e| panic!("{bad} must parse, got {e}"));
|
||||
assert_eq!(v.maj_i, 0, "{bad} should degrade maj_i to primary");
|
||||
assert_eq!(v.min_i, 2, "{bad} must not disturb the sibling field");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_recipient_still_yields_a_usable_unspent_output() {
|
||||
// The whole-response property, which is the one that actually protects
|
||||
// the balance: a garbage index must not cost us the output itself.
|
||||
let json = r#"{
|
||||
"amount": "1000000000000",
|
||||
"public_key": "aa",
|
||||
"tx_pub_key": "bb",
|
||||
"index": 3,
|
||||
"global_index": 42,
|
||||
"height": 100,
|
||||
"recipient": {"maj_i": -5, "min_i": 1.25}
|
||||
}"#;
|
||||
let out: UnspentOutput = serde_json::from_str(json).expect("output must still parse");
|
||||
assert_eq!(out.amount, 1_000_000_000_000);
|
||||
assert_eq!(out.recipient, SubaddrIndex::default());
|
||||
}
|
||||
|
||||
// ── provision_subaddrs request / response wire shapes ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn provision_request_serializes_expected_fields() {
|
||||
let req = ProvisionSubaddrsRequest {
|
||||
address: "9addr".into(),
|
||||
view_key: "vk".into(),
|
||||
maj_i: 0,
|
||||
min_i: 0,
|
||||
n_maj: 1,
|
||||
n_min: 200,
|
||||
get_all: true,
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["address"], "9addr");
|
||||
assert_eq!(v["view_key"], "vk");
|
||||
assert_eq!(v["maj_i"], 0);
|
||||
assert_eq!(v["min_i"], 0);
|
||||
assert_eq!(v["n_maj"], 1);
|
||||
assert_eq!(v["n_min"], 200);
|
||||
assert_eq!(v["get_all"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_response_parses_and_tolerates_missing_lists() {
|
||||
let full: ProvisionSubaddrsResponse = serde_json::from_str(
|
||||
r#"{"new_subaddrs":[{"key":0,"value":[[0,199]]}],"all_subaddrs":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(full.new_subaddrs.len(), 1);
|
||||
assert_eq!(full.new_subaddrs[0].key, 0);
|
||||
assert_eq!(full.new_subaddrs[0].value, vec![[0, 199]]);
|
||||
assert!(full.all_subaddrs.is_empty());
|
||||
// An empty object (either list absent) still parses.
|
||||
let empty: ProvisionSubaddrsResponse = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert!(empty.new_subaddrs.is_empty() && empty.all_subaddrs.is_empty());
|
||||
// `value` is written with `wire::optional_field`, so it can be absent.
|
||||
let no_value: ProvisionSubaddrsResponse =
|
||||
serde_json::from_str(r#"{"all_subaddrs":[{"key":0}]}"#).unwrap();
|
||||
assert!(no_value.all_subaddrs[0].value.is_empty());
|
||||
}
|
||||
|
||||
// ── the confirmed ceiling is READ from the LWS, never assumed ─────────────
|
||||
|
||||
fn all(json: &str) -> ProvisionSubaddrsResponse {
|
||||
serde_json::from_str(json).expect("valid provision response")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_minor_max_reads_the_contiguous_run_from_zero() {
|
||||
// The ordinary case: one range [0, 199] => ceiling 199 (200 indices).
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[0,199]]}]}"#).confirmed_minor_max(),
|
||||
Some(199)
|
||||
);
|
||||
// Major 0 is selected even when other majors are present and listed first.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":3,"value":[[0,999]]},{"key":0,"value":[[0,49]]}]}"#)
|
||||
.confirmed_minor_max(),
|
||||
Some(49)
|
||||
);
|
||||
// Adjacent/contiguous ranges extend the run.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[0,99],[100,149]]}]}"#).confirmed_minor_max(),
|
||||
Some(149)
|
||||
);
|
||||
// Out-of-order ranges are sorted before the walk.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[100,149],[0,99]]}]}"#).confirmed_minor_max(),
|
||||
Some(149)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_minor_max_stops_at_a_gap_and_never_over_reports() {
|
||||
// [[0,99],[200,299]] leaves 100..=199 UNSCANNED. Reporting 299 would let
|
||||
// the wallet hand out an address the LWS never watches.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[0,99],[200,299]]}]}"#).confirmed_minor_max(),
|
||||
Some(99)
|
||||
);
|
||||
// A run that does not start at minor 0 confirms nothing usable.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[5,99]]}]}"#).confirmed_minor_max(),
|
||||
None
|
||||
);
|
||||
// Major 0 absent, or present with no ranges at all.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":1,"value":[[0,99]]}]}"#).confirmed_minor_max(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[]}]}"#).confirmed_minor_max(),
|
||||
None
|
||||
);
|
||||
// `new_subaddrs` is never consulted: an idempotent repeat returns it
|
||||
// empty, so trusting it would silently under- or over-report.
|
||||
assert_eq!(
|
||||
all(r#"{"new_subaddrs":[{"key":0,"value":[[0,199]]}],"all_subaddrs":[]}"#)
|
||||
.confirmed_minor_max(),
|
||||
None
|
||||
);
|
||||
// An inverted range is dropped rather than trusted.
|
||||
assert_eq!(
|
||||
all(r#"{"all_subaddrs":[{"key":0,"value":[[9,1]]}]}"#).confirmed_minor_max(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_response_rejects_a_malformed_range_entry() {
|
||||
// Strict parse: a missing/garbage major index is an error, never a
|
||||
// default that could be mistaken for major 0.
|
||||
assert!(serde_json::from_str::<ProvisionSubaddrsResponse>(
|
||||
r#"{"all_subaddrs":[{"value":[[0,199]]}]}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(serde_json::from_str::<ProvisionSubaddrsResponse>(
|
||||
r#"{"all_subaddrs":[{"key":"nope","value":[[0,199]]}]}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(serde_json::from_str::<ProvisionSubaddrsResponse>(
|
||||
r#"{"all_subaddrs":[{"key":0,"value":[[0]]}]}"#
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// ── LWS capability probe ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn version_response_reads_max_subaddresses_strictly() {
|
||||
let v: LwsVersionResponse =
|
||||
serde_json::from_str(r#"{"server_type":"monero-lws","max_subaddresses":200}"#).unwrap();
|
||||
assert_eq!(v.max_subaddresses, 200);
|
||||
// String-encoded is accepted (monero-lws stringifies some numerics).
|
||||
let s: LwsVersionResponse = serde_json::from_str(r#"{"max_subaddresses":"0"}"#).unwrap();
|
||||
assert_eq!(s.max_subaddresses, 0);
|
||||
// Absent or garbage must ERROR, never default to a capable-looking value.
|
||||
assert!(serde_json::from_str::<LwsVersionResponse>(r#"{"server_type":"x"}"#).is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<LwsVersionResponse>(r#"{"max_subaddresses":null}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── spend records carry the index of the output being spent ───────────────
|
||||
|
||||
#[test]
|
||||
fn spent_output_parses_the_sender_subaddress_index() {
|
||||
// monero-lws emits the spent output's own index as `sender`.
|
||||
let s: SpentOutput = serde_json::from_str(
|
||||
r#"{"amount":"7","key_image":"ki","tx_pub_key":"tp","out_index":2,"mixin":15,
|
||||
"sender":{"maj_i":0,"min_i":12}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.sender,
|
||||
SubaddrIndex {
|
||||
maj_i: 0,
|
||||
min_i: 12
|
||||
}
|
||||
);
|
||||
assert_eq!(s.amount, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spent_output_without_an_index_still_parses_as_primary() {
|
||||
// A pre-subaddress LWS omits the field entirely; the response must still
|
||||
// parse (money gate G2) and read as the primary index.
|
||||
let s: SpentOutput = serde_json::from_str(
|
||||
r#"{"amount":"7","key_image":"ki","tx_pub_key":"tp","out_index":2,"mixin":15}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.sender, SubaddrIndex::default());
|
||||
// `recipient` is accepted as an alias.
|
||||
let aliased: SpentOutput = serde_json::from_str(
|
||||
r#"{"amount":"7","key_image":"ki","tx_pub_key":"tp","recipient":{"maj_i":0,"min_i":4}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(aliased.sender, SubaddrIndex { maj_i: 0, min_i: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_spend_index_is_independent_of_the_enclosing_tx_recipient() {
|
||||
// The tx-level `recipient` is the CHANGE index; the spend's own index is
|
||||
// what recomputes the key image. They must not be conflated.
|
||||
let json = r#"{
|
||||
"transactions": [{
|
||||
"hash": "h1",
|
||||
"height": 10,
|
||||
"total_received": "5",
|
||||
"recipient": {"maj_i": 0, "min_i": 1},
|
||||
"spent_outputs": [
|
||||
{"amount":"9","key_image":"ki","tx_pub_key":"tp","sender":{"maj_i":0,"min_i":7}}
|
||||
]
|
||||
}]
|
||||
}"#;
|
||||
let resp: AddressTxsResponse = serde_json::from_str(json).unwrap();
|
||||
let tx = &resp.transactions[0];
|
||||
assert_eq!(tx.recipient, SubaddrIndex { maj_i: 0, min_i: 1 });
|
||||
assert_eq!(
|
||||
tx.spent_outputs[0].sender,
|
||||
SubaddrIndex { maj_i: 0, min_i: 7 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ mod tests {
|
||||
//! Unit tests for the pure `classify_funding_amount` branch logic. The async
|
||||
//! DB-backed paths are covered by `tests/tips.rs` (gated on TEST_DATABASE_URL).
|
||||
use super::*;
|
||||
use crate::infra::lws::{AddressTx, AddressTxsResponse};
|
||||
use crate::infra::lws::{AddressTx, AddressTxsResponse, SubaddrIndex};
|
||||
|
||||
fn tx(received: u64, sent: u64, height: u64, mempool: bool) -> AddressTx {
|
||||
AddressTx {
|
||||
@@ -521,6 +521,7 @@ mod tests {
|
||||
unlock_time: 0,
|
||||
payment_id: None,
|
||||
spent_outputs: Vec::new(),
|
||||
recipient: SubaddrIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! L1 integration: the dark `FEATURE_XMR_SUBADDR_PROVISIONING` gate.
|
||||
//!
|
||||
//! Deterministic, no-network paths only: whether the route is mounted, and
|
||||
//! whether `/capabilities` advertises the same answer the router enforces. The
|
||||
//! networked happy path (a real LWS confirming a subaddress range) needs a live
|
||||
//! gate and lives in the separate gitignored L3 harness.
|
||||
//!
|
||||
//! The flag is a process-wide environment variable, so these tests live in their
|
||||
//! own binary (env is per-process) and take a lock around every set/restore.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
const FLAG: &str = "FEATURE_XMR_SUBADDR_PROVISIONING";
|
||||
const ROUTE: &str = "/api/v1/wallet/lws/provision_subaddrs";
|
||||
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Set the flag, run `body`, then restore the previous value. Held across the
|
||||
/// whole test because `build_router` reads the flag when it mounts routes.
|
||||
struct FlagGuard {
|
||||
prev: Option<String>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl FlagGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var(FLAG).ok();
|
||||
match value {
|
||||
Some(v) => std::env::set_var(FLAG, v),
|
||||
None => std::env::remove_var(FLAG),
|
||||
}
|
||||
Self { prev, _lock: lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FlagGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var(FLAG, v),
|
||||
None => std::env::remove_var(FLAG),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provision_route_is_absent_while_the_flag_is_off() {
|
||||
let _flag = FlagGuard::set(None);
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
// Not mounted at all: an authenticated, well-formed request still 404s, so a
|
||||
// client cannot reach the feature by guessing the path.
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
// And the capability says so, so a client never has to probe.
|
||||
let (status, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["features"]["xmr_subaddr_provisioning"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provision_route_is_mounted_and_validates_while_the_flag_is_on() {
|
||||
let _flag = FlagGuard::set(Some("1"));
|
||||
let app = require_app!();
|
||||
|
||||
// Mounted: an unauthenticated request is rejected by the JWT gate (401),
|
||||
// NOT by the router (404), which is what proves the route exists.
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
None,
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
// Authed but an unknown asset -> 400, still resolved before any LWS call.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
|
||||
// A malformed view key is rejected before any LWS call too.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({
|
||||
"asset": "xmr",
|
||||
"address": "4SomeAddress",
|
||||
"view_key": "tooshort",
|
||||
"max_minor": 31
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flag_parsing_agrees_between_the_router_and_capabilities() {
|
||||
// Every accepted spelling must mount the route AND be advertised, so a
|
||||
// client can negotiate instead of blindly calling a route that 404s.
|
||||
for spelling in ["1", "true", "TRUE", "On", " yes "] {
|
||||
let _flag = FlagGuard::set(Some(spelling));
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{spelling:?} should mount the route"
|
||||
);
|
||||
|
||||
let (_s, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
// Advertised only when a CryptoNote chain is actually serviceable; when
|
||||
// it is not, the route may exist but the capability must stay false
|
||||
// rather than promise a chain this instance cannot serve.
|
||||
let advertised = body["features"]["xmr_subaddr_provisioning"] == json!(true);
|
||||
let xmr_or_wow = body["chains"]["xmr"]["enabled"] == json!(true)
|
||||
|| body["chains"]["wow"]["enabled"] == json!(true);
|
||||
assert_eq!(advertised, xmr_or_wow, "{spelling:?}");
|
||||
}
|
||||
|
||||
for spelling in ["0", "false", "off", "no", "", "maybe"] {
|
||||
let _flag = FlagGuard::set(Some(spelling));
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{spelling:?} must leave the feature dark"
|
||||
);
|
||||
|
||||
let (_s, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
assert_eq!(
|
||||
body["features"]["xmr_subaddr_provisioning"],
|
||||
json!(false),
|
||||
"{spelling:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user