feat: Grin wallet handlers (view-only, non-custodial)

Third wallet-handler slice. Authenticated, view-only proxies over the
GrinClient — the backend holds no Grin spend key; the wallet exports its
rewind_hash (a public-derived view credential) per scan, the backend forwards
it to grin-wallet's scan_rewind_hash and stores no secret. Spending happens in
the wallet; the backend only broadcasts the finalized tx.

Endpoints (under /api/v1/wallet/grin):
- POST scan {rewind_hash, start_height?} -> {outputs, total_balance,
  last_pmmr_index}  (incremental resume index)
- GET  height -> {height}
- POST broadcast {tx} -> {ok}  (node push_transaction)

- JWT-gated. rewind_hash is a view credential: GrinScanRequest omits Debug and
  is skipped from every span (never logged), and is validated as 64 hex before
  any grin call (applying the wallet-handler review's secret-DTO lesson).
- Feature-gated: grin disabled -> 400.
- Shared validate_hex64 in wallet/mod.rs (view_key + rewind_hash); 3 paths +
  schemas + grin tag registered; spec regenerated, drift-clean.

4 L1 integration tests (auth gate on scan/height/broadcast; malformed
rewind_hash 400).

Also: dropped Debug from the xmr_wow view_key-bearing request DTOs
(ViewRequest/RegisterRequest) per the review — defense-in-depth against a
cleartext view_key dump.
This commit is contained in:
jwinterm
2026-06-23 16:02:44 -04:00
parent 10b508fa8e
commit e62f1bec8e
5 changed files with 524 additions and 9 deletions
+241
View File
@@ -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)."
}
]
}
+11 -1
View File
@@ -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;
+191
View File
@@ -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<u64>,
}
/// 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<GrinOutput>,
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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<GrinScanRequest>,
) -> Result<Json<GrinScanResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<GrinHeightResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<GrinBroadcastRequest>,
) -> Result<Json<GrinBroadcastResponse>, 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<Arc<AppState>> {
Router::new()
.route("/wallet/grin/scan", post(scan))
.route("/wallet/grin/height", get(height))
.route("/wallet/grin/broadcast", post(broadcast))
}
+17 -8
View File
@@ -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<Arc<AppState>> {
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> {
+64
View File
@@ -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);
}