feat: Grin slatepack relay (non-custodial async mailbox, feature-flagged)

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.
This commit is contained in:
jwinterm
2026-06-23 17:39:13 -04:00
parent e62f1bec8e
commit 586f681287
9 changed files with 1076 additions and 31 deletions
+5 -2
View File
@@ -84,7 +84,10 @@ CREATE INDEX idx_user_keys_user_id ON user_keys (user_id);
-- ── grin_slatepacks ───────────────────────────────────────────────────────── -- ── grin_slatepacks ─────────────────────────────────────────────────────────
CREATE TABLE grin_slatepacks ( CREATE TABLE grin_slatepacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, sender_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL, recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
recipient_address TEXT, recipient_address TEXT,
@@ -98,7 +101,7 @@ CREATE TABLE grin_slatepacks (
finalized_at TIMESTAMPTZ, finalized_at TIMESTAMPTZ,
tx_hash TEXT 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_sender ON grin_slatepacks (sender_user_id);
CREATE INDEX idx_grin_slatepacks_recipient ON grin_slatepacks (recipient_user_id); CREATE INDEX idx_grin_slatepacks_recipient ON grin_slatepacks (recipient_user_id);
CREATE INDEX idx_grin_slatepacks_status ON grin_slatepacks (status); CREATE INDEX idx_grin_slatepacks_status ON grin_slatepacks (status);
+387
View File
@@ -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": { "/wallet/grin/scan": {
"post": { "post": {
"tags": [ "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": { "ExtensionRegisterRequest": {
"type": "object", "type": "object",
"description": "Register a new extension wallet or re-authenticate an existing one.", "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": { "GrinBroadcastRequest": {
"type": "object", "type": "object",
"description": "Broadcast a finalized, signed Grin transaction (built + signed by the wallet).", "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": { "PublicKeysInfo": {
"type": "object", "type": "object",
"description": "A user's per-asset receiving public keys, by asset.", "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": { "SetUsernameRequest": {
"type": "object", "type": "object",
"required": [ "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": { "SpentOutputDto": {
"type": "object", "type": "object",
"description": "A candidate spent output (verify with the spend key before trusting).", "description": "A candidate spent output (verify with the spend key before trusting).",
@@ -2708,6 +3091,10 @@
{ {
"name": "grin", "name": "grin",
"description": "Grin view-only chain access (rewind_hash scan, tip height, broadcast)." "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)."
} }
] ]
} }
+14 -1
View File
@@ -49,6 +49,12 @@ use utoipa::OpenApi;
crate::api::wallet::grin::scan, crate::api::wallet::grin::scan,
crate::api::wallet::grin::height, crate::api::wallet::grin::height,
crate::api::wallet::grin::broadcast, 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, crate::api::nip05::well_known_nostr,
), ),
components(schemas( components(schemas(
@@ -112,6 +118,12 @@ use utoipa::OpenApi;
crate::api::wallet::grin::GrinHeightResponse, crate::api::wallet::grin::GrinHeightResponse,
crate::api::wallet::grin::GrinBroadcastRequest, crate::api::wallet::grin::GrinBroadcastRequest,
crate::api::wallet::grin::GrinBroadcastResponse, 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, crate::api::nip05::WellKnownResponse,
)), )),
tags( tags(
@@ -119,7 +131,8 @@ use utoipa::OpenApi;
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."), (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)."), (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; pub struct ApiDoc;
+420
View File
@@ -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<GrinSlatepack, AppError> {
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<Utc>) -> 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<String>,
/// 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<String>,
pub amount_nanogrin: i64,
pub status: String,
pub created_at: String,
pub expires_at: String,
pub finalized_at: Option<String>,
pub tx_hash: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PendingRelaysResponse {
pub relays: Vec<RelayEntry>,
}
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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<CreateRelayRequest>,
) -> Result<Json<RelayEntry>, 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<PendingRelaysResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<SlateIdRequest>,
) -> Result<Json<RelayEntry>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RespondRelayRequest>,
) -> Result<Json<RelayEntry>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<FinalizeRelayRequest>,
) -> Result<Json<RelayEntry>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<SlateIdRequest>,
) -> Result<Json<RelayEntry>, 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<Arc<AppState>> {
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))
}
+2
View File
@@ -13,6 +13,7 @@ use crate::AppState;
pub mod btc_ltc; pub mod btc_ltc;
pub mod grin; pub mod grin;
pub mod grin_relay;
pub mod xmr_wow; pub mod xmr_wow;
/// All wallet routes, RELATIVE to the `/api/v1` mount point. /// All wallet routes, RELATIVE to the `/api/v1` mount point.
@@ -20,6 +21,7 @@ pub fn routes() -> Router<Arc<AppState>> {
btc_ltc::routes() btc_ltc::routes()
.merge(xmr_wow::routes()) .merge(xmr_wow::routes())
.merge(grin::routes()) .merge(grin::routes())
.merge(grin_relay::routes())
} }
// ── shared input validators ─────────────────────────────────────────────────── // ── shared input validators ───────────────────────────────────────────────────
+6
View File
@@ -160,6 +160,10 @@ pub struct FeatureFlags {
pub tips: bool, pub tips: bool,
/// Nostr-native identity (NIP-98 login/link, NIP-05 directory). /// Nostr-native identity (NIP-98 login/link, NIP-05 directory).
pub nostr_identity: bool, 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)] #[derive(Clone, Copy)]
@@ -306,6 +310,7 @@ impl Config {
prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?, prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?,
tips: env_bool("FEATURE_TIPS", false), tips: env_bool("FEATURE_TIPS", false),
nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true), nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true),
grin_relay: env_bool("FEATURE_GRIN_RELAY", true),
}, },
chains: ChainConfig { chains: ChainConfig {
btc: UtxoConfig { btc: UtxoConfig {
@@ -541,6 +546,7 @@ mod tests {
prices_interval_secs: 300, prices_interval_secs: 300,
tips: false, tips: false,
nostr_identity: true, nostr_identity: true,
grin_relay: true,
}, },
chains: ChainConfig { chains: ChainConfig {
btc: utxo(), btc: utxo(),
+42 -28
View File
@@ -42,7 +42,12 @@ impl Database {
.bind(input.amount_nanogrin) .bind(input.amount_nanogrin)
.bind(input.expires_at) .bind(input.expires_at)
.fetch_one(self.pool()) .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) Ok(slatepack)
} }
@@ -87,63 +92,72 @@ impl Database {
.await?) .await?)
} }
/// Attach the recipient's response slatepack, advancing the relay to /// Attach the recipient's response, advancing `PendingRecipient` →
/// `PendingSender` so the sender can finalize. /// `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))] #[instrument(skip(self, response_slatepack))]
pub async fn add_slatepack_response( pub async fn add_slatepack_response(
&self, &self,
slate_id: &str, id: Uuid,
response_slatepack: &str, response_slatepack: &str,
) -> Result<GrinSlatepack, AppError> { ) -> Result<Option<GrinSlatepack>, AppError> {
let sql = format!( let sql = format!(
"UPDATE grin_slatepacks \ "UPDATE grin_slatepacks \
SET response_slatepack = $2, status = $3, updated_at = NOW() \ 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) Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
.bind(slate_id) .bind(id)
.bind(response_slatepack) .bind(response_slatepack)
.bind(SlatepackStatus::PendingSender) .bind(SlatepackStatus::PendingSender)
.fetch_one(self.pool()) .bind(SlatepackStatus::PendingRecipient)
.await?; .fetch_optional(self.pool())
Ok(slatepack) .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))] #[instrument(skip(self))]
pub async fn finalize_slatepack( pub async fn finalize_slatepack(
&self, &self,
slate_id: &str, id: Uuid,
tx_hash: &str, tx_hash: &str,
) -> Result<GrinSlatepack, AppError> { ) -> Result<Option<GrinSlatepack>, AppError> {
let sql = format!( let sql = format!(
"UPDATE grin_slatepacks \ "UPDATE grin_slatepacks \
SET status = $2, tx_hash = $3, finalized_at = NOW(), updated_at = NOW() \ 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) Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
.bind(slate_id) .bind(id)
.bind(SlatepackStatus::Finalized) .bind(SlatepackStatus::Finalized)
.bind(tx_hash) .bind(tx_hash)
.fetch_one(self.pool()) .bind(SlatepackStatus::PendingSender)
.await?; .fetch_optional(self.pool())
Ok(slatepack) .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))] #[instrument(skip(self))]
pub async fn cancel_slatepack(&self, slate_id: &str) -> Result<GrinSlatepack, AppError> { pub async fn cancel_slatepack(&self, id: Uuid) -> Result<Option<GrinSlatepack>, AppError> {
let sql = format!( let sql = format!(
"UPDATE grin_slatepacks \ "UPDATE grin_slatepacks \
SET status = $2, updated_at = NOW() \ 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) Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
.bind(slate_id) .bind(id)
.bind(SlatepackStatus::Cancelled) .bind(SlatepackStatus::Cancelled)
.fetch_one(self.pool()) .bind(SlatepackStatus::PendingRecipient)
.await?; .bind(SlatepackStatus::PendingSender)
Ok(slatepack) .fetch_optional(self.pool())
.await?)
} }
/// Expire pending slatepacks whose `expires_at` has passed. Returns the /// Expire pending slatepacks whose `expires_at` has passed. Returns the
+17
View File
@@ -59,6 +59,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
// 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 app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
+183
View File
@@ -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);
}