diff options
| -rw-r--r-- | routes/payments.ts | 55 |
1 files changed, 38 insertions, 17 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, + // 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, }); + 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 { id: pid } = await debit({ - hash, - amount, - memo: id, - user: sender, - type: PaymentType.fund, - }); + 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); - l("funded fund", id); + 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`); |
