mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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);
|
||||
|
||||
+387
@@ -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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+14
-1
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<Arc<AppState>> {
|
||||
btc_ltc::routes()
|
||||
.merge(xmr_wow::routes())
|
||||
.merge(grin::routes())
|
||||
.merge(grin_relay::routes())
|
||||
}
|
||||
|
||||
// ── shared input validators ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<GrinSlatepack, AppError> {
|
||||
) -> Result<Option<GrinSlatepack>, 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<GrinSlatepack, AppError> {
|
||||
) -> Result<Option<GrinSlatepack>, 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<GrinSlatepack, AppError> {
|
||||
pub async fn cancel_slatepack(&self, id: Uuid) -> Result<Option<GrinSlatepack>, 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
|
||||
|
||||
+17
@@ -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 listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user