Files
jwinterm 54ae9fadac 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.
2026-06-30 21:30:12 -04:00

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"
);
}