feat: XMR/WOW wallet handlers (LWS proxy, stateless-forward)

Second wallet-handler slice. Authenticated, non-custodial, stateless-forward
proxies over the LwsClient: the wallet sends its private view key per request,
the backend forwards it (never stores it; the LWS holds it for scanning), and
the wallet constructs + signs spends locally.

Endpoints (POST, under /api/v1/wallet/lws, asset = xmr|wow in body):
- register     -> add/import account (view key) for LWS scanning (+start_height)
- balance      -> total_received / unlocked / locked + scan state
- history      -> txs incl. mempool pending + candidate spent_outputs
- unspent_outs -> outputs + fee params for client-side spend construction
- random_outs  -> ring decoys (count clamped + fan-out capped in the client)
- submit_tx    -> broadcast a finalized signed tx
- height       -> daemon chain height
- confirmations-> tx confirmation count

- JWT-gated; the view key is skipped from every tracing span (never logged) and
  never stored. Feature-gated dispatch; unknown/disabled asset -> 400.
- Boundary validation: view_key (64 hex), address (bounded), txid (64 hex),
  tx_hex (even hex, <=2 MiB) — all before any LWS call.
- Shared validators in wallet/mod.rs (validate_hex/view_key/cn_address).
- Colliding DTO names disambiguated (Lws-prefixed) so the openapi schema
  registry stays correct; 8 paths + schemas + xmr_wow tag registered; spec
  regenerated, drift-clean.

6 L1 integration tests (auth gate on balance/register; unknown-asset,
bad-view-key, malformed-tx-hex, malformed-txid 400s) + validator unit tests.
This commit is contained in:
jwinterm
2026-06-23 14:32:38 -04:00
parent 1ec4639652
commit 10b508fa8e
5 changed files with 1563 additions and 2 deletions
+810
View File
@@ -584,6 +584,326 @@
}
}
},
"/wallet/lws/balance": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Balance + scan state for an account.",
"operationId": "balance",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ViewRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Account balance and scan state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LwsBalanceResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or view key"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/confirmations": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Confirmation count for a transaction (daemon).",
"operationId": "confirmations",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfirmationsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Confirmation count",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfirmationsResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or txid"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/height": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Current chain height (daemon) for confirmation counting.",
"operationId": "height",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LwsAssetRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Chain height",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HeightResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/history": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Transaction history (confirmed + mempool) for an account.",
"operationId": "history",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ViewRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Account transaction history",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LwsHistoryResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or view key"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/random_outs": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Random decoy outputs for ring construction.",
"operationId": "random_outs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RandomOutsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Decoy outputs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RandomOutsResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or request"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/register": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Register an account (its view key) with the LWS so it begins scanning.\nIdempotent at the LWS; pass `start_height` to scan from a wallet birthday.",
"operationId": "register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Account registered for scanning",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or view key"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/submit_tx": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Broadcast a finalized, signed Monero/Wownero transaction.",
"operationId": "submit_tx",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmitRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Transaction submitted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset or malformed tx hex"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/lws/unspent_outs": {
"post": {
"tags": [
"xmr_wow"
],
"summary": "Unspent outputs for an account (for client-side spend construction).",
"operationId": "unspent_outs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ViewRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Unspent outputs + fee parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnspentOutsResponse"
}
}
}
},
"400": {
"description": "Invalid/disabled asset, address, or view key"
},
"401": {
"description": "Missing or invalid token"
},
"503": {
"description": "Upstream node unavailable"
}
}
}
},
"/wallet/utxo/balance": {
"post": {
"tags": [
@@ -845,6 +1165,24 @@
}
}
},
"AmountOutsDto": {
"type": "object",
"required": [
"amount",
"outputs"
],
"properties": {
"amount": {
"type": "string"
},
"outputs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RandomOutputDto"
}
}
}
},
"AssetPublicKey": {
"type": "object",
"description": "A per-asset public key submitted by the wallet.",
@@ -1049,6 +1387,43 @@
}
}
},
"ConfirmationsRequest": {
"type": "object",
"description": "Confirmation-count query for a tx.",
"required": [
"asset",
"txid"
],
"properties": {
"asset": {
"type": "string"
},
"txid": {
"type": "string",
"description": "Transaction id (64 hex)."
}
}
},
"ConfirmationsResponse": {
"type": "object",
"required": [
"asset"
],
"properties": {
"asset": {
"type": "string"
},
"confirmations": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Confirmations, or `null` if the tx is unknown / not yet in a block.",
"minimum": 0
}
}
},
"ExtensionRegisterRequest": {
"type": "object",
"description": "Register a new extension wallet or re-authenticate an existing one.",
@@ -1175,6 +1550,23 @@
}
}
},
"HeightResponse": {
"type": "object",
"required": [
"asset",
"height"
],
"properties": {
"asset": {
"type": "string"
},
"height": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"HistoryEntry": {
"type": "object",
"description": "A transaction-history entry.",
@@ -1284,6 +1676,85 @@
}
}
},
"LwsAssetRequest": {
"type": "object",
"description": "Asset-only query.",
"required": [
"asset"
],
"properties": {
"asset": {
"type": "string",
"description": "`xmr` or `wow`."
}
}
},
"LwsBalanceResponse": {
"type": "object",
"description": "Balance + scan state. `total_received` is the balance (an LWS cannot confirm\nspends without the spend key; the wallet subtracts its own known spends).",
"required": [
"asset",
"total_received",
"unlocked",
"locked",
"scanned_height",
"blockchain_height",
"transaction_count"
],
"properties": {
"asset": {
"type": "string"
},
"blockchain_height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"locked": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"scanned_height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"total_received": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"transaction_count": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"unlocked": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"LwsHistoryResponse": {
"type": "object",
"required": [
"asset",
"transactions"
],
"properties": {
"asset": {
"type": "string"
},
"transactions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TxDto"
}
}
}
},
"MyUsernameResponse": {
"type": "object",
"properties": {
@@ -1326,6 +1797,17 @@
}
}
},
"OkResponse": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
}
},
"PublicKeysInfo": {
"type": "object",
"description": "A user's per-asset receiving public keys, by asset.",
@@ -1362,6 +1844,73 @@
}
}
},
"RandomOutputDto": {
"type": "object",
"description": "A decoy output.",
"required": [
"global_index",
"public_key",
"rct"
],
"properties": {
"global_index": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"public_key": {
"type": "string"
},
"rct": {
"type": "string"
}
}
},
"RandomOutsRequest": {
"type": "object",
"description": "Request decoy outputs for ring construction.",
"required": [
"asset",
"count",
"amounts"
],
"properties": {
"amounts": {
"type": "array",
"items": {
"type": "string"
},
"description": "Amounts to request decoys for (`[\"0\"]` for RingCT)."
},
"asset": {
"type": "string"
},
"count": {
"type": "integer",
"format": "int32",
"description": "Decoys per real output (protocol ring size; clamped server-side).",
"minimum": 0
}
}
},
"RandomOutsResponse": {
"type": "object",
"required": [
"asset",
"amount_outs"
],
"properties": {
"amount_outs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AmountOutsDto"
}
},
"asset": {
"type": "string"
}
}
},
"RefreshTokenRequest": {
"type": "object",
"required": [
@@ -1398,6 +1947,36 @@
}
}
},
"RegisterRequest": {
"type": "object",
"description": "Register (or import-with-height) an account for LWS scanning.",
"required": [
"asset",
"address",
"view_key"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string"
},
"start_height": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Scan from this block height (wallet birthday). Omit to scan from now.",
"minimum": 0
},
"view_key": {
"type": "string",
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
}
}
},
"SetUsernameRequest": {
"type": "object",
"required": [
@@ -1422,6 +2001,57 @@
}
}
},
"SpentOutputDto": {
"type": "object",
"description": "A candidate spent output (verify with the spend key before trusting).",
"required": [
"amount",
"key_image",
"tx_pub_key",
"out_index",
"mixin"
],
"properties": {
"amount": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"key_image": {
"type": "string"
},
"mixin": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"out_index": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"tx_pub_key": {
"type": "string"
}
}
},
"SubmitRequest": {
"type": "object",
"description": "Submit a finalized, signed transaction.",
"required": [
"asset",
"tx_hex"
],
"properties": {
"asset": {
"type": "string"
},
"tx_hex": {
"type": "string",
"description": "Hex-encoded signed transaction blob."
}
}
},
"TipResponse": {
"type": "object",
"required": [
@@ -1439,6 +2069,160 @@
}
}
},
"TxDto": {
"type": "object",
"description": "A transaction in the account's history.",
"required": [
"hash",
"height",
"timestamp",
"total_received",
"total_sent",
"mempool",
"unlock_time",
"spent_outputs"
],
"properties": {
"hash": {
"type": "string"
},
"height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"mempool": {
"type": "boolean"
},
"payment_id": {
"type": [
"string",
"null"
]
},
"spent_outputs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SpentOutputDto"
}
},
"timestamp": {
"type": "string"
},
"total_received": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"total_sent": {
"type": "integer",
"format": "int64",
"description": "\"Possible\" sent — candidate spends, not authoritative.",
"minimum": 0
},
"unlock_time": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"UnspentOutputDto": {
"type": "object",
"description": "An unspent output for spend construction.",
"required": [
"amount",
"public_key",
"tx_pub_key",
"index",
"global_index",
"height",
"timestamp",
"tx_hash",
"rct",
"spend_key_images"
],
"properties": {
"amount": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"global_index": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"index": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"public_key": {
"type": "string"
},
"rct": {
"type": "string"
},
"spend_key_images": {
"type": "array",
"items": {
"type": "string"
},
"description": "Key images seen on-chain that may correspond to this output being spent."
},
"timestamp": {
"type": "string"
},
"tx_hash": {
"type": "string"
},
"tx_pub_key": {
"type": "string"
}
}
},
"UnspentOutsResponse": {
"type": "object",
"required": [
"asset",
"outputs",
"per_byte_fee",
"fee_mask",
"fork_version"
],
"properties": {
"asset": {
"type": "string"
},
"fee_mask": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"fork_version": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"outputs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UnspentOutputDto"
}
},
"per_byte_fee": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"UserInfo": {
"type": "object",
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
@@ -1556,6 +2340,28 @@
}
}
},
"ViewRequest": {
"type": "object",
"description": "Address + private view key for a per-account query.",
"required": [
"asset",
"address",
"view_key"
],
"properties": {
"address": {
"type": "string"
},
"asset": {
"type": "string",
"description": "`xmr` or `wow`."
},
"view_key": {
"type": "string",
"description": "Private view key (64 hex). Forwarded to the LWS; never stored or logged."
}
}
},
"WebsiteChallengeRequest": {
"type": "object",
"description": "Request a website-auth challenge for a calling origin.",
@@ -1657,6 +2463,10 @@
{
"name": "btc_ltc",
"description": "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."
},
{
"name": "xmr_wow",
"description": "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."
}
]
}
+28 -1
View File
@@ -38,6 +38,14 @@ use utoipa::OpenApi;
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::balance,
crate::api::wallet::xmr_wow::history,
crate::api::wallet::xmr_wow::unspent_outs,
crate::api::wallet::xmr_wow::random_outs,
crate::api::wallet::xmr_wow::submit_tx,
crate::api::wallet::xmr_wow::height,
crate::api::wallet::xmr_wow::confirmations,
crate::api::nip05::well_known_nostr,
),
components(schemas(
@@ -77,12 +85,31 @@ use utoipa::OpenApi;
crate::api::wallet::btc_ltc::FeeResponse,
crate::api::wallet::btc_ltc::BroadcastRequest,
crate::api::wallet::btc_ltc::BroadcastResponse,
crate::api::wallet::xmr_wow::ViewRequest,
crate::api::wallet::xmr_wow::RegisterRequest,
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::SpentOutputDto,
crate::api::wallet::xmr_wow::TxDto,
crate::api::wallet::xmr_wow::LwsHistoryResponse,
crate::api::wallet::xmr_wow::UnspentOutputDto,
crate::api::wallet::xmr_wow::UnspentOutsResponse,
crate::api::wallet::xmr_wow::RandomOutputDto,
crate::api::wallet::xmr_wow::AmountOutsDto,
crate::api::wallet::xmr_wow::RandomOutsResponse,
crate::api::wallet::xmr_wow::HeightResponse,
crate::api::wallet::xmr_wow::ConfirmationsResponse,
crate::api::wallet::xmr_wow::OkResponse,
crate::api::nip05::WellKnownResponse,
)),
tags(
(name = "system", description = "Service health and metadata."),
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."),
(name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast).")
(name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."),
(name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations).")
)
)]
pub struct ApiDoc;
+76 -1
View File
@@ -8,11 +8,86 @@ use std::sync::Arc;
use axum::Router;
use crate::error::AppError;
use crate::AppState;
pub mod btc_ltc;
pub mod xmr_wow;
/// All wallet routes, RELATIVE to the `/api/v1` mount point.
pub fn routes() -> Router<Arc<AppState>> {
btc_ltc::routes()
btc_ltc::routes().merge(xmr_wow::routes())
}
// ── shared input validators ───────────────────────────────────────────────────
//
// These bound client-supplied values at the API boundary before they reach a
// chain client. They never reveal secret content (length/charset only).
/// Even-length hex within `max` bytes, non-empty. `field` names the value for the
/// error message (never the value itself).
pub(crate) fn validate_hex(value: &str, field: &str, max: usize) -> Result<(), AppError> {
if value.is_empty() || value.len() > max {
return Err(AppError::ValidationError(format!(
"{field} has invalid length"
)));
}
if !value.len().is_multiple_of(2) || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(AppError::ValidationError(format!(
"{field} must be even-length hexadecimal"
)));
}
Ok(())
}
/// A Monero/Wownero private view key: 32 bytes = 64 hex chars. Validated only by
/// shape; never logged.
pub(crate) fn validate_view_key(view_key: &str) -> Result<(), AppError> {
if view_key.len() != 64 || !view_key.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(AppError::ValidationError(
"view_key must be 64 hexadecimal characters".into(),
));
}
Ok(())
}
/// A CryptoNote address — base58, ~95 (standard) to ~106 (integrated) chars.
/// Bounded generously; the LWS does full validation.
pub(crate) fn validate_cn_address(address: &str) -> Result<(), AppError> {
if address.is_empty() || address.len() > 256 {
return Err(AppError::ValidationError(
"address has invalid length".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_validation() {
assert!(validate_hex("deadbeef", "tx", 100).is_ok());
assert!(validate_hex("", "tx", 100).is_err());
assert!(validate_hex("abc", "tx", 100).is_err()); // odd
assert!(validate_hex("zz", "tx", 100).is_err()); // non-hex
assert!(validate_hex("aaaa", "tx", 2).is_err()); // too long
}
#[test]
fn view_key_validation() {
assert!(validate_view_key(&"a".repeat(64)).is_ok());
assert!(validate_view_key(&"a".repeat(63)).is_err());
assert!(validate_view_key(&"z".repeat(64)).is_err()); // non-hex
}
#[test]
fn cn_address_validation() {
assert!(
validate_cn_address("4AdkPJoxn7JCvAby9szgnt93MSEwdnxdhaASxbTBm6x5dCwmsDep").is_ok()
);
assert!(validate_cn_address("").is_err());
assert!(validate_cn_address(&"x".repeat(257)).is_err());
}
}
+545
View File
@@ -0,0 +1,545 @@
//! Monero/Wownero wallet handlers — authenticated, stateless-forward proxies
//! over the [`LwsClient`](crate::infra::lws::LwsClient).
//!
//! The backend is non-custodial and holds no view secret: the wallet sends its
//! private view key per request, the backend forwards it to the LWS (which holds
//! it only for scanning), and the wallet constructs + signs spends locally. The
//! one-time `register` call forwards the view key to the LWS `add_account` so it
//! begins scanning.
//!
//! Conventions (matching [`crate::api::users`] / [`super::btc_ltc`]):
//! * JWT-gated; the view key is `skip`-ped from every span so it is never logged.
//! * `asset` is a lowercase string (`"xmr"`/`"wow"`); disabled/unknown is a 400.
//! * Amounts are atomic units (piconero / wownoshi) as `u64`.
//! * snake_case wire fields; every DTO derives `utoipa::ToSchema`.
//! * Routes are RELATIVE to the `/api/v1` mount point; see [`routes`].
use std::sync::Arc;
use axum::{extract::State, http::HeaderMap, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use super::{validate_cn_address, validate_hex, validate_view_key};
use crate::api::middleware::extract_user_id_from_token;
use crate::error::AppError;
use crate::infra::lws::{AddressTx, LwsClient, RandomOutput, SpentOutput, UnspentOutput};
use crate::AppState;
/// Cap on a submitted Monero/Wownero tx hex. CryptoNote txs (rings, bulletproofs)
/// 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;
/// 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 {
"xmr" => state.chains.xmr.as_ref(),
"wow" => state.chains.wow.as_ref(),
other => {
return Err(AppError::ValidationError(format!(
"Invalid CryptoNote asset: {other} (expected xmr or wow)"
)))
}
};
client.ok_or_else(|| {
AppError::ValidationError(format!("{asset} support is not enabled on this server"))
})
}
// ── request DTOs ──────────────────────────────────────────────────────────────
/// Address + private view key for a per-account query.
// Deliberately omits `Debug`: it carries the private `view_key`, matching the
// crate convention for secret-bearing request structs — so it can't be dumped in
// cleartext via a stray `{:?}`, panic message, or added log line.
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ViewRequest {
/// `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,
}
/// Register (or import-with-height) an account for LWS scanning.
// Omits `Debug` (carries the private `view_key`) — see `ViewRequest`.
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub asset: String,
pub address: String,
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
pub view_key: String,
/// Scan from this block height (wallet birthday). Omit to scan from now.
pub start_height: Option<u64>,
}
/// Request decoy outputs for ring construction.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RandomOutsRequest {
pub asset: String,
/// Decoys per real output (protocol ring size; clamped server-side).
pub count: u32,
/// Amounts to request decoys for (`["0"]` for RingCT).
pub amounts: Vec<String>,
}
/// Submit a finalized, signed transaction.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SubmitRequest {
pub asset: String,
/// Hex-encoded signed transaction blob.
pub tx_hex: String,
}
/// Asset-only query.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LwsAssetRequest {
/// `xmr` or `wow`.
pub asset: String,
}
/// Confirmation-count query for a tx.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ConfirmationsRequest {
pub asset: String,
/// Transaction id (64 hex).
pub txid: String,
}
// ── response DTOs ─────────────────────────────────────────────────────────────
/// Balance + scan state. `total_received` is the balance (an LWS cannot confirm
/// spends without the spend key; the wallet subtracts its own known spends).
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LwsBalanceResponse {
pub asset: String,
pub total_received: u64,
pub unlocked: u64,
pub locked: u64,
pub scanned_height: u64,
pub blockchain_height: u64,
pub transaction_count: u64,
}
/// A candidate spent output (verify with the spend key before trusting).
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SpentOutputDto {
pub amount: u64,
pub key_image: String,
pub tx_pub_key: String,
pub out_index: u64,
pub mixin: u64,
}
/// A transaction in the account's history.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TxDto {
pub hash: String,
pub height: u64,
pub timestamp: String,
pub total_received: u64,
/// "Possible" sent — candidate spends, not authoritative.
pub total_sent: u64,
pub mempool: bool,
pub unlock_time: u64,
pub payment_id: Option<String>,
pub spent_outputs: Vec<SpentOutputDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LwsHistoryResponse {
pub asset: String,
pub transactions: Vec<TxDto>,
}
/// An unspent output for spend construction.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UnspentOutputDto {
pub amount: u64,
pub public_key: String,
pub tx_pub_key: String,
pub index: u32,
pub global_index: u64,
pub height: u64,
pub timestamp: String,
pub tx_hash: String,
pub rct: String,
/// Key images seen on-chain that may correspond to this output being spent.
pub spend_key_images: Vec<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UnspentOutsResponse {
pub asset: String,
pub outputs: Vec<UnspentOutputDto>,
pub per_byte_fee: u64,
pub fee_mask: u64,
pub fork_version: u8,
}
/// A decoy output.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RandomOutputDto {
pub global_index: u64,
pub public_key: String,
pub rct: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AmountOutsDto {
pub amount: String,
pub outputs: Vec<RandomOutputDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RandomOutsResponse {
pub asset: String,
pub amount_outs: Vec<AmountOutsDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct HeightResponse {
pub asset: String,
pub height: u64,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfirmationsResponse {
pub asset: String,
/// Confirmations, or `null` if the tx is unknown / not yet in a block.
pub confirmations: Option<u64>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct OkResponse {
pub ok: bool,
}
// ── mappers (infra type -> wire DTO) ──────────────────────────────────────────
fn spent_dto(s: SpentOutput) -> SpentOutputDto {
SpentOutputDto {
amount: s.amount,
key_image: s.key_image,
tx_pub_key: s.tx_pub_key,
out_index: s.out_index,
mixin: s.mixin,
}
}
fn tx_dto(t: AddressTx) -> TxDto {
TxDto {
hash: t.hash,
height: t.height,
timestamp: t.timestamp,
total_received: t.total_received,
total_sent: t.total_sent,
mempool: t.mempool,
unlock_time: t.unlock_time,
payment_id: t.payment_id,
spent_outputs: t.spent_outputs.into_iter().map(spent_dto).collect(),
}
}
fn unspent_dto(u: UnspentOutput) -> UnspentOutputDto {
UnspentOutputDto {
amount: u.amount,
public_key: u.public_key,
tx_pub_key: u.tx_pub_key,
index: u.index,
global_index: u.global_index,
height: u.height,
timestamp: u.timestamp,
tx_hash: u.tx_hash,
rct: u.rct,
spend_key_images: u.spend_key_images,
}
}
fn random_dto(r: RandomOutput) -> RandomOutputDto {
RandomOutputDto {
global_index: r.global_index,
public_key: r.public_key,
rct: r.rct,
}
}
// ── handlers ──────────────────────────────────────────────────────────────────
/// Register an account (its view key) with the LWS so it begins scanning.
/// Idempotent at the LWS; pass `start_height` to scan from a wallet birthday.
#[utoipa::path(
post,
path = "/wallet/lws/register",
request_body = RegisterRequest,
responses(
(status = 200, description = "Account registered for scanning", body = OkResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn register(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<Json<OkResponse>, AppError> {
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)?;
match req.start_height {
Some(h) => {
client
.import_account(&req.address, &req.view_key, h)
.await?
}
None => client.register_account(&req.address, &req.view_key).await?,
}
Ok(Json(OkResponse { ok: true }))
}
/// Balance + scan state for an account.
#[utoipa::path(
post,
path = "/wallet/lws/balance",
request_body = ViewRequest,
responses(
(status = 200, description = "Account balance and scan state", body = LwsBalanceResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn balance(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<LwsBalanceResponse>, AppError> {
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)?;
let info = client.get_address_info(&req.address, &req.view_key).await?;
Ok(Json(LwsBalanceResponse {
asset,
total_received: info.balance(),
unlocked: info.unlocked_balance(),
locked: info.locked_funds,
scanned_height: info.scanned_height,
blockchain_height: info.blockchain_height,
transaction_count: info.transaction_count,
}))
}
/// Transaction history (confirmed + mempool) for an account.
#[utoipa::path(
post,
path = "/wallet/lws/history",
request_body = ViewRequest,
responses(
(status = 200, description = "Account transaction history", body = LwsHistoryResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn history(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<LwsHistoryResponse>, AppError> {
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)?;
let txs = client.get_address_txs(&req.address, &req.view_key).await?;
Ok(Json(LwsHistoryResponse {
asset,
transactions: txs.transactions.into_iter().map(tx_dto).collect(),
}))
}
/// Unspent outputs for an account (for client-side spend construction).
#[utoipa::path(
post,
path = "/wallet/lws/unspent_outs",
request_body = ViewRequest,
responses(
(status = 200, description = "Unspent outputs + fee parameters", body = UnspentOutsResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn unspent_outs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<UnspentOutsResponse>, AppError> {
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)?;
let outs = client.get_unspent_outs(&req.address, &req.view_key).await?;
Ok(Json(UnspentOutsResponse {
asset,
outputs: outs.outputs.into_iter().map(unspent_dto).collect(),
per_byte_fee: outs.per_byte_fee,
fee_mask: outs.fee_mask,
fork_version: outs.fork_version,
}))
}
/// Random decoy outputs for ring construction.
#[utoipa::path(
post,
path = "/wallet/lws/random_outs",
request_body = RandomOutsRequest,
responses(
(status = 200, description = "Decoy outputs", body = RandomOutsResponse),
(status = 400, description = "Invalid/disabled asset or request"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn random_outs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RandomOutsRequest>,
) -> Result<Json<RandomOutsResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
// The client clamps `count` and caps `amounts`/fan-out defensively.
let outs = client.get_random_outs(req.count, req.amounts).await?;
Ok(Json(RandomOutsResponse {
asset,
amount_outs: outs
.amount_outs
.into_iter()
.map(|a| AmountOutsDto {
amount: a.amount,
outputs: a.outputs.into_iter().map(random_dto).collect(),
})
.collect(),
}))
}
/// Broadcast a finalized, signed Monero/Wownero transaction.
#[utoipa::path(
post,
path = "/wallet/lws/submit_tx",
request_body = SubmitRequest,
responses(
(status = 200, description = "Transaction submitted", body = OkResponse),
(status = 400, description = "Invalid/disabled asset or malformed tx hex"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn submit_tx(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<SubmitRequest>,
) -> Result<Json<OkResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_hex(&req.tx_hex, "tx_hex", MAX_CN_TX_HEX_LEN)?;
client.submit_raw_tx(&req.tx_hex).await?;
Ok(Json(OkResponse { ok: true }))
}
/// Current chain height (daemon) for confirmation counting.
#[utoipa::path(
post,
path = "/wallet/lws/height",
request_body = LwsAssetRequest,
responses(
(status = 200, description = "Chain height", body = HeightResponse),
(status = 400, description = "Invalid/disabled asset"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn height(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<LwsAssetRequest>,
) -> Result<Json<HeightResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let height = lws_for(&state, &asset)?.get_blockchain_height().await?;
Ok(Json(HeightResponse { asset, height }))
}
/// Confirmation count for a transaction (daemon).
#[utoipa::path(
post,
path = "/wallet/lws/confirmations",
request_body = ConfirmationsRequest,
responses(
(status = 200, description = "Confirmation count", body = ConfirmationsResponse),
(status = 400, description = "Invalid/disabled asset or txid"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn confirmations(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ConfirmationsRequest>,
) -> Result<Json<ConfirmationsResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_hex(&req.txid, "txid", 64)?;
let confirmations = client.get_transaction_confirmations(&req.txid).await?;
Ok(Json(ConfirmationsResponse {
asset,
confirmations,
}))
}
// ── router ────────────────────────────────────────────────────────────────────
/// Monero/Wownero routes, RELATIVE to the `/api/v1` mount point.
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/wallet/lws/register", post(register))
.route("/wallet/lws/balance", post(balance))
.route("/wallet/lws/history", post(history))
.route("/wallet/lws/unspent_outs", post(unspent_outs))
.route("/wallet/lws/random_outs", post(random_outs))
.route("/wallet/lws/submit_tx", post(submit_tx))
.route("/wallet/lws/height", post(height))
.route("/wallet/lws/confirmations", post(confirmations))
}
+104
View File
@@ -0,0 +1,104 @@
//! L1 integration: Monero/Wownero wallet endpoints through the real router.
//!
//! Deterministic, no-network paths only: the JWT gate, asset validation, and
//! view-key / tx-hex / txid validation — all resolved before any LWS call. The
//! funded/networked happy paths live in the separate gitignored L3 harness.
mod common;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn balance_requires_a_token() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/api/v1/wallet/lws/balance",
None,
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn balance_rejects_unknown_asset() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/lws/balance",
Some(&access),
Some(json!({ "asset": "doge", "address": "x", "view_key": "00" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}
#[tokio::test]
async fn balance_rejects_malformed_view_key() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
// Authed + valid asset + address, but the view key is not 64 hex -> 400
// before any LWS call.
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/lws/balance",
Some(&access),
Some(json!({ "asset": "xmr", "address": "4SomeAddress", "view_key": "tooshort" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}
#[tokio::test]
async fn register_requires_a_token() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/api/v1/wallet/lws/register",
None,
Some(json!({ "asset": "xmr", "address": "4Addr", "view_key": "00" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn submit_rejects_malformed_tx_hex() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/lws/submit_tx",
Some(&access),
Some(json!({ "asset": "xmr", "tx_hex": "nothex!!" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}
#[tokio::test]
async fn confirmations_rejects_malformed_txid() {
let app = require_app!();
let (_uid, access, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/wallet/lws/confirmations",
Some(&access),
Some(json!({ "asset": "xmr", "txid": "not-a-txid" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VALIDATION_ERROR");
}