diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 10:38:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 10:38:04 -0700 |
| commit | a9268a81c9538ddc9b2f122fb4a75f47ec129353 (patch) | |
| tree | 24702ee4010d37a1b3bef1b4fff71b1b440ed2c3 | |
| parent | 239f1fd498b4511d8c6a1f4ae4c8cc3b5aeb7b5f (diff) | |
| download | coinos-server-a9268a81c9538ddc9b2f122fb4a75f47ec129353.tar.gz coinos-server-a9268a81c9538ddc9b2f122fb4a75f47ec129353.zip | |
payments: guard tip credit and self-referential autowithdraw
Clamp a non-internal receipt's tip to the settled amount so a tip
mutated larger than the amount actually received can't produce a
negative, reversible payment record on the recipient.
Also reject an autowithdraw whose destination resolves back to the
sender: that self-send re-fires completePayment and, since the balance
nets out, never drops below threshold, looping unbounded. The route's
create() already has the equivalent self-send guard; the internal
pay() helper lacked it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
| -rw-r--r-- | lib/payments.ts | 21 |
1 files changed, 20 insertions, 1 deletions
diff --git a/lib/payments.ts b/lib/payments.ts index 34029233..2fb891a2 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -356,6 +356,16 @@ export const credit = async ({ if (!memo) ({ memo } = inv); if (memo && memo.length > 5000) fail("memo too long"); if (amount < 0 || tip < 0) fail("Invalid amount"); + // For a NON-internal receipt the tip is only a cosmetic split of the real + // settled `amount` — there is no paired coinos debit carrying it — so it can + // never legitimately exceed `amount`. If the invoice tip was mutated via + // PUT /invoice to a value larger than the amount actually received, the + // fallback above would pull in that inflated tip and the stored record's + // `amount - tip` would go negative: a corrupt record that could be reversed + // into balance. The balance credit itself is `amount` and is unaffected, but + // clamp the tip so the record can never be negative. Internal transfers keep + // the full debited tip (their record stores the base amount, non-negative). + if (type !== PaymentType.internal && tip > amount) tip = amount; if (type === PaymentType.internal) amount += tip; const user = await getUser(inv.uid); @@ -537,12 +547,21 @@ const pay = async ({ aid = undefined, amount, to, user }) => { } const recipient = await getUser(to); - if (recipient) + if (recipient) { + // An autowithdraw destination that resolves back to the sender is a + // self-send: the internal credit re-fires completePayment, which re-runs + // autowithdraw, and since the balance nets to the same value it never + // falls below threshold — an unbounded loop. Reject it here (the route's + // create() has the equivalent `Cannot send to self` guard; pay() lacked + // it, which is what let a self-pointed autowithdraw spin forever). + if (recipient.id === aid) + fail("Cannot autowithdraw to your own account"); return sendInternal({ amount, recipient, sender: user, }); + } const fee = Math.max(5, Math.round(amount * 0.02)); if (lnurl) { |
