From dfd31ad83d24e780523f4e76d04a76aba1fa8be1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 17 May 2026 23:46:58 -0700 Subject: mirror payment records to arc + atomic /confirm lock to prevent double-credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apr 29 double-credit prevention, ported from master 0e368dab and adapted to staging's hono/tigerbeetle structure. Two parallel callers of /confirm (walletnotify, catchUp, bulk sweeps) could each read p.confirmed=false and each run tbConfirm — doubling the pending→balance transfer. tbConfirm itself isn't idempotent on its own, so the race materializes as a real double-credit even with TB. - routes/payments.ts: SET NX EX 60 lock around the confirmation block keyed by `confirmlock:${txid}:${vout}`. Concurrent callers short-circuit cleanly. Released on success and on every early-return. - lib/payments.ts credit() + routes/payments.ts /confirm: mirror payment + invoice records and the `payment::` / `payment:` pointer keys into arc as soon as they're written. gf() will then find the prior credit via arc fallback even if the main-db copy is wiped by a future data-loss event. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/payments.ts | 11 ++++++++++- routes/payments.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/payments.ts b/lib/payments.ts index 8b1d6366..5984a813 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1,7 +1,7 @@ import config from "$config"; import api from "$lib/api"; import changeid from "$lib/changeid"; -import { db, g, gf, s } from "$lib/db"; +import { db, g, gf, s, sa } from "$lib/db"; import { broadcastTx, btcNetwork, @@ -356,8 +356,13 @@ export const credit = async ({ p.confirmed = false; balanceKey = "pending"; await s(`payment:${txid}:${vout}`, id); + // Mirror the txid:vout pointer into arc so future bulk /confirm sweeps find + // the prior credit via gf() fallback and don't double-credit even if the + // main-db pointer is wiped by a data-loss event. + await sa(`payment:${txid}:${vout}`, id); } else { await s(`payment:${hash}`, id); + await sa(`payment:${hash}`, id); } let creditType = type; @@ -374,6 +379,10 @@ export const credit = async ({ .set(`${aid || uid}:payments:last`, p.created) .exec(); + // Mirror the payment + invoice records into arc for the same protection. + await sa(`payment:${p.id}`, p); + await sa(`invoice:${inv.id}`, inv); + if (inv.items?.length) { formatReceipt(inv.items, inv.currency); p.items = inv.items; diff --git a/routes/payments.ts b/routes/payments.ts index 81698e10..65086afd 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -2,7 +2,7 @@ import config from "$config"; import api from "$lib/api"; import { getArkAddress, sendArk, verifyArkVtxo } from "$lib/ark"; import { requireAccountOwnership, requirePin } from "$lib/auth"; -import { archive, db, g, gf, gfAll, s } from "$lib/db"; +import { archive, db, g, gf, gfAll, s, sa } from "$lib/db"; import { getTx } from "$lib/esplora"; import { generate } from "$lib/invoices"; import { replay } from "$lib/lightning"; @@ -598,10 +598,25 @@ export default { type, }); } else if (confirmations >= 1) { - if (p.confirmed) continue; + // Atomic guard against concurrent /confirm callers double-crediting: + // walletnotify, catchUp, and bulk sweeps can fire for the same + // txid:vout in parallel. Without this, two callers can each read + // p.confirmed=false and each run tbConfirm — doubling the pending→ + // balance transfer. + const lockKey = `confirmlock:${txid}:${vout}`; + const acquired = await db.set(lockKey, "1", { NX: true, EX: 60 }); + if (!acquired) continue; + + if (p.confirmed) { + await db.del(lockKey); + continue; + } const invoice = await getInvoice(address); - if (!invoice) continue; + if (!invoice) { + await db.del(lockKey); + continue; + } p.confirmed = true; invoice.received += Number.parseInt(invoice.pending); @@ -617,6 +632,13 @@ export default { .set(`payment:${p.id}`, JSON.stringify(p)) .exec(); + // Mirror the now-confirmed record into arc so a future bulk sweep + // finds it via gf() fallback even if the main-db pointer is wiped. + await sa(`payment:${p.id}`, p); + await sa(`invoice:${invoice.id}`, invoice); + + await db.del(lockKey); + const user = await g(`user:${p.uid}`); await completePayment(invoice, p, user); } -- cgit v1.2.3