Skip to content

Commit 23a3d2f

Browse files
grunchclaude
andauthored
fix: prevent double withdrawal of community earnings (#835)
* fix: prevent double withdrawal of community earnings Atomically claim community earnings before scheduling a payout by zeroing them with a conditional compare-and-set. Concurrent withdrawal attempts now race on this update so only one wins, preventing the node from paying out the same earnings more than once. If the payout later fails permanently (invoice expired or payment attempts exhausted), the pending-payments job restores the earnings so the community creator can withdraw again without losing funds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review feedback on earnings claim accounting - Roll back the atomic earnings claim if creating the PendingPayment fails (user lookup or save throws). Without a persisted pending payment the retry job can never restore the claimed earnings, so they would be stranded. - Restore earnings before any notification can abort the run: wrap the expired-invoice notification in try/catch so a failed sendMessage no longer skips the restore and leaves funds locked. - Stop re-zeroing community.earnings on a successful payout; the earnings were already claimed at scheduling time, and resetting them again would wipe out earnings accrued in the meantime. - Use an atomic $inc to restore earnings on permanent failure instead of a stale read-modify-write, so the update applies against the current DB value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: sync package-lock.json version to 0.15.2 package.json was bumped to 0.15.2 (commit 056284a) but package-lock.json still declared 0.15.1. The CI 'Run prettier' step runs 'npm install' followed by 'git diff --exit-code', and npm rewrites the lockfile version to match package.json, producing an uncommitted diff that fails the check. Sync the lockfile version so the working tree stays clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 056284a commit 23a3d2f

3 files changed

Lines changed: 87 additions & 34 deletions

File tree

bot/modules/community/scenes.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,7 +1004,9 @@ export const addEarningsInvoiceWizard = new Scenes.WizardScene(
10041004
const res = await isValidInvoice(ctx, lnInvoice);
10051005
if (!res.success) return;
10061006

1007-
if (!!res.invoice.tokens && res.invoice.tokens !== community.earnings)
1007+
const amountToWithdraw = community.earnings;
1008+
1009+
if (!!res.invoice.tokens && res.invoice.tokens !== amountToWithdraw)
10081010
return await ctx.reply(ctx.i18n.t('invoice_with_incorrect_amount'));
10091011

10101012
const isScheduled = await PendingPayment.findOne({
@@ -1019,18 +1021,42 @@ export const addEarningsInvoiceWizard = new Scenes.WizardScene(
10191021
if (!!isScheduled || !!isPending)
10201022
return await ctx.reply(ctx.i18n.t('invoice_already_being_paid'));
10211023

1022-
const user = await User.findById(community.creator_id);
1023-
if (user === null) throw new Error('user was not found');
1024-
logger.debug(`Creating pending payment for community ${community.id}`);
1025-
const pp = new PendingPayment({
1026-
amount: community.earnings,
1027-
payment_request: lnInvoice,
1028-
user_id: user.id,
1029-
community_id: community._id,
1030-
description: `Retiro por admin @${user.username}`,
1031-
hash: res.invoice.hash,
1032-
});
1033-
await pp.save();
1024+
// SECURITY: atomically claim the earnings before scheduling the payout.
1025+
// We zero the community's earnings only if they still equal the amount
1026+
// we snapshotted. Two concurrent withdrawals race on this compare-and-set
1027+
// and only one wins; the loser gets null and aborts, which prevents the
1028+
// node from paying the same earnings twice. If the payout later fails
1029+
// permanently the job restores the earnings (see attemptCommunities-
1030+
// PendingPayments) so the creator can withdraw again.
1031+
const claimed = await Community.findOneAndUpdate(
1032+
{ _id: community._id, earnings: amountToWithdraw },
1033+
{ earnings: 0 },
1034+
);
1035+
if (claimed === null)
1036+
return await ctx.reply(ctx.i18n.t('invoice_already_being_paid'));
1037+
1038+
// The earnings were already claimed (zeroed) above. If creating the
1039+
// pending payment fails there is nothing for the retry job to restore
1040+
// from, so roll the claim back here to avoid stranding the earnings.
1041+
try {
1042+
const user = await User.findById(community.creator_id);
1043+
if (user === null) throw new Error('user was not found');
1044+
logger.debug(`Creating pending payment for community ${community.id}`);
1045+
const pp = new PendingPayment({
1046+
amount: amountToWithdraw,
1047+
payment_request: lnInvoice,
1048+
user_id: user.id,
1049+
community_id: community._id,
1050+
description: `Retiro por admin @${user.username}`,
1051+
hash: res.invoice.hash,
1052+
});
1053+
await pp.save();
1054+
} catch (error) {
1055+
await Community.findByIdAndUpdate(community._id, {
1056+
$inc: { earnings: amountToWithdraw },
1057+
});
1058+
throw error;
1059+
}
10341060
await ctx.reply(ctx.i18n.t('invoice_updated_and_will_be_paid'));
10351061

10361062
return ctx.scene.leave();

jobs/pending_payments.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -217,22 +217,32 @@ export const attemptCommunitiesPendingPayments = async (
217217
// If the buyer's invoice is expired we let it know and don't try to pay again
218218
if (!!payment && payment.is_expired) {
219219
pending.is_invoice_expired = true;
220-
await bot.telegram.sendMessage(
221-
user.tg_id,
222-
i18nCtx.t('invoice_expired_earnings'),
223-
{
224-
reply_markup: {
225-
inline_keyboard: [
226-
[
227-
{
228-
text: i18nCtx.t('withdraw_earnings'),
229-
callback_data: `withdrawEarnings_${pending.community_id}`,
230-
},
220+
// Don't let a notification failure abort the run before the earnings
221+
// are restored further down; restoring the balance is the critical
222+
// step and the pending payment is excluded from future runs once it is
223+
// flagged as expired.
224+
try {
225+
await bot.telegram.sendMessage(
226+
user.tg_id,
227+
i18nCtx.t('invoice_expired_earnings'),
228+
{
229+
reply_markup: {
230+
inline_keyboard: [
231+
[
232+
{
233+
text: i18nCtx.t('withdraw_earnings'),
234+
callback_data: `withdrawEarnings_${pending.community_id}`,
235+
},
236+
],
231237
],
232-
],
238+
},
233239
},
234-
},
235-
);
240+
);
241+
} catch (error) {
242+
logger.error(
243+
`Failed to notify user ${user.tg_id} about expired invoice: ${error}`,
244+
);
245+
}
236246
}
237247

238248
const community = await Community.findById(pending.community_id);
@@ -241,8 +251,10 @@ export const attemptCommunitiesPendingPayments = async (
241251
pending.paid = true;
242252
pending.paid_at = new Date();
243253

244-
// Reset the community's values
245-
community.earnings = 0;
254+
// The earnings were already atomically zeroed when this withdrawal was
255+
// claimed at scheduling time, so don't reset them again here: doing so
256+
// would wipe out any earnings accrued between the claim and this
257+
// successful payment.
246258
community.orders_to_redeem = 0;
247259
await community.save();
248260
logger.info(
@@ -270,10 +282,25 @@ export const attemptCommunitiesPendingPayments = async (
270282
);
271283
}
272284

273-
if (
285+
const attemptsExhausted =
274286
process.env.PAYMENT_ATTEMPTS !== undefined &&
275-
pending.attempts >= parseInt(process.env.PAYMENT_ATTEMPTS)
276-
) {
287+
pending.attempts >= parseInt(process.env.PAYMENT_ATTEMPTS);
288+
289+
// SECURITY/accounting: the earnings were atomically claimed (zeroed)
290+
// when this withdrawal was scheduled. If the payout has now failed
291+
// permanently (invoice expired or no attempts left) we restore them so
292+
// the community can withdraw again without losing funds. Because the
293+
// pending payment is then excluded from future runs (is_invoice_expired
294+
// or attempts >= PAYMENT_ATTEMPTS), this restore happens exactly once.
295+
if (pending.is_invoice_expired || attemptsExhausted) {
296+
// Atomic increment against the current DB value so we don't clobber
297+
// earnings accrued since this community document was read above.
298+
await Community.findByIdAndUpdate(community._id, {
299+
$inc: { earnings: pending.amount },
300+
});
301+
}
302+
303+
if (attemptsExhausted) {
277304
await bot.telegram.sendMessage(
278305
user.tg_id,
279306
i18nCtx.t('pending_payment_failed_earnings', {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)