mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat(wallet): subaddress provisioning and batched UTXO endpoints (off by default)
Backend support for two address-privacy features. Both are behind env flags
that default to off, so an unconfigured instance behaves exactly as before:
the routes are not mounted and no provisioning is attempted.
FEATURE_XMR_SUBADDR_PROVISIONING
Adds POST /wallet/lws/provision_subaddrs and provisions account-0 subaddress
indices on the LWS at register/import.
Three money-safety properties worth calling out:
* The provisioned ceiling is read from the LWS response, never echoed back
from the request. It is the contiguous run starting at minor 0, not
max(hi): given ranges [[0,99],[200,299]], reporting 299 would let a wallet
hand out indices 100..199 that the LWS never scans, so funds sent there
would be invisible. No usable run is a hard error, not a guess.
* import_account now always runs the backward rescan even when provisioning
fails. Previously a provisioning error aborted first, leaving the account
registered at the chain tip while every retry returned 200 OK, so a
restoring wallet would show a zero balance permanently.
* A capacity probe reads max_subaddresses from the LWS and refuses up front
with a message naming the required --max-subaddresses value, rather than
failing per-account later. The option defaults to 0 upstream.
Spent outputs now carry their subaddress index. monero-lws emits this as
`sender` (the index the spent output was received at), not `recipient`;
without it a client recomputes a primary key image for a subaddress spend,
treats the mismatch as a decoy, and never subtracts the amount.
FEATURE_UTXO_MULTI_ADDRESS
Adds balance_multi / utxos_multi / history_multi for HD gap-limit wallets.
Capped at 32 addresses, summed with checked_add, per-UTXO address tag
preserved so the client never has to re-guess which key owns an output.
Bounded concurrency with a per-batch deadline returning 504, so a slow
Electrum server cannot stack n * IO_TIMEOUT.
Both flags are advertised in /capabilities so a client can negotiate instead
of calling routes that 404.
This commit is contained in:
@@ -19,6 +19,20 @@ use crate::AppState;
|
||||
/// soft-notice a higher value and ignore unknown (additive) keys.
|
||||
pub const CAPABILITIES_CONTRACT_VERSION: u32 = 1;
|
||||
|
||||
/// The one parser for the environment-held feature flags, so every reader of a
|
||||
/// flag agrees on what "on" means. Accepts `1` / `true` / `on` / `yes`, trimmed
|
||||
/// and ASCII-case-insensitive; anything else (including an unset variable) is
|
||||
/// off. A flag that gates a money path must never mean one thing where it is
|
||||
/// enforced and another where it is advertised.
|
||||
pub(crate) fn env_flag_enabled(name: &str) -> bool {
|
||||
std::env::var(name).is_ok_and(|raw| {
|
||||
let v = raw.trim();
|
||||
["1", "true", "on", "yes"]
|
||||
.iter()
|
||||
.any(|truthy| v.eq_ignore_ascii_case(truthy))
|
||||
})
|
||||
}
|
||||
|
||||
/// Per-chain availability. `network` is the configured network for UTXO chains
|
||||
/// (so the wallet derives addresses for the right one); `null` for chains whose
|
||||
/// network isn't a backend setting.
|
||||
@@ -60,6 +74,15 @@ pub struct FeatureCapabilities {
|
||||
pub feed: bool,
|
||||
/// Tipping (parked).
|
||||
pub tips: bool,
|
||||
/// Monero/Wownero subaddress provisioning: this instance registers a batch
|
||||
/// of account-0 subaddress indices with its LWS and serves
|
||||
/// `POST /wallet/lws/provision_subaddrs`. Off ⇒ that route is not mounted
|
||||
/// (404) and the wallet must keep receiving on the primary address only.
|
||||
pub xmr_subaddr_provisioning: bool,
|
||||
/// Batch (multi-address) BTC/LTC queries: this instance serves
|
||||
/// `POST /wallet/utxo/{balance,utxos,history}_multi`. Off ⇒ those routes are
|
||||
/// not mounted (404) and the wallet must query one address at a time.
|
||||
pub utxo_multi_address: bool,
|
||||
}
|
||||
|
||||
/// First-party Nostr relay details (present only when `features.nostr_relay`).
|
||||
@@ -251,6 +274,15 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
premium_relay: premium_advertised(config),
|
||||
feed: feed_advertised(config),
|
||||
tips: tips_advertised(config),
|
||||
// Advertised through the SAME predicates the routers gate on, so a
|
||||
// client can never be told a route exists that would 404 (or the
|
||||
// reverse). Both are environment flags, and both are chain-gated:
|
||||
// never advertise subaddress provisioning on an instance with no
|
||||
// CryptoNote chain, or batch UTXO queries with no UTXO chain.
|
||||
xmr_subaddr_provisioning: crate::api::wallet::xmr_wow::subaddr_provisioning_enabled()
|
||||
&& (chain_serviceable(config, "xmr") || chain_serviceable(config, "wow")),
|
||||
utxo_multi_address: crate::api::wallet::btc_ltc::utxo_multi_enabled()
|
||||
&& (chain_serviceable(config, "btc") || chain_serviceable(config, "ltc")),
|
||||
},
|
||||
restore: RestoreCapability {
|
||||
policy: config.restore.policy.as_str().to_string(),
|
||||
|
||||
@@ -42,10 +42,14 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::balance,
|
||||
crate::api::wallet::btc_ltc::utxos,
|
||||
crate::api::wallet::btc_ltc::history,
|
||||
crate::api::wallet::btc_ltc::balance_multi,
|
||||
crate::api::wallet::btc_ltc::utxos_multi,
|
||||
crate::api::wallet::btc_ltc::history_multi,
|
||||
crate::api::wallet::btc_ltc::tip,
|
||||
crate::api::wallet::btc_ltc::fee,
|
||||
crate::api::wallet::btc_ltc::broadcast,
|
||||
crate::api::wallet::xmr_wow::register,
|
||||
crate::api::wallet::xmr_wow::provision_subaddrs,
|
||||
crate::api::wallet::xmr_wow::balance,
|
||||
crate::api::wallet::xmr_wow::history,
|
||||
crate::api::wallet::xmr_wow::unspent_outs,
|
||||
@@ -125,6 +129,12 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::UtxosResponse,
|
||||
crate::api::wallet::btc_ltc::HistoryEntry,
|
||||
crate::api::wallet::btc_ltc::HistoryResponse,
|
||||
crate::api::wallet::btc_ltc::MultiAddressRequest,
|
||||
crate::api::wallet::btc_ltc::MultiBalanceResponse,
|
||||
crate::api::wallet::btc_ltc::TaggedUtxo,
|
||||
crate::api::wallet::btc_ltc::MultiUtxosResponse,
|
||||
crate::api::wallet::btc_ltc::TaggedHistoryEntry,
|
||||
crate::api::wallet::btc_ltc::MultiHistoryResponse,
|
||||
crate::api::wallet::btc_ltc::AssetRequest,
|
||||
crate::api::wallet::btc_ltc::TipResponse,
|
||||
crate::api::wallet::btc_ltc::FeeRequest,
|
||||
@@ -133,11 +143,14 @@ use utoipa::OpenApi;
|
||||
crate::api::wallet::btc_ltc::BroadcastResponse,
|
||||
crate::api::wallet::xmr_wow::ViewRequest,
|
||||
crate::api::wallet::xmr_wow::RegisterRequest,
|
||||
crate::api::wallet::xmr_wow::ProvisionRequest,
|
||||
crate::api::wallet::xmr_wow::ProvisionResponse,
|
||||
crate::api::wallet::xmr_wow::RandomOutsRequest,
|
||||
crate::api::wallet::xmr_wow::SubmitRequest,
|
||||
crate::api::wallet::xmr_wow::LwsAssetRequest,
|
||||
crate::api::wallet::xmr_wow::ConfirmationsRequest,
|
||||
crate::api::wallet::xmr_wow::LwsBalanceResponse,
|
||||
crate::api::wallet::xmr_wow::SubaddrIndexDto,
|
||||
crate::api::wallet::xmr_wow::SpentOutputDto,
|
||||
crate::api::wallet::xmr_wow::TxDto,
|
||||
crate::api::wallet::xmr_wow::LwsHistoryResponse,
|
||||
|
||||
+268
-3
@@ -24,13 +24,69 @@ use tracing::instrument;
|
||||
|
||||
use crate::api::middleware::extract_user_id_from_token;
|
||||
use crate::error::AppError;
|
||||
use crate::infra::electrum::ElectrumClient;
|
||||
use crate::infra::electrum::{BatchError, ElectrumClient};
|
||||
use crate::AppState;
|
||||
|
||||
/// Generous cap on a raw transaction hex (~100 KB of tx). Bounds the broadcast
|
||||
/// body before it reaches the node; the axum body limit is a second backstop.
|
||||
const MAX_TX_HEX_LEN: usize = 200_000;
|
||||
|
||||
/// HARD cap on the number of addresses in one batch (`*_multi`) request. Mirrors
|
||||
/// the fixed-ceiling discipline of [`MAX_TX_HEX_LEN`]: a client-supplied length
|
||||
/// is bounded before any network fan-out so one request cannot fan out
|
||||
/// unboundedly. A wallet with more addresses batches in groups of this size.
|
||||
const MAX_MULTI_ADDRESSES: usize = 32;
|
||||
|
||||
/// Dark feature flag (default OFF): the additive batch (`*_multi`) endpoints are
|
||||
/// only mounted when `FEATURE_UTXO_MULTI_ADDRESS` is set truthy in the
|
||||
/// environment. OFF ⇒ the routes do not exist (404) and every existing
|
||||
/// single-address endpoint is byte-for-byte unchanged. Kept as an environment
|
||||
/// read (not a new `Config` field) to stay within this change's file lane.
|
||||
pub(crate) fn utxo_multi_enabled() -> bool {
|
||||
crate::api::capabilities::env_flag_enabled("FEATURE_UTXO_MULTI_ADDRESS")
|
||||
}
|
||||
|
||||
/// HTTP mapping for a batch query outcome.
|
||||
///
|
||||
/// [`BatchError::Deadline`] is its own status (504) rather than the generic
|
||||
/// upstream-unavailable 503: a batch that ran out of time is not a broken node,
|
||||
/// and a wallet should back off and re-batch smaller rather than mark the chain
|
||||
/// down. Everything else delegates to the shared [`AppError`] mapping, so the
|
||||
/// error envelope (`{error, code}`) and the CWE-209 redaction rules are
|
||||
/// unchanged.
|
||||
impl axum::response::IntoResponse for BatchError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
BatchError::App(e) => e.into_response(),
|
||||
BatchError::Deadline => (
|
||||
axum::http::StatusCode::GATEWAY_TIMEOUT,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "Batch address query timed out",
|
||||
"code": "BATCH_TIMEOUT",
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound a batch address list: non-empty and within [`MAX_MULTI_ADDRESSES`].
|
||||
/// Rejects (never truncates) an oversized list so the caller's intent is never
|
||||
/// silently narrowed.
|
||||
fn validate_address_batch(addresses: &[String]) -> Result<(), AppError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(AppError::ValidationError(
|
||||
"addresses must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if addresses.len() > MAX_MULTI_ADDRESSES {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"too many addresses (max {MAX_MULTI_ADDRESSES})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the Electrum client for a UTXO asset, or a 400 (unknown / disabled).
|
||||
fn electrum_for<'a>(state: &'a AppState, asset: &str) -> Result<&'a ElectrumClient, AppError> {
|
||||
let client = match asset {
|
||||
@@ -117,6 +173,61 @@ pub struct HistoryResponse {
|
||||
pub transactions: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// A batch query for several addresses of one UTXO asset. The list is bounded
|
||||
/// server-side (see [`MAX_MULTI_ADDRESSES`]).
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct MultiAddressRequest {
|
||||
/// `btc` or `ltc`.
|
||||
pub asset: String,
|
||||
/// The addresses to query (each validated by the Electrum client).
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Confirmed/unconfirmed balance summed across the requested addresses. Summed
|
||||
/// with checked arithmetic server-side (a hostile value errors, never wraps);
|
||||
/// the wallet computes any grand total from these integer fields.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MultiBalanceResponse {
|
||||
pub asset: String,
|
||||
pub confirmed: u64,
|
||||
pub unconfirmed: i64,
|
||||
}
|
||||
|
||||
/// A single unspent output TAGGED with the address it belongs to. The tag is
|
||||
/// load-bearing: the wallet never re-derives which address owns a UTXO.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TaggedUtxo {
|
||||
pub address: String,
|
||||
pub txid: String,
|
||||
pub vout: u64,
|
||||
pub value: u64,
|
||||
/// Block height; `0` if unconfirmed.
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MultiUtxosResponse {
|
||||
pub asset: String,
|
||||
pub utxos: Vec<TaggedUtxo>,
|
||||
}
|
||||
|
||||
/// A transaction-history entry TAGGED with its owning address.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct TaggedHistoryEntry {
|
||||
pub address: String,
|
||||
pub txid: String,
|
||||
/// Block height (`0`/negative for unconfirmed).
|
||||
pub height: i64,
|
||||
/// Fee in satoshis (mempool entries only).
|
||||
pub fee: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MultiHistoryResponse {
|
||||
pub asset: String,
|
||||
pub transactions: Vec<TaggedHistoryEntry>,
|
||||
}
|
||||
|
||||
/// An asset-only query.
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AssetRequest {
|
||||
@@ -358,17 +469,147 @@ pub async fn broadcast(
|
||||
Ok(Json(BroadcastResponse { txid }))
|
||||
}
|
||||
|
||||
// ── batch (multi-address) handlers ────────────────────────────────────────────
|
||||
|
||||
/// Confirmed/unconfirmed balance summed across a batch of BTC/LTC addresses.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/balance_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Summed balance in satoshis", body = MultiBalanceResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn balance_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiBalanceResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let bal = electrum_for(&state, &asset)?
|
||||
.get_balance_sum(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiBalanceResponse {
|
||||
asset,
|
||||
confirmed: bal.confirmed,
|
||||
unconfirmed: bal.unconfirmed,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unspent outputs across a batch of BTC/LTC addresses, each tagged with its
|
||||
/// owning address (the wallet never re-guesses UTXO ownership).
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/utxos_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Address-tagged unspent outputs", body = MultiUtxosResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn utxos_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiUtxosResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let tagged = electrum_for(&state, &asset)?
|
||||
.get_utxos_tagged(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiUtxosResponse {
|
||||
asset,
|
||||
utxos: tagged
|
||||
.into_iter()
|
||||
.map(|(address, u)| TaggedUtxo {
|
||||
address,
|
||||
txid: u.tx_hash,
|
||||
vout: u.tx_pos,
|
||||
value: u.value,
|
||||
height: u.height,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Confirmed + mempool history across a batch of BTC/LTC addresses, each entry
|
||||
/// tagged with its owning address.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/utxo/history_multi",
|
||||
request_body = MultiAddressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Address-tagged transaction history", body = MultiHistoryResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or oversized batch"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 503, description = "Upstream node unavailable"),
|
||||
(status = 504, description = "The batch exceeded its deadline; retry with a smaller batch")
|
||||
),
|
||||
tag = "btc_ltc"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn history_multi(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MultiAddressRequest>,
|
||||
) -> Result<Json<MultiHistoryResponse>, BatchError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
validate_address_batch(&req.addresses)?;
|
||||
let tagged = electrum_for(&state, &asset)?
|
||||
.get_history_tagged(&req.addresses)
|
||||
.await?;
|
||||
Ok(Json(MultiHistoryResponse {
|
||||
asset,
|
||||
transactions: tagged
|
||||
.into_iter()
|
||||
.map(|(address, e)| TaggedHistoryEntry {
|
||||
address,
|
||||
txid: e.tx_hash,
|
||||
height: e.height,
|
||||
fee: e.fee,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BTC/LTC routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
let mut router = Router::new()
|
||||
.route("/wallet/utxo/balance", post(balance))
|
||||
.route("/wallet/utxo/utxos", post(utxos))
|
||||
.route("/wallet/utxo/history", post(history))
|
||||
.route("/wallet/utxo/tip", post(tip))
|
||||
.route("/wallet/utxo/fee", post(fee))
|
||||
.route("/wallet/utxo/broadcast", post(broadcast))
|
||||
.route("/wallet/utxo/broadcast", post(broadcast));
|
||||
|
||||
// Dark by default: the batch endpoints exist only when explicitly enabled.
|
||||
if utxo_multi_enabled() {
|
||||
router = router
|
||||
.route("/wallet/utxo/balance_multi", post(balance_multi))
|
||||
.route("/wallet/utxo/utxos_multi", post(utxos_multi))
|
||||
.route("/wallet/utxo/history_multi", post(history_multi));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -384,4 +625,28 @@ mod tests {
|
||||
assert!(validate_tx_hex("xyz!").is_err()); // non-hex
|
||||
assert!(validate_tx_hex(&"a".repeat(MAX_TX_HEX_LEN + 1)).is_err()); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_address_request_deserializes() {
|
||||
let req: MultiAddressRequest =
|
||||
serde_json::from_str(r#"{"asset":"btc","addresses":["a1","a2","a3"]}"#).unwrap();
|
||||
assert_eq!(req.asset, "btc");
|
||||
assert_eq!(req.addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_batch_bounds() {
|
||||
// A normal batch passes.
|
||||
assert!(validate_address_batch(&["a".into(), "b".into()]).is_ok());
|
||||
// Exactly at the cap passes.
|
||||
let at_cap: Vec<String> = (0..MAX_MULTI_ADDRESSES).map(|i| i.to_string()).collect();
|
||||
assert!(validate_address_batch(&at_cap).is_ok());
|
||||
// Empty is rejected (never a silent no-op).
|
||||
assert!(validate_address_batch(&[]).is_err());
|
||||
// Oversized is rejected (never silently truncated) — money gate G13.
|
||||
let over: Vec<String> = (0..MAX_MULTI_ADDRESSES + 1)
|
||||
.map(|i| i.to_string())
|
||||
.collect();
|
||||
assert!(validate_address_batch(&over).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+366
-4
@@ -37,6 +37,67 @@ use crate::AppState;
|
||||
/// are larger than UTXO txs; 2 MiB of hex is well above any real single tx.
|
||||
const MAX_CN_TX_HEX_LEN: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Hard cap on the batch of minor subaddress indices provisioned for account 0
|
||||
/// at register time. Bounds one registration's ask on the LWS; the LWS's own
|
||||
/// `--max-subaddresses` is the ultimate bound (and must be `>=` this to enable
|
||||
/// the feature at all).
|
||||
const MAX_PROVISION_MINORS: u32 = 200;
|
||||
|
||||
/// Dark feature flag (default OFF): whether this instance provisions account-0
|
||||
/// subaddress ranges at the LWS. Also advertised on `/capabilities` so a client
|
||||
/// can negotiate instead of calling a route that is not mounted.
|
||||
///
|
||||
/// Kept as an environment read (rather than a new `Config` field) to stay within
|
||||
/// this change's file lane. ENABLING IT ALSO REQUIRES the LWS to run with
|
||||
/// `--max-subaddresses >= MAX_PROVISION_MINORS` (it defaults to `0` =
|
||||
/// subaddresses disabled); the client probes that ceiling and fails closed with
|
||||
/// an operator-legible error rather than half-provisioning.
|
||||
pub(crate) fn subaddr_provisioning_enabled() -> bool {
|
||||
crate::api::capabilities::env_flag_enabled("FEATURE_XMR_SUBADDR_PROVISIONING")
|
||||
}
|
||||
|
||||
/// The number of account-0 minor subaddress indices to provision at the LWS.
|
||||
/// `0` (no provisioning; behavior identical to before) unless the flag is on.
|
||||
fn subaddr_provisioning_minors() -> u32 {
|
||||
if subaddr_provisioning_enabled() {
|
||||
MAX_PROVISION_MINORS
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch width to provision for one register call.
|
||||
///
|
||||
/// A client-supplied `subaddr_count` may RAISE the instance default (clamped to
|
||||
/// [`MAX_PROVISION_MINORS`]) but can never enable provisioning: with the flag
|
||||
/// off this returns `0` for every input, so the dark path stays byte-identical.
|
||||
/// A money-gating field is never silently dropped, which is what an absent
|
||||
/// `deny_unknown_fields` used to do to it.
|
||||
fn effective_provision_minors(requested: Option<u32>) -> u32 {
|
||||
let base = subaddr_provisioning_minors();
|
||||
if base == 0 {
|
||||
return 0;
|
||||
}
|
||||
base.max(requested.unwrap_or(0).min(MAX_PROVISION_MINORS))
|
||||
}
|
||||
|
||||
/// The LWS batch width for an on-demand `max_minor` ask.
|
||||
///
|
||||
/// `max_minor` is the highest minor INDEX the caller wants, so the width is
|
||||
/// `max_minor + 1`, clamped so one call never provisions more than
|
||||
/// [`MAX_PROVISION_MINORS`] indices (i.e. an ask above `MAX_PROVISION_MINORS - 1`
|
||||
/// is clamped down, never rejected). An absent ask uses the instance default.
|
||||
/// `0` whenever provisioning is off, so this can never turn the feature on.
|
||||
fn provision_width_for(max_minor: Option<u32>) -> u32 {
|
||||
if !subaddr_provisioning_enabled() {
|
||||
return 0;
|
||||
}
|
||||
match max_minor {
|
||||
Some(m) => m.saturating_add(1).min(MAX_PROVISION_MINORS),
|
||||
None => subaddr_provisioning_minors(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the LWS client for a CryptoNote asset, or a 400 (unknown / disabled).
|
||||
fn lws_for<'a>(state: &'a AppState, asset: &str) -> Result<&'a LwsClient, AppError> {
|
||||
let client = match asset {
|
||||
@@ -83,6 +144,40 @@ pub struct RegisterRequest {
|
||||
/// otherwise. Bound to `(asset, address, start_height)` (see `restore_pow`).
|
||||
#[serde(default)]
|
||||
pub restore_pow_nonce: Option<u64>,
|
||||
/// How many account-0 minor subaddress indices to provision at the LWS.
|
||||
/// Clamped to the server ceiling, and may only RAISE this instance's
|
||||
/// default; it can never enable provisioning on an instance that has it off.
|
||||
/// Omit to use the instance default.
|
||||
#[serde(default)]
|
||||
pub subaddr_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Provision a batch of account-0 subaddress indices for an account. Additive to
|
||||
/// `register`; the identity comes from the bearer token, so any `user_id` on the
|
||||
/// wire is ignored.
|
||||
// Omits `Debug` (carries the private `view_key`) - see `ViewRequest`.
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProvisionRequest {
|
||||
/// `xmr` or `wow`.
|
||||
pub asset: String,
|
||||
pub address: String,
|
||||
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
|
||||
pub view_key: String,
|
||||
/// Highest minor index wanted for account 0. Clamped to the server ceiling.
|
||||
/// Omit for the instance default.
|
||||
#[serde(default)]
|
||||
pub max_minor: Option<u32>,
|
||||
}
|
||||
|
||||
/// The subaddress ceiling the LWS CONFIRMED for account 0.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ProvisionResponse {
|
||||
pub asset: String,
|
||||
/// Highest minor index the LWS confirmed it is scanning for account 0, read
|
||||
/// back from its response - never an echo of the request. Every index in
|
||||
/// `0..=provisioned_minor_max` is provisioned; the wallet must not hand out
|
||||
/// an index above it.
|
||||
pub provisioned_minor_max: u32,
|
||||
}
|
||||
|
||||
/// Request decoy outputs for ring construction.
|
||||
@@ -157,6 +252,15 @@ pub struct LwsBalanceResponse {
|
||||
pub spent_outputs: Vec<SpentOutputDto>,
|
||||
}
|
||||
|
||||
/// A subaddress index `(major, minor)` an output/tx was received at. Nested to
|
||||
/// match the wallet's wasm `LwsOutput` shape (`subaddr_index: {major, minor}`);
|
||||
/// mapped from the LWS `recipient` field. `(0, 0)` is the primary address.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SubaddrIndexDto {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
}
|
||||
|
||||
/// A candidate spent output (verify with the spend key before trusting).
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SpentOutputDto {
|
||||
@@ -167,6 +271,17 @@ pub struct SpentOutputDto {
|
||||
pub tx_pub_key: String,
|
||||
pub out_index: u64,
|
||||
pub mixin: u64,
|
||||
/// Subaddress index of the output BEING SPENT (`(0, 0)` = primary address),
|
||||
/// taken from the spend record itself.
|
||||
///
|
||||
/// Load-bearing for the balance: the wallet recomputes this output's key
|
||||
/// image to tell a real spend from a ring decoy, and the key image depends
|
||||
/// on the subaddress index. Without it a subaddress spend is recomputed
|
||||
/// against the primary index, the key images never match, the spend is
|
||||
/// dismissed as a decoy, and the amount is never subtracted - the balance
|
||||
/// over-reports forever. It is deliberately NOT the enclosing transaction's
|
||||
/// `subaddr_index`, which is the change index and would be just as wrong.
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
/// A transaction in the account's history.
|
||||
@@ -186,6 +301,8 @@ pub struct TxDto {
|
||||
pub unlock_time: u64,
|
||||
pub payment_id: Option<String>,
|
||||
pub spent_outputs: Vec<SpentOutputDto>,
|
||||
/// Subaddress index this tx was received at (`(0, 0)` = primary address).
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
@@ -210,6 +327,8 @@ pub struct UnspentOutputDto {
|
||||
pub rct: String,
|
||||
/// Key images seen on-chain that may correspond to this output being spent.
|
||||
pub spend_key_images: Vec<String>,
|
||||
/// Subaddress index this output was received at (`(0, 0)` = primary address).
|
||||
pub subaddr_index: SubaddrIndexDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
@@ -268,6 +387,12 @@ fn spent_dto(s: SpentOutput) -> SpentOutputDto {
|
||||
tx_pub_key: s.tx_pub_key,
|
||||
out_index: s.out_index,
|
||||
mixin: s.mixin,
|
||||
// From the SPEND RECORD (monero-lws `sender`), never from the enclosing
|
||||
// tx's `recipient` - that is the change index.
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: s.sender.maj_i,
|
||||
minor: s.sender.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +407,10 @@ fn tx_dto(t: AddressTx) -> TxDto {
|
||||
unlock_time: t.unlock_time,
|
||||
payment_id: t.payment_id,
|
||||
spent_outputs: t.spent_outputs.into_iter().map(spent_dto).collect(),
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: t.recipient.maj_i,
|
||||
minor: t.recipient.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +426,10 @@ fn unspent_dto(u: UnspentOutput) -> UnspentOutputDto {
|
||||
tx_hash: u.tx_hash,
|
||||
rct: u.rct,
|
||||
spend_key_images: u.spend_key_images,
|
||||
subaddr_index: SubaddrIndexDto {
|
||||
major: u.recipient.maj_i,
|
||||
minor: u.recipient.min_i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +470,14 @@ pub async fn register(
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
// Dark by default: 0 ⇒ no subaddress provisioning (register behaves exactly
|
||||
// as before). When the flag is on, both paths provision a bounded batch of
|
||||
// account-0 minor indices and HARD-error on provision failure (the `?`
|
||||
// propagates) — never silently skipping so a wallet is not told scanning is
|
||||
// ready when the subaddresses were not registered. A client-supplied
|
||||
// `subaddr_count` may raise the batch within the server ceiling.
|
||||
let provision_minors = effective_provision_minors(req.subaddr_count);
|
||||
|
||||
match req.start_height {
|
||||
Some(h) => {
|
||||
// Restore: gate the scan depth against this instance's policy (the
|
||||
@@ -351,14 +492,65 @@ pub async fn register(
|
||||
req.restore_pow_nonce,
|
||||
)?;
|
||||
client
|
||||
.import_account(&req.address, &req.view_key, h)
|
||||
.import_account(&req.address, &req.view_key, h, provision_minors)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
client
|
||||
.register_account(&req.address, &req.view_key, provision_minors)
|
||||
.await?
|
||||
}
|
||||
None => client.register_account(&req.address, &req.view_key).await?,
|
||||
}
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
/// Provision account-0 subaddress indices at the LWS for an already-registered
|
||||
/// account, and report the ceiling the LWS confirmed.
|
||||
///
|
||||
/// Mounted only when `FEATURE_XMR_SUBADDR_PROVISIONING` is on (see
|
||||
/// `/capabilities` → `features.xmr_subaddr_provisioning`); otherwise the route
|
||||
/// does not exist and the request 404s.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
path = "/wallet/lws/provision_subaddrs",
|
||||
request_body = ProvisionRequest,
|
||||
responses(
|
||||
(status = 200, description = "LWS-confirmed subaddress ceiling for account 0", body = ProvisionResponse),
|
||||
(status = 400, description = "Invalid/disabled asset, address, or view key"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 404, description = "Subaddress provisioning is not enabled on this instance"),
|
||||
(status = 503, description = "Upstream node unavailable, or it cannot provision subaddresses")
|
||||
),
|
||||
tag = "xmr_wow"
|
||||
)]
|
||||
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
|
||||
pub async fn provision_subaddrs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ProvisionRequest>,
|
||||
) -> Result<Json<ProvisionResponse>, AppError> {
|
||||
// Identity comes from the bearer token alone. Any `user_id` on the wire is
|
||||
// ignored (unknown fields are dropped), so it can never select an account.
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let asset = req.asset.to_lowercase();
|
||||
let client = lws_for(&state, &asset)?;
|
||||
validate_cn_address(&req.address)?;
|
||||
validate_view_key(&req.view_key)?;
|
||||
|
||||
// Clamped to the hard server ceiling; an absent ask uses the instance
|
||||
// default. The route is only mounted with the flag on, so the width is
|
||||
// never 0 here, but `provision_account0` rejects a 0 ask regardless.
|
||||
let n_min = provision_width_for(req.max_minor);
|
||||
let provisioned_minor_max = client
|
||||
.provision_account0(&req.address, &req.view_key, n_min)
|
||||
.await?;
|
||||
Ok(Json(ProvisionResponse {
|
||||
asset,
|
||||
provisioned_minor_max,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Balance + scan state for an account.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
@@ -616,7 +808,7 @@ pub async fn confirmations(
|
||||
|
||||
/// Monero/Wownero routes, RELATIVE to the `/api/v1` mount point.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
let mut router = Router::new()
|
||||
.route("/wallet/lws/register", post(register))
|
||||
.route("/wallet/lws/balance", post(balance))
|
||||
.route("/wallet/lws/history", post(history))
|
||||
@@ -629,7 +821,14 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
post(submit_tx).layer(DefaultBodyLimit::max(MAX_CN_TX_HEX_LEN + 64 * 1024)),
|
||||
)
|
||||
.route("/wallet/lws/height", post(height))
|
||||
.route("/wallet/lws/confirmations", post(confirmations))
|
||||
.route("/wallet/lws/confirmations", post(confirmations));
|
||||
|
||||
// Dark by default: the provisioning endpoint exists only when enabled, so a
|
||||
// client that has not negotiated the capability gets a clean 404.
|
||||
if subaddr_provisioning_enabled() {
|
||||
router = router.route("/wallet/lws/provision_subaddrs", post(provision_subaddrs));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -653,12 +852,39 @@ mod amount_wire_tests {
|
||||
tx_hash: "h".into(),
|
||||
rct: String::new(),
|
||||
spend_key_images: vec![],
|
||||
subaddr_index: SubaddrIndexDto { major: 0, minor: 7 },
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["amount"], serde_json::json!("9007199254740993"));
|
||||
assert!(v["amount"].is_string(), "amount must be a JSON string");
|
||||
// A non-amount field stays a number.
|
||||
assert!(v["global_index"].is_number());
|
||||
// The subaddress index is nested `{major, minor}` (wasm LwsOutput shape).
|
||||
assert_eq!(v["subaddr_index"]["major"], serde_json::json!(0));
|
||||
assert_eq!(v["subaddr_index"]["minor"], serde_json::json!(7));
|
||||
}
|
||||
|
||||
// The LWS `recipient` field maps straight into the nested `subaddr_index`
|
||||
// DTO — including the fail-open `(0, 0)` for a primary-address receive.
|
||||
#[test]
|
||||
fn unspent_recipient_maps_to_nested_subaddr_index() {
|
||||
use crate::infra::lws::{SubaddrIndex, UnspentOutput};
|
||||
let infra = UnspentOutput {
|
||||
amount: 1,
|
||||
public_key: "aa".into(),
|
||||
tx_pub_key: "bb".into(),
|
||||
index: 0,
|
||||
global_index: 1,
|
||||
height: 2,
|
||||
timestamp: String::new(),
|
||||
tx_hash: "h".into(),
|
||||
rct: String::new(),
|
||||
spend_key_images: vec![],
|
||||
recipient: SubaddrIndex { maj_i: 1, min_i: 9 },
|
||||
};
|
||||
let dto = unspent_dto(infra);
|
||||
assert_eq!(dto.subaddr_index.major, 1);
|
||||
assert_eq!(dto.subaddr_index.minor, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -679,4 +905,140 @@ mod amount_wire_tests {
|
||||
assert!(v["locked_balance"].is_string() && v["pending_balance"].is_string());
|
||||
assert!(v["blockchain_height"].is_number(), "heights stay numbers");
|
||||
}
|
||||
|
||||
// A spend's subaddress index comes from the SPEND RECORD, so a subaddress
|
||||
// spend's key image can be recomputed. Taking it from the enclosing tx (the
|
||||
// change index) would mislabel it, the key image would never match, and the
|
||||
// spend would be dismissed as a decoy - the balance would over-report.
|
||||
#[test]
|
||||
fn spent_output_index_comes_from_the_spend_record_not_the_tx() {
|
||||
use crate::infra::lws::{SpentOutput as InfraSpent, SubaddrIndex};
|
||||
let tx = AddressTx {
|
||||
hash: "h".into(),
|
||||
height: 5,
|
||||
timestamp: String::new(),
|
||||
total_received: 1,
|
||||
total_sent: 9,
|
||||
mempool: false,
|
||||
unlock_time: 0,
|
||||
payment_id: None,
|
||||
spent_outputs: vec![InfraSpent {
|
||||
amount: 9,
|
||||
key_image: "ki".into(),
|
||||
tx_pub_key: "tp".into(),
|
||||
out_index: 2,
|
||||
mixin: 15,
|
||||
sender: SubaddrIndex { maj_i: 0, min_i: 7 },
|
||||
}],
|
||||
// The tx-level index is the CHANGE index and must not leak into the
|
||||
// spend's index.
|
||||
recipient: SubaddrIndex { maj_i: 0, min_i: 1 },
|
||||
};
|
||||
let dto = tx_dto(tx);
|
||||
assert_eq!(dto.subaddr_index.minor, 1, "tx keeps its own index");
|
||||
assert_eq!(dto.spent_outputs[0].subaddr_index.major, 0);
|
||||
assert_eq!(
|
||||
dto.spent_outputs[0].subaddr_index.minor, 7,
|
||||
"the spend's index must be the spent output's own"
|
||||
);
|
||||
// It also crosses the wire under the nested `{major, minor}` shape.
|
||||
let v = serde_json::to_value(&dto.spent_outputs[0]).unwrap();
|
||||
assert_eq!(v["subaddr_index"]["minor"], serde_json::json!(7));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provision_gate_tests {
|
||||
use super::*;
|
||||
|
||||
// These read a process-wide env var, so they run under one lock and always
|
||||
// restore the previous value.
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn with_flag<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var("FEATURE_XMR_SUBADDR_PROVISIONING").ok();
|
||||
match value {
|
||||
Some(v) => std::env::set_var("FEATURE_XMR_SUBADDR_PROVISIONING", v),
|
||||
None => std::env::remove_var("FEATURE_XMR_SUBADDR_PROVISIONING"),
|
||||
}
|
||||
let out = f();
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("FEATURE_XMR_SUBADDR_PROVISIONING", v),
|
||||
None => std::env::remove_var("FEATURE_XMR_SUBADDR_PROVISIONING"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_parsing_is_case_insensitive_and_trimmed() {
|
||||
for on in ["1", "true", "TRUE", "True", "on", "ON", "Yes", " yes "] {
|
||||
assert!(with_flag(Some(on), subaddr_provisioning_enabled), "{on}");
|
||||
}
|
||||
for off in ["0", "false", "no", "off", "", "maybe"] {
|
||||
assert!(!with_flag(Some(off), subaddr_provisioning_enabled), "{off}");
|
||||
}
|
||||
assert!(!with_flag(None, subaddr_provisioning_enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subaddr_count_is_ignored_while_the_flag_is_off() {
|
||||
// Flag OFF must stay byte-identical: no client-supplied count can turn
|
||||
// provisioning on.
|
||||
with_flag(None, || {
|
||||
assert_eq!(effective_provision_minors(None), 0);
|
||||
assert_eq!(effective_provision_minors(Some(200)), 0);
|
||||
assert_eq!(effective_provision_minors(Some(u32::MAX)), 0);
|
||||
assert_eq!(provision_width_for(Some(50)), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subaddr_count_is_accepted_and_clamped_when_enabled() {
|
||||
with_flag(Some("1"), || {
|
||||
// Absent => the instance default.
|
||||
assert_eq!(effective_provision_minors(None), MAX_PROVISION_MINORS);
|
||||
// Never below the instance default (a client cannot narrow it).
|
||||
assert_eq!(effective_provision_minors(Some(1)), MAX_PROVISION_MINORS);
|
||||
// Never above the hard server ceiling.
|
||||
assert_eq!(
|
||||
effective_provision_minors(Some(u32::MAX)),
|
||||
MAX_PROVISION_MINORS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_width_treats_max_minor_as_an_index() {
|
||||
with_flag(Some("yes"), || {
|
||||
// `max_minor` is the highest wanted INDEX, so the width is +1.
|
||||
assert_eq!(provision_width_for(Some(0)), 1);
|
||||
assert_eq!(provision_width_for(Some(49)), 50);
|
||||
// Clamped to the ceiling, never rejected, and never overflowing.
|
||||
assert_eq!(provision_width_for(Some(10_000)), MAX_PROVISION_MINORS);
|
||||
assert_eq!(provision_width_for(Some(u32::MAX)), MAX_PROVISION_MINORS);
|
||||
// Absent => the instance default.
|
||||
assert_eq!(provision_width_for(None), MAX_PROVISION_MINORS);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_accepts_and_ignores_extra_wire_fields() {
|
||||
// `subaddr_count` is read (never silently dropped) and an unknown
|
||||
// `user_id` on the provision request is ignored - identity comes from
|
||||
// the bearer token.
|
||||
let r: RegisterRequest = serde_json::from_str(
|
||||
r#"{"asset":"xmr","address":"9a","view_key":"vk","subaddr_count":64}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.subaddr_count, Some(64));
|
||||
let r2: RegisterRequest =
|
||||
serde_json::from_str(r#"{"asset":"xmr","address":"9a","view_key":"vk"}"#).unwrap();
|
||||
assert_eq!(r2.subaddr_count, None);
|
||||
let p: ProvisionRequest = serde_json::from_str(
|
||||
r#"{"asset":"xmr","address":"9a","view_key":"vk","max_minor":31,"user_id":"attacker"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.max_minor, Some(31));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user