Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
34178f8
handle slow payments to buyer
Luquitasjeffrey Jun 23, 2026
0dad14c
fix: address grunch review on handle-delayed-payment PR
Matobi98 Jun 23, 2026
549ac69
refactor: share idempotent order success routine between job and seti…
Matobi98 Jun 26, 2026
a8ee15d
test: stub Order.findOneAndUpdate for atomic order success routine
Matobi98 Jun 26, 2026
9a40700
refactor: move completeOrderAsSuccess to util per review
Matobi98 Jun 26, 2026
cf3ccd2
Merge pull request #845 from Matobi98/fix/handle-delayed-payment
Luquitasjeffrey Jun 28, 2026
ce1bf3f
Rethrow unexpected error to the caller on getPaymentStatus and add an…
Luquitasjeffrey Jun 29, 2026
62f7005
Fix CI
Luquitasjeffrey Jun 30, 2026
dd6f0c2
test: add test case for another pending payment already paid
Luquitasjeffrey Jun 30, 2026
41adeef
Code formatting
Luquitasjeffrey Jun 30, 2026
9f7cc2b
fix: handle getPaymentStatus errors by failing closed and logging to …
Luquitasjeffrey Jul 2, 2026
f0c1d9a
fix: fail-closed on confirmed payment missing payload in pending-paym…
Luquitasjeffrey Jul 2, 2026
2758140
fix: fail-closed on getPaymentStatus error for community pending paym…
Luquitasjeffrey Jul 2, 2026
c09a587
fix: prevent double-pay for community withdrawals if confirmed paymen…
Luquitasjeffrey Jul 2, 2026
7b3563e
Handle points 2 to 5 of grunch's review
Luquitasjeffrey Jul 6, 2026
14c1b54
fix: advance next_retry with backoff on in-flight payment skips
Matobi98 Jul 6, 2026
9957ede
refactor: extract shared healConfirmedOrder helper for confirmed paym…
Matobi98 Jul 6, 2026
d2baf61
refactor: move logOrderError to bot/messages as toAdminChannelOrderEr…
Matobi98 Jul 6, 2026
3c70021
feat: allow paytobuyer on ERROR orders for manual payout resolution
Matobi98 Jul 6, 2026
7b515d1
fix: restrict paytobuyer on ERROR orders to superadmins only
Matobi98 Jul 6, 2026
af225d8
Merge pull request #863 from Matobi98/fix/review-points-6-9
Luquitasjeffrey Jul 7, 2026
bd22407
Notify to admins when order transitioned to ERROR state when trying t…
Luquitasjeffrey Jul 7, 2026
e2923c9
* Alert community administrators if an order transitions to ERROR sta…
Luquitasjeffrey Jul 8, 2026
c9b85a7
Code formatting
Luquitasjeffrey Jul 8, 2026
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
9 changes: 4 additions & 5 deletions bot/modules/community/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Scenes } from 'telegraf';
import { logger } from '../../../logger';
import { Community, User, PendingPayment } from '../../../models';
import { IOrderChannel, IUsernameId } from '../../../models/community';
import { isPendingPayment } from '../../../ln';
import { isPendingOrConfirmed } from '../../../ln';
import { isGroupAdmin, itemsFromMessage, removeAtSymbol } from '../../../util';
import * as messages from '../../messages';
import { isValidInvoice } from '../../validations';
Expand Down Expand Up @@ -1015,10 +1015,9 @@ export const addEarningsInvoiceWizard = new Scenes.WizardScene(
paid: false,
is_invoice_expired: false,
});
// We check if the payment is on flight
const isPending = await isPendingPayment(lnInvoice);

if (!!isScheduled || !!isPending)
// Block update if payment is already in-flight or was confirmed
const isPaymentPendingOrConfirmed = await isPendingOrConfirmed(lnInvoice);
if (!!isScheduled || isPaymentPendingOrConfirmed)
return await ctx.reply(ctx.i18n.t('invoice_already_being_paid'));

// SECURITY: atomically claim the earnings before scheduling the payout.
Expand Down
24 changes: 19 additions & 5 deletions bot/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Order, PendingPayment } from '../models';
import { waitPayment, addInvoice, showHoldInvoice } from './commands';
import { getCurrency, getUserI18nContext } from '../util';
import * as messages from './messages';
import { isPendingPayment } from '../ln';
import { isConfirmedPayment, isPendingOrConfirmed } from '../ln';
import { logger } from '../logger';
import { resolvLightningAddress } from '../lnurl/lnurl-pay';
import { CommunityContext } from './modules/community/communityContext';
Expand Down Expand Up @@ -168,15 +168,29 @@ const addInvoicePHIWizard = new Scenes.WizardScene(
if (!!res.invoice.tokens && res.invoice.tokens !== order.amount)
return await messages.incorrectAmountInvoiceMessage(ctx);

// If the original invoice was already paid (e.g. bot restarted mid-payment
// while a hold invoice was ACCEPTED), heal the order and reject the update.
// Without this check an attacker can settle the hold invoice after restart
// and then claim a second payment via /setinvoice.
if (order.buyer_invoice) {
const alreadyPaid = await isConfirmedPayment(order.buyer_invoice);
if (alreadyPaid) {
order.status = 'SUCCESS';
await order.save();
return await messages.invoiceAlreadyUpdatedMessage(ctx);
Comment thread
grunch marked this conversation as resolved.
Outdated
}
}

const isScheduled = await PendingPayment.findOne({
order_id: order._id,
attempts: { $lt: process.env.PAYMENT_ATTEMPTS },
is_invoice_expired: false,
});
// We check if the payment is on flight
const isPending = await isPendingPayment(order.buyer_invoice);

if (!!isScheduled || !!isPending)
// Block update if payment is already in-flight or was confirmed (covers restart mid-payment)
const isPaymentPendingOrConfirmed = await isPendingOrConfirmed(
order.buyer_invoice,
);
if (!!isScheduled || isPaymentPendingOrConfirmed)
return await messages.invoiceAlreadyUpdatedMessage(ctx);

// if the payment is not on flight, we create a pending payment
Expand Down
177 changes: 149 additions & 28 deletions jobs/pending_payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as messages from '../bot/messages';
import { logger } from '../logger';
import { Telegraf } from 'telegraf';
import { I18nContext } from '@grammyjs/i18n';
import { payRequest, isPendingPayment } from '../ln';
import { payRequest, getPaymentStatus } from '../ln';
import { getUserI18nContext } from '../util';
import { CommunityContext } from '../bot/modules/community/communityContext';
import { orderUpdated } from '../bot/modules/events/orders';
Expand All @@ -22,33 +22,113 @@ export const attemptPendingPayments = async (
const order = await Order.findOne({ _id: pending.order_id });
try {
if (order === null) throw Error('Order was not found in DB');
pending.attempts++;

// Calculate exponential backoff delay
const baseDelay = 5 * 60 * 1000; // 5 minutes
const exponentialDelay = baseDelay * Math.pow(2, pending.attempts - 1);
const maxDelay = 60 * 60 * 1000; // 1 hour max
const nextRetryDelay = Math.min(exponentialDelay, maxDelay);
pending.next_retry = new Date(Date.now() + nextRetryDelay);

if (order.status === 'SUCCESS') {
pending.paid = true;
await pending.save();
logger.info(`Order id: ${order._id} was already paid`);
continue;
}
// We check if the old payment is on flight
const isPendingOldPayment: boolean = await isPendingPayment(
order.buyer_invoice,
);

// We check if this new payment is on flight
const isPending: boolean = await isPendingPayment(
pending.payment_request,
);
// Guard against double-pay after a bot restart mid-payment: if the
// original buyer invoice was already confirmed by LND (e.g. the attacker
// held it as a hold invoice and settled after restart), heal the order
// and skip the retry instead of paying a second time.
if (order.buyer_invoice) {
const originalStatus = await getPaymentStatus(order.buyer_invoice);
if (originalStatus.is_confirmed) {
order.status = 'SUCCESS';
pending.paid = true;
logger.info(
`Order ${order._id}: original buyer invoice already confirmed, marking SUCCESS and skipping retry`,
);
continue;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Healing branch doesn't notify or credit. When order.buyer_invoice is already confirmed the buyer did receive the sats, but here we only set status='SUCCESS' + pending.paid=true — no buyer message, no trades_completed++ for either party, no routing_fee, no rateUserMessage. Same for the previousPendingPayments-confirmed branch below and the /setinvoice heal in bot/scenes.ts. Only the currentStatus.is_confirmed branch runs the full routine. Result: "phantom" completed orders with no reputation/rating and an uninformed buyer. Run the same success routine when healing by confirmation.

}
if (originalStatus.is_pending) {
logger.info(
`Order ${order._id}: original buyer invoice is pending (in-flight), skipping retry without incrementing attempts`,
);
continue;
}
Comment thread
grunch marked this conversation as resolved.
}

const previousPendingPayments = await PendingPayment.find({
_id: { $ne: pending._id },
order_id: order._id,
is_invoice_expired: false,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N+1 LND RPCs per job run: this now does getPaymentStatus(order.buyer_invoice) + one per previousPendingPayments + one for the current request, for every pending, every 5 min. Acceptable at current scale but worth noting. This query also doesn't filter paid:false/attempts, so it scans more rows than needed.


let shouldSkip = false;

for (const prev of previousPendingPayments) {
const prevStatus = await getPaymentStatus(prev.payment_request);
if (prevStatus.is_confirmed) {
order.status = 'SUCCESS';
prev.paid = true;
await prev.save();
pending.paid = true;
logger.info(
`Order ${order._id}: previous payment already confirmed, marking order as SUCCESS and skipping retry`,
);
shouldSkip = true;
break;
} else if (prevStatus.is_pending) {
logger.info(
`Order ${order._id}: previous payment already in-flight, skipping attempt`,
);
shouldSkip = true;
break;
}
}

if (shouldSkip) continue;

// If one of the payments is on flight we don't do anything
if (isPending || isPendingOldPayment) continue;
const currentStatus = await getPaymentStatus(pending.payment_request);

// If already confirmed, process the SUCCESS routine immediately!
if (currentStatus.is_confirmed && currentStatus.payment) {
Comment thread
Luquitasjeffrey marked this conversation as resolved.
Outdated
Comment thread
Luquitasjeffrey marked this conversation as resolved.
Outdated
const payment = currentStatus.payment;
order.status = 'SUCCESS';
order.routing_fee = payment.fee;
pending.paid = true;
pending.paid_at = new Date();
const buyerUser = await User.findOne({ _id: order.buyer_id });
if (buyerUser === null) throw Error('buyerUser was not found in DB');
const i18nCtx: I18nContext = await getUserI18nContext(buyerUser);
buyerUser.trades_completed++;
await buyerUser.save();
const sellerUser = await User.findOne({ _id: order.seller_id });
if (sellerUser === null) throw Error('sellerUser was not found in DB');
sellerUser.trades_completed++;
await sellerUser.save();
logger.info(
`Invoice with hash: ${pending.hash} already paid, processing SUCCESS routine`,
);
await messages.toAdminChannelPendingPaymentSuccessMessage(
bot,
buyerUser,
order,
pending,
payment,
i18nCtx,
);
await messages.toBuyerPendingPaymentSuccessMessage(
bot,
buyerUser,
order,
payment,
i18nCtx,
);
await messages.rateUserMessage(bot, buyerUser, order, i18nCtx);
continue;
}

if (currentStatus.is_pending) {
logger.info(
`Order ${order._id}: current payment is already in-flight, skipping retry without incrementing attempts`,
);
continue;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
grunch marked this conversation as resolved.
// SECURITY (defense in depth): the amount to pay must equal the order
// amount. payRequest also enforces this, but we stop retries early here
Expand All @@ -63,6 +143,16 @@ export const attemptPendingPayments = async (
continue;
}

// Increment attempts and update backoff only when we actually attempt payment
pending.attempts++;

// Calculate exponential backoff delay
const baseDelay = 5 * 60 * 1000; // 5 minutes
const exponentialDelay = baseDelay * Math.pow(2, pending.attempts - 1);
const maxDelay = 60 * 60 * 1000; // 1 hour max
const nextRetryDelay = Math.min(exponentialDelay, maxDelay);
pending.next_retry = new Date(Date.now() + nextRetryDelay);

const payment = await payRequest({
amount: pending.amount,
request: pending.payment_request,
Expand Down Expand Up @@ -95,7 +185,7 @@ export const attemptPendingPayments = async (
const sellerUser = await User.findOne({ _id: order.seller_id });
if (sellerUser === null) throw Error('sellerUser was not found in DB');
sellerUser.trades_completed++;
sellerUser.save();
await sellerUser.save();
logger.info(`Invoice with hash: ${pending.hash} paid`);
await messages.toAdminChannelPendingPaymentSuccessMessage(
bot,
Expand Down Expand Up @@ -190,6 +280,45 @@ export const attemptCommunitiesPendingPayments = async (

for (const pending of pendingPayments) {
try {
const status = await getPaymentStatus(pending.payment_request);

if (status.is_confirmed && status.payment) {
Comment thread
Luquitasjeffrey marked this conversation as resolved.
Outdated
const payment = status.payment;
pending.paid = true;
pending.paid_at = new Date();

const community = await Community.findById(pending.community_id);
if (community === null) throw Error('Community was not found in DB');
community.orders_to_redeem = 0;
await community.save();

const user = await User.findById(pending.user_id);
if (user === null) throw Error('User was not found in DB');
const i18nCtx: I18nContext = await getUserI18nContext(user);

logger.info(
`Community ${community.id} withdrew ${pending.amount} sats, invoice with hash: ${payment.id} already confirmed, processing SUCCESS routine`,
);

await bot.telegram.sendMessage(
user.tg_id,
i18nCtx.t('pending_payment_success', {
id: community.id,
amount: pending.amount,
paymentSecret: payment.secret,
}),
);
continue;
}

if (status.is_pending) {
logger.info(
`Community pending payment is already in-flight, skipping attempt without incrementing attempts`,
);
continue;
}

// Increment attempts and update backoff only when we actually attempt payment
pending.attempts++;

// Calculate exponential backoff delay for community payments
Expand All @@ -199,14 +328,6 @@ export const attemptCommunitiesPendingPayments = async (
const nextRetryDelay = Math.min(exponentialDelay, maxDelay);
pending.next_retry = new Date(Date.now() + nextRetryDelay);

// We check if this new payment is on flight
const isPending: boolean = await isPendingPayment(
pending.payment_request,
);

// If the payments is on flight we don't do anything
if (isPending) return;

const payment = await payRequest({
amount: pending.amount,
request: pending.payment_request,
Expand Down
12 changes: 11 additions & 1 deletion ln/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {
import { subscribeInvoice, payHoldInvoice } from './subscribe_invoice';
import { subscribeProbe } from './subscribe_probe';
import { resubscribeInvoices } from './resubscribe_invoices';
import { payRequest, payToBuyer, isPendingPayment } from './pay_request';
import {
payRequest,
payToBuyer,
isConfirmedPayment,
isPendingPayment,
isPendingOrConfirmed,
getPaymentStatus,
} from './pay_request';
import { getInfo } from './info';

export {
Expand All @@ -19,7 +26,10 @@ export {
payRequest,
payToBuyer,
getInfo,
isConfirmedPayment,
isPendingPayment,
isPendingOrConfirmed,
getPaymentStatus,
subscribeProbe,
getInvoice,
payHoldInvoice,
Expand Down
Loading
Loading