From fb09303ff274a241c28cccc0cd29550f6e59d0b2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 23:22:50 -0700 Subject: confirm: guard the first-seen deposit credit atomically (COINOS-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /confirm is unauthenticated, and its first-seen branch credited a deposit after a plain getPayment() read with no lock. Concurrent callers (walletnotify, catchUp, bulk sweeps, or anyone posting the endpoint) all read p == null for one deposit and each ran credit(), inflating both the pending balance and the spendable credit:: fee accumulator — a fee waiver nobody paid for. Verified on regtest: 10 concurrent /confirm on one 100,000 sat deposit credited 1,000,000 pending and 4,000 fee credit. With the NX guard, exactly 100,000 and 400. Mirrors the lock the confirm stage already had. Reported-by: Muhammed Shekho (@SYR-ROOT) Co-Authored-By: Claude Fable 5 --- routes/payments.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/routes/payments.ts b/routes/payments.ts index c4033943..9751b421 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -515,6 +515,14 @@ export default { const invoice = await getInvoice(address); if (!hot && aid !== invoice?.aid) continue; if (sats(amount) < 300) continue; + // Atomic first-seen guard (COINOS-2). The getPayment() read above is not + // a lock: concurrent callers (walletnotify, catchUp, bulk /confirm + // sweeps — /confirm is unauthenticated) can all read p==null for one + // deposit and each run credit(), double-crediting the pending balance + // AND inflating the spendable credit:: fee accumulator. Mirror + // the confirm-stage lock so only the first caller credits. + const firstlock = `firstseenlock:${txid}:${vout}`; + if (!(await db.set(firstlock, "1", { NX: true, EX: 60 }))) continue; await credit({ hash: address, amount: sats(amount), -- cgit v1.2.3