diff options
| -rw-r--r-- | lib/payments.ts | 13 | ||||
| -rw-r--r-- | routes/invoices.ts | 17 | ||||
| -rw-r--r-- | routes/payments.ts | 2 |
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); |
