diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 23:22:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 23:22:41 -0700 |
| commit | 733929010b0ec96a580c65b69345a854e0112611 (patch) | |
| tree | 00f89a62a92816826f46ca5dc1c7ca0c7eb1a215 /routes | |
| parent | 974c9cae7e11ffafdc571af0f9c47d896a901131 (diff) | |
| download | coinos-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.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`); |
