aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--routes/payments.ts55
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`);