aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 10:38:04 -0700
committerAdam Soltys <[email protected]>2026-08-03 10:38:04 -0700
commita9268a81c9538ddc9b2f122fb4a75f47ec129353 (patch)
tree24702ee4010d37a1b3bef1b4fff71b1b440ed2c3
parent239f1fd498b4511d8c6a1f4ae4c8cc3b5aeb7b5f (diff)
downloadcoinos-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.ts21
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) {