add pending tip system for unregistered receivers#9
Conversation
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
|
bugbot run |
There was a problem hiding this comment.
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.
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()|
|
||
| except Exception as exc: | ||
| print(exc) | ||
| traceback.print_exc() |
There was a problem hiding this comment.
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)
| self.col_users.update_one( | ||
| {"_id": user_id}, | ||
| {"$set": {"Balance": new_balance}} | ||
| ) |
There was a problem hiding this comment.
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.



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_tipscollection, the sender is debited immediately,tip_logsentries are markedpending, 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 asrefunded.Written by Cursor Bugbot for commit 4103397. This will update automatically on new commits. Configure here.