security(auth): make gated registration fully atomic (F3/F4 fast-follow)

The prior fixes closed the common cases with pre-checks; this closes the
residual races by consuming the single-use gate token(s) AND creating the
user AND persisting keys in ONE transaction, so a failure never burns a
token without an account.

- New Database::create_user_consuming_gates: claims the invite + consumes
  the payment invoice + inserts the user + upserts keys inside a tx;
  rolls the whole thing back on a username collision (mapped to 409) or a
  raced/already-spent token.
- New plan_gate_consume: resolves which tokens to spend WITHOUT consuming
  (the payment settled-check stays non-consuming), so `all`-mode verifies
  the payment is settled before anything is spent, then both tokens are
  consumed atomically at creation.
- extension_register + nostr_register route NEW users through the atomic
  path (returning users keep the ordinary backfill path); the npub F2
  fail-closed check runs before the tx. Removed the now-dead
  enforce_method_gates / enforce_invite / enforce_payment.
- tests/registration_atomicity.rs proves the rollback: a username
  collision keeps the invite; an already-spent payment keeps the invite.

Validated against Postgres: full suite 286 passed / 0 failed.
This commit is contained in:
jwinterm
2026-07-06 05:58:04 -04:00
parent c802f416cd
commit b32b8a9059
3 changed files with 456 additions and 215 deletions
+192 -214
View File
@@ -56,7 +56,7 @@ use crate::core::crypto::nip98::{
use crate::core::crypto::signatures::verify_bitcoin_signature; use crate::core::crypto::signatures::verify_bitcoin_signature;
use crate::core::session::{hash_refresh_token, Platform}; use crate::core::session::{hash_refresh_token, Platform};
use crate::error::AppError; use crate::error::AppError;
use crate::models::db::{AssetType, NewSession, NewUserKey}; use crate::models::db::{AssetType, NewSession, NewUser, NewUserKey};
use crate::AppState; use crate::AppState;
// ── shared constants ──────────────────────────────────────────────────────── // ── shared constants ────────────────────────────────────────────────────────
@@ -131,6 +131,14 @@ fn parse_asset(asset: &str) -> Result<AssetType, AppError> {
} }
} }
/// Parse the wire key list into the `(AssetType, public_key, public_spend_key)`
/// tuples the atomic-registration DB path binds directly. A bad asset is a 400.
fn parse_keys(keys: &[AssetPublicKey]) -> Result<Vec<(AssetType, String, Option<String>)>, AppError> {
keys.iter()
.map(|k| Ok((parse_asset(&k.asset)?, k.public_key.clone(), k.public_spend_key.clone())))
.collect()
}
/// Validate a reserved username (3-32 chars, `[a-z0-9_]`, no leading/trailing `_`). /// Validate a reserved username (3-32 chars, `[a-z0-9_]`, no leading/trailing `_`).
fn validate_username(username: &str) -> Result<(), AppError> { fn validate_username(username: &str) -> Result<(), AppError> {
if username.len() < 3 || username.len() > 32 { if username.len() < 3 || username.len() > 32 {
@@ -483,24 +491,9 @@ pub async fn extension_register(
returning_by_pubkey, returning_by_pubkey,
req.altcha_solution.as_ref(), req.altcha_solution.as_ref(),
)?; )?;
// Reject a taken username BEFORE consuming any single-use gate token. // Never attach a seed_fingerprint that already belongs to ANOTHER user (it
ensure_username_available(&state, returning_by_pubkey, req.username.as_deref()).await?; // would collide on the UNIQUE or hijack the lookup); a returning user only
// Invite + payment gates, composed per the operator's gate mode (`all` = // backfills NULLs anyway. Used as the new row's fingerprint below.
// every enabled gate; `any` = one-of). PoW above is orthogonal to both.
enforce_method_gates(
&state,
returning_by_pubkey,
&pubkey_hash,
req.invite_code.as_deref(),
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
// constraint would either collide or hijack the lookup. Only attach the
// fingerprint when it is genuinely unknown, or when this is the same pubkey
// (a plain returning user, where get_or_create only backfills NULLs anyway).
let fingerprint_for_row = match &req.seed_fingerprint { let fingerprint_for_row = match &req.seed_fingerprint {
Some(fp) => { Some(fp) => {
let belongs_to_other = let belongs_to_other =
@@ -514,27 +507,54 @@ pub async fn extension_register(
None => None, None => None,
}; };
let user = state let (user, is_new) = if returning_by_pubkey {
.db // Returning wallet: no gate token consumed. Backfill optionals + rotate keys
.get_or_create_user_by_pubkey_hash( // through the ordinary path.
let user = state
.db
.get_or_create_user_by_pubkey_hash(
&pubkey_hash,
req.username.clone(),
wallet_birthday,
fingerprint_for_row,
req.xmr_start_height,
req.wow_start_height,
)
.await?;
upsert_all_keys(&state, user.id, &req.keys).await?;
(user, false)
} else {
// NEW wallet: reject a taken username up front (fast, friendly), then consume
// the gate token(s) + create the user + persist keys ATOMICALLY. A username
// collision or a raced token rolls the whole tx back, so a paid invoice /
// invite is never burned without an account being created.
ensure_username_available(&state, false, req.username.as_deref()).await?;
let gates = plan_gate_consume(
&state,
&pubkey_hash, &pubkey_hash,
req.username.clone(), req.invite_code.as_deref(),
wallet_birthday, req.payment_invoice_id.as_deref(),
fingerprint_for_row,
req.xmr_start_height,
req.wow_start_height,
) )
.await?; .await?;
let user = state
// Derive is_new from the resolved row, not the pre-read: a concurrent .db
// first-registration race is settled in the DB. `created_at == updated_at` .create_user_consuming_gates(
// on a freshly-inserted row and diverges on the COALESCE backfill update or gates.invite_code_hash.as_deref(),
// any later write, so it is a reliable "created just now" marker. gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())),
let is_new = user.created_at == user.updated_at && !returning_by_pubkey; NewUser {
username: req.username.clone(),
// Upsert keys unconditionally: idempotent, and this honors asset-key rotation pubkey_hash: Some(pubkey_hash.clone()),
// when a returning user re-registers with an updated key. nostr_pubkey: None,
upsert_all_keys(&state, user.id, &req.keys).await?; wallet_birthday,
seed_fingerprint: fingerprint_for_row,
xmr_start_height: req.xmr_start_height,
wow_start_height: req.wow_start_height,
},
&parse_keys(&req.keys)?,
)
.await?;
(user, true)
};
if is_new { if is_new {
info!(user_id = %user.id, num_keys = req.keys.len(), "registered new extension user"); info!(user_id = %user.id, num_keys = req.keys.len(), "registered new extension user");
@@ -614,74 +634,6 @@ fn enforce_pow(
Ok(()) Ok(())
} }
/// Compose the invite + payment gates per the operator's gate mode. PoW is
/// enforced separately (orthogonal) and always applies on top. Returning wallets
/// and self-hosting (both gates off) pass trivially.
///
/// - `All` (default): every ENABLED gate must pass, each consuming its own token
/// — the historical conjunctive behavior (invite AND payment when both on).
/// - `Any`: the enabled gates are ALTERNATIVES. With a single gate on it is just
/// that gate. With BOTH on, the client presents exactly ONE method's credential
/// and only that gate runs (and only it consumes its single-use token).
/// Presenting BOTH is rejected outright — a one-of choice must never burn two
/// tokens (an invite AND a paid invoice) in a single request.
///
/// Dispatching to a single gate's own `enforce_*` preserves its exact errors,
/// including the payment "not yet confirmed" literal the client polls on.
async fn enforce_method_gates(
state: &AppState,
returning: bool,
pubkey_hash: &str,
invite_code: Option<&str>,
payment_invoice_id: Option<&str>,
) -> Result<(), AppError> {
if returning {
return Ok(());
}
let has_invite = invite_code.map(str::trim).is_some_and(|c| !c.is_empty());
let has_payment = payment_invoice_id
.map(str::trim)
.is_some_and(|s| !s.is_empty());
match plan_gates(
state.config.registration.gate_mode,
state.config.registration.require_invite,
state.config.registration.payment.require_payment,
has_invite,
has_payment,
) {
GatePlan::Open => Ok(()),
// Conjunction: both enabled gates must pass (each self-disables when its
// own flag is off). Verify the payment gate is redeemable (settled,
// NON-consuming) BEFORE consuming the single-use invite, so a payment that
// is still confirming — the common poll state — can never burn the invite.
// Residual: a concurrent double-spend of the invoice between this check and
// the consume below could still burn the invite; that window is far
// narrower than the "still confirming" case. Full atomicity would require
// consuming both tokens + the user INSERT in one shared DB transaction.
GatePlan::All => {
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?;
enforce_invite(state, returning, invite_code).await?;
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await?;
Ok(())
}
// Exactly ONE gate runs (and only it consumes its single-use token),
// preserving that gate's own error literals.
GatePlan::Invite => enforce_invite(state, returning, invite_code).await,
GatePlan::Payment => {
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await
}
GatePlan::RejectBothPresented => Err(AppError::ValidationError(
"Present exactly one registration method, not both.".into(),
)),
GatePlan::RequireOne => Err(AppError::ValidationError(
"Registration on this instance requires an invite code or a settled payment. \
Choose one and retry."
.into(),
)),
}
}
/// What to do about the invite + payment gates for a NEW wallet. The pure /// What to do about the invite + payment gates for a NEW wallet. The pure
/// decision (no db / async), extracted so the fund-critical one-of dispatch is /// decision (no db / async), extracted so the fund-critical one-of dispatch is
/// unit-testable. PoW is handled separately (orthogonal). /// unit-testable. PoW is handled separately (orthogonal).
@@ -733,87 +685,83 @@ fn plan_gates(
} }
} }
/// Invite-code registration gate. A genuinely new wallet must present a valid, /// The single-use tokens a NEW registration must consume, resolved from the gate
/// unused operator-minted invite code when the instance requires one; a /// config WITHOUT spending anything. Feeds [`Database::create_user_consuming_gates`],
/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an /// which consumes these AND creates the user in one transaction — so a username
/// instance with the gate off accepts everyone. The claim is atomic and /// collision or a raced token never burns a paid invoice / invite.
/// single-use (db layer), so a code is redeemable by at most one registration struct GatedRegistration {
/// even under concurrent submission. Self-hosting bypasses this entirely /// Hash of the invite code to claim, when the invite gate applies.
/// (operators simply leave the gate off). invite_code_hash: Option<String>,
async fn enforce_invite( /// `(invoice_id, pubkey_hash)` to consume, when the payment gate applies.
state: &AppState, payment: Option<(String, String)>,
returning: bool,
invite_code: Option<&str>,
) -> Result<(), AppError> {
if returning || !state.config.registration.require_invite {
return Ok(());
}
let code = invite_code
.map(str::trim)
.filter(|c| !c.is_empty())
.ok_or_else(|| {
warn!(
gate = "invite",
"invite required for new wallet but none supplied"
);
AppError::ValidationError(
"An invite code is required to register on this instance.".into(),
)
})?;
if !state
.db
.claim_invite_code(&crate::core::invite::hash_invite_code(code))
.await?
{
// One literal for "no such code", "already used", and "expired" — never
// an oracle that distinguishes them.
return Err(AppError::ValidationError(
"Invalid or already-used invite code.".into(),
));
}
info!(gate = "invite", "invite code accepted (new user)");
Ok(())
} }
/// Pay-to-register gate (PULL model). A genuinely new wallet must present the id /// Require an invite code and return its hash, erroring if the gate needs one but
/// of a payment invoice — minted at [`payment_invoice`] and paid to the /// none was supplied (the presence check + literal the old `enforce_invite` used).
/// operator's own wallet via the configured processor — that THIS backend reads fn require_invite_hash(invite_code: Option<&str>) -> Result<String, AppError> {
/// back as `Settled`. Returning wallets bypass it, an instance with the gate off let code = invite_code.map(str::trim).filter(|c| !c.is_empty()).ok_or_else(|| {
/// accepts everyone, and self-hosting bypasses it (leave the gate off). AppError::ValidationError("An invite code is required to register on this instance.".into())
/// })?;
/// The grant decision reads the processor's authenticated API as the source of Ok(crate::core::invite::hash_invite_code(code))
/// 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> {
// Verify (non-consuming) then consume. Callers that must confirm the gate is
// redeemable BEFORE committing ANOTHER single-use token (the `All` gate plan
// pairs this with the invite) reuse `verify_payment_settled` first so a
// still-confirming payment can never cost the invite.
let Some(id) =
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?
else {
return Ok(());
};
// Atomic single-use consume: exactly one registration per invoice, even under /// Plan (do NOT consume) the gate tokens for a genuinely-NEW wallet, mirroring the
// a concurrent race (the loser gets the already-used literal). /// pure `plan_gates` decision. The payment invoice's settled-check runs here and is
if !state.db.consume_payment_invoice(&id, pubkey_hash).await? { /// non-consuming, so a still-confirming payment errors BEFORE any token is spent
return Err(AppError::ValidationError( /// (an invite is never burned on a pending payment — the F3 invariant, now made
"This payment invoice has already been used.".into(), /// atomic by consuming both tokens together at creation). Returns the same
)); /// caller-facing errors the per-gate enforcers did.
async fn plan_gate_consume(
state: &AppState,
pubkey_hash: &str,
invite_code: Option<&str>,
payment_invoice_id: Option<&str>,
) -> Result<GatedRegistration, AppError> {
let has_invite = invite_code.map(str::trim).is_some_and(|c| !c.is_empty());
let has_payment = payment_invoice_id
.map(str::trim)
.is_some_and(|s| !s.is_empty());
match plan_gates(
state.config.registration.gate_mode,
state.config.registration.require_invite,
state.config.registration.payment.require_payment,
has_invite,
has_payment,
) {
GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }),
GatePlan::All => {
let invite_code_hash = if state.config.registration.require_invite {
Some(require_invite_hash(invite_code)?)
} else {
None
};
Ok(GatedRegistration {
invite_code_hash,
payment: verify_payment_settled(state, false, pubkey_hash, payment_invoice_id)
.await?
.map(|id| (id, pubkey_hash.to_string())),
})
}
GatePlan::Invite => Ok(GatedRegistration {
invite_code_hash: Some(require_invite_hash(invite_code)?),
payment: None,
}),
GatePlan::Payment => Ok(GatedRegistration {
invite_code_hash: None,
payment: verify_payment_settled(state, false, pubkey_hash, payment_invoice_id)
.await?
.map(|id| (id, pubkey_hash.to_string())),
}),
GatePlan::RejectBothPresented => Err(AppError::ValidationError(
"Present exactly one registration method, not both.".into(),
)),
GatePlan::RequireOne => Err(AppError::ValidationError(
"Registration on this instance requires an invite code or a settled payment. \
Choose one and retry."
.into(),
)),
} }
info!(
gate = "payment",
"payment invoice settled + consumed (new user)"
);
Ok(())
} }
/// Non-consuming half of the pay-to-register gate: confirm the invoice exists, /// Non-consuming half of the pay-to-register gate: confirm the invoice exists,
@@ -1710,43 +1658,73 @@ pub async fn nostr_register(
// invoices bind to sha256(btc pubkey), so the payment check still uses the // invoices bind to sha256(btc pubkey), so the payment check still uses the
// BTC key carried in `keys` even though it is no longer the identity. // BTC key carried in `keys` even though it is no longer the identity.
enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?; enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?;
// Reject a taken username BEFORE consuming any single-use gate token. // 5. Resolve the user. Returning npub: backfill optionals + rotate keys. New
ensure_username_available(&state, returning, req.username.as_deref()).await?; // npub: consume the gate token(s) + create the user + persist keys (tip
let btc_hash = req // addresses / restore anchors) ATOMICALLY, so a username collision or a raced
.keys // token never burns a paid invoice / invite without an account.
.iter()
.find(|k| k.asset.eq_ignore_ascii_case("btc"))
.map(|k| hash_public_key(&k.public_key));
enforce_method_gates(
&state,
returning,
btc_hash.as_deref().unwrap_or(&pubkey),
req.invite_code.as_deref(),
req.payment_invoice_id.as_deref(),
)
.await?;
// 5. Get-or-create by npub (seed_fingerprint MERGE dedup inside).
let wallet_birthday = req let wallet_birthday = req
.wallet_birthday .wallet_birthday
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
let user = state let user = if returning {
.db let user = state
.get_or_create_user_by_nostr_pubkey( .db
&pubkey, .get_or_create_user_by_nostr_pubkey(
req.username.clone(), &pubkey,
wallet_birthday, req.username.clone(),
req.seed_fingerprint.clone(), wallet_birthday,
req.xmr_start_height, req.seed_fingerprint.clone(),
req.wow_start_height, req.xmr_start_height,
req.wow_start_height,
)
.await?;
upsert_all_keys(&state, user.id, &req.keys).await?;
user
} else {
// Fail closed: never bind an npub onto a pre-existing wallet on a
// seed_fingerprint match alone (this endpoint proves only npub control) —
// link via the authenticated /auth/nostr/link flow instead.
if let Some(ref fp) = req.seed_fingerprint {
if state.db.get_user_by_seed_fingerprint(fp).await?.is_some() {
return Err(AppError::Conflict(
"This wallet already has an account. Sign in with your existing \
credentials and link your Nostr identity from settings."
.into(),
));
}
}
ensure_username_available(&state, false, req.username.as_deref()).await?;
// Pay-to-register invoices bind to sha256(btc pubkey), so the payment gate
// keys on the carried BTC key even though the npub is the identity.
let btc_hash = req
.keys
.iter()
.find(|k| k.asset.eq_ignore_ascii_case("btc"))
.map(|k| hash_public_key(&k.public_key));
let gates = plan_gate_consume(
&state,
btc_hash.as_deref().unwrap_or(&pubkey),
req.invite_code.as_deref(),
req.payment_invoice_id.as_deref(),
) )
.await?; .await?;
state
// 5b. Persist the submitted chain keys (tip addresses + restore anchors). .db
// The BTC path does this at registration; the npub path previously .create_user_consuming_gates(
// skipped it, leaving user_keys empty so receive-by-username lookups and gates.invite_code_hash.as_deref(),
// restore self-verification broke for npub-native wallets. Idempotent. gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())),
upsert_all_keys(&state, user.id, &req.keys).await?; NewUser {
username: req.username.clone(),
pubkey_hash: None,
nostr_pubkey: Some(pubkey.clone()),
wallet_birthday,
seed_fingerprint: req.seed_fingerprint.clone(),
xmr_start_height: req.xmr_start_height,
wow_start_height: req.wow_start_height,
},
&parse_keys(&req.keys)?,
)
.await?
};
// 6. Session. // 6. Session.
let pair = issue_session( let pair = issue_session(
+132 -1
View File
@@ -9,10 +9,29 @@ use tracing::instrument;
use uuid::Uuid; use uuid::Uuid;
use crate::error::AppError; use crate::error::AppError;
use crate::models::db::{NewUser, User}; use crate::models::db::{AssetType, NewUser, User};
use super::{unique_violation_as, Database}; use super::{unique_violation_as, Database};
/// Map a UNIQUE violation on the registration INSERT to a clean 409 the client can
/// act on — a taken username vs the rarer concurrent same-identity race. Any other
/// database error passes through unchanged (a generic 500; never raw SQL).
fn registration_conflict(e: sqlx::Error) -> AppError {
if let sqlx::Error::Database(ref db) = e {
if db.is_unique_violation() {
if db.constraint().unwrap_or_default().contains("username") {
return AppError::Conflict(
"That username is already taken. Choose another.".into(),
);
}
return AppError::Conflict(
"This wallet is already being registered. Please retry.".into(),
);
}
}
AppError::from(e)
}
/// Explicit `users` columns (matches `User` field names; FromRow maps by name). /// Explicit `users` columns (matches `User` field names; FromRow maps by name).
const USER_COLS: &str = "id, username, pubkey_hash, nostr_pubkey, wallet_birthday, \ const USER_COLS: &str = "id, username, pubkey_hash, nostr_pubkey, wallet_birthday, \
seed_fingerprint, xmr_start_height, wow_start_height, created_at, updated_at, last_seen_at"; seed_fingerprint, xmr_start_height, wow_start_height, created_at, updated_at, last_seen_at";
@@ -49,6 +68,118 @@ impl Database {
Ok(user) Ok(user)
} }
/// Atomically consume the gate token(s) AND create the user AND persist their
/// chain keys in ONE transaction. If any step fails — a raced invite / invoice,
/// a username collision, a concurrent same-identity insert — the WHOLE tx rolls
/// back, so a single-use token (a paid invoice, an invite) is never burned
/// without a user being created. This closes the consume-then-fail windows the
/// separate per-gate consumes left open.
///
/// For genuinely-NEW users only: returning users bypass the gates and consume
/// nothing. `invite_code_hash` / `payment` name the single-use tokens to spend.
/// The payment invoice's settled-check is non-consuming and already done by the
/// caller; the invite has no non-consuming check, so it is claimed here and a
/// failed claim rolls the tx back with the caller-facing literal.
#[instrument(skip(self, new_user, keys))]
pub async fn create_user_consuming_gates(
&self,
invite_code_hash: Option<&str>,
payment: Option<(&str, &str)>,
new_user: NewUser,
keys: &[(AssetType, String, Option<String>)],
) -> Result<User, AppError> {
let mut tx = self.pool().begin().await?;
// 1. Claim the invite (single-use) inside the tx.
if let Some(hash) = invite_code_hash {
let claimed = sqlx::query_scalar::<_, String>(
"UPDATE invite_codes SET used_at = NOW() \
WHERE code_hash = $1 AND used_at IS NULL \
AND (expires_at IS NULL OR expires_at > NOW()) \
RETURNING code_hash",
)
.bind(hash)
.fetch_optional(&mut *tx)
.await?;
if claimed.is_none() {
tx.rollback().await?;
return Err(AppError::ValidationError(
"Invalid or already-used invite code.".into(),
));
}
}
// 2. Consume the payment invoice (single-use) inside the tx. If this loses a
// concurrent race the tx rolls back, so the invite claimed above is NOT
// burned — the money-safety invariant for `all`-mode conjunction.
if let Some((invoice_id, pubkey_hash)) = payment {
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(&mut *tx)
.await?;
if consumed.is_none() {
tx.rollback().await?;
return Err(AppError::ValidationError(
"This payment invoice has already been used.".into(),
));
}
}
// 3. Create the user inside the tx. A UNIQUE violation (a taken username, or
// a concurrent registration for the same identity) rolls back the token
// consume(s) above, so the caller can retry without having lost a token.
let pubkey_hash = new_user
.pubkey_hash
.as_deref()
.map(|v| self.pepper("pubkey_hash", v));
let seed_fingerprint = new_user
.seed_fingerprint
.as_deref()
.map(|v| self.pepper("seed_fingerprint", v));
let user = sqlx::query_as::<_, User>(&format!(
"INSERT INTO users \
(username, pubkey_hash, nostr_pubkey, wallet_birthday, seed_fingerprint, \
xmr_start_height, wow_start_height) \
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {USER_COLS}"
))
.bind(&new_user.username)
.bind(&pubkey_hash)
.bind(&new_user.nostr_pubkey)
.bind(new_user.wallet_birthday)
.bind(&seed_fingerprint)
.bind(new_user.xmr_start_height)
.bind(new_user.wow_start_height)
.fetch_one(&mut *tx)
.await
.map_err(registration_conflict)?;
// 4. Persist the chain keys in the SAME tx (mirrors upsert_user_key).
for (asset, public_key, public_spend_key) in keys {
sqlx::query(
"INSERT INTO user_keys (user_id, asset, public_key, public_spend_key, key_type) \
VALUES ($1, $2, $3, $4, 'primary') \
ON CONFLICT (user_id, asset, key_type) DO UPDATE SET \
public_key = EXCLUDED.public_key, \
public_spend_key = EXCLUDED.public_spend_key, \
updated_at = NOW()",
)
.bind(user.id)
.bind(asset)
.bind(public_key)
.bind(public_spend_key)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(user)
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, AppError> { pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, AppError> {
let sql = format!("SELECT {USER_COLS} FROM users WHERE id = $1"); let sql = format!("SELECT {USER_COLS} FROM users WHERE id = $1");
+132
View File
@@ -0,0 +1,132 @@
//! L1: registration atomicity. The gate token(s) and the new user row are
//! committed in ONE transaction (`create_user_consuming_gates`), so a failure
//! never burns a single-use token without an account being created. Covers the
//! two residual races the separate per-gate consumes left open:
//! - a username collision must NOT burn a paid invoice / invite;
//! - in `all` mode (invite AND payment), a failed payment consume must NOT burn
//! the invite that was claimed earlier in the same request.
mod common;
use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code};
use smirk_backend_core::error::AppError;
use smirk_backend_core::models::db::{AssetType, NewUser};
use uuid::Uuid;
fn new_user(username: Option<&str>) -> NewUser {
NewUser {
username: username.map(str::to_string),
pubkey_hash: Some(format!("pkh-{}", Uuid::new_v4())),
nostr_pubkey: None,
wallet_birthday: None,
seed_fingerprint: None,
xmr_start_height: None,
wow_start_height: None,
}
}
fn keys() -> Vec<(AssetType, String, Option<String>)> {
vec![(AssetType::Btc, format!("btc-{}", Uuid::new_v4()), None)]
}
#[tokio::test]
async fn happy_path_consumes_tokens_creates_user_and_persists_keys() {
let app = require_app!();
let db = &app.state.db;
let hash = hash_invite_code(&generate_invite_code());
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
let pkh = format!("pay-pkh-{}", Uuid::new_v4());
let invoice = format!("inv-{}", Uuid::new_v4());
db.insert_payment_invoice(&invoice, &pkh, "test", "1000", "USD")
.await
.unwrap();
let ks = keys();
let user = db
.create_user_consuming_gates(Some(&hash), Some((&invoice, &pkh)), new_user(None), &ks)
.await
.expect("gated registration commits");
// Both single-use tokens were consumed inside the tx.
assert!(
!db.claim_invite_code(&hash).await.unwrap(),
"invite must be consumed on a successful registration"
);
assert!(
!db.consume_payment_invoice(&invoice, &pkh).await.unwrap(),
"invoice must be consumed on a successful registration"
);
// Keys were persisted in the same tx.
assert!(
!db.get_user_keys(user.id).await.unwrap().is_empty(),
"chain keys must be persisted"
);
}
#[tokio::test]
async fn username_collision_rolls_back_the_invite() {
let app = require_app!();
let db = &app.state.db;
// Occupy a username.
let taken = format!("taken-{}", Uuid::new_v4());
db.create_user(new_user(Some(&taken))).await.unwrap();
// A fresh, claimable invite.
let hash = hash_invite_code(&generate_invite_code());
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
// Register a NEW wallet that collides on the taken username.
let err = db
.create_user_consuming_gates(Some(&hash), None, new_user(Some(&taken)), &keys())
.await
.expect_err("a colliding username must fail the registration");
assert!(matches!(err, AppError::Conflict(_)), "expected 409, got {err:?}");
// The invite must NOT have been burned — the tx rolled back.
assert!(
db.claim_invite_code(&hash).await.unwrap(),
"the invite must be intact after a username collision"
);
}
#[tokio::test]
async fn failed_payment_consume_does_not_burn_the_invite() {
let app = require_app!();
let db = &app.state.db;
// A fresh, claimable invite.
let hash = hash_invite_code(&generate_invite_code());
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
// A payment invoice that is ALREADY spent (simulates losing the consume race
// in `all` mode after the invite was claimed).
let pkh = format!("pay-pkh-{}", Uuid::new_v4());
let invoice = format!("inv-{}", Uuid::new_v4());
db.insert_payment_invoice(&invoice, &pkh, "test", "1000", "USD")
.await
.unwrap();
assert!(
db.consume_payment_invoice(&invoice, &pkh).await.unwrap(),
"pre-consume the invoice so the registration's payment consume will fail"
);
// all-mode conjunction: consume invite THEN payment. The payment consume fails
// (already spent), which must roll the invite claim back too.
let err = db
.create_user_consuming_gates(Some(&hash), Some((&invoice, &pkh)), new_user(None), &keys())
.await
.expect_err("an already-spent payment must fail the registration");
assert!(
matches!(err, AppError::ValidationError(_)),
"expected the already-used literal, got {err:?}"
);
// The invite must NOT have been burned by the failed payment consume.
assert!(
db.claim_invite_code(&hash).await.unwrap(),
"the invite must be intact when the payment consume fails"
);
}