aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 23:22:41 -0700
committerAdam Soltys <[email protected]>2026-07-31 23:22:41 -0700
commit733929010b0ec96a580c65b69345a854e0112611 (patch)
tree00f89a62a92816826f46ca5dc1c7ca0c7eb1a215 /routes
parent974c9cae7e11ffafdc571af0f9c47d896a901131 (diff)
downloadcoinos-server-733929010b0ec96a580c65b69345a854e0112611.tar.gz
coinos-server-733929010b0ec96a580c65b69345a854e0112611.zip
take: claim single-use authorizations atomically (COINOS-3)
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) <[email protected]> Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'routes')
-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`);