diff --git a/openapi.json b/openapi.json index 75222aa..55c8c93 100644 --- a/openapi.json +++ b/openapi.json @@ -584,6 +584,116 @@ } } }, + "/wallet/grin/broadcast": { + "post": { + "tags": [ + "grin" + ], + "summary": "Broadcast a finalized, signed Grin transaction.", + "operationId": "broadcast", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinBroadcastRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Transaction broadcast", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinBroadcastResponse" + } + } + } + }, + "400": { + "description": "Grin disabled or malformed transaction" + }, + "401": { + "description": "Missing or invalid token" + }, + "503": { + "description": "Upstream node unavailable" + } + } + } + }, + "/wallet/grin/height": { + "get": { + "tags": [ + "grin" + ], + "summary": "Current Grin chain tip height (for confirmation counting).", + "operationId": "height", + "responses": { + "200": { + "description": "Chain tip height", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinHeightResponse" + } + } + } + }, + "400": { + "description": "Grin disabled" + }, + "401": { + "description": "Missing or invalid token" + }, + "503": { + "description": "Upstream node unavailable" + } + } + } + }, + "/wallet/grin/scan": { + "post": { + "tags": [ + "grin" + ], + "summary": "View-only scan for the outputs a `rewind_hash` recognizes.", + "operationId": "scan", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinScanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recognized outputs + balance + resume index", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinScanResponse" + } + } + } + }, + "400": { + "description": "Grin disabled or malformed rewind_hash" + }, + "401": { + "description": "Missing or invalid token" + }, + "503": { + "description": "Upstream node unavailable" + } + } + } + }, "/wallet/lws/balance": { "post": { "tags": [ @@ -1537,6 +1647,133 @@ } } }, + "GrinBroadcastRequest": { + "type": "object", + "description": "Broadcast a finalized, signed Grin transaction (built + signed by the wallet).", + "required": [ + "tx" + ], + "properties": { + "tx": { + "type": "object", + "description": "The finalized transaction object (grin node `push_transaction` input)." + } + } + }, + "GrinBroadcastResponse": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "GrinHeightResponse": { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "GrinOutput": { + "type": "object", + "description": "A single output recovered by a view-only scan. Amounts are nanogrin.", + "required": [ + "commit", + "value", + "height", + "mmr_index", + "is_coinbase", + "lock_height" + ], + "properties": { + "commit": { + "type": "string" + }, + "height": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "is_coinbase": { + "type": "boolean" + }, + "lock_height": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "mmr_index": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "value": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "GrinScanRequest": { + "type": "object", + "description": "A view-only scan request. Carries the `rewind_hash` view credential, so it\ndeliberately omits `Debug` (never logged).", + "required": [ + "rewind_hash" + ], + "properties": { + "rewind_hash": { + "type": "string", + "description": "The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored." + }, + "start_height": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Scan from this block height (wallet birthday / last scanned). Omit for full.", + "minimum": 0 + } + } + }, + "GrinScanResponse": { + "type": "object", + "description": "Result of a view-only scan: recognized outputs, total, and the resume index.", + "required": [ + "outputs", + "total_balance", + "last_pmmr_index" + ], + "properties": { + "last_pmmr_index": { + "type": "integer", + "format": "int64", + "description": "Resume point (`last_pmmr_index`) for the next incremental scan.", + "minimum": 0 + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrinOutput" + } + }, + "total_balance": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "HealthResponse": { "type": "object", "description": "Liveness response.", @@ -2467,6 +2704,10 @@ { "name": "xmr_wow", "description": "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)." + }, + { + "name": "grin", + "description": "Grin view-only chain access (rewind_hash scan, tip height, broadcast)." } ] } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 559284d..5a12531 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -46,6 +46,9 @@ use utoipa::OpenApi; crate::api::wallet::xmr_wow::submit_tx, crate::api::wallet::xmr_wow::height, crate::api::wallet::xmr_wow::confirmations, + crate::api::wallet::grin::scan, + crate::api::wallet::grin::height, + crate::api::wallet::grin::broadcast, crate::api::nip05::well_known_nostr, ), components(schemas( @@ -103,13 +106,20 @@ use utoipa::OpenApi; crate::api::wallet::xmr_wow::HeightResponse, crate::api::wallet::xmr_wow::ConfirmationsResponse, crate::api::wallet::xmr_wow::OkResponse, + crate::api::wallet::grin::GrinScanRequest, + crate::api::wallet::grin::GrinOutput, + crate::api::wallet::grin::GrinScanResponse, + crate::api::wallet::grin::GrinHeightResponse, + crate::api::wallet::grin::GrinBroadcastRequest, + crate::api::wallet::grin::GrinBroadcastResponse, 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 = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations).") + (name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."), + (name = "grin", description = "Grin view-only chain access (rewind_hash scan, tip height, broadcast).") ) )] pub struct ApiDoc; diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs new file mode 100644 index 0000000..5f8ed67 --- /dev/null +++ b/src/api/wallet/grin.rs @@ -0,0 +1,191 @@ +//! Grin wallet handlers — authenticated, **view-only** proxies over the +//! [`GrinClient`](crate::infra::grin::GrinClient). +//! +//! The backend holds no Grin spend key. The wallet exports its `rewind_hash` (a +//! view credential derived from its *public* root key — it can recognize the +//! wallet's outputs and read amounts, but cannot spend) and sends it per scan; +//! the backend forwards it to grin-wallet's `scan_rewind_hash` and stores no +//! secret. Spending (input selection, kernel signing) happens in the wallet; the +//! backend only broadcasts the finalized transaction. See +//! `docs/private/GRIN_LWS_DESIGN.md`. +//! +//! Conventions (matching [`super::btc_ltc`] / [`super::xmr_wow`]): +//! * JWT-gated; the `rewind_hash` is a view credential — its request struct omits +//! `Debug` and is `skip`-ped from every span, so it is never logged. +//! * 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::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +use super::validate_hex64; +use crate::api::middleware::extract_user_id_from_token; +use crate::error::AppError; +use crate::infra::grin::{GrinClient, ViewWalletOutputResult}; +use crate::AppState; + +/// Resolve the Grin client, or a 400 if Grin support is disabled. +fn grin_client(state: &AppState) -> Result<&GrinClient, AppError> { + state.chains.grin.as_deref().ok_or_else(|| { + AppError::ValidationError("grin support is not enabled on this server".into()) + }) +} + +// ── DTOs ──────────────────────────────────────────────────────────────────── + +/// A view-only scan request. Carries the `rewind_hash` view credential, so it +/// deliberately omits `Debug` (never logged). +#[derive(Deserialize, utoipa::ToSchema)] +pub struct GrinScanRequest { + /// The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored. + pub rewind_hash: String, + /// Scan from this block height (wallet birthday / last scanned). Omit for full. + pub start_height: Option, +} + +/// A single output recovered by a view-only scan. Amounts are nanogrin. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GrinOutput { + pub commit: String, + pub value: u64, + pub height: u64, + pub mmr_index: u64, + pub is_coinbase: bool, + pub lock_height: u64, +} + +/// Result of a view-only scan: recognized outputs, total, and the resume index. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GrinScanResponse { + pub outputs: Vec, + pub total_balance: u64, + /// Resume point (`last_pmmr_index`) for the next incremental scan. + pub last_pmmr_index: u64, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GrinHeightResponse { + pub height: u64, +} + +/// Broadcast a finalized, signed Grin transaction (built + signed by the wallet). +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct GrinBroadcastRequest { + /// The finalized transaction object (grin node `push_transaction` input). + #[schema(value_type = Object)] + pub tx: serde_json::Value, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GrinBroadcastResponse { + pub ok: bool, +} + +fn output_dto(o: ViewWalletOutputResult) -> GrinOutput { + GrinOutput { + commit: o.commit, + value: o.value, + height: o.height, + mmr_index: o.mmr_index, + is_coinbase: o.is_coinbase, + lock_height: o.lock_height, + } +} + +// ── handlers ────────────────────────────────────────────────────────────────── + +/// View-only scan for the outputs a `rewind_hash` recognizes. +#[utoipa::path( + post, + path = "/wallet/grin/scan", + request_body = GrinScanRequest, + responses( + (status = 200, description = "Recognized outputs + balance + resume index", body = GrinScanResponse), + (status = 400, description = "Grin disabled or malformed rewind_hash"), + (status = 401, description = "Missing or invalid token"), + (status = 503, description = "Upstream node unavailable") + ), + tag = "grin" +)] +#[instrument(skip(state, headers, req))] +pub async fn scan( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + extract_user_id_from_token(&state, &headers).await?; + validate_hex64(&req.rewind_hash, "rewind_hash")?; + + let view = grin_client(&state)? + .scan_rewind_hash(&req.rewind_hash, req.start_height) + .await?; + Ok(Json(GrinScanResponse { + outputs: view.output_result.into_iter().map(output_dto).collect(), + total_balance: view.total_balance, + last_pmmr_index: view.last_pmmr_index, + })) +} + +/// Current Grin chain tip height (for confirmation counting). +#[utoipa::path( + get, + path = "/wallet/grin/height", + responses( + (status = 200, description = "Chain tip height", body = GrinHeightResponse), + (status = 400, description = "Grin disabled"), + (status = 401, description = "Missing or invalid token"), + (status = 503, description = "Upstream node unavailable") + ), + tag = "grin" +)] +#[instrument(skip(state, headers))] +pub async fn height( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + extract_user_id_from_token(&state, &headers).await?; + let height = grin_client(&state)?.get_height().await?; + Ok(Json(GrinHeightResponse { height })) +} + +/// Broadcast a finalized, signed Grin transaction. +#[utoipa::path( + post, + path = "/wallet/grin/broadcast", + request_body = GrinBroadcastRequest, + responses( + (status = 200, description = "Transaction broadcast", body = GrinBroadcastResponse), + (status = 400, description = "Grin disabled or malformed transaction"), + (status = 401, description = "Missing or invalid token"), + (status = 503, description = "Upstream node unavailable") + ), + tag = "grin" +)] +#[instrument(skip(state, headers, req))] +pub async fn broadcast( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + extract_user_id_from_token(&state, &headers).await?; + grin_client(&state)?.broadcast(&req.tx).await?; + Ok(Json(GrinBroadcastResponse { ok: true })) +} + +// ── router ──────────────────────────────────────────────────────────────────── + +/// Grin routes, RELATIVE to the `/api/v1` mount point. +pub fn routes() -> Router> { + Router::new() + .route("/wallet/grin/scan", post(scan)) + .route("/wallet/grin/height", get(height)) + .route("/wallet/grin/broadcast", post(broadcast)) +} diff --git a/src/api/wallet/mod.rs b/src/api/wallet/mod.rs index 3af2b38..31af373 100644 --- a/src/api/wallet/mod.rs +++ b/src/api/wallet/mod.rs @@ -12,11 +12,14 @@ use crate::error::AppError; use crate::AppState; pub mod btc_ltc; +pub mod grin; pub mod xmr_wow; /// All wallet routes, RELATIVE to the `/api/v1` mount point. pub fn routes() -> Router> { - btc_ltc::routes().merge(xmr_wow::routes()) + btc_ltc::routes() + .merge(xmr_wow::routes()) + .merge(grin::routes()) } // ── shared input validators ─────────────────────────────────────────────────── @@ -40,17 +43,23 @@ pub(crate) fn validate_hex(value: &str, field: &str, max: usize) -> Result<(), A 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(), - )); +/// Exactly 32 bytes as 64 hex chars — the shape of the view credentials this +/// backend forwards (Monero/Wownero private view key; Grin `rewind_hash`). +/// `field` names the value for the error; the value itself is never logged. +pub(crate) fn validate_hex64(value: &str, field: &str) -> Result<(), AppError> { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(AppError::ValidationError(format!( + "{field} must be 64 hexadecimal characters" + ))); } Ok(()) } +/// A Monero/Wownero private view key (64 hex). Validated by shape; never logged. +pub(crate) fn validate_view_key(view_key: &str) -> Result<(), AppError> { + validate_hex64(view_key, "view_key") +} + /// 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> { diff --git a/tests/wallet_grin.rs b/tests/wallet_grin.rs new file mode 100644 index 0000000..9ec42fa --- /dev/null +++ b/tests/wallet_grin.rs @@ -0,0 +1,64 @@ +//! L1 integration: Grin (view-only) wallet endpoints through the real router. +//! +//! Deterministic, no-network paths: the JWT gate and rewind_hash validation, +//! resolved before any grin-wallet call. The networked happy paths live in the +//! separate gitignored L3 harness. + +mod common; + +use axum::http::StatusCode; +use serde_json::json; + +#[tokio::test] +async fn scan_requires_a_token() { + let app = require_app!(); + let (status, _) = app + .request( + "POST", + "/api/v1/wallet/grin/scan", + None, + Some(json!({ "rewind_hash": "00" })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn scan_rejects_malformed_rewind_hash() { + let app = require_app!(); + let (_uid, access, _r) = app.mint_session().await; + // Authed, but the rewind_hash is not 64 hex -> 400 before any grin call. + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/scan", + Some(&access), + Some(json!({ "rewind_hash": "tooshort" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "VALIDATION_ERROR"); +} + +#[tokio::test] +async fn height_requires_a_token() { + let app = require_app!(); + let (status, _) = app + .request("GET", "/api/v1/wallet/grin/height", None, None) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn broadcast_requires_a_token() { + let app = require_app!(); + let (status, _) = app + .request( + "POST", + "/api/v1/wallet/grin/broadcast", + None, + Some(json!({ "tx": {} })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +}