mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +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:
@@ -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