diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 23:22:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 23:22:50 -0700 |
| commit | fb09303ff274a241c28cccc0cd29550f6e59d0b2 (patch) | |
| tree | 523cd275593ed8ddc2cd8d66faa6bc00a2425803 | |
| parent | 733929010b0ec96a580c65b69345a854e0112611 (diff) | |
| download | coinos-server-fb09303ff274a241c28cccc0cd29550f6e59d0b2.tar.gz coinos-server-fb09303ff274a241c28cccc0cd29550f6e59d0b2.zip | |
confirm: guard the first-seen deposit credit atomically (COINOS-2)
/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:<net>:<uid> 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) <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | routes/payments.ts | 8 |
1 files changed, 8 insertions, 0 deletions
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:<net>:<uid> 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), |
