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

Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.

Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create   {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET  pending  -> the caller's inbox
- POST get      {slate_id} -> poll a relay's state (sender/recipient)
- POST respond  {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel   {slate_id} (sender or recipient)

Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).

Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
  gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
  authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
  brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
  touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
  UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
  cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
  now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).

6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
This commit is contained in:
jwinterm
2026-06-23 17:39:13 -04:00
parent e62f1bec8e
commit 586f681287
9 changed files with 1076 additions and 31 deletions
+387
View File
@@ -654,6 +654,236 @@
}
}
},
"/wallet/grin/relay/cancel": {
"post": {
"tags": [
"grin_relay"
],
"summary": "Cancel a relay (sender or recipient), unless already finalized.",
"operationId": "cancel",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlateIdRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Relay cancelled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RelayEntry"
}
}
}
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay not found, not yours, or feature disabled"
},
"409": {
"description": "Relay is already finalized"
}
}
}
},
"/wallet/grin/relay/create": {
"post": {
"tags": [
"grin_relay"
],
"summary": "Post a slatepack addressed to a registered recipient (sender = caller).",
"operationId": "create",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateRelayRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Relay created (PendingRecipient)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RelayEntry"
}
}
}
},
"400": {
"description": "Invalid input or unregistered recipient"
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay feature disabled"
}
}
}
},
"/wallet/grin/relay/finalize": {
"post": {
"tags": [
"grin_relay"
],
"summary": "Record that the sender finalized + broadcast the transaction (sender only).\nThe backend does NOT finalize or broadcast — the wallet does that locally and\nreports the resulting tx hash here.",
"operationId": "finalize",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FinalizeRelayRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Relay marked finalized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RelayEntry"
}
}
}
},
"400": {
"description": "Invalid tx hash"
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay not found, not yours, or feature disabled"
},
"409": {
"description": "Relay is not awaiting finalization"
}
}
}
},
"/wallet/grin/relay/get": {
"post": {
"tags": [
"grin_relay"
],
"summary": "Poll a relay's current state (sender or recipient). The sender uses this to\nfetch the recipient's response once status is `pending_sender`.",
"operationId": "get_relay",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlateIdRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The relay entry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RelayEntry"
}
}
}
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay not found, not yours, or feature disabled"
}
}
}
},
"/wallet/grin/relay/pending": {
"get": {
"tags": [
"grin_relay"
],
"summary": "The caller's inbox: relays awaiting their response.",
"operationId": "pending",
"responses": {
"200": {
"description": "Relays awaiting the caller's response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PendingRelaysResponse"
}
}
}
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay feature disabled"
}
}
}
},
"/wallet/grin/relay/respond": {
"post": {
"tags": [
"grin_relay"
],
"summary": "Attach the recipient's response, advancing to `pending_sender`.",
"operationId": "respond",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RespondRelayRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Response stored (PendingSender)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RelayEntry"
}
}
}
},
"400": {
"description": "Invalid response slatepack"
},
"401": {
"description": "Missing or invalid token"
},
"404": {
"description": "Relay not found, not yours, or feature disabled"
},
"409": {
"description": "Relay is not awaiting a recipient response"
}
}
}
},
"/wallet/grin/scan": {
"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)."
}
]
}