Skip to content

fix: prevent double withdrawal of community earnings#835

Merged
grunch merged 3 commits into
mainfrom
fix/community-earnings-double-withdraw
Jun 13, 2026
Merged

fix: prevent double withdrawal of community earnings#835
grunch merged 3 commits into
mainfrom
fix/community-earnings-double-withdraw

Conversation

@grunch

@grunch grunch commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the community earnings withdrawal flow against a race condition that could allow the same earnings balance to be paid out more than once.

Previously, the withdrawal wizard read community.earnings, scheduled a pending payment, and only later relied on bookkeeping to reduce the balance. Because the read and the payout scheduling were not atomic, two withdrawal attempts processed close together could each observe the same non-zero balance and both proceed, resulting in the node paying out the same earnings twice.

Changes

  • bot/modules/community/scenes.ts — Atomically claim the earnings before scheduling the payout. The community's earnings is zeroed with a conditional findOneAndUpdate that only matches when the balance still equals the snapshotted amount. Concurrent attempts race on this compare-and-set, so only one wins; the loser aborts with the "already being paid" message. The pending payment is created from the snapshotted amount.
  • jobs/pending_payments.ts — When a community payout fails permanently (invoice expired or payment attempts exhausted), restore the claimed earnings so the creator can withdraw again without losing funds. The restore runs exactly once because the pending payment is excluded from subsequent runs once it is expired or out of attempts.

Testing

  • npx tsc --noEmit passes.

Summary by CodeRabbit

  • Bug Fixes
    • Added atomic concurrency guard preventing duplicate payouts from simultaneous withdrawal attempts.
    • Improved failed payment handling with proper earnings restoration.
    • Enhanced error messaging for expired or exhausted payment attempts.

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>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@grunch, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 11 minutes and 47 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6f5446d7-d166-46c1-8907-4acf471cc65a

📥 Commits

Reviewing files that changed from the base of the PR and between b4381e5 and fe47019.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • bot/modules/community/scenes.ts
  • jobs/pending_payments.ts

Walkthrough

The PR adds atomic concurrency protection to community earnings withdrawal. The invoice wizard now snapshots earnings, performs a compare-and-set claim to prevent duplicate withdrawals, and aborts if already claimed. The payment failure handler restores earnings for both expired and exhausted-attempts cases.

Changes

Concurrent Earnings Withdrawal Safety

Layer / File(s) Summary
Atomic earnings snapshot and claim guard
bot/modules/community/scenes.ts
The wizard snapshots current earnings into amountToWithdraw, validates the invoice against this snapshot, and performs an atomic Community.findOneAndUpdate compare-and-set that sets earnings to zero only if it still matches the snapshot. Concurrent claims fail atomically, preventing duplicate pending payouts. On success, a PendingPayment is created using the snapped amount.
Earnings restoration in payment failure handling
jobs/pending_payments.ts
When a pending community payment fails (invoice expired or max attempts reached), an attemptsExhausted condition drives both earnings restoration and failure messaging. Earnings are now restored for both invoice-expired and exhausted-attempts cases, and the failure message is sent only when attempts are exhausted.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • lnp2pBot/bot#784: Both PRs change the community earnings withdrawal flow in the same places—addEarningsInvoiceWizard (pending-invoice/invoice-expired handling) and attemptCommunitiesPendingPayments (invoice-expired/max-attempts failure path messaging)—so the main PR's concurrency/amount and restoration logic is directly connected to the retry/UX failures addressed in #784.

Suggested reviewers

  • Luquitasjeffrey
  • mostronatorcoder

Poem

A rabbit hops through earnings flows so fast,
With atomic guards to hold the claim—
No double-dash when claims amass,
And restoration heals the payment's flame. 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix: prevent double withdrawal of community earnings' directly and precisely describes the main objective: preventing a race condition that allows the same earnings to be withdrawn multiple times.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/community-earnings-double-withdraw

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bot/modules/community/scenes.ts (1)

1042-1049: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the claim and pending-payment insert one atomic unit.

Because Line 1031 has already zeroed the community balance, any failure from here through pp.save() strands the earnings permanently. User.findById can throw, PendingPayment.save() can fail, and the catch path only logs/leaves; without a persisted PendingPayment, attemptCommunitiesPendingPayments has nothing to restore later.

A transaction or an explicit rollback of the claim is needed here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bot/modules/community/scenes.ts` around lines 1042 - 1049, The code zeroes
the community balance then creates a PendingPayment (pp.save()) and looks up
User.findById, but these steps are not atomic so failures can permanently strand
earnings; wrap the claim/withdrawal and PendingPayment insertion in a single
atomic operation (use a MongoDB session/transaction around creating the Claim
record, creating/saving the PendingPayment, and any user lookup/updates) or, if
transactions are not available, implement an explicit rollback: if User.findById
or pp.save() throws, revert the claim by restoring the community balance and
deleting any created Claim/partial records so attemptCommunitiesPendingPayments
can later restore funds. Ensure the code updates/creates PendingPayment only
inside the transaction (or after successful persistence of compensating rollback
logic) and surface errors so callers can retry or abort consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bot/modules/community/scenes.ts`:
- Around line 1024-1034: The code now atomically zeros community.earnings at
scheduling via Community.findOneAndUpdate (claimed), which conflicts with the
existing success path in jobs/pending_payments.ts that also zeroes
community.earnings on successful payment; update the success handler in
jobs/pending_payments.ts to stop blindly setting earnings = 0 and instead either
(a) leave community.earnings untouched on success and only clear the claimed
amount (e.g. subtract the claimed amount or remove a pendingPayouts entry), or
(b) use a dedicated pendingPayouts/pending_claims field to track the snapshot
and clear only that field on success/failure; also adjust any logic that writes
orders_to_redeem to ensure it reflects only the claimed batch (or document that
it is cumulative) so subsequent saves do not erase earnings accrued after claim.
Ensure references to the claim use the claimed variable returned by
Community.findOneAndUpdate and perform updates with atomic increments/decrements
(e.g., $inc) or targeted field clears rather than overwriting the whole earnings
field.

In `@jobs/pending_payments.ts`:
- Around line 273-283: The restore of claimed earnings is only executed inside
the if (pending.is_invoice_expired || attemptsExhausted) block but currently
happens after the awaited bot.telegram.sendMessage(...) call on the
expired-invoice path, so a thrown sendMessage prevents the restore and leaves
funds locked; move the restore logic so it runs before any external/awaited
notification or, alternatively, wrap the notification in its own try/catch and
perform the restore in a finally or immediately prior to calling
bot.telegram.sendMessage; update the code paths around attemptsExhausted,
pending.is_invoice_expired and the bot.telegram.sendMessage(...) invocation to
ensure the balance-restoration code always executes even if sendMessage fails.
- Around line 284-285: The restore is doing a stale read-modify-write by
mutating community.earnings in memory and calling community.save(); replace this
with a database-side atomic increment (use the community._id and an $inc on the
earnings field) so the update is applied against the current DB value (e.g.,
call the model's updateOne/findByIdAndUpdate with {$inc: { earnings:
pending.amount }}), rather than using community.earnings += pending.amount and
community.save().

---

Outside diff comments:
In `@bot/modules/community/scenes.ts`:
- Around line 1042-1049: The code zeroes the community balance then creates a
PendingPayment (pp.save()) and looks up User.findById, but these steps are not
atomic so failures can permanently strand earnings; wrap the claim/withdrawal
and PendingPayment insertion in a single atomic operation (use a MongoDB
session/transaction around creating the Claim record, creating/saving the
PendingPayment, and any user lookup/updates) or, if transactions are not
available, implement an explicit rollback: if User.findById or pp.save() throws,
revert the claim by restoring the community balance and deleting any created
Claim/partial records so attemptCommunitiesPendingPayments can later restore
funds. Ensure the code updates/creates PendingPayment only inside the
transaction (or after successful persistence of compensating rollback logic) and
surface errors so callers can retry or abort consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cd5e8a16-bf85-42fd-b416-f7e434e01906

📥 Commits

Reviewing files that changed from the base of the PR and between 056284a and b4381e5.

📒 Files selected for processing (2)
  • bot/modules/community/scenes.ts
  • jobs/pending_payments.ts

Comment thread bot/modules/community/scenes.ts
Comment thread jobs/pending_payments.ts
Comment thread jobs/pending_payments.ts Outdated
grunch and others added 2 commits June 13, 2026 08:39
- 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>
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>
@grunch

grunch commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@grunch grunch requested a review from Luquitasjeffrey June 13, 2026 11:53

@Luquitasjeffrey Luquitasjeffrey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tACK

@grunch grunch merged commit 23a3d2f into main Jun 13, 2026
7 checks passed
@grunch grunch deleted the fix/community-earnings-double-withdraw branch June 13, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants