aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/payments.ts13
-rw-r--r--routes/invoices.ts17
-rw-r--r--routes/payments.ts2
3 files changed, 25 insertions, 7 deletions
diff --git a/lib/payments.ts b/lib/payments.ts
index c715af47..c256171c 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -273,6 +273,7 @@ export const credit = async ({
aid = undefined,
payment_hash = undefined,
created = undefined,
+ tip = undefined,
}) => {
amount = Number.parseInt(amount) || 0;
@@ -288,8 +289,14 @@ export const credit = async ({
return;
}
- let { path, tip } = inv;
- tip = Number.parseInt(tip) || 0;
+ let { path } = inv;
+ // Use the tip the sender was actually debited (passed in for internal
+ // transfers) rather than re-reading the invoice. The invoice's tip can be
+ // mutated between the sender debit and this credit (PUT /invoice), which
+ // otherwise lets a payer credit a tip they were never charged — minting
+ // balance. Fall back to the invoice tip only for external receipts
+ // (lightning/ecash) where there is no paired coinos debit to diverge from.
+ tip = tip !== undefined ? Number.parseInt(tip) || 0 : Number.parseInt(inv.tip) || 0;
if (!memo) ({ memo } = inv);
if (memo && memo.length > 5000) fail("memo too long");
@@ -890,7 +897,7 @@ export const sendInternal = async ({
const { hash } = invoice;
const p = await debit({ hash, amount, memo, user: sender });
- await credit({ hash, amount, memo, ref: sender.id });
+ await credit({ hash, amount, memo, ref: sender.id, tip: p.tip });
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: hash });
diff --git a/routes/invoices.ts b/routes/invoices.ts
index 0cb73687..61cf203b 100644
--- a/routes/invoices.ts
+++ b/routes/invoices.ts
@@ -49,12 +49,23 @@ export default {
const { body } = req;
const { tip, webhook, secret } = body.invoice;
- if (tip < 0) fail("Invalid tip");
-
let invoice = await gf(`invoice:${id}`);
+ if (!invoice) fail("invoice not found");
const user = await g(`user:${invoice.uid}`);
- if (typeof tip !== "undefined") invoice.tip = tip;
+ if (typeof tip !== "undefined") {
+ const t = Number.parseInt(tip);
+ if (!Number.isSafeInteger(t) || t < 0 || t > 2_100_000_000_000_000)
+ fail("Invalid tip");
+ // Defense-in-depth: never change the tip once the invoice is paid/
+ // settled. The primary mint guard is on the credit side (it uses the
+ // tip the sender was actually debited); this stops post-settlement
+ // tampering. The endpoint stays open by design — payers set tips on
+ // merchants' invoices before paying — so we do NOT require ownership.
+ if (invoice.received > 0 || invoice.settled)
+ fail("Invoice already settled");
+ invoice.tip = t;
+ }
if (webhook && secret) {
if (invoice.uid !== req.user?.id) fail("Unauthorized");
diff --git a/routes/payments.ts b/routes/payments.ts
index 7dc45058..9c62d2bb 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -641,7 +641,7 @@ export default {
let p;
if (payee === id) {
p = await debit({ hash: pr, amount, memo, user });
- await credit({ hash: pr, amount, memo, ref: user.id });
+ await credit({ hash: pr, amount, memo, ref: user.id, tip: p.tip });
} else p = await sendLightning({ user, pr, amount, fee, memo });
res.send(p);