From 733929010b0ec96a580c65b69345a854e0112611 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 23:22:41 -0700 Subject: take: claim single-use authorizations atomically (COINOS-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit take() enforced single-use by reading authorization.claimed, checking it, then writing it back — with yielding awaits in between and no lock. N concurrent POST /take for one authorization all passed the gate, so each debited the authorizer and funded the pool. Verified on regtest: 10 concurrent takes redeemed one 10,000 sat single-use authorization 5 times (50,000 debited from the authorizer, 50,000 credited to the caller). With the SET NX claim, exactly one wins. Funding also runs inside a try that releases the claim on failure and only marks the record claimed once the money has landed — otherwise a funding that throws (insufficient balance, server limit) burns the authorization: claim key set, fund never funded, every later take skipping the funding block. Reported-by: Muhammed Shekho (@SYR-ROOT) Co-Authored-By: Claude Fable 5 --- routes/payments.ts | 59 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/routes/payments.ts b/routes/payments.ts index 9adc10ee..c4033943 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -337,26 +337,47 @@ export default { const { currency, fiat } = authorization; amount = Math.min(amount, sats(fiat / rates[currency])); - const sender = await getUser(authorization.uid); - authorization.claimed = true; - await s(`authorization:${id}`, authorization); - - const { hash } = await generate({ - invoice: { amount, type: "lightning" }, - user: sender, - }); - - const { id: pid } = await debit({ - hash, - amount, - memo: id, - user: sender, - type: PaymentType.fund, + // Atomic single-use claim. The read above is not a lock: concurrent + // POST /take for the same authorization all pass the `!claimed` gate and, + // without an atomic gate, each would debit the authorizer and fund the + // pool — redeeming a single-use authorization N times (COINOS-3). SET NX + // lets exactly one caller win the claim; the rest skip the funding. + const claimed = await db.set(`authorization:${id}:claimed`, user.id, { + NX: true, }); - - await db.incrBy(`fund:${id}`, amount); - await db.lPush(`fund:${id}:payments`, pid); - l("funded fund", id); + if (claimed) { + // Mark the record claimed only once the funding has actually landed, + // and release the claim if it throws — otherwise a failed funding + // (an insufficient balance, a server limit) burns the authorization + // permanently: the key is set, the fund never gets the money, and + // every later /take skips the funding block. + try { + const sender = await getUser(authorization.uid); + + const { hash } = await generate({ + invoice: { amount, type: "lightning" }, + user: sender, + }); + + const { id: pid } = await debit({ + hash, + amount, + memo: id, + user: sender, + type: PaymentType.fund, + }); + + await db.incrBy(`fund:${id}`, amount); + await db.lPush(`fund:${id}:payments`, pid); + + authorization.claimed = true; + await s(`authorization:${id}`, authorization); + l("funded fund", id); + } catch (e) { + await db.del(`authorization:${id}:claimed`); + throw e; + } + } } const managers = await db.sMembers(`fund:${id}:managers`); -- cgit v1.2.3