mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an invoice on an external, non-custodial payment processor before it can register. Pull model — the grant reads the processor's authenticated API as the source of truth, so there is no inbound webhook to secure and a settled invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass it, exactly like the invite and PoW gates. - infra/payment: a pluggable PaymentProvider seam (neutral Invoice / InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero plugin, so "operator picks any coin" costs one adapter. A future ETH/native adapter (BTCPay serves neither) implements the same trait. Only `Settled` grants (unknown status fails safe); the API key rides in a redacting Secret and hostile responses are size-capped. - payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume (UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one invoice grants at most one registration and never a different identity. - POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor `Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id on the register request. - Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation (bars a 0-conf settled-then-double-spent free registration), bounded expiry. /capabilities advertises payment_required + price; OpenAPI + .env.example. Tests: adapter status/speedPolicy mapping + config validation (unit), and L1 binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected). Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full adapter contract, rotation-branch bypass guard) are addressed here.
165 lines
4.5 KiB
Rust
165 lines
4.5 KiB
Rust
//! L1: pay-to-register payment-invoice binding + single-use.
|
|
//!
|
|
//! Two security-critical invariants, both enforced by a single-statement
|
|
//! `UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash = $ ... RETURNING`:
|
|
//! * BINDING — an invoice bound to one `pubkey_hash` can NEVER be consumed for
|
|
//! a different identity (else a settled invoice could be replayed to register
|
|
//! someone else's wallet);
|
|
//! * ATOMIC SINGLE-USE — a settled invoice grants at most ONE registration,
|
|
//! even under concurrent completion.
|
|
//!
|
|
//! These tests lock both in (incl. a concurrency race).
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use uuid::Uuid;
|
|
|
|
/// A fresh (invoice_id, pubkey_hash) pair, unique per call so the tests stay
|
|
/// isolated under parallel execution against a shared database.
|
|
fn ids() -> (String, String) {
|
|
(
|
|
format!("inv-{}", Uuid::new_v4()),
|
|
format!("pk-{}", Uuid::new_v4()),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_then_get_roundtrips() {
|
|
let app = require_app!();
|
|
let (invoice_id, pubkey_hash) = ids();
|
|
app.state
|
|
.db
|
|
.insert_payment_invoice(&invoice_id, &pubkey_hash, "btcpay", "0.01", "XMR")
|
|
.await
|
|
.unwrap();
|
|
|
|
let row = app
|
|
.state
|
|
.db
|
|
.get_payment_invoice(&invoice_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("row exists");
|
|
assert_eq!(row.pubkey_hash, pubkey_hash);
|
|
assert_eq!(row.amount, "0.01");
|
|
assert_eq!(row.currency, "XMR");
|
|
assert!(row.consumed_at.is_none(), "a fresh invoice is unspent");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_unknown_invoice_is_none() {
|
|
let app = require_app!();
|
|
let (invoice_id, _) = ids();
|
|
assert!(app
|
|
.state
|
|
.db
|
|
.get_payment_invoice(&invoice_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn consume_is_single_use() {
|
|
let app = require_app!();
|
|
let (invoice_id, pubkey_hash) = ids();
|
|
app.state
|
|
.db
|
|
.insert_payment_invoice(&invoice_id, &pubkey_hash, "btcpay", "0.01", "XMR")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
app.state
|
|
.db
|
|
.consume_payment_invoice(&invoice_id, &pubkey_hash)
|
|
.await
|
|
.unwrap(),
|
|
"first consume must succeed"
|
|
);
|
|
assert!(
|
|
!app.state
|
|
.db
|
|
.consume_payment_invoice(&invoice_id, &pubkey_hash)
|
|
.await
|
|
.unwrap(),
|
|
"a reused invoice must be rejected"
|
|
);
|
|
let row = app
|
|
.state
|
|
.db
|
|
.get_payment_invoice(&invoice_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
row.consumed_at.is_some(),
|
|
"consumed invoice records the time"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn consume_bound_to_other_identity_is_rejected() {
|
|
let app = require_app!();
|
|
let (invoice_id, pubkey_hash) = ids();
|
|
app.state
|
|
.db
|
|
.insert_payment_invoice(&invoice_id, &pubkey_hash, "btcpay", "0.01", "XMR")
|
|
.await
|
|
.unwrap();
|
|
|
|
// A DIFFERENT identity cannot consume this invoice...
|
|
let (_, attacker) = ids();
|
|
assert!(
|
|
!app.state
|
|
.db
|
|
.consume_payment_invoice(&invoice_id, &attacker)
|
|
.await
|
|
.unwrap(),
|
|
"an invoice bound to one identity is not consumable by another"
|
|
);
|
|
// ...and the mis-bound attempt did NOT burn it — the rightful owner still can.
|
|
assert!(
|
|
app.state
|
|
.db
|
|
.consume_payment_invoice(&invoice_id, &pubkey_hash)
|
|
.await
|
|
.unwrap(),
|
|
"the bound identity can still consume after a mis-bound attempt"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_consume_redeems_at_most_once() {
|
|
let app = require_app!();
|
|
let (invoice_id, pubkey_hash) = ids();
|
|
app.state
|
|
.db
|
|
.insert_payment_invoice(&invoice_id, &pubkey_hash, "btcpay", "0.01", "XMR")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Fire many concurrent consumes of the SAME invoice; exactly one may win.
|
|
let mut handles = Vec::new();
|
|
for _ in 0..8 {
|
|
let state = Arc::clone(&app.state);
|
|
let inv = invoice_id.clone();
|
|
let pk = pubkey_hash.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
state.db.consume_payment_invoice(&inv, &pk).await.unwrap()
|
|
}));
|
|
}
|
|
let mut wins = 0;
|
|
for handle in handles {
|
|
if handle.await.unwrap() {
|
|
wins += 1;
|
|
}
|
|
}
|
|
assert_eq!(
|
|
wins, 1,
|
|
"exactly one concurrent consume may redeem a single-use invoice"
|
|
);
|
|
}
|