From fb48e04ea67890940879e36c28cd3c910d59600e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 28 Jul 2026 16:01:25 -0400 Subject: [PATCH] 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. --- .env.example | 17 + openapi.json | 451 ++++++++++++++++++++++- src/api/capabilities.rs | 32 ++ src/api/openapi.rs | 13 + src/api/wallet/btc_ltc.rs | 271 +++++++++++++- src/api/wallet/xmr_wow.rs | 370 ++++++++++++++++++- src/infra/electrum.rs | 202 +++++++++++ src/infra/lws/client.rs | 356 ++++++++++++++++-- src/infra/lws/mod.rs | 2 +- src/infra/lws/types.rs | 525 +++++++++++++++++++++++++++ src/tips/confirmation.rs | 3 +- tests/wallet_subaddr_provisioning.rs | 181 +++++++++ 12 files changed, 2379 insertions(+), 44 deletions(-) create mode 100644 tests/wallet_subaddr_provisioning.rs diff --git a/.env.example b/.env.example index 9a4d80f..7d7ac0e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/openapi.json b/openapi.json index 5d65d13..a949f55 100644 --- a/openapi.json +++ b/openapi.json @@ -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" }, diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 0481d1c..d70b5f6 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -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(), diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 14d2e40..236fbf3 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -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, diff --git a/src/api/wallet/btc_ltc.rs b/src/api/wallet/btc_ltc.rs index b716447..69267e3 100644 --- a/src/api/wallet/btc_ltc.rs +++ b/src/api/wallet/btc_ltc.rs @@ -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, } +/// 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, +} + +/// 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, +} + +/// 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, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct MultiHistoryResponse { + pub asset: String, + pub transactions: Vec, +} + /// 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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> { - 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 = (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 = (0..MAX_MULTI_ADDRESSES + 1) + .map(|i| i.to_string()) + .collect(); + assert!(validate_address_batch(&over).is_err()); + } } diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index 651d7e3..8f03cc1 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -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 { + 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 { + 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, + /// 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, +} + +/// 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, +} + +/// 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, } +/// 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, pub spent_outputs: Vec, + /// 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, + /// 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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> { - 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> { 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(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)); + } } diff --git a/src/infra/electrum.rs b/src/infra/electrum.rs index 12fc03e..5393308 100644 --- a/src/infra/electrum.rs +++ b/src/infra/electrum.rs @@ -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 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(queries: I) -> Result, BatchError> +where + I: IntoIterator, + I::Item: std::future::Future>, +{ + use futures::stream::{self, StreamExt, TryStreamExt}; + + let work = stream::iter(queries) + .buffered(BATCH_CONCURRENCY) + .try_collect::>(); + + 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 { 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, +) -> Result { + 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 { + // 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, 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, 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 { @@ -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()); diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index 6fec50f..c1ce975 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -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>, } 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 { + 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::(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 { + 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, 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, 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 { + 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 diff --git a/src/infra/lws/mod.rs b/src/infra/lws/mod.rs index 229b650..60f69d9 100644 --- a/src/infra/lws/mod.rs +++ b/src/infra/lws/mod.rs @@ -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, }; diff --git a/src/infra/lws/types.rs b/src/infra/lws/types.rs index ac95811..7442c5f 100644 --- a/src/infra/lws/types.rs +++ b/src/infra/lws/types.rs @@ -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 + 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::::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`, +/// 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, + /// The account's complete current range set (requires `get_all: true`). + #[serde(default)] + pub all_subaddrs: Vec, +} + +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 { + 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, + /// 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, + /// 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::( + r#"{"all_subaddrs":[{"value":[[0,199]]}]}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"all_subaddrs":[{"key":"nope","value":[[0,199]]}]}"# + ) + .is_err()); + assert!(serde_json::from_str::( + 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::(r#"{"server_type":"x"}"#).is_err()); + assert!( + serde_json::from_str::(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 } + ); + } } diff --git a/src/tips/confirmation.rs b/src/tips/confirmation.rs index c9adf5e..3d2c317 100644 --- a/src/tips/confirmation.rs +++ b/src/tips/confirmation.rs @@ -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(), } } diff --git a/tests/wallet_subaddr_provisioning.rs b/tests/wallet_subaddr_provisioning.rs new file mode 100644 index 0000000..0cb9a60 --- /dev/null +++ b/tests/wallet_subaddr_provisioning.rs @@ -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, + _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:?}" + ); + } +}