mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(registration): pay-to-register via pluggable payment provider (gate #3)
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.
This commit is contained in:
@@ -70,6 +70,8 @@ pub async fn try_app() -> Option<TestApp> {
|
||||
);
|
||||
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
|
||||
let chains = ChainClients::from_config(&config).expect("build chain clients");
|
||||
let payment =
|
||||
smirk_backend_core::infra::payment::from_config(&config).expect("build payment provider");
|
||||
let prices = Arc::new(tokio::sync::RwLock::new(
|
||||
smirk_backend_core::infra::prices::PriceSnapshot::empty(&config.features.prices_currency),
|
||||
));
|
||||
@@ -82,6 +84,7 @@ pub async fn try_app() -> Option<TestApp> {
|
||||
db,
|
||||
sessions,
|
||||
chains,
|
||||
payment,
|
||||
web_challenges: Arc::default(),
|
||||
prices,
|
||||
admin_sessions,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user