Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bot/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ const addInvoice = async (
buyer.lightning_address,
order.amount * 1000,
);
if (!!laRes && !laRes.pr) {
if (!laRes || !laRes.pr) {
logger.error(
`lightning address ${buyer.lightning_address} not available`,
);
Expand Down
12 changes: 12 additions & 0 deletions bot/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ const addInvoiceWizard = new Scenes.WizardScene(
lnInvoice,
order.amount * 1000,
);
if (!laRes || !laRes.pr) {
await ctx.reply(
ctx.i18n.t('unavailable_lightning_address', { la: lnInvoice }),
);
return;
}
lnInvoice = laRes.pr;
res.invoice = parsePaymentRequest({ request: lnInvoice });
} else {
Expand Down Expand Up @@ -146,6 +152,12 @@ const addInvoicePHIWizard = new Scenes.WizardScene(
lnInvoice,
order.amount * 1000,
);
if (!laRes || !laRes.pr) {
await ctx.reply(
ctx.i18n.t('unavailable_lightning_address', { la: lnInvoice }),
);
return;
}
lnInvoice = laRes.pr;
res.invoice = parsePaymentRequest({ request: lnInvoice });
} else {
Expand Down
26 changes: 23 additions & 3 deletions jobs/pending_payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const attemptPendingPayments = async (
pending.paid = true;
await pending.save();
logger.info(`Order id: ${order._id} was already paid`);
return;
continue;
}
// We check if the old payment is on flight
const isPendingOldPayment: boolean = await isPendingPayment(
Expand All @@ -48,7 +48,20 @@ export const attemptPendingPayments = async (
);

// If one of the payments is on flight we don't do anything
if (isPending || isPendingOldPayment) return;
if (isPending || isPendingOldPayment) continue;

// SECURITY (defense in depth): the amount to pay must equal the order
// amount. payRequest also enforces this, but we stop retries early here
// so a structurally-wrong pending payment is never re-attempted.
if (pending.amount !== order.amount) {
pending.last_error = 'AMOUNT_MISMATCH';
pending.is_invoice_expired = true; // prevents further retries
logger.error(
`attemptPendingPayments: amount mismatch for order ${order._id} ` +
`(pending ${pending.amount} != order ${order.amount}); stopping retries`,
);
continue;
}

const payment = await payRequest({
amount: pending.amount,
Expand All @@ -61,12 +74,13 @@ export const attemptPendingPayments = async (
if (!!payment && payment.is_expired) {
pending.is_invoice_expired = true;
order.paid_hold_buyer_invoice_updated = false;
return await messages.expiredInvoiceOnPendingMessage(
await messages.expiredInvoiceOnPendingMessage(
bot,
buyerUser,
order,
i18nCtx,
);
continue;
}

if (!!payment && !!payment.confirmed_at) {
Expand Down Expand Up @@ -112,6 +126,12 @@ export const attemptPendingPayments = async (
logger.warning(
`Routing failed for order ${order._id}, attempt ${pending.attempts}`,
);
} else if (payment.error === 'AMOUNT_MISMATCH') {
// Structural error: never retry an invoice with the wrong amount.
pending.is_invoice_expired = true;
logger.error(
`AMOUNT_MISMATCH for order ${order._id}; stopping retries`,
);
} else {
logger.error(
`Payment failed for order ${order._id}, attempt ${pending.attempts}, error: ${payment.error}`,
Expand Down
30 changes: 30 additions & 0 deletions ln/pay_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ const payRequest = async ({
// If the invoice is expired we return is_expired = true
if (invoice.is_expired) return invoice;

// SECURITY: never pay an amount different from the order amount.
// If the invoice encodes an amount, it MUST match the expected amount
// exactly. This prevents an attacker from supplying an inflated invoice
// (e.g. via a malicious LNURL/Lightning Address endpoint) and draining
// the node by being paid more than was held from the seller.
if (invoice.tokens && invoice.tokens !== amount) {
logger.error(
`payRequest: invoice amount (${invoice.tokens}) does not match the expected amount (${amount}); refusing to pay`,
);
return {
error: 'AMOUNT_MISMATCH',
message: 'invoice amount does not match order amount',
};
}

const maxRoutingFee = process.env.MAX_ROUTING_FEE;
if (maxRoutingFee === undefined)
throw new Error('Environment variable MAX_ROUTING_FEE is not defined');
Expand Down Expand Up @@ -147,6 +162,21 @@ const payToBuyer = async (bot: HasTelegram, order: IOrder) => {
);
await messages.rateUserMessage(bot, buyerUser, order, i18nCtx);
} else {
// SECURITY: a structural amount mismatch must never be retried — the
// invoice itself is wrong. Alert and stop instead of scheduling a retry,
// otherwise the pending payments job would keep attempting to pay it.
if (
payment &&
typeof payment === 'object' &&
'error' in payment &&
payment.error === 'AMOUNT_MISMATCH'
) {
logger.error(
`payToBuyer: AMOUNT_MISMATCH for order ${order._id}; refusing to pay and not scheduling a retry`,
);
await messages.invoicePaymentFailedMessage(bot, buyerUser, i18nCtx);
return;
}
// Handle different types of payment failures
if (payment && typeof payment === 'object' && 'error' in payment) {
const errorType = payment.error as string;
Expand Down
25 changes: 25 additions & 0 deletions lnurl/lnurl-pay.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import axios from 'axios';
import { logger } from '../logger';
// @ts-ignore
const { parsePaymentRequest } = require('invoices');

// {
// pr: String, // bech32-serialized lightning invoice
Expand Down Expand Up @@ -28,6 +30,29 @@ const resolvLightningAddress = async (address: string, amountMsat: number) => {
await axios.get(`${lnAddressRes.callback}${'?'}amount=${amountMsat}`)
).data;

// SECURITY: do not trust the amount the server claims via min/maxSendable.
// Verify the returned invoice actually encodes the exact requested amount,
// otherwise a malicious LNURL endpoint could return an inflated invoice.
if (res && res.pr) {
try {
const parsed = parsePaymentRequest({ request: res.pr });
const prMsat = parsed.mtokens
? Number(parsed.mtokens)
: (parsed.tokens || 0) * 1000;
if (prMsat !== amountMsat) {
logger.error(
`resolvLightningAddress: returned invoice amount ${prMsat} msat does not match requested ${amountMsat} msat`,
);
return false;
}
} catch (error) {
logger.error(
`resolvLightningAddress: could not parse returned invoice: ${error}`,
);
return false;
}
}

return res;
};

Expand Down
Loading