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
+23 -1
View File
@@ -31,9 +31,31 @@ ALTCHA_COST=100000
# Require an operator-minted invite code to create a NEW wallet. Mint codes with
# `smirk-admin mint-invite --count <n>`. Returning wallets bypass it, and
# self-hosting bypasses all gates (run your own backend). PoW is the ALTCHA gate
# above; pay-to-register is planned. Compose freely (e.g. invite + PoW).
# above. Compose freely (e.g. invite + PoW + pay-to-register).
REGISTRATION_REQUIRE_INVITE=false
# Pay-to-register: require a SETTLED payment invoice to create a new wallet. The
# invoice is minted at POST /auth/payment-invoice and paid — directly to YOUR own
# wallet — via an external, non-custodial processor. The `btcpay` adapter speaks
# the BTCPay-compatible invoice API, which also covers this project's xmrcheckout
# /wowcheckout apps and the BTCPay Monero plugin, so you pick the coin by pointing
# at the processor that accepts it. The backend only READS invoice status (pull
# model) — it never holds funds or keys. Self-hosting bypasses this (leave off).
REGISTRATION_REQUIRE_PAYMENT=false
PAYMENT_PROVIDER=btcpay
# Processor base URL, store/merchant id, and an API key that can create + read
# invoices (held redacted; never logged). Required when the gate is on.
PAYMENT_PROVIDER_URL=https://pay.example.org
PAYMENT_STORE_ID=
PAYMENT_API_KEY=
# Registration price + currency ('XMR', or a fiat code the processor converts).
PAYMENT_AMOUNT=0.01
PAYMENT_CURRENCY=XMR
# Confirmations-to-finalize (mapped to the processor's speed policy), and how long
# an unpaid invoice stays payable before it expires.
PAYMENT_CONFIRMATIONS=1
PAYMENT_EXPIRES_MINUTES=60
# ── Bitcoin ───────────────────────────────────────────────────────────────
BTC_NETWORK=mainnet
BTC_USE_LOCAL_NODE=false
@@ -0,0 +1,30 @@
-- Pay-to-register: a per-registration payment invoice created via an external,
-- non-custodial payment processor (the BTCPay-compatible adapter covers this
-- project's own xmrcheckout/wowcheckout apps, BTCPay Server, and its Monero
-- plugin). One composable registration gate alongside PoW and invite codes.
--
-- The backend is a THIN client: it creates the invoice on the operator's
-- processor, binds it here to the registrant's `pubkey_hash`, and — in the pull
-- model — grants the registration only after reading `Settled` from the
-- processor's authenticated API and ATOMICALLY consuming this row (single-use).
--
-- No funds and no view/spend keys ever touch this table: only the processor's
-- opaque invoice id, the identity the invoice is bound to, and the price we
-- asked for. The payer pays the OPERATOR's own wallet directly (view-only
-- detection on the processor side), so this stays non-custodial like every other
-- chain path. Self-hosting bypasses the gate entirely (leave the gate off).
--
-- `invoice_id` is the processor's id, NOT attacker-chosen; the row is the
-- binding authority (the processor-side metadata bind is only defense-in-depth).
CREATE TABLE payment_invoices (
invoice_id TEXT PRIMARY KEY, -- processor's opaque invoice id
pubkey_hash TEXT NOT NULL, -- registrant identity bound to it (hex sha256 of the BTC pubkey)
provider TEXT NOT NULL, -- adapter kind that minted it (e.g. 'btcpay')
amount TEXT NOT NULL, -- operator-configured price, as a decimal string (no float math)
currency TEXT NOT NULL, -- price currency ('XMR', or a fiat code the processor converts)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
consumed_at TIMESTAMPTZ -- NULL = unspent; set once, atomically, when it grants a registration
);
-- Binding lookup on completion: a registrant's still-unspent invoices.
CREATE INDEX idx_payment_invoices_unspent ON payment_invoices (pubkey_hash) WHERE consumed_at IS NULL;
+105 -1
View File
@@ -239,6 +239,44 @@
]
}
},
"/auth/payment-invoice": {
"post": {
"tags": [
"auth"
],
"summary": "Create a registration-payment invoice on the operator's processor.",
"description": "Public (pre-registration) and deliberately cheap: it only binds an invoice to\nthe wallet's `pubkey_hash`, which is worthless to anyone who cannot later\nprove control of that BTC key at `/auth/extension`. The unauthenticated-surface\nrate limiter bounds invoice spam and unpaid invoices expire on the processor,\nso no signature proof is required here. Returns 400 when the pay gate is off\nor the wallet is already registered (it would bypass payment anyway).",
"operationId": "payment_invoice",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaymentInvoiceRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Payment invoice created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaymentInvoiceResponse"
}
}
}
},
"400": {
"description": "Pay gate off, already registered, or invalid key"
},
"503": {
"description": "Payment processor unavailable"
}
}
}
},
"/auth/pow-challenge": {
"post": {
"tags": [
@@ -2118,6 +2156,13 @@
},
"description": "Public keys for each supported asset. A `btc` key is required (it is the\nidentity)."
},
"payment_invoice_id": {
"type": [
"string",
"null"
],
"description": "Settled payment-invoice id from `/auth/payment-invoice`. Required when the\ninstance enables the pay-to-register gate (see `/capabilities` →\n`registration.payment_required`); ignored otherwise and for returning\nwallets. Its settlement is verified against the processor and the invoice\natomically consumed (single-use) before the wallet is granted."
},
"rotation_signature": {
"type": [
"string",
@@ -2719,6 +2764,47 @@
}
}
},
"PaymentInvoiceRequest": {
"type": "object",
"description": "Request a registration-payment invoice for a wallet about to register.",
"required": [
"btc_public_key"
],
"properties": {
"btc_public_key": {
"type": "string",
"description": "The wallet's BTC public key. Its hash is the identity the invoice binds\nto; only a later `/auth/extension` proving control of this key can redeem\nthe settled invoice, so this endpoint needs no separate proof."
}
}
},
"PaymentInvoiceResponse": {
"type": "object",
"description": "A created payment invoice: what to pay, and the id to present at registration.",
"required": [
"invoice_id",
"pay_to",
"amount",
"currency"
],
"properties": {
"amount": {
"type": "string",
"description": "Price to pay."
},
"currency": {
"type": "string",
"description": "Price currency."
},
"invoice_id": {
"type": "string",
"description": "Opaque invoice id — send it back as `/auth/extension`'s `payment_invoice_id`."
},
"pay_to": {
"type": "string",
"description": "Where to pay (a hosted checkout URL, or an address)."
}
}
},
"PendingRelaysResponse": {
"type": "object",
"required": [
@@ -2947,13 +3033,31 @@
"description": "Registration gates this instance enforces for a NEW wallet (returning wallets\nand self-hosting bypass them). The wallet uses these to shape onboarding —\nprompt for an invite code, solve PoW, etc.",
"required": [
"invite_required",
"pow_required"
"pow_required",
"payment_required"
],
"properties": {
"invite_required": {
"type": "boolean",
"description": "A valid operator-minted invite code is required to register."
},
"payment_amount": {
"type": [
"string",
"null"
],
"description": "The registration price + currency — present only when `payment_required`,\nso the wallet can show \"registration costs X\" before minting an invoice."
},
"payment_currency": {
"type": [
"string",
"null"
]
},
"payment_required": {
"type": "boolean",
"description": "A settled payment invoice (from `/auth/payment-invoice`) is required to\nregister a new wallet."
},
"pow_required": {
"type": "boolean",
"description": "A proof-of-work solution is required to register."
+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,
+3
View File
@@ -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,
+164
View File
@@ -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"
);
}