From 586f681287031cb57f855cbb1f6b60a39eb9ae5c Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 23 Jun 2026 16:56:59 -0400 Subject: [PATCH] feat: Grin slatepack relay (non-custodial async mailbox, feature-flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final wallet-handler slice. An async store-and-forward mailbox for interactive Grin transfers between two registered Smirk users, over the grin_slatepacks db. Reworked clean from the legacy relay — NOT a port. Endpoints (under /api/v1/wallet/grin/relay, JWT-gated): - POST create {recipient_user_id, slate_id, slatepack, amount_nanogrin} - GET pending -> the caller's inbox - POST get {slate_id} -> poll a relay's state (sender/recipient) - POST respond {slate_id, response_slatepack} (recipient) - POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only) - POST cancel {slate_id} (sender or recipient) Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status; never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via /wallet/grin/broadcast) and reports the txid here — diverging from the legacy server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md). Controls + hardening (incl. fixes from the adversarial review workflow): - Per-action authz: caller must be the slate's sender/recipient; a non-party gets 404 (no existence oracle). - slate_id is UNIQUE (+ validated as a UUID) and mutations key on the authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE touch another row. create maps the unique violation to 409. - State transitions are ATOMIC: the status + expiry preconditions live in the UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and cancel-races-finalize cannot occur; a non-qualifying transition -> 409. - TTL enforced at the action boundary (respond/finalize require expires_at > now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only). - Recipient must be a registered user; slatepack sizes capped (64 KiB). - Behind FEATURE_GRIN_RELAY (default on). 6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration tests against live Postgres: full lifecycle, unregistered-recipient 400, auth gate, non-party/non-recipient 404s. --- migrations/20260622000001_initial_schema.sql | 7 +- openapi.json | 387 +++++++++++++++++ src/api/openapi.rs | 15 +- src/api/wallet/grin_relay.rs | 420 +++++++++++++++++++ src/api/wallet/mod.rs | 2 + src/config.rs | 6 + src/infra/db/grin_slatepacks.rs | 70 ++-- src/main.rs | 17 + tests/wallet_grin_relay.rs | 183 ++++++++ 9 files changed, 1076 insertions(+), 31 deletions(-) create mode 100644 src/api/wallet/grin_relay.rs create mode 100644 tests/wallet_grin_relay.rs diff --git a/migrations/20260622000001_initial_schema.sql b/migrations/20260622000001_initial_schema.sql index fb00db6..893aaff 100644 --- a/migrations/20260622000001_initial_schema.sql +++ b/migrations/20260622000001_initial_schema.sql @@ -84,7 +84,10 @@ CREATE INDEX idx_user_keys_user_id ON user_keys (user_id); -- ── grin_slatepacks ───────────────────────────────────────────────────────── CREATE TABLE grin_slatepacks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - slate_id TEXT NOT NULL, + -- UNIQUE: the relay's authorization + state machine key off slate_id, so it + -- must map to exactly one row (a collision would let mutations keyed on + -- slate_id touch another user's relay). Also serves as the lookup index. + slate_id TEXT NOT NULL UNIQUE, sender_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL, recipient_address TEXT, @@ -98,7 +101,7 @@ CREATE TABLE grin_slatepacks ( finalized_at TIMESTAMPTZ, tx_hash TEXT ); -CREATE INDEX idx_grin_slatepacks_slate_id ON grin_slatepacks (slate_id); +-- (slate_id lookup index is provided by the UNIQUE constraint above.) CREATE INDEX idx_grin_slatepacks_sender ON grin_slatepacks (sender_user_id); CREATE INDEX idx_grin_slatepacks_recipient ON grin_slatepacks (recipient_user_id); CREATE INDEX idx_grin_slatepacks_status ON grin_slatepacks (status); diff --git a/openapi.json b/openapi.json index 55c8c93..cb43771 100644 --- a/openapi.json +++ b/openapi.json @@ -654,6 +654,236 @@ } } }, + "/wallet/grin/relay/cancel": { + "post": { + "tags": [ + "grin_relay" + ], + "summary": "Cancel a relay (sender or recipient), unless already finalized.", + "operationId": "cancel", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SlateIdRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Relay cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay not found, not yours, or feature disabled" + }, + "409": { + "description": "Relay is already finalized" + } + } + } + }, + "/wallet/grin/relay/create": { + "post": { + "tags": [ + "grin_relay" + ], + "summary": "Post a slatepack addressed to a registered recipient (sender = caller).", + "operationId": "create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRelayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Relay created (PendingRecipient)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, + "400": { + "description": "Invalid input or unregistered recipient" + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay feature disabled" + } + } + } + }, + "/wallet/grin/relay/finalize": { + "post": { + "tags": [ + "grin_relay" + ], + "summary": "Record that the sender finalized + broadcast the transaction (sender only).\nThe backend does NOT finalize or broadcast — the wallet does that locally and\nreports the resulting tx hash here.", + "operationId": "finalize", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinalizeRelayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Relay marked finalized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, + "400": { + "description": "Invalid tx hash" + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay not found, not yours, or feature disabled" + }, + "409": { + "description": "Relay is not awaiting finalization" + } + } + } + }, + "/wallet/grin/relay/get": { + "post": { + "tags": [ + "grin_relay" + ], + "summary": "Poll a relay's current state (sender or recipient). The sender uses this to\nfetch the recipient's response once status is `pending_sender`.", + "operationId": "get_relay", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SlateIdRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The relay entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay not found, not yours, or feature disabled" + } + } + } + }, + "/wallet/grin/relay/pending": { + "get": { + "tags": [ + "grin_relay" + ], + "summary": "The caller's inbox: relays awaiting their response.", + "operationId": "pending", + "responses": { + "200": { + "description": "Relays awaiting the caller's response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PendingRelaysResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay feature disabled" + } + } + } + }, + "/wallet/grin/relay/respond": { + "post": { + "tags": [ + "grin_relay" + ], + "summary": "Attach the recipient's response, advancing to `pending_sender`.", + "operationId": "respond", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RespondRelayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Response stored (PendingSender)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, + "400": { + "description": "Invalid response slatepack" + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "Relay not found, not yours, or feature disabled" + }, + "409": { + "description": "Relay is not awaiting a recipient response" + } + } + } + }, "/wallet/grin/scan": { "post": { "tags": [ @@ -1534,6 +1764,36 @@ } } }, + "CreateRelayRequest": { + "type": "object", + "description": "Create a relay: post an (encrypted) slatepack addressed to a registered user.", + "required": [ + "recipient_user_id", + "slate_id", + "slatepack", + "amount_nanogrin" + ], + "properties": { + "amount_nanogrin": { + "type": "integer", + "format": "int64", + "description": "Amount in nanogrin (informational; the real amount is in the slate)." + }, + "recipient_user_id": { + "type": "string", + "format": "uuid", + "description": "Recipient (a registered Smirk user id)." + }, + "slate_id": { + "type": "string", + "description": "The Grin slate id (the transaction's slate UUID)." + }, + "slatepack": { + "type": "string", + "description": "The armored slatepack (encrypted to the recipient)." + } + } + }, "ExtensionRegisterRequest": { "type": "object", "description": "Register a new extension wallet or re-authenticate an existing one.", @@ -1647,6 +1907,23 @@ } } }, + "FinalizeRelayRequest": { + "type": "object", + "description": "Record that the sender finalized + broadcast the transaction.", + "required": [ + "slate_id", + "tx_hash" + ], + "properties": { + "slate_id": { + "type": "string" + }, + "tx_hash": { + "type": "string", + "description": "The broadcast transaction hash/kernel (recorded for reference)." + } + } + }, "GrinBroadcastRequest": { "type": "object", "description": "Broadcast a finalized, signed Grin transaction (built + signed by the wallet).", @@ -2045,6 +2322,20 @@ } } }, + "PendingRelaysResponse": { + "type": "object", + "required": [ + "relays" + ], + "properties": { + "relays": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RelayEntry" + } + } + } + }, "PublicKeysInfo": { "type": "object", "description": "A user's per-asset receiving public keys, by asset.", @@ -2214,6 +2505,86 @@ } } }, + "RelayEntry": { + "type": "object", + "description": "A relay entry as returned to an authorized party.", + "required": [ + "slate_id", + "sender_user_id", + "slatepack_content", + "amount_nanogrin", + "status", + "created_at", + "expires_at" + ], + "properties": { + "amount_nanogrin": { + "type": "integer", + "format": "int64" + }, + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "finalized_at": { + "type": [ + "string", + "null" + ] + }, + "recipient_user_id": { + "type": [ + "string", + "null" + ] + }, + "response_slatepack": { + "type": [ + "string", + "null" + ], + "description": "The recipient's response slatepack, once provided." + }, + "sender_user_id": { + "type": "string" + }, + "slate_id": { + "type": "string" + }, + "slatepack_content": { + "type": "string", + "description": "The sender's armored slatepack (what the recipient responds to)." + }, + "status": { + "type": "string" + }, + "tx_hash": { + "type": [ + "string", + "null" + ] + } + } + }, + "RespondRelayRequest": { + "type": "object", + "description": "The recipient's response to a pending relay.", + "required": [ + "slate_id", + "response_slatepack" + ], + "properties": { + "response_slatepack": { + "type": "string", + "description": "The armored response slatepack (recipient's partial signature added)." + }, + "slate_id": { + "type": "string" + } + } + }, "SetUsernameRequest": { "type": "object", "required": [ @@ -2238,6 +2609,18 @@ } } }, + "SlateIdRequest": { + "type": "object", + "description": "Identify a relay by its slate id (poll / cancel).", + "required": [ + "slate_id" + ], + "properties": { + "slate_id": { + "type": "string" + } + } + }, "SpentOutputDto": { "type": "object", "description": "A candidate spent output (verify with the spend key before trusting).", @@ -2708,6 +3091,10 @@ { "name": "grin", "description": "Grin view-only chain access (rewind_hash scan, tip height, broadcast)." + }, + { + "name": "grin_relay", + "description": "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged)." } ] } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 5a12531..020861c 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -49,6 +49,12 @@ use utoipa::OpenApi; crate::api::wallet::grin::scan, crate::api::wallet::grin::height, crate::api::wallet::grin::broadcast, + crate::api::wallet::grin_relay::create, + crate::api::wallet::grin_relay::pending, + crate::api::wallet::grin_relay::get_relay, + crate::api::wallet::grin_relay::respond, + crate::api::wallet::grin_relay::finalize, + crate::api::wallet::grin_relay::cancel, crate::api::nip05::well_known_nostr, ), components(schemas( @@ -112,6 +118,12 @@ use utoipa::OpenApi; crate::api::wallet::grin::GrinHeightResponse, crate::api::wallet::grin::GrinBroadcastRequest, crate::api::wallet::grin::GrinBroadcastResponse, + crate::api::wallet::grin_relay::CreateRelayRequest, + crate::api::wallet::grin_relay::RespondRelayRequest, + crate::api::wallet::grin_relay::FinalizeRelayRequest, + crate::api::wallet::grin_relay::SlateIdRequest, + crate::api::wallet::grin_relay::RelayEntry, + crate::api::wallet::grin_relay::PendingRelaysResponse, crate::api::nip05::WellKnownResponse, )), tags( @@ -119,7 +131,8 @@ use utoipa::OpenApi; (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 = "grin", description = "Grin view-only chain access (rewind_hash scan, tip height, broadcast).") + (name = "grin", description = "Grin view-only chain access (rewind_hash scan, tip height, broadcast)."), + (name = "grin_relay", description = "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged).") ) )] pub struct ApiDoc; diff --git a/src/api/wallet/grin_relay.rs b/src/api/wallet/grin_relay.rs new file mode 100644 index 0000000..7795942 --- /dev/null +++ b/src/api/wallet/grin_relay.rs @@ -0,0 +1,420 @@ +//! Grin slatepack relay — an async, non-custodial store-and-forward mailbox for +//! interactive Grin transfers between two registered Smirk users. +//! +//! Grin transactions are interactive: the sender's partial slate must reach the +//! recipient, who adds their output + partial signature and returns it, after +//! which the sender finalizes and broadcasts. This relay makes that asynchronous: +//! the sender posts an (encrypted) slatepack addressed to a recipient; the +//! recipient fetches and responds when online; the sender polls for the response, +//! finalizes and broadcasts LOCALLY (via `/wallet/grin/broadcast`), then records +//! the txid here. +//! +//! Non-custodial by construction: the relay stores only opaque slatepack text +//! (slatepacks are encrypted to the recipient's address) and lifecycle status. It +//! never holds keys, never finalizes, never broadcasts — unlike the legacy +//! backend, which finalized + broadcast server-side. See +//! `docs/private/OWN_BACKEND_CONVERGENCE.md`. +//! +//! Controls: JWT-gated; every action authorizes the caller as the slate's sender +//! or recipient (a non-party gets 404, never an existence oracle); the recipient +//! must be a registered user (no blind spam to arbitrary addresses); slatepack +//! sizes are capped; entries expire; and the whole feature is behind +//! `FEATURE_GRIN_RELAY` so an operator can disable the mailbox. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use crate::api::middleware::extract_user_id_from_token; +use crate::error::AppError; +use crate::models::db::{GrinSlatepack, NewGrinSlatepack, SlatepackStatus}; +use crate::AppState; + +/// Cap on an armored slatepack (sender payload or recipient response). Real +/// slatepacks are a few KB; this is a generous bound against storage abuse. +const MAX_SLATEPACK_LEN: usize = 64 * 1024; +/// How long a relay entry lives before it expires. +const RELAY_TTL_DAYS: i64 = 7; + +/// 400 unless the relay feature is enabled on this server. +fn ensure_enabled(state: &AppState) -> Result<(), AppError> { + if state.config.features.grin_relay { + Ok(()) + } else { + Err(AppError::NotFound( + "grin relay is not enabled on this server".into(), + )) + } +} + +/// Which party an action requires the caller to be. +enum Party { + Sender, + Recipient, + Either, +} + +/// Fetch a relay by slate id and authorize the caller as the required party. A +/// non-party (or unknown slate) gets `NotFound` — "not yours" is indistinguishable +/// from "doesn't exist", so the endpoint is not an existence oracle. +async fn fetch_authorized( + state: &AppState, + slate_id: &str, + user_id: Uuid, + party: Party, +) -> Result { + let row = state + .db + .get_slatepack_by_slate_id(slate_id) + .await? + .ok_or_else(|| AppError::NotFound("relay not found".into()))?; + let is_sender = row.sender_user_id == user_id; + let is_recipient = row.recipient_user_id == Some(user_id); + let authorized = match party { + Party::Sender => is_sender, + Party::Recipient => is_recipient, + Party::Either => is_sender || is_recipient, + }; + if authorized { + Ok(row) + } else { + Err(AppError::NotFound("relay not found".into())) + } +} + +fn status_str(status: SlatepackStatus) -> &'static str { + match status { + SlatepackStatus::PendingRecipient => "pending_recipient", + SlatepackStatus::PendingSender => "pending_sender", + SlatepackStatus::Finalized => "finalized", + SlatepackStatus::Expired => "expired", + SlatepackStatus::Cancelled => "cancelled", + } +} + +fn ts(t: DateTime) -> String { + t.to_rfc3339() +} + +// ── DTOs ──────────────────────────────────────────────────────────────────── + +/// Create a relay: post an (encrypted) slatepack addressed to a registered user. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateRelayRequest { + /// Recipient (a registered Smirk user id). + pub recipient_user_id: Uuid, + /// The Grin slate id (the transaction's slate UUID). + pub slate_id: String, + /// The armored slatepack (encrypted to the recipient). + pub slatepack: String, + /// Amount in nanogrin (informational; the real amount is in the slate). + pub amount_nanogrin: i64, +} + +/// The recipient's response to a pending relay. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RespondRelayRequest { + pub slate_id: String, + /// The armored response slatepack (recipient's partial signature added). + pub response_slatepack: String, +} + +/// Record that the sender finalized + broadcast the transaction. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct FinalizeRelayRequest { + pub slate_id: String, + /// The broadcast transaction hash/kernel (recorded for reference). + pub tx_hash: String, +} + +/// Identify a relay by its slate id (poll / cancel). +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SlateIdRequest { + pub slate_id: String, +} + +/// A relay entry as returned to an authorized party. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RelayEntry { + pub slate_id: String, + pub sender_user_id: String, + pub recipient_user_id: Option, + /// The sender's armored slatepack (what the recipient responds to). + pub slatepack_content: String, + /// The recipient's response slatepack, once provided. + pub response_slatepack: Option, + pub amount_nanogrin: i64, + pub status: String, + pub created_at: String, + pub expires_at: String, + pub finalized_at: Option, + pub tx_hash: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PendingRelaysResponse { + pub relays: Vec, +} + +fn relay_entry(s: GrinSlatepack) -> RelayEntry { + RelayEntry { + slate_id: s.slate_id, + sender_user_id: s.sender_user_id.to_string(), + recipient_user_id: s.recipient_user_id.map(|id| id.to_string()), + slatepack_content: s.slatepack_content, + response_slatepack: s.response_slatepack, + amount_nanogrin: s.amount_nanogrin, + status: status_str(s.status).to_string(), + created_at: ts(s.created_at), + expires_at: ts(s.expires_at), + finalized_at: s.finalized_at.map(ts), + tx_hash: s.tx_hash, + } +} + +fn validate_slate_id(slate_id: &str) -> Result<(), AppError> { + // Grin slate ids are UUIDs; requiring that bounds the value and avoids + // arbitrary client-chosen keys for the (now-UNIQUE) slate_id column. + Uuid::parse_str(slate_id) + .map(|_| ()) + .map_err(|_| AppError::ValidationError("slate_id must be a valid UUID".into())) +} + +fn validate_slatepack(value: &str, field: &str) -> Result<(), AppError> { + if value.is_empty() || value.len() > MAX_SLATEPACK_LEN { + return Err(AppError::ValidationError(format!( + "{field} has invalid length" + ))); + } + Ok(()) +} + +// ── handlers ────────────────────────────────────────────────────────────────── + +/// Post a slatepack addressed to a registered recipient (sender = caller). +#[utoipa::path( + post, + path = "/wallet/grin/relay/create", + request_body = CreateRelayRequest, + responses( + (status = 200, description = "Relay created (PendingRecipient)", body = RelayEntry), + (status = 400, description = "Invalid input or unregistered recipient"), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay feature disabled") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers, req))] +pub async fn create( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + validate_slate_id(&req.slate_id)?; + validate_slatepack(&req.slatepack, "slatepack")?; + if req.amount_nanogrin < 0 { + return Err(AppError::ValidationError( + "amount_nanogrin must be non-negative".into(), + )); + } + + // Anti-spam: the recipient must be a registered user. + let recipient = state + .db + .get_user_by_id(req.recipient_user_id) + .await? + .ok_or_else(|| AppError::ValidationError("recipient is not a registered user".into()))?; + + let row = state + .db + .create_grin_slatepack(NewGrinSlatepack { + slate_id: req.slate_id, + sender_user_id: user_id, + recipient_user_id: Some(recipient.id), + recipient_address: None, + slatepack_content: req.slatepack, + amount_nanogrin: req.amount_nanogrin, + expires_at: Utc::now() + Duration::days(RELAY_TTL_DAYS), + }) + .await?; + Ok(Json(relay_entry(row))) +} + +/// The caller's inbox: relays awaiting their response. +#[utoipa::path( + get, + path = "/wallet/grin/relay/pending", + responses( + (status = 200, description = "Relays awaiting the caller's response", body = PendingRelaysResponse), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay feature disabled") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers))] +pub async fn pending( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + let rows = state.db.get_pending_slatepacks_for_user(user_id).await?; + Ok(Json(PendingRelaysResponse { + relays: rows.into_iter().map(relay_entry).collect(), + })) +} + +/// Poll a relay's current state (sender or recipient). The sender uses this to +/// fetch the recipient's response once status is `pending_sender`. +#[utoipa::path( + post, + path = "/wallet/grin/relay/get", + request_body = SlateIdRequest, + responses( + (status = 200, description = "The relay entry", body = RelayEntry), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay not found, not yours, or feature disabled") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers, req))] +pub async fn get_relay( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + let row = fetch_authorized(&state, &req.slate_id, user_id, Party::Either).await?; + Ok(Json(relay_entry(row))) +} + +/// Attach the recipient's response, advancing to `pending_sender`. +#[utoipa::path( + post, + path = "/wallet/grin/relay/respond", + request_body = RespondRelayRequest, + responses( + (status = 200, description = "Response stored (PendingSender)", body = RelayEntry), + (status = 400, description = "Invalid response slatepack"), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay not found, not yours, or feature disabled"), + (status = 409, description = "Relay is not awaiting a recipient response") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers, req))] +pub async fn respond( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + validate_slatepack(&req.response_slatepack, "response_slatepack")?; + let row = fetch_authorized(&state, &req.slate_id, user_id, Party::Recipient).await?; + // The status + expiry guard is atomic in the UPDATE (keyed on row.id); a + // non-qualifying row returns None -> 409. + let updated = state + .db + .add_slatepack_response(row.id, &req.response_slatepack) + .await? + .ok_or_else(|| { + AppError::Conflict("relay is not awaiting a recipient response (or has expired)".into()) + })?; + Ok(Json(relay_entry(updated))) +} + +/// Record that the sender finalized + broadcast the transaction (sender only). +/// The backend does NOT finalize or broadcast — the wallet does that locally and +/// reports the resulting tx hash here. +#[utoipa::path( + post, + path = "/wallet/grin/relay/finalize", + request_body = FinalizeRelayRequest, + responses( + (status = 200, description = "Relay marked finalized", body = RelayEntry), + (status = 400, description = "Invalid tx hash"), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay not found, not yours, or feature disabled"), + (status = 409, description = "Relay is not awaiting finalization") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers, req))] +pub async fn finalize( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + if req.tx_hash.is_empty() || req.tx_hash.len() > 128 { + return Err(AppError::ValidationError( + "tx_hash has invalid length".into(), + )); + } + let row = fetch_authorized(&state, &req.slate_id, user_id, Party::Sender).await?; + let updated = state + .db + .finalize_slatepack(row.id, &req.tx_hash) + .await? + .ok_or_else(|| { + AppError::Conflict("relay is not awaiting finalization (or has expired)".into()) + })?; + Ok(Json(relay_entry(updated))) +} + +/// Cancel a relay (sender or recipient), unless already finalized. +#[utoipa::path( + post, + path = "/wallet/grin/relay/cancel", + request_body = SlateIdRequest, + responses( + (status = 200, description = "Relay cancelled", body = RelayEntry), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "Relay not found, not yours, or feature disabled"), + (status = 409, description = "Relay is already finalized") + ), + tag = "grin_relay" +)] +#[instrument(skip(state, headers, req))] +pub async fn cancel( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + let row = fetch_authorized(&state, &req.slate_id, user_id, Party::Either).await?; + let updated = state.db.cancel_slatepack(row.id).await?.ok_or_else(|| { + AppError::Conflict( + "relay can no longer be cancelled (already finalized or cancelled)".into(), + ) + })?; + Ok(Json(relay_entry(updated))) +} + +// ── router ──────────────────────────────────────────────────────────────────── + +/// Grin relay routes, RELATIVE to the `/api/v1` mount point. +pub fn routes() -> Router> { + Router::new() + .route("/wallet/grin/relay/create", post(create)) + .route("/wallet/grin/relay/pending", get(pending)) + .route("/wallet/grin/relay/get", post(get_relay)) + .route("/wallet/grin/relay/respond", post(respond)) + .route("/wallet/grin/relay/finalize", post(finalize)) + .route("/wallet/grin/relay/cancel", post(cancel)) +} diff --git a/src/api/wallet/mod.rs b/src/api/wallet/mod.rs index 31af373..61484c8 100644 --- a/src/api/wallet/mod.rs +++ b/src/api/wallet/mod.rs @@ -13,6 +13,7 @@ use crate::AppState; pub mod btc_ltc; pub mod grin; +pub mod grin_relay; pub mod xmr_wow; /// All wallet routes, RELATIVE to the `/api/v1` mount point. @@ -20,6 +21,7 @@ pub fn routes() -> Router> { btc_ltc::routes() .merge(xmr_wow::routes()) .merge(grin::routes()) + .merge(grin_relay::routes()) } // ── shared input validators ─────────────────────────────────────────────────── diff --git a/src/config.rs b/src/config.rs index 6c108d9..6d5ba07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,10 @@ pub struct FeatureFlags { pub tips: bool, /// Nostr-native identity (NIP-98 login/link, NIP-05 directory). pub nostr_identity: bool, + /// Grin slatepack relay (async store-and-forward mailbox for interactive + /// Grin transfers). A non-custodial encrypted mailbox; operators can disable + /// it independently of Grin chain access. + pub grin_relay: bool, } #[derive(Clone, Copy)] @@ -306,6 +310,7 @@ impl Config { prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?, tips: env_bool("FEATURE_TIPS", false), nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true), + grin_relay: env_bool("FEATURE_GRIN_RELAY", true), }, chains: ChainConfig { btc: UtxoConfig { @@ -541,6 +546,7 @@ mod tests { prices_interval_secs: 300, tips: false, nostr_identity: true, + grin_relay: true, }, chains: ChainConfig { btc: utxo(), diff --git a/src/infra/db/grin_slatepacks.rs b/src/infra/db/grin_slatepacks.rs index 0cd8961..20d7909 100644 --- a/src/infra/db/grin_slatepacks.rs +++ b/src/infra/db/grin_slatepacks.rs @@ -42,7 +42,12 @@ impl Database { .bind(input.amount_nanogrin) .bind(input.expires_at) .fetch_one(self.pool()) - .await?; + .await + // slate_id is UNIQUE: a duplicate is a 409, never a second row that + // the slate_id-keyed auth/state machine could be tricked across. + .map_err(super::unique_violation_as( + "a relay for this slate_id already exists", + ))?; Ok(slatepack) } @@ -87,63 +92,72 @@ impl Database { .await?) } - /// Attach the recipient's response slatepack, advancing the relay to - /// `PendingSender` so the sender can finalize. + /// Attach the recipient's response, advancing `PendingRecipient` → + /// `PendingSender`. Targets the authorized row by primary key `id`, and the + /// status + expiry preconditions live in the `WHERE` clause so the transition + /// is atomic (no read-check-write race). `None` = the row no longer qualifies + /// (wrong status / expired) → the caller maps it to a 409. #[instrument(skip(self, response_slatepack))] pub async fn add_slatepack_response( &self, - slate_id: &str, + id: Uuid, response_slatepack: &str, - ) -> Result { + ) -> Result, AppError> { let sql = format!( "UPDATE grin_slatepacks \ SET response_slatepack = $2, status = $3, updated_at = NOW() \ - WHERE slate_id = $1 RETURNING {GRIN_SLATEPACK_COLS}" + WHERE id = $1 AND status = $4 AND expires_at > NOW() \ + RETURNING {GRIN_SLATEPACK_COLS}" ); - let slatepack = sqlx::query_as::<_, GrinSlatepack>(&sql) - .bind(slate_id) + Ok(sqlx::query_as::<_, GrinSlatepack>(&sql) + .bind(id) .bind(response_slatepack) .bind(SlatepackStatus::PendingSender) - .fetch_one(self.pool()) - .await?; - Ok(slatepack) + .bind(SlatepackStatus::PendingRecipient) + .fetch_optional(self.pool()) + .await?) } - /// Mark a slatepack as finalized once the transaction is broadcast. + /// Mark a relay finalized once the sender has broadcast. Atomic: only from + /// `PendingSender` and not expired, by primary key. `None` → 409. #[instrument(skip(self))] pub async fn finalize_slatepack( &self, - slate_id: &str, + id: Uuid, tx_hash: &str, - ) -> Result { + ) -> Result, AppError> { let sql = format!( "UPDATE grin_slatepacks \ SET status = $2, tx_hash = $3, finalized_at = NOW(), updated_at = NOW() \ - WHERE slate_id = $1 RETURNING {GRIN_SLATEPACK_COLS}" + WHERE id = $1 AND status = $4 AND expires_at > NOW() \ + RETURNING {GRIN_SLATEPACK_COLS}" ); - let slatepack = sqlx::query_as::<_, GrinSlatepack>(&sql) - .bind(slate_id) + Ok(sqlx::query_as::<_, GrinSlatepack>(&sql) + .bind(id) .bind(SlatepackStatus::Finalized) .bind(tx_hash) - .fetch_one(self.pool()) - .await?; - Ok(slatepack) + .bind(SlatepackStatus::PendingSender) + .fetch_optional(self.pool()) + .await?) } - /// Cancel a slatepack relay. + /// Cancel a relay (by primary key). Atomic: only from an active state + /// (`PendingRecipient`/`PendingSender`), so a finalized/cancelled/expired + /// relay cannot be flipped. `None` → 409. #[instrument(skip(self))] - pub async fn cancel_slatepack(&self, slate_id: &str) -> Result { + pub async fn cancel_slatepack(&self, id: Uuid) -> Result, AppError> { let sql = format!( "UPDATE grin_slatepacks \ SET status = $2, updated_at = NOW() \ - WHERE slate_id = $1 RETURNING {GRIN_SLATEPACK_COLS}" + WHERE id = $1 AND status IN ($3, $4) RETURNING {GRIN_SLATEPACK_COLS}" ); - let slatepack = sqlx::query_as::<_, GrinSlatepack>(&sql) - .bind(slate_id) + Ok(sqlx::query_as::<_, GrinSlatepack>(&sql) + .bind(id) .bind(SlatepackStatus::Cancelled) - .fetch_one(self.pool()) - .await?; - Ok(slatepack) + .bind(SlatepackStatus::PendingRecipient) + .bind(SlatepackStatus::PendingSender) + .fetch_optional(self.pool()) + .await?) } /// Expire pending slatepacks whose `expires_at` has passed. Returns the diff --git a/src/main.rs b/src/main.rs index 23661ad..368ff0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,23 @@ async fn main() -> Result<(), Box> { }); } + // Periodic expiry of stale Grin relay slatepacks (only when the relay is + // enabled). The respond/finalize paths already reject expired relays in their + // UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays + // bounded and the lifecycle reflects reality. + if state.config.features.grin_relay { + let db = state.db.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600)); + loop { + tick.tick().await; + if let Err(e) = db.expire_old_slatepacks().await { + tracing::warn!(error = %e, "grin slatepack expiry sweep failed"); + } + } + }); + } + let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/tests/wallet_grin_relay.rs b/tests/wallet_grin_relay.rs new file mode 100644 index 0000000..b36422f --- /dev/null +++ b/tests/wallet_grin_relay.rs @@ -0,0 +1,183 @@ +//! L1 integration: the Grin slatepack relay through the real router. +//! +//! The relay is pure store-and-forward (DB-backed, no chain network), so this +//! exercises the FULL lifecycle and the authorization rules end to end against a +//! real Postgres: create -> pending -> respond -> get -> finalize, plus the +//! non-party 404s. + +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use uuid::Uuid; + +fn slate_id() -> String { + // Grin slate ids are UUIDs (validated as such by the handler). + Uuid::new_v4().to_string() +} + +#[tokio::test] +async fn create_requires_a_token() { + let app = require_app!(); + let (status, _) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/create", + None, + Some(json!({ + "recipient_user_id": Uuid::new_v4().to_string(), + "slate_id": slate_id(), + "slatepack": "BEGINSLATEPACK.abc.ENDSLATEPACK", + "amount_nanogrin": 1000 + })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_rejects_unregistered_recipient() { + let app = require_app!(); + let (_uid, access, _r) = app.mint_session().await; + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/create", + Some(&access), + Some(json!({ + "recipient_user_id": Uuid::new_v4().to_string(), // not a real user + "slate_id": slate_id(), + "slatepack": "BEGINSLATEPACK.abc.ENDSLATEPACK", + "amount_nanogrin": 1000 + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "VALIDATION_ERROR"); +} + +#[tokio::test] +async fn full_relay_lifecycle() { + let app = require_app!(); + let (_sender_id, sender, _r1) = app.mint_session().await; + let (recipient_id, recipient, _r2) = app.mint_session().await; + let sid = slate_id(); + + // Sender posts the slatepack to the recipient. + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/create", + Some(&sender), + Some(json!({ + "recipient_user_id": recipient_id.to_string(), + "slate_id": sid, + "slatepack": "BEGINSLATEPACK.sender.ENDSLATEPACK", + "amount_nanogrin": 5000 + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "pending_recipient"); + + // Recipient sees it in their inbox. + let (status, body) = app + .request( + "GET", + "/api/v1/wallet/grin/relay/pending", + Some(&recipient), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let found = body["relays"] + .as_array() + .unwrap() + .iter() + .any(|r| r["slate_id"] == sid); + assert!(found, "recipient inbox should contain the relay"); + + // Recipient responds. + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/respond", + Some(&recipient), + Some(json!({ "slate_id": sid, "response_slatepack": "BEGINSLATEPACK.recipient.ENDSLATEPACK" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "pending_sender"); + + // Sender polls and gets the response back. + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/get", + Some(&sender), + Some(json!({ "slate_id": sid })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["response_slatepack"], + "BEGINSLATEPACK.recipient.ENDSLATEPACK" + ); + + // Sender finalizes (records the txid; the wallet did the broadcast). + let (status, body) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/finalize", + Some(&sender), + Some(json!({ "slate_id": sid, "tx_hash": "deadbeef" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "finalized"); + assert_eq!(body["tx_hash"], "deadbeef"); +} + +#[tokio::test] +async fn third_party_cannot_read_relay() { + let app = require_app!(); + let (_sender_id, sender, _r1) = app.mint_session().await; + let (recipient_id, _recipient, _r2) = app.mint_session().await; + let (_third_id, third, _r3) = app.mint_session().await; + let sid = slate_id(); + + app.request( + "POST", + "/api/v1/wallet/grin/relay/create", + Some(&sender), + Some(json!({ + "recipient_user_id": recipient_id.to_string(), + "slate_id": sid, + "slatepack": "BEGINSLATEPACK.x.ENDSLATEPACK", + "amount_nanogrin": 1 + })), + ) + .await; + + // A non-party gets 404 (no existence oracle), not 403. + let (status, _) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/get", + Some(&third), + Some(json!({ "slate_id": sid })), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // And a non-recipient cannot respond. + let (status, _) = app + .request( + "POST", + "/api/v1/wallet/grin/relay/respond", + Some(&third), + Some(json!({ "slate_id": sid, "response_slatepack": "BEGINSLATEPACK.evil.ENDSLATEPACK" })), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +}