diff --git a/openapi.json b/openapi.json index 8feb927..75222aa 100644 --- a/openapi.json +++ b/openapi.json @@ -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)." } ] } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index e89b8ed..559284d 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -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; diff --git a/src/api/wallet/mod.rs b/src/api/wallet/mod.rs index cd2bfdb..3af2b38 100644 --- a/src/api/wallet/mod.rs +++ b/src/api/wallet/mod.rs @@ -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> { - 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()); + } } diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs new file mode 100644 index 0000000..970082f --- /dev/null +++ b/src/api/wallet/xmr_wow.rs @@ -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, +} + +/// 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, +} + +/// 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, + pub spent_outputs: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct LwsHistoryResponse { + pub asset: String, + pub transactions: Vec, +} + +/// 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, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UnspentOutsResponse { + pub asset: String, + pub outputs: Vec, + 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, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RandomOutsResponse { + pub asset: String, + pub amount_outs: Vec, +} + +#[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, +} + +#[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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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> { + 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)) +} diff --git a/tests/wallet_xmr_wow.rs b/tests/wallet_xmr_wow.rs new file mode 100644 index 0000000..fa93c65 --- /dev/null +++ b/tests/wallet_xmr_wow.rs @@ -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"); +}