Skip to content

add pending tip system for unregistered receivers#9

Open
reubenyap wants to merge 1 commit intomasterfrom
claude/improve-tip-flow-MTRbm
Open

add pending tip system for unregistered receivers#9
reubenyap wants to merge 1 commit intomasterfrom
claude/improve-tip-flow-MTRbm

Conversation

@reubenyap
Copy link
Copy Markdown
Member

@reubenyap reubenyap commented Mar 9, 2026

Instead of rejecting tips to users who haven't started the bot,
the tip is now held in a pending_tips collection. The sender's
balance is deducted immediately and the receiver gets credited
when they /start the bot. A group notification with a deeplink
is sent to prompt the receiver. Unclaimed tips are automatically
refunded after 24 hours. Anonymous tips also work for unregistered
users.

https://claude.ai/code/session_01RpTqUteK39dKfbizUcsKhi


Note

High Risk
Changes tip balance accounting by deducting senders immediately while deferring receiver credits, plus adds an automated 24h refund job; bugs here could create incorrect balances or double credit/refund scenarios.

Overview
Tipping now supports unregistered/unverified receivers. When tipping a user who hasn’t started the bot, the tip is stored in a new pending_tips collection, the sender is debited immediately, tip_logs entries are marked pending, and (in group chats) the receiver is prompted with a deeplink to start the bot.

Claim + expiry flow added. On /start, the bot processes and credits any pending tips to the new user (sending the usual receive images), and a scheduled task periodically expires pending tips older than 24 hours by refunding the sender and marking the corresponding log as refunded.

Written by Cursor Bugbot for commit 4103397. This will update automatically on new commits. Configure here.

Instead of rejecting tips to users who haven't started the bot,
the tip is now held in a pending_tips collection. The sender's
balance is deducted immediately and the receiver gets credited
when they /start the bot. A group notification with a deeplink
is sent to prompt the receiver. Unclaimed tips are automatically
refunded after 24 hours. Anonymous tips also work for unregistered
users.

https://claude.ai/code/session_01RpTqUteK39dKfbizUcsKhi
@reubenyap
Copy link
Copy Markdown
Member Author

bugbot run

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Race condition causes double-spending of pending tips
    • Replaced list(find()) + delete_one() with atomic find_one_and_delete() in both expire_pending_tips and process_pending_tips to prevent both threads from processing the same tip.
  • ✅ Fixed: Pending tips lost if loop exception before balance credit
    • Moved balance credit inside the loop immediately after each atomic deletion, so each tip is fully processed before moving to the next one.

Create PR

Or push these changes by commenting:

@cursor push dcd074e0f1
Preview (dcd074e0f1)
diff --git a/tipbot.py b/tipbot.py
--- a/tipbot.py
+++ b/tipbot.py
@@ -101,9 +101,13 @@
         """Refund pending tips older than 24 hours"""
         try:
             cutoff = datetime.datetime.now() - datetime.timedelta(days=1)
-            expired_tips = list(self.col_pending_tips.find({"created_at": {"$lt": cutoff}}))
 
-            for tip in expired_tips:
+            # Use find_one_and_delete to atomically fetch and delete each expired tip
+            while True:
+                tip = self.col_pending_tips.find_one_and_delete({"created_at": {"$lt": cutoff}})
+                if tip is None:
+                    break
+
                 # Refund sender
                 sender = self.col_users.find_one({"_id": tip['from_user_id']})
                 if sender is not None:
@@ -122,9 +126,6 @@
                     except Exception:
                         pass
 
-                # Remove the pending tip
-                self.col_pending_tips.delete_one({"_id": tip['_id']})
-
                 # Update tip log
                 self.col_tip_logs.update_one(
                     {
@@ -143,13 +144,22 @@
     def process_pending_tips(self, user_id):
         """Process all pending tips for a user who just started the bot"""
         try:
-            pending_tips = list(self.col_pending_tips.find({"to_user_id": user_id}))
-            total_amount = 0
+            # Use find_one_and_delete to atomically fetch and delete each pending tip
+            while True:
+                tip = self.col_pending_tips.find_one_and_delete({"to_user_id": user_id})
+                if tip is None:
+                    break
 
-            for tip in pending_tips:
-                total_amount += tip['amount']
+                # Credit receiver immediately after atomic deletion
+                receiver = self.col_users.find_one({"_id": user_id})
+                if receiver is not None:
+                    new_balance = float("{0:.8f}".format(float(receiver['Balance']) + float(tip['amount'])))
+                    self.col_users.update_one(
+                        {"_id": user_id},
+                        {"$set": {"Balance": new_balance}}
+                    )
 
-                # Send receive image for each tip
+                # Send receive image for the tip
                 try:
                     self.create_receive_tips_image(
                         user_id,
@@ -171,18 +181,6 @@
                     {"$set": {"pending": False}}
                 )
 
-                # Remove pending tip
-                self.col_pending_tips.delete_one({"_id": tip['_id']})
-
-            if total_amount > 0:
-                # Credit all pending tips to the receiver
-                receiver = self.col_users.find_one({"_id": user_id})
-                new_balance = float("{0:.8f}".format(float(receiver['Balance']) + float(total_amount)))
-                self.col_users.update_one(
-                    {"_id": user_id},
-                    {"$set": {"Balance": new_balance}}
-                )
-
         except Exception as exc:
             print(exc)
             traceback.print_exc()
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Comment thread tipbot.py

except Exception as exc:
print(exc)
traceback.print_exc()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Race condition causes double-spending of pending tips

High Severity

expire_pending_tips runs in the background pending_tasks thread (via schedule), while process_pending_tips runs in the main message-processing thread. Both methods fetch pending tips, then independently process and delete them with no locking or atomic checks. If a user /starts the bot right when the expiry job runs on the same 24h+ old tip, both methods load the same tip into memory, and both succeed — the sender gets refunded AND the receiver gets credited, creating funds from nothing.

Additional Locations (1)

Fix in Cursor Fix in Web

Comment thread tipbot.py
self.col_users.update_one(
{"_id": user_id},
{"$set": {"Balance": new_balance}}
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pending tips lost if loop exception before balance credit

Medium Severity

In process_pending_tips, each pending tip is deleted from col_pending_tips inside the loop (via delete_one), but the receiver's balance credit happens after the loop completes. If an unhandled exception occurs mid-loop (e.g., col_tip_logs.update_one fails on a later iteration), the outer except catches it, and tips already deleted in earlier iterations are never credited — those funds are permanently lost.

Fix in Cursor Fix in Web

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.

3 participants