mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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.
179 lines
7.1 KiB
Rust
179 lines
7.1 KiB
Rust
//! Grin slatepack relay queries.
|
|
//!
|
|
//! Backs an interactive two-party Grin transaction by relaying slatepacks
|
|
//! between a sender and a recipient. The relay never holds key material; it
|
|
//! stores the opaque slatepack payloads and the lifecycle status. Explicit
|
|
//! column lists (no `SELECT *`) keep `FromRow` mapping stable as columns evolve.
|
|
|
|
use tracing::instrument;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::AppError;
|
|
use crate::models::db::{GrinSlatepack, NewGrinSlatepack, SlatepackStatus};
|
|
|
|
use super::Database;
|
|
|
|
/// Explicit `grin_slatepacks` columns (matches `GrinSlatepack` field names;
|
|
/// FromRow maps by name).
|
|
const GRIN_SLATEPACK_COLS: &str = "id, slate_id, sender_user_id, recipient_user_id, \
|
|
recipient_address, slatepack_content, amount_nanogrin, status, response_slatepack, \
|
|
created_at, updated_at, expires_at, finalized_at, tx_hash";
|
|
|
|
impl Database {
|
|
/// Create a new slatepack relay entry. Starts in `PendingRecipient`: the
|
|
/// recipient must add their response before the sender can finalize.
|
|
#[instrument(skip(self, input), fields(slate_id = %input.slate_id))]
|
|
pub async fn create_grin_slatepack(
|
|
&self,
|
|
input: NewGrinSlatepack,
|
|
) -> Result<GrinSlatepack, AppError> {
|
|
let sql = format!(
|
|
"INSERT INTO grin_slatepacks \
|
|
(slate_id, sender_user_id, recipient_user_id, recipient_address, \
|
|
slatepack_content, amount_nanogrin, expires_at) \
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {GRIN_SLATEPACK_COLS}"
|
|
);
|
|
let slatepack = sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(&input.slate_id)
|
|
.bind(input.sender_user_id)
|
|
.bind(input.recipient_user_id)
|
|
.bind(&input.recipient_address)
|
|
.bind(&input.slatepack_content)
|
|
.bind(input.amount_nanogrin)
|
|
.bind(input.expires_at)
|
|
.fetch_one(self.pool())
|
|
.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)
|
|
}
|
|
|
|
/// Fetch a slatepack by its slate ID.
|
|
#[instrument(skip(self))]
|
|
pub async fn get_slatepack_by_slate_id(
|
|
&self,
|
|
slate_id: &str,
|
|
) -> Result<Option<GrinSlatepack>, AppError> {
|
|
let sql = format!("SELECT {GRIN_SLATEPACK_COLS} FROM grin_slatepacks WHERE slate_id = $1");
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(slate_id)
|
|
.fetch_optional(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// Fetch a slatepack by its row ID.
|
|
#[instrument(skip(self))]
|
|
pub async fn get_slatepack_by_id(&self, id: Uuid) -> Result<Option<GrinSlatepack>, AppError> {
|
|
let sql = format!("SELECT {GRIN_SLATEPACK_COLS} FROM grin_slatepacks WHERE id = $1");
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(id)
|
|
.fetch_optional(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// List unexpired slatepacks awaiting a response from the given recipient.
|
|
#[instrument(skip(self))]
|
|
pub async fn get_pending_slatepacks_for_user(
|
|
&self,
|
|
user_id: Uuid,
|
|
) -> Result<Vec<GrinSlatepack>, AppError> {
|
|
let sql = format!(
|
|
"SELECT {GRIN_SLATEPACK_COLS} FROM grin_slatepacks \
|
|
WHERE recipient_user_id = $1 AND status = $2 AND expires_at > NOW() \
|
|
ORDER BY created_at DESC"
|
|
);
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(user_id)
|
|
.bind(SlatepackStatus::PendingRecipient)
|
|
.fetch_all(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// 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,
|
|
id: Uuid,
|
|
response_slatepack: &str,
|
|
) -> Result<Option<GrinSlatepack>, AppError> {
|
|
let sql = format!(
|
|
"UPDATE grin_slatepacks \
|
|
SET response_slatepack = $2, status = $3, updated_at = NOW() \
|
|
WHERE id = $1 AND status = $4 AND expires_at > NOW() \
|
|
RETURNING {GRIN_SLATEPACK_COLS}"
|
|
);
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(id)
|
|
.bind(response_slatepack)
|
|
.bind(SlatepackStatus::PendingSender)
|
|
.bind(SlatepackStatus::PendingRecipient)
|
|
.fetch_optional(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// 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,
|
|
id: Uuid,
|
|
tx_hash: &str,
|
|
) -> Result<Option<GrinSlatepack>, AppError> {
|
|
let sql = format!(
|
|
"UPDATE grin_slatepacks \
|
|
SET status = $2, tx_hash = $3, finalized_at = NOW(), updated_at = NOW() \
|
|
WHERE id = $1 AND status = $4 AND expires_at > NOW() \
|
|
RETURNING {GRIN_SLATEPACK_COLS}"
|
|
);
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(id)
|
|
.bind(SlatepackStatus::Finalized)
|
|
.bind(tx_hash)
|
|
.bind(SlatepackStatus::PendingSender)
|
|
.fetch_optional(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// 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, id: Uuid) -> Result<Option<GrinSlatepack>, AppError> {
|
|
let sql = format!(
|
|
"UPDATE grin_slatepacks \
|
|
SET status = $2, updated_at = NOW() \
|
|
WHERE id = $1 AND status IN ($3, $4) RETURNING {GRIN_SLATEPACK_COLS}"
|
|
);
|
|
Ok(sqlx::query_as::<_, GrinSlatepack>(&sql)
|
|
.bind(id)
|
|
.bind(SlatepackStatus::Cancelled)
|
|
.bind(SlatepackStatus::PendingRecipient)
|
|
.bind(SlatepackStatus::PendingSender)
|
|
.fetch_optional(self.pool())
|
|
.await?)
|
|
}
|
|
|
|
/// Expire pending slatepacks whose `expires_at` has passed. Returns the
|
|
/// number of rows affected.
|
|
#[instrument(skip(self))]
|
|
pub async fn expire_old_slatepacks(&self) -> Result<u64, AppError> {
|
|
let result = sqlx::query(
|
|
"UPDATE grin_slatepacks SET status = $1, updated_at = NOW() \
|
|
WHERE status IN ($2, $3) AND expires_at < NOW()",
|
|
)
|
|
.bind(SlatepackStatus::Expired)
|
|
.bind(SlatepackStatus::PendingRecipient)
|
|
.bind(SlatepackStatus::PendingSender)
|
|
.execute(self.pool())
|
|
.await?;
|
|
Ok(result.rows_affected())
|
|
}
|
|
}
|