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:
+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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user