mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(wallet): subaddress provisioning and batched UTXO endpoints (off by default)
Backend support for two address-privacy features. Both are behind env flags
that default to off, so an unconfigured instance behaves exactly as before:
the routes are not mounted and no provisioning is attempted.
FEATURE_XMR_SUBADDR_PROVISIONING
Adds POST /wallet/lws/provision_subaddrs and provisions account-0 subaddress
indices on the LWS at register/import.
Three money-safety properties worth calling out:
* The provisioned ceiling is read from the LWS response, never echoed back
from the request. It is the contiguous run starting at minor 0, not
max(hi): given ranges [[0,99],[200,299]], reporting 299 would let a wallet
hand out indices 100..199 that the LWS never scans, so funds sent there
would be invisible. No usable run is a hard error, not a guess.
* import_account now always runs the backward rescan even when provisioning
fails. Previously a provisioning error aborted first, leaving the account
registered at the chain tip while every retry returned 200 OK, so a
restoring wallet would show a zero balance permanently.
* A capacity probe reads max_subaddresses from the LWS and refuses up front
with a message naming the required --max-subaddresses value, rather than
failing per-account later. The option defaults to 0 upstream.
Spent outputs now carry their subaddress index. monero-lws emits this as
`sender` (the index the spent output was received at), not `recipient`;
without it a client recomputes a primary key image for a subaddress spend,
treats the mismatch as a decoy, and never subtracts the amount.
FEATURE_UTXO_MULTI_ADDRESS
Adds balance_multi / utxos_multi / history_multi for HD gap-limit wallets.
Capped at 32 addresses, summed with checked_add, per-UTXO address tag
preserved so the client never has to re-guess which key owns an output.
Bounded concurrency with a per-batch deadline returning 504, so a slow
Electrum server cannot stack n * IO_TIMEOUT.
Both flags are advertised in /capabilities so a client can negotiate instead
of calling routes that 404.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
//! L1 integration: the dark `FEATURE_XMR_SUBADDR_PROVISIONING` gate.
|
||||
//!
|
||||
//! Deterministic, no-network paths only: whether the route is mounted, and
|
||||
//! whether `/capabilities` advertises the same answer the router enforces. The
|
||||
//! networked happy path (a real LWS confirming a subaddress range) needs a live
|
||||
//! gate and lives in the separate gitignored L3 harness.
|
||||
//!
|
||||
//! The flag is a process-wide environment variable, so these tests live in their
|
||||
//! own binary (env is per-process) and take a lock around every set/restore.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
const FLAG: &str = "FEATURE_XMR_SUBADDR_PROVISIONING";
|
||||
const ROUTE: &str = "/api/v1/wallet/lws/provision_subaddrs";
|
||||
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Set the flag, run `body`, then restore the previous value. Held across the
|
||||
/// whole test because `build_router` reads the flag when it mounts routes.
|
||||
struct FlagGuard {
|
||||
prev: Option<String>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl FlagGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var(FLAG).ok();
|
||||
match value {
|
||||
Some(v) => std::env::set_var(FLAG, v),
|
||||
None => std::env::remove_var(FLAG),
|
||||
}
|
||||
Self { prev, _lock: lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FlagGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var(FLAG, v),
|
||||
None => std::env::remove_var(FLAG),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provision_route_is_absent_while_the_flag_is_off() {
|
||||
let _flag = FlagGuard::set(None);
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
// Not mounted at all: an authenticated, well-formed request still 404s, so a
|
||||
// client cannot reach the feature by guessing the path.
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
// And the capability says so, so a client never has to probe.
|
||||
let (status, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["features"]["xmr_subaddr_provisioning"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provision_route_is_mounted_and_validates_while_the_flag_is_on() {
|
||||
let _flag = FlagGuard::set(Some("1"));
|
||||
let app = require_app!();
|
||||
|
||||
// Mounted: an unauthenticated request is rejected by the JWT gate (401),
|
||||
// NOT by the router (404), which is what proves the route exists.
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
None,
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
// Authed but an unknown asset -> 400, still resolved before any LWS call.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
|
||||
// A malformed view key is rejected before any LWS call too.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({
|
||||
"asset": "xmr",
|
||||
"address": "4SomeAddress",
|
||||
"view_key": "tooshort",
|
||||
"max_minor": 31
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flag_parsing_agrees_between_the_router_and_capabilities() {
|
||||
// Every accepted spelling must mount the route AND be advertised, so a
|
||||
// client can negotiate instead of blindly calling a route that 404s.
|
||||
for spelling in ["1", "true", "TRUE", "On", " yes "] {
|
||||
let _flag = FlagGuard::set(Some(spelling));
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{spelling:?} should mount the route"
|
||||
);
|
||||
|
||||
let (_s, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
// Advertised only when a CryptoNote chain is actually serviceable; when
|
||||
// it is not, the route may exist but the capability must stay false
|
||||
// rather than promise a chain this instance cannot serve.
|
||||
let advertised = body["features"]["xmr_subaddr_provisioning"] == json!(true);
|
||||
let xmr_or_wow = body["chains"]["xmr"]["enabled"] == json!(true)
|
||||
|| body["chains"]["wow"]["enabled"] == json!(true);
|
||||
assert_eq!(advertised, xmr_or_wow, "{spelling:?}");
|
||||
}
|
||||
|
||||
for spelling in ["0", "false", "off", "no", "", "maybe"] {
|
||||
let _flag = FlagGuard::set(Some(spelling));
|
||||
let app = require_app!();
|
||||
let (_uid, access, _r) = app.mint_session().await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
ROUTE,
|
||||
Some(&access),
|
||||
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{spelling:?} must leave the feature dark"
|
||||
);
|
||||
|
||||
let (_s, body) = app.request("GET", "/api/v1/capabilities", None, None).await;
|
||||
assert_eq!(
|
||||
body["features"]["xmr_subaddr_provisioning"],
|
||||
json!(false),
|
||||
"{spelling:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user