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:
jwinterm
2026-06-30 21:30:12 -04:00
parent 56238dcac8
commit 54ae9fadac
16 changed files with 1419 additions and 4 deletions
+236 -1
View File
@@ -308,6 +308,13 @@ pub struct ExtensionRegisterRequest {
/// ignored otherwise and for returning wallets.
#[serde(default)]
pub invite_code: Option<String>,
/// Settled payment-invoice id from `/auth/payment-invoice`. Required when the
/// instance enables the pay-to-register gate (see `/capabilities` →
/// `registration.payment_required`); ignored otherwise and for returning
/// wallets. Its settlement is verified against the processor and the invoice
/// atomically consumed (single-use) before the wallet is granted.
#[serde(default)]
pub payment_invoice_id: Option<String>,
}
/// Register a new extension wallet or authenticate an existing one.
@@ -408,7 +415,13 @@ pub async fn extension_register(
if rotation_proven {
// PoW still applies to the new pubkey, exactly like any other
// new-pubkey registration.
// new-pubkey registration. The invite and pay-to-register gates
// are INTENTIONALLY skipped: this branch only re-points an
// already-registered (already-gated) user's row to a new key —
// a move, not a mint (`is_new=false`, no new identity) — and is
// reachable only by proving control of that user's on-file key.
// Do NOT add a create / get-or-create here without also running
// enforce_invite + enforce_payment, or it becomes a gate bypass.
enforce_pow(&state, &pubkey_hash_lc, false, req.altcha_solution.as_ref())?;
info!(user_id = %target.id, "auto key-rotation: fingerprint match + rotation proof verified");
@@ -466,6 +479,13 @@ pub async fn extension_register(
req.altcha_solution.as_ref(),
)?;
enforce_invite(&state, returning_by_pubkey, req.invite_code.as_deref()).await?;
enforce_payment(
&state,
returning_by_pubkey,
&pubkey_hash,
req.payment_invoice_id.as_deref(),
)
.await?;
// If we reached here after an unproven fingerprint match, do NOT attach that
// fingerprint to the new row — it belongs to another user, and the UNIQUE
@@ -627,12 +647,226 @@ async fn enforce_invite(
Ok(())
}
/// Pay-to-register gate (PULL model). A genuinely new wallet must present the id
/// of a payment invoice — minted at [`payment_invoice`] and paid to the
/// operator's own wallet via the configured processor — that THIS backend reads
/// back as `Settled`. Returning wallets bypass it, an instance with the gate off
/// accepts everyone, and self-hosting bypasses it (leave the gate off).
///
/// The grant decision reads the processor's authenticated API as the source of
/// truth — never a client claim, never an inbound webhook. The invoice must
/// exist HERE bound to THIS `pubkey_hash`, be unspent, and read `Settled`; it is
/// then atomically consumed (single-use), so one invoice grants at most one
/// registration even under concurrent completion (the race loser is rejected).
async fn enforce_payment(
state: &AppState,
returning: bool,
pubkey_hash: &str,
payment_invoice_id: Option<&str>,
) -> Result<(), AppError> {
if returning || !state.config.registration.payment.require_payment {
return Ok(());
}
// Gate on but no provider built — config validation prevents this, so it is
// an internal misconfiguration (generic 500), not a client-facing error.
let provider = state.payment.as_ref().ok_or_else(|| {
warn!(
gate = "payment",
"payment gate on but no provider configured"
);
AppError::Internal("payment provider not configured".into())
})?;
let id = payment_invoice_id
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
warn!(
gate = "payment",
"payment required for new wallet but no invoice id"
);
AppError::ValidationError(
"Registration on this instance requires payment. Request an invoice from \
/auth/payment-invoice, pay it, then retry."
.into(),
)
})?;
// Binding precheck against our OWN record. A missing row and a row bound to a
// DIFFERENT identity collapse to ONE literal — never an oracle distinguishing
// "unknown" from "someone else's".
let row = match state.db.get_payment_invoice(id).await? {
Some(r) if r.pubkey_hash == pubkey_hash => r,
_ => {
warn!(
gate = "payment",
"unknown or mis-bound payment invoice presented"
);
return Err(AppError::ValidationError(
"Unknown or invalid payment invoice.".into(),
));
}
};
if row.consumed_at.is_some() {
return Err(AppError::ValidationError(
"This payment invoice has already been used.".into(),
));
}
// Source of truth: read the processor. Only `Settled` grants — and by the
// `InvoiceStatus::Settled` adapter contract that means paid IN FULL, so the
// amount is not re-checked here (it is server-set at mint and the row is the
// binding anchor; a foreign or underpaid invoice never reaches this point).
let invoice = provider.get_invoice(id).await?;
if invoice.status != crate::infra::payment::InvoiceStatus::Settled {
return Err(AppError::ValidationError(
"Payment not yet confirmed. Complete the payment and retry.".into(),
));
}
// Defense-in-depth: the processor-side metadata bind, when present, must
// agree with the row (the row is the authority; this catches a swapped id).
if let Some(ref bound) = invoice.bind {
if bound.as_str() != pubkey_hash {
warn!(gate = "payment", "settled invoice metadata bind mismatch");
return Err(AppError::ValidationError(
"Unknown or invalid payment invoice.".into(),
));
}
}
// Atomic single-use consume: exactly one registration per invoice, even under
// a concurrent race (the loser gets the already-used literal).
if !state.db.consume_payment_invoice(id, pubkey_hash).await? {
return Err(AppError::ValidationError(
"This payment invoice has already been used.".into(),
));
}
info!(
gate = "payment",
"payment invoice settled + consumed (new user)"
);
Ok(())
}
/// SHA-256 hex of a public key string — the wallet's stable identity handle.
fn hash_public_key(public_key: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(public_key.as_bytes()))
}
// ── POST /auth/payment-invoice ───────────────────────────────────────────────
/// Request a registration-payment invoice for a wallet about to register.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PaymentInvoiceRequest {
/// The wallet's BTC public key. Its hash is the identity the invoice binds
/// to; only a later `/auth/extension` proving control of this key can redeem
/// the settled invoice, so this endpoint needs no separate proof.
pub btc_public_key: String,
}
/// A created payment invoice: what to pay, and the id to present at registration.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PaymentInvoiceResponse {
/// Opaque invoice id — send it back as `/auth/extension`'s `payment_invoice_id`.
pub invoice_id: String,
/// Where to pay (a hosted checkout URL, or an address).
pub pay_to: String,
/// Price to pay.
pub amount: String,
/// Price currency.
pub currency: String,
}
/// Create a registration-payment invoice on the operator's processor.
///
/// Public (pre-registration) and deliberately cheap: it only binds an invoice to
/// the wallet's `pubkey_hash`, which is worthless to anyone who cannot later
/// prove control of that BTC key at `/auth/extension`. The unauthenticated-surface
/// rate limiter bounds invoice spam and unpaid invoices expire on the processor,
/// so no signature proof is required here. Returns 400 when the pay gate is off
/// or the wallet is already registered (it would bypass payment anyway).
#[utoipa::path(
post,
path = "/auth/payment-invoice",
request_body = PaymentInvoiceRequest,
responses(
(status = 200, description = "Payment invoice created", body = PaymentInvoiceResponse),
(status = 400, description = "Pay gate off, already registered, or invalid key"),
(status = 503, description = "Payment processor unavailable")
),
tag = "auth"
)]
#[instrument(skip(state, req))]
pub async fn payment_invoice(
State(state): State<Arc<AppState>>,
Json(req): Json<PaymentInvoiceRequest>,
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
let cfg = &state.config.registration.payment;
if !cfg.require_payment {
return Err(AppError::ValidationError(
"This instance does not require registration payment.".into(),
));
}
let provider = state.payment.as_ref().ok_or_else(|| {
warn!(
gate = "payment",
"payment gate on but no provider configured"
);
AppError::Internal("payment provider not configured".into())
})?;
// Light hygiene only (the authoritative key-format check is at
// /auth/extension): non-empty, bounded, printable — so we don't mint invoices
// for obvious garbage. A bind to a junk hash is harmless (never redeemable).
let key = req.btc_public_key.trim();
if key.is_empty() || key.len() > 200 || !key.bytes().all(|b| b.is_ascii_graphic()) {
return Err(AppError::ValidationError("Invalid BTC public key.".into()));
}
let pubkey_hash = hash_public_key(key);
// A known wallet bypasses payment; minting for it would waste the payer's money.
if state
.db
.get_user_by_pubkey_hash(&pubkey_hash)
.await?
.is_some()
{
return Err(AppError::ValidationError(
"This wallet is already registered; no payment is required.".into(),
));
}
let invoice = provider
.create_invoice(&crate::infra::payment::InvoiceRequest {
amount: cfg.amount.clone(),
currency: cfg.currency.clone(),
confirmations: cfg.confirmations,
bind: pubkey_hash.clone(),
expires_minutes: cfg.expires_minutes,
})
.await?;
state
.db
.insert_payment_invoice(
&invoice.id,
&pubkey_hash,
provider.kind(),
&cfg.amount,
&cfg.currency,
)
.await?;
info!(gate = "payment", "created registration payment invoice");
Ok(Json(PaymentInvoiceResponse {
invoice_id: invoice.id,
pay_to: invoice.pay_to,
amount: cfg.amount.clone(),
currency: cfg.currency.clone(),
}))
}
// ── POST /auth/check-restore ─────────────────────────────────────────────────
/// Ask whether a wallet (by seed fingerprint) was created on this backend, and
@@ -1139,6 +1373,7 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/auth/extension", post(extension_register))
.route("/auth/check-restore", post(check_restore))
.route("/auth/pow-challenge", post(pow_challenge))
.route("/auth/payment-invoice", post(payment_invoice))
.route("/auth/refresh", post(refresh_token))
.route("/auth/logout", post(logout))
.route("/auth/me", get(get_me))
+18
View File
@@ -76,6 +76,13 @@ pub struct RegistrationCapability {
pub invite_required: bool,
/// A proof-of-work solution is required to register.
pub pow_required: bool,
/// A settled payment invoice (from `/auth/payment-invoice`) is required to
/// register a new wallet.
pub payment_required: bool,
/// The registration price + currency — present only when `payment_required`,
/// so the wallet can show "registration costs X" before minting an invoice.
pub payment_amount: Option<String>,
pub payment_currency: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
@@ -161,6 +168,17 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
registration: RegistrationCapability {
invite_required: config.registration.require_invite,
pow_required: config.pow.enabled && config.pow.required,
payment_required: config.registration.payment.require_payment,
payment_amount: config
.registration
.payment
.require_payment
.then(|| config.registration.payment.amount.clone()),
payment_currency: config
.registration
.payment
.require_payment
.then(|| config.registration.payment.currency.clone()),
},
}
}
+3
View File
@@ -22,6 +22,7 @@ use utoipa::OpenApi;
crate::api::auth::pow_challenge,
crate::api::auth::extension_register,
crate::api::auth::check_restore,
crate::api::auth::payment_invoice,
crate::api::auth::refresh_token,
crate::api::auth::logout,
crate::api::auth::get_me,
@@ -76,6 +77,8 @@ use utoipa::OpenApi;
crate::api::auth::ExtensionRegisterRequest,
crate::api::auth::CheckRestoreRequest,
crate::api::auth::CheckRestoreResponse,
crate::api::auth::PaymentInvoiceRequest,
crate::api::auth::PaymentInvoiceResponse,
crate::api::auth::RefreshTokenRequest,
crate::api::auth::LogoutRequest,
crate::api::auth::LogoutResponse,
+228 -1
View File
@@ -38,6 +38,26 @@ fn looks_placeholder(s: &str) -> bool {
PLACEHOLDERS.iter().any(|p| l.contains(p))
}
/// Whether `s` is a positive decimal literal — digits with at most one dot and
/// at least one non-zero digit (`"0.01"`, `"1"`, `"10.5"` yes; `"0"`, `"0.00"`,
/// `"-1"`, `"1e5"`, `""` no). Validates a price without pulling in float math.
fn is_positive_decimal(s: &str) -> bool {
let mut seen_dot = false;
let mut seen_digit = false;
let mut seen_nonzero = false;
for c in s.chars() {
match c {
'0'..='9' => {
seen_digit = true;
seen_nonzero |= c != '0';
}
'.' if !seen_dot => seen_dot = true,
_ => return false,
}
}
seen_digit && seen_nonzero
}
// ── env helpers ─────────────────────────────────────────────────────────────
/// Non-empty env value, or `None`.
@@ -449,10 +469,41 @@ impl RestoreConfig {
/// via `/capabilities`. Each is a gate the wallet must satisfy to create a NEW
/// identity; returning wallets bypass them, and self-hosting bypasses all of
/// them (run your own backend). PoW lives in [`PowConfig`]; this holds the rest.
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct RegistrationConfig {
/// Require a valid operator-minted invite code to register a new wallet.
pub require_invite: bool,
/// Pay-to-register gate (settle an invoice on an external, non-custodial
/// processor before a new wallet is granted).
pub payment: PaymentConfig,
}
/// Pay-to-register gate. When `require_payment` is on, a NEW wallet must present
/// a SETTLED payment invoice — minted at `/auth/payment-invoice` and paid to the
/// operator's OWN wallet via an external processor (the `btcpay` adapter also
/// serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
/// plugin). Returning wallets bypass it; self-hosting bypasses all gates. Does
/// not derive `Debug` (holds the processor API key).
#[derive(Clone)]
pub struct PaymentConfig {
pub require_payment: bool,
/// Processor adapter kind (currently only `btcpay`).
pub provider: String,
/// Processor base URL (e.g. `https://pay.example.org`).
pub provider_url: String,
/// Processor store/merchant id.
pub store_id: String,
/// Processor API key (create/read invoices). Never logged.
pub api_key: String,
/// Registration price, as a decimal string (no float math end-to-end).
pub amount: String,
/// Price currency (e.g. `XMR`, or a fiat code the processor converts).
pub currency: String,
/// Operator's confirmations-to-finalize; the adapter maps it to the
/// processor's own finality control.
pub confirmations: u32,
/// Invoice lifetime (minutes) before it expires unpaid.
pub expires_minutes: u32,
}
impl Config {
@@ -628,6 +679,17 @@ impl Config {
},
registration: RegistrationConfig {
require_invite: env_bool("REGISTRATION_REQUIRE_INVITE", false),
payment: PaymentConfig {
require_payment: env_bool("REGISTRATION_REQUIRE_PAYMENT", false),
provider: env_or("PAYMENT_PROVIDER", "btcpay").to_lowercase(),
provider_url: env_or("PAYMENT_PROVIDER_URL", ""),
store_id: env_or("PAYMENT_STORE_ID", ""),
api_key: env_or("PAYMENT_API_KEY", ""),
amount: env_or("PAYMENT_AMOUNT", ""),
currency: env_or("PAYMENT_CURRENCY", "").to_uppercase(),
confirmations: env_parse("PAYMENT_CONFIRMATIONS", 1u32)?,
expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?,
},
},
};
@@ -809,6 +871,65 @@ impl Config {
}
}
// Pay-to-register gate: when on, the processor wiring must be complete
// and sane, or /capabilities would advertise payment_required while every
// registration then fails. Fail closed at startup instead.
if self.registration.payment.require_payment {
let p = &self.registration.payment;
if !matches!(p.provider.as_str(), "btcpay") {
return Err(cfg_err(format!(
"PAYMENT_PROVIDER {:?} is not supported; supported: btcpay",
p.provider
)));
}
if p.provider_url.is_empty() {
return Err(cfg_err(
"PAYMENT_PROVIDER_URL is required when REGISTRATION_REQUIRE_PAYMENT is on",
));
}
let url = url::Url::parse(&p.provider_url)
.map_err(|_| cfg_err("PAYMENT_PROVIDER_URL must be an absolute URL"))?;
if prod && url.scheme() != "https" {
return Err(cfg_err("PAYMENT_PROVIDER_URL must be https in production"));
}
if p.store_id.is_empty() {
return Err(cfg_err(
"PAYMENT_STORE_ID is required when REGISTRATION_REQUIRE_PAYMENT is on",
));
}
// Processor API keys are long; 8 is a floor against an empty/typo'd value.
require_secret("PAYMENT_API_KEY", &p.api_key, 8)?;
if !is_positive_decimal(&p.amount) {
return Err(cfg_err(
"PAYMENT_AMOUNT must be a positive decimal (e.g. 0.01) when REGISTRATION_REQUIRE_PAYMENT is on",
));
}
if p.currency.is_empty() {
return Err(cfg_err(
"PAYMENT_CURRENCY is required when REGISTRATION_REQUIRE_PAYMENT is on",
));
}
// 0-conf ("HighSpeed") marks an invoice settled on first mempool
// sighting, which a payer can then double-spend away AFTER registering
// — a settled-then-reversed free registration. An on-chain gate must
// require at least one confirmation. (A genuinely-final 0-conf rail
// like Lightning would be a different provider, not this one.)
if p.confirmations == 0 {
return Err(cfg_err(
"PAYMENT_CONFIRMATIONS must be >= 1 (0-conf lets a settled payment be \
double-spent after registration)",
));
}
// Bound the unpaid-invoice window: 0 never expires, and > BTCPay's own
// max (10080 min = 7 days) both accumulates stale rows and would be
// rejected by the processor at create time.
if p.expires_minutes == 0 || p.expires_minutes > 10080 {
return Err(cfg_err(
"PAYMENT_EXPIRES_MINUTES must be between 1 and 10080 (7 days)",
));
}
}
Ok(())
}
}
@@ -931,10 +1052,36 @@ mod tests {
},
registration: RegistrationConfig {
require_invite: false,
payment: PaymentConfig {
require_payment: false,
provider: "btcpay".into(),
provider_url: String::new(),
store_id: String::new(),
api_key: String::new(),
amount: String::new(),
currency: String::new(),
confirmations: 1,
expires_minutes: 60,
},
},
}
}
/// A fully-wired pay-to-register config (for the require_payment tests).
fn valid_payment() -> PaymentConfig {
PaymentConfig {
require_payment: true,
provider: "btcpay".into(),
provider_url: "https://pay.example.org".into(),
store_id: "store-1".into(),
api_key: "xmrcheckout_abcdefgh".into(),
amount: "0.01".into(),
currency: "XMR".into(),
confirmations: 1,
expires_minutes: 60,
}
}
#[test]
fn valid_config_passes() {
assert!(valid().validate().is_ok());
@@ -1144,4 +1291,84 @@ mod tests {
|| crate::core::restore_pow::verify("xmr", "addr", start, nonce.wrapping_add(1), 8)
);
}
#[test]
fn positive_decimal_rules() {
assert!(is_positive_decimal("0.01"));
assert!(is_positive_decimal("1"));
assert!(is_positive_decimal("10.5"));
assert!(!is_positive_decimal("0")); // zero is not positive
assert!(!is_positive_decimal("0.00")); // still zero
assert!(!is_positive_decimal("")); // empty
assert!(!is_positive_decimal("-1")); // sign not allowed
assert!(!is_positive_decimal("1.2.3")); // two dots
assert!(!is_positive_decimal("abc")); // non-numeric
assert!(!is_positive_decimal("1e5")); // no scientific notation
}
#[test]
fn payment_off_skips_provider_validation() {
let mut c = valid();
c.registration.payment.require_payment = false;
c.registration.payment.provider = "nonsense".into(); // ignored when off
assert!(c.validate().is_ok());
}
#[test]
fn payment_required_with_full_config_passes() {
let mut c = valid();
c.registration.payment = valid_payment();
assert!(c.validate().is_ok());
}
#[test]
fn payment_required_missing_wiring_rejected() {
let mut c = valid();
c.registration.payment.require_payment = true; // url/store/key/amount empty
assert!(c.validate().is_err());
}
#[test]
fn payment_rejects_unsupported_provider() {
let mut c = valid();
c.registration.payment = valid_payment();
c.registration.payment.provider = "stripe".into();
assert!(c.validate().is_err());
}
#[test]
fn payment_rejects_nonpositive_amount() {
let mut c = valid();
c.registration.payment = valid_payment();
c.registration.payment.amount = "0".into();
assert!(c.validate().is_err());
}
#[test]
fn payment_requires_https_provider_url_in_production() {
let mut c = valid();
c.environment = "production".into();
c.registration.payment = valid_payment();
c.registration.payment.provider_url = "http://pay.example.org".into();
assert!(c.validate().is_err());
}
#[test]
fn payment_rejects_zero_confirmations() {
// 0-conf would let a settled payment be double-spent after registration.
let mut c = valid();
c.registration.payment = valid_payment();
c.registration.payment.confirmations = 0;
assert!(c.validate().is_err());
}
#[test]
fn payment_rejects_out_of_range_expiry() {
let mut c = valid();
c.registration.payment = valid_payment();
c.registration.payment.expires_minutes = 0;
assert!(c.validate().is_err(), "0 (never expires) rejected");
c.registration.payment.expires_minutes = 100_000; // > 10080
assert!(c.validate().is_err(), "beyond BTCPay's 7-day max rejected");
}
}
+2
View File
@@ -15,6 +15,7 @@ mod erasure;
mod grin_slatepacks;
mod invite_codes;
mod login_events;
mod payment_invoices;
mod restore_attempts;
mod server_config;
mod sessions;
@@ -24,6 +25,7 @@ mod users;
pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome};
pub use challenges::ConsumedChallenge;
pub use login_events::LoginStats;
pub use payment_invoices::PaymentInvoiceRow;
pub use server_config::SetupState;
use sqlx::PgPool;
+102
View File
@@ -0,0 +1,102 @@
//! Pay-to-register payment-invoice queries — one composable registration gate.
//!
//! Binds a processor invoice id to the registrant `pubkey_hash` it was created
//! for, and enforces **atomic single-use** on redemption: the grant path
//! consumes the row with a single-statement `UPDATE ... WHERE consumed_at IS
//! NULL AND pubkey_hash = $ ... RETURNING`, so two concurrent completions of the
//! same settled invoice can never both grant. Only public data lives here (the
//! opaque invoice id, the identity it binds, the price) — never funds or keys.
use chrono::{DateTime, Utc};
use tracing::instrument;
use crate::error::AppError;
use super::Database;
/// A persisted payment invoice: its binding and single-use state.
#[derive(Debug, Clone)]
pub struct PaymentInvoiceRow {
pub invoice_id: String,
pub pubkey_hash: String,
pub amount: String,
pub currency: String,
/// `None` while unspent; set once when the invoice grants a registration.
pub consumed_at: Option<DateTime<Utc>>,
}
impl Database {
/// Record a freshly-created invoice, bound to `pubkey_hash`. A duplicate
/// `invoice_id` (astronomically unlikely for a processor UUID) is a
/// unique-violation error.
#[instrument(skip(self))]
pub async fn insert_payment_invoice(
&self,
invoice_id: &str,
pubkey_hash: &str,
provider: &str,
amount: &str,
currency: &str,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO payment_invoices (invoice_id, pubkey_hash, provider, amount, currency) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(invoice_id)
.bind(pubkey_hash)
.bind(provider)
.bind(amount)
.bind(currency)
.execute(self.pool())
.await?;
Ok(())
}
/// Fetch an invoice by id (its binding + consumed state), or `None`.
#[instrument(skip(self))]
pub async fn get_payment_invoice(
&self,
invoice_id: &str,
) -> Result<Option<PaymentInvoiceRow>, AppError> {
let row = sqlx::query_as::<_, (String, String, String, String, Option<DateTime<Utc>>)>(
"SELECT invoice_id, pubkey_hash, amount, currency, consumed_at \
FROM payment_invoices WHERE invoice_id = $1",
)
.bind(invoice_id)
.fetch_optional(self.pool())
.await?;
Ok(row.map(
|(invoice_id, pubkey_hash, amount, currency, consumed_at)| PaymentInvoiceRow {
invoice_id,
pubkey_hash,
amount,
currency,
consumed_at,
},
))
}
/// Atomically consume a single-use invoice bound to `pubkey_hash`. Returns
/// `true` iff THIS call consumed a still-unspent, correctly-bound row. The
/// `WHERE consumed_at IS NULL AND pubkey_hash = $` predicate + `RETURNING`
/// make check-and-consume one statement, so concurrent completions race
/// safely — exactly one wins, and an invoice bound to a different identity is
/// never consumable here.
#[instrument(skip(self))]
pub async fn consume_payment_invoice(
&self,
invoice_id: &str,
pubkey_hash: &str,
) -> Result<bool, AppError> {
let consumed = sqlx::query_scalar::<_, String>(
"UPDATE payment_invoices SET consumed_at = NOW() \
WHERE invoice_id = $1 AND pubkey_hash = $2 AND consumed_at IS NULL \
RETURNING invoice_id",
)
.bind(invoice_id)
.bind(pubkey_hash)
.fetch_optional(self.pool())
.await?;
Ok(consumed.is_some())
}
}
+1
View File
@@ -6,4 +6,5 @@ pub mod db;
pub mod electrum;
pub mod grin;
pub mod lws;
pub mod payment;
pub mod prices;
+371
View File
@@ -0,0 +1,371 @@
//! BTCPay-compatible payment adapter.
//!
//! Speaks the BTCPay Greenfield invoice API (`POST/GET
//! /api/v1/stores/{store}/invoices`), which this project's own non-custodial
//! checkout apps (xmrcheckout / wowcheckout) implement, alongside BTCPay Server
//! and its Monero plugin — so one adapter serves every coin those processors
//! support.
//!
//! Response hygiene mirrors the LWS client (the processor may be remote):
//! * one shared `reqwest::Client` with request + connect timeouts;
//! * bodies read through a streaming size cap, then `serde_json::from_slice`
//! (the content-length header is attacker-asserted);
//! * non-success maps to a generic [`AppError::NodeError`] with a static label
//! + status — the response body is never interpolated into a log or error;
//! * the API key rides in a redacting [`Secret`], `skip`-ped from every span.
use std::time::Duration;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::instrument;
use super::{Invoice, InvoiceRequest, InvoiceStatus, PaymentProvider};
use crate::config::PaymentConfig;
use crate::core::secret::Secret;
use crate::error::AppError;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Hard cap on any processor response body (enforced while streaming).
const MAX_BODY_BYTES: usize = 1024 * 1024;
/// A BTCPay-compatible invoice processor (one store).
#[derive(Clone)]
pub struct BtcPayProvider {
/// Base URL, no trailing slash (e.g. `https://pay.example.org`).
base_url: String,
store_id: String,
api_key: Secret,
http: reqwest::Client,
}
impl BtcPayProvider {
/// Build from the pay-to-register config. Assumes the config was validated
/// (non-empty URL/store/key); a malformed HTTP client build fails closed.
pub fn new(cfg: &PaymentConfig) -> Result<Self, AppError> {
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.map_err(|_| AppError::ConfigError("failed to build payment HTTP client".into()))?;
Ok(Self {
base_url: cfg.provider_url.trim_end_matches('/').to_string(),
store_id: cfg.store_id.clone(),
api_key: Secret::new(cfg.api_key.clone()),
http,
})
}
/// Map the operator's numeric confirmations-to-finalize onto BTCPay's
/// coarser `speedPolicy` buckets. BTCPay expresses finality as a policy, not
/// an arbitrary N, so this rounds UP to the nearest bucket (an operator
/// needing an exact/deeper count sets it on the processor merchant default).
fn speed_policy(confirmations: u32) -> &'static str {
match confirmations {
0 => "HighSpeed", // 0-conf
1 => "MediumSpeed", // 1 conf
2..=5 => "LowMediumSpeed", // 2 conf
_ => "LowSpeed", // 6 conf
}
}
/// Map a BTCPay invoice status string onto the neutral status. Unknown /
/// unrecognized values fall to [`InvoiceStatus::Pending`] — fail-safe, since
/// only `Settled` grants a registration.
fn map_status(s: &str) -> InvoiceStatus {
match s {
// Greenfield v1 terminal-paid state, plus legacy synonyms for
// cross-version robustness.
"Settled" | "Complete" | "Confirmed" => InvoiceStatus::Settled,
"Processing" | "Paid" => InvoiceStatus::Detected,
"Expired" => InvoiceStatus::Expired,
"Invalid" => InvoiceStatus::Invalid,
_ => InvoiceStatus::Pending, // "New" and anything unrecognized
}
}
fn to_invoice(resp: BtcPayInvoiceResp) -> Invoice {
let bind = resp
.metadata
.as_ref()
.and_then(|m| m.get("smirkBind"))
.and_then(|v| v.as_str())
.map(str::to_string);
Invoice {
id: resp.id,
// Prefer the hosted checkout link; fall back to any address field.
pay_to: resp.checkout_link.or(resp.address).unwrap_or_default(),
status: Self::map_status(&resp.status),
bind,
}
}
fn node_err(&self, label: &str, detail: &str) -> AppError {
AppError::NodeError(format!("payment processor {label}: {detail}"))
}
/// POST a JSON body and deserialize a size-capped success response.
async fn post_json<B, R>(
&self,
url: String,
label: &'static str,
body: &B,
) -> Result<R, AppError>
where
B: Serialize,
R: DeserializeOwned,
{
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key.expose()))
.json(body)
.send()
.await
.map_err(|_| self.node_err(label, "request failed"))?;
self.parse(resp, label).await
}
/// GET and deserialize a size-capped success response.
async fn get_json<R: DeserializeOwned>(
&self,
url: String,
label: &'static str,
) -> Result<R, AppError> {
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.api_key.expose()))
.send()
.await
.map_err(|_| self.node_err(label, "request failed"))?;
self.parse(resp, label).await
}
async fn parse<R: DeserializeOwned>(
&self,
resp: reqwest::Response,
label: &'static str,
) -> Result<R, AppError> {
if !resp.status().is_success() {
return Err(AppError::NodeError(format!(
"payment processor {label} failed (HTTP {})",
resp.status().as_u16()
)));
}
let bytes = read_capped(resp, MAX_BODY_BYTES).await?;
serde_json::from_slice::<R>(&bytes).map_err(|_| self.node_err(label, "invalid response"))
}
}
#[async_trait::async_trait]
impl PaymentProvider for BtcPayProvider {
fn kind(&self) -> &'static str {
"btcpay"
}
#[instrument(skip(self, req), fields(store = %self.store_id))]
async fn create_invoice(&self, req: &InvoiceRequest) -> Result<Invoice, AppError> {
let url = format!("{}/api/v1/stores/{}/invoices", self.base_url, self.store_id);
let body = BtcPayCreateReq {
amount: &req.amount,
currency: &req.currency,
metadata: serde_json::json!({ "smirkBind": req.bind }),
checkout: BtcPayCheckout {
speed_policy: Self::speed_policy(req.confirmations),
expiration_minutes: req.expires_minutes,
},
};
let resp: BtcPayInvoiceResp = self.post_json(url, "create_invoice", &body).await?;
Ok(Self::to_invoice(resp))
}
#[instrument(skip(self), fields(store = %self.store_id))]
async fn get_invoice(&self, id: &str) -> Result<Invoice, AppError> {
// `id` is always one we minted and stored, but percent-encode the path
// segment anyway so a stray value can never traverse the processor URL.
let enc = urlencoding::encode(id);
let url = format!(
"{}/api/v1/stores/{}/invoices/{}",
self.base_url, self.store_id, enc
);
let resp: BtcPayInvoiceResp = self.get_json(url, "get_invoice").await?;
Ok(Self::to_invoice(resp))
}
}
// ── wire types ────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct BtcPayCreateReq<'a> {
amount: &'a str,
currency: &'a str,
metadata: serde_json::Value,
checkout: BtcPayCheckout,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BtcPayCheckout {
speed_policy: &'static str,
expiration_minutes: u32,
}
/// The subset of a BTCPay invoice we consume. Extra fields are ignored.
#[derive(Deserialize)]
struct BtcPayInvoiceResp {
id: String,
#[serde(default)]
status: String,
/// Hosted checkout page URL (`checkoutLink`).
#[serde(default, rename = "checkoutLink")]
checkout_link: Option<String>,
/// A direct address, if the processor returns one (xmrcheckout native shape).
#[serde(default)]
address: Option<String>,
#[serde(default)]
metadata: Option<serde_json::Value>,
}
/// Read a response body into memory, enforcing `cap` as bytes arrive (the
/// content-length header is attacker-asserted, so it is not trusted).
async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<Vec<u8>, AppError> {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|_| AppError::NodeError("payment processor read failed".into()))?;
if buf.len().saturating_add(chunk.len()) > cap {
return Err(AppError::NodeError(
"payment processor response exceeded size limit".into(),
));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> PaymentConfig {
PaymentConfig {
require_payment: true,
provider: "btcpay".into(),
provider_url: "https://pay.example.org/".into(),
store_id: "store-123".into(),
api_key: "xmrcheckout_secretkey".into(),
amount: "0.01".into(),
currency: "XMR".into(),
confirmations: 1,
expires_minutes: 60,
}
}
#[test]
fn base_url_trailing_slash_trimmed() {
let p = BtcPayProvider::new(&cfg()).unwrap();
assert_eq!(p.base_url, "https://pay.example.org");
}
#[test]
fn api_key_is_redacted_in_debug() {
let p = BtcPayProvider::new(&cfg()).unwrap();
assert_eq!(format!("{:?}", p.api_key), "Secret(***)");
assert!(!format!("{:?}", p.api_key).contains("secretkey"));
}
#[test]
fn speed_policy_buckets() {
assert_eq!(BtcPayProvider::speed_policy(0), "HighSpeed");
assert_eq!(BtcPayProvider::speed_policy(1), "MediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(2), "LowMediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(5), "LowMediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(6), "LowSpeed");
assert_eq!(BtcPayProvider::speed_policy(100), "LowSpeed");
}
#[test]
fn status_map_only_settled_grants() {
assert_eq!(
BtcPayProvider::map_status("Settled"),
InvoiceStatus::Settled
);
assert_eq!(
BtcPayProvider::map_status("Complete"),
InvoiceStatus::Settled
);
assert_eq!(
BtcPayProvider::map_status("Confirmed"),
InvoiceStatus::Settled
);
assert_eq!(
BtcPayProvider::map_status("Processing"),
InvoiceStatus::Detected
);
assert_eq!(
BtcPayProvider::map_status("Expired"),
InvoiceStatus::Expired
);
assert_eq!(
BtcPayProvider::map_status("Invalid"),
InvoiceStatus::Invalid
);
// "New" and anything unrecognized are Pending — never grant.
assert_eq!(BtcPayProvider::map_status("New"), InvoiceStatus::Pending);
assert_eq!(BtcPayProvider::map_status("weird"), InvoiceStatus::Pending);
assert_eq!(BtcPayProvider::map_status(""), InvoiceStatus::Pending);
}
#[test]
fn to_invoice_extracts_bind_and_pay_to() {
let resp: BtcPayInvoiceResp = serde_json::from_value(serde_json::json!({
"id": "inv-1",
"status": "Settled",
"checkoutLink": "https://pay.example.org/i/inv-1",
"metadata": { "smirkBind": "abc123", "orderId": "x" }
}))
.unwrap();
let inv = BtcPayProvider::to_invoice(resp);
assert_eq!(inv.id, "inv-1");
assert_eq!(inv.pay_to, "https://pay.example.org/i/inv-1");
assert_eq!(inv.status, InvoiceStatus::Settled);
assert_eq!(inv.bind.as_deref(), Some("abc123"));
}
#[test]
fn to_invoice_tolerates_missing_optional_fields() {
// No checkoutLink / metadata: address fallback, no bind, safe status.
let resp: BtcPayInvoiceResp = serde_json::from_value(serde_json::json!({
"id": "inv-2",
"status": "New",
"address": "4ADDR"
}))
.unwrap();
let inv = BtcPayProvider::to_invoice(resp);
assert_eq!(inv.pay_to, "4ADDR");
assert_eq!(inv.status, InvoiceStatus::Pending);
assert!(inv.bind.is_none());
}
#[test]
fn create_request_serializes_camelcase_checkout() {
let body = BtcPayCreateReq {
amount: "0.01",
currency: "XMR",
metadata: serde_json::json!({ "smirkBind": "abc" }),
checkout: BtcPayCheckout {
speed_policy: "MediumSpeed",
expiration_minutes: 60,
},
};
let v = serde_json::to_value(&body).unwrap();
assert_eq!(v["amount"], "0.01");
assert_eq!(v["currency"], "XMR");
assert_eq!(v["metadata"]["smirkBind"], "abc");
assert_eq!(v["checkout"]["speedPolicy"], "MediumSpeed");
assert_eq!(v["checkout"]["expirationMinutes"], 60);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Payment-processor seam for the optional pay-to-register registration gate.
//!
//! A pluggable [`PaymentProvider`] the operator wires per instance. It mirrors
//! the chain-provider seam: one NEUTRAL interface, swappable adapters. The gate
//! logic ([`crate::api::auth`]) is processor-agnostic — it only creates an
//! invoice and reads its status; nothing in the auth path knows about BTCPay.
//!
//! The first adapter ([`btcpay`]) speaks the BTCPay-compatible invoice + status
//! API, which covers this project's own non-custodial checkout apps
//! (xmrcheckout / wowcheckout), BTCPay Server, and its Monero plugin — so
//! "operator picks any coin" costs one adapter. A future ETH or native adapter
//! (BTCPay serves neither) implements the SAME trait; the seam is not
//! BTCPay-shaped.
//!
//! ## Trust model
//! The processor is the OPERATOR's own self-hosted, view-only service; the payer
//! pays the operator's wallet directly. The backend never holds funds or a spend
//! key — identical to every other chain path here. Self-hosting a backend
//! bypasses the gate outright.
//!
//! ## Pull, not push (v1)
//! The grant decision READS the processor's authenticated API
//! ([`PaymentProvider::get_invoice`]) as the source of truth, rather than
//! trusting an inbound webhook. There is no public webhook endpoint to secure,
//! and a settled invoice cannot be forged over HTTP. A push (webhook) adapter
//! can be added behind this same trait later without touching the gate.
mod btcpay;
pub use btcpay::BtcPayProvider;
use std::sync::Arc;
use async_trait::async_trait;
use crate::config::Config;
use crate::error::AppError;
/// A processor-neutral invoice status. Adapters map their processor's status
/// vocabulary onto this; the gate only grants on [`InvoiceStatus::Settled`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvoiceStatus {
/// Created; no sufficient payment detected yet (never grants).
Pending,
/// Payment seen, but not yet at the operator's required finality (never grants).
Detected,
/// Paid IN FULL and finalized to the operator's confirmation policy — the
/// ONLY status that grants a registration.
///
/// ADAPTER CONTRACT: map a processor status to `Settled` **only** when the
/// invoice is paid in full (never on a partial / tolerance / underpayment).
/// The gate does not re-verify the paid amount — it trusts this invariant,
/// because the amount is server-set at mint and the invoice row is the
/// binding anchor. (BTCPay upholds it: it reports `Settled` only on full
/// payment.) A future underpayment-settling processor MUST enforce the amount
/// in its adapter before returning `Settled`.
Settled,
/// Expired unpaid, paid too late, or voided (never grants).
Expired,
/// Marked invalid by the processor/operator (never grants).
Invalid,
}
/// A request to create a registration-payment invoice.
pub struct InvoiceRequest {
/// Price to charge, as a decimal string (no float math end-to-end).
pub amount: String,
/// Currency of `amount` (e.g. `"XMR"`, or a fiat code the processor converts).
pub currency: String,
/// The operator's confirmations-to-finalize. The adapter maps it onto the
/// processor's own finality control (BTCPay `speedPolicy`, etc.); a future
/// zero-conf rail (Lightning) simply ignores it.
pub confirmations: u32,
/// The registrant identity this invoice binds to (hex `pubkey_hash`). Set as
/// processor-side metadata for traceability; the binding OF RECORD is the
/// `payment_invoices` row the caller writes.
pub bind: String,
/// Invoice lifetime (minutes) before it expires unpaid.
pub expires_minutes: u32,
}
/// A processor-neutral invoice.
#[derive(Debug, Clone)]
pub struct Invoice {
/// The processor's opaque invoice id — the binding key the caller persists.
pub id: String,
/// Where the payer sends funds (a hosted checkout URL, or an address).
pub pay_to: String,
pub status: InvoiceStatus,
/// The identity echoed back from processor metadata, if present.
/// Defense-in-depth only; the `payment_invoices` row is the binding authority.
pub bind: Option<String>,
}
/// A pluggable, non-custodial payment processor backing the pay-to-register gate.
#[async_trait]
pub trait PaymentProvider: Send + Sync {
/// The adapter kind, persisted on the invoice row (e.g. `"btcpay"`).
fn kind(&self) -> &'static str;
/// Create an invoice for `req`. The returned [`Invoice::id`] is the binding
/// key the caller must persist.
async fn create_invoice(&self, req: &InvoiceRequest) -> Result<Invoice, AppError>;
/// Fetch an invoice's current state from the processor — the source of truth
/// for the grant decision.
async fn get_invoice(&self, id: &str) -> Result<Invoice, AppError>;
}
/// Build the configured payment provider, or `None` when the pay-to-register
/// gate is off. Fail-closed: an unknown provider kind aborts startup (config
/// validation already rejects it when the gate is on, so this is defensive).
pub fn from_config(cfg: &Config) -> Result<Option<Arc<dyn PaymentProvider>>, AppError> {
let p = &cfg.registration.payment;
if !p.require_payment {
return Ok(None);
}
let provider: Arc<dyn PaymentProvider> = match p.provider.as_str() {
"btcpay" => Arc::new(BtcPayProvider::new(p)?),
other => {
return Err(AppError::ConfigError(format!(
"unsupported payment provider: {other}"
)))
}
};
Ok(Some(provider))
}
+3
View File
@@ -59,6 +59,9 @@ pub struct AppState {
pub sessions: SessionManager,
/// Per-chain data-source clients (present only for enabled chains).
pub chains: ChainClients,
/// Payment processor backing the optional pay-to-register gate; `Some` only
/// when `REGISTRATION_REQUIRE_PAYMENT` is on.
pub payment: Option<Arc<dyn crate::infra::payment::PaymentProvider>>,
/// In-memory website-auth challenges, keyed by nonce. Single-node store;
/// a shared/stateless variant is the load-balanced-fleet path.
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
+3
View File
@@ -36,6 +36,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
let chains = ChainClients::from_config(&config)?;
// Payment processor for the pay-to-register gate (None unless it's enabled).
let payment = smirk_backend_core::infra::payment::from_config(&config)?;
// First-run bootstrap latch (operator §3.2). Only meaningful with the admin
// surface enabled. Fail closed on a tampered latch (restore-to-pre-bootstrap).
@@ -87,6 +89,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
db,
sessions,
chains,
payment,
web_challenges: Arc::default(),
prices: prices_cache,
admin_sessions,