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

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

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

  Three money-safety properties worth calling out:

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

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

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

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