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 ─────────────────────────────────────────────────────────
|
-- ── 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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 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 ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,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
@@ -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?;
|
||||||
|
|||||||
@@ -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