aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-05-17 23:46:58 -0700
committerAdam Soltys <[email protected]>2026-05-17 23:46:58 -0700
commitdfd31ad83d24e780523f4e76d04a76aba1fa8be1 (patch)
treeef9fc2c2732d2a34cf51896cbf74d3f53be59dc5
parentca4bdc84f1eac1859044639ffa16bac9aa8fce1b (diff)
downloadcoinos-server-dfd31ad83d24e780523f4e76d04a76aba1fa8be1.tar.gz
coinos-server-dfd31ad83d24e780523f4e76d04a76aba1fa8be1.zip
mirror payment records to arc + atomic /confirm lock to prevent double-credits
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:<txid>:<vout>` / `payment:<hash>` 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) <[email protected]>
-rw-r--r--lib/payments.ts11
-rw-r--r--routes/payments.ts28
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);
}