feat: add order-take rate limit to prevent spam#828
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds per-user order-taking rate limiting with persisted counters, configurable cooldowns, atomic reservation and rollback, localized rejection messages, and state reset during reputation processing. ChangesOrder-take rate limiting
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
bot/modules/orders/takeOrder.ts (2)
86-97:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftPersisting the user counter before order save can over-penalize users.
At Line 86/147,
incrementTakeOrderCount(user)is saved before Line 96/153order.save(). If order persistence fails, the user still gets count/cooldown updates for a take that never completed. This should be one atomic unit (or at least update the counter only after order save succeeds).Also applies to: 147-153, 194-195
🤖 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/orders/takeOrder.ts` around lines 86 - 97, The incrementTakeOrderCount(user) call is executed before persisting the order, which will penalize the user if order.save() fails; move the incrementTakeOrderCount(user) to after a successful await order.save() (or wrap both operations in a DB transaction if supported) so the counter/cooldown is only updated once order.status, order.seller_id, taken_at and order.random_image have been persisted; adjust the same pattern wherever incrementTakeOrderCount appears (lines referencing incrementTakeOrderCount, order.save, generateRandomImage, and order.status) to ensure counter updates occur post-save or inside the same atomic transaction.
62-63:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftPer-order mutex does not protect per-user counter updates.
At Line 62 and Line 114, locking is keyed by
orderId, so the same user can take different orders concurrently and race ontake_order_countvia Line 194user.save(). Use an atomic DB update ($inc+ conditional cooldown update) or add a per-user lock to avoid lost updates.Also applies to: 114-115, 184-195
🤖 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/orders/takeOrder.ts` around lines 62 - 63, The per-order mutex (PerOrderIdMutex.instance.runExclusive keyed by orderId) does not prevent concurrent updates to the same user's take_order_count because different orders for the same user can run concurrently; replace or augment this by performing an atomic database update (use a single update with $inc on take_order_count plus conditional update of the cooldown fields via a conditional operator/aggregation pipeline) or acquire a per-user lock (e.g., PerUserIdMutex.instance.runExclusive keyed by user.id) around the critical section that reads/modifies user and calls user.save(); locate uses of PerOrderIdMutex.instance.runExclusive, the user.save() call and the take_order_count update and change them to either an atomic DB operation or wrap the user mutation in a per-user mutex to prevent lost updates.
🤖 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/orders/takeOrder.ts`:
- Around line 170-182: The cooldown check is leaving stale state in
user.take_order_count when user.take_order_cooldown_until is in the past; update
the logic in the function in takeOrder.ts that checks
user.take_order_cooldown_until to first detect an expired timestamp and
reset/clear user.take_order_count and user.take_order_cooldown_until before
proceeding, then persist/update the user as needed; continue to call
messages.orderTakeRateLimitMessage(ctx, user, user.take_order_cooldown_until)
only when the cooldown is still in the future. Also apply the same
expired-timestamp reset to the similar block referenced around the 188–193
region.
- Around line 185-186: The env parsing for rate-limit config (maxOrdersTake and
cooldownHours in takeOrder.ts) can yield NaN or non-positive values and disable
cooldown logic; after parsing parseInt(process.env.MAX_ORDERS_TAKE || '10') and
parseInt(process.env.ORDER_TAKE_COOLDOWN_HOURS || '24'), validate each parsed
value (maxOrdersTake and cooldownHours) with Number.isFinite/Number.isInteger
and ensure they are > 0, otherwise reset to safe defaults (e.g., 10 for
maxOrdersTake and 24 for cooldownHours) and optionally clamp to sensible bounds;
update any usages that rely on these variables (e.g., rate-limit checks like
count >= cooldownHours) to use the validated values.
---
Outside diff comments:
In `@bot/modules/orders/takeOrder.ts`:
- Around line 86-97: The incrementTakeOrderCount(user) call is executed before
persisting the order, which will penalize the user if order.save() fails; move
the incrementTakeOrderCount(user) to after a successful await order.save() (or
wrap both operations in a DB transaction if supported) so the counter/cooldown
is only updated once order.status, order.seller_id, taken_at and
order.random_image have been persisted; adjust the same pattern wherever
incrementTakeOrderCount appears (lines referencing incrementTakeOrderCount,
order.save, generateRandomImage, and order.status) to ensure counter updates
occur post-save or inside the same atomic transaction.
- Around line 62-63: The per-order mutex (PerOrderIdMutex.instance.runExclusive
keyed by orderId) does not prevent concurrent updates to the same user's
take_order_count because different orders for the same user can run
concurrently; replace or augment this by performing an atomic database update
(use a single update with $inc on take_order_count plus conditional update of
the cooldown fields via a conditional operator/aggregation pipeline) or acquire
a per-user lock (e.g., PerUserIdMutex.instance.runExclusive keyed by user.id)
around the critical section that reads/modifies user and calls user.save();
locate uses of PerOrderIdMutex.instance.runExclusive, the user.save() call and
the take_order_count update and change them to either an atomic DB operation or
wrap the user mutation in a per-user mutex to prevent lost updates.
🪄 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: 870f3837-70a7-4381-a84d-d62b7257d716
📒 Files selected for processing (15)
.env-samplebot/messages.tsbot/modules/orders/takeOrder.tslocales/de.yamllocales/en.yamllocales/es.yamllocales/fa.yamllocales/fr.yamllocales/it.yamllocales/ko.yamllocales/pt.yamllocales/ru.yamllocales/uk.yamlmodels/user.tsutil/index.ts
Luquitasjeffrey
left a comment
There was a problem hiding this comment.
Please solve your conflicts with main to proceed with the review
…e-limit # Conflicts: # bot/messages.ts # bot/modules/orders/takeOrder.ts # models/user.ts # package-lock.json
There was a problem hiding this comment.
Code Review Summary
Verdict: Request changes
The stale cooldown/env parsing issues are fixed on the current head, but one blocker remains: the take counter is still updated from stale in-memory state, and it is written before the order itself is persisted. That leaves the spam cap bypassable under concurrent takes from the same user, and it can also penalize a user for a take that never completed because order.save() failed.
There was a problem hiding this comment.
Code Review Summary
Verdict: Request changes
The save-before-increment issue is fixed on the current head, but one blocker remains: the take limit is still enforced only after a stale read. checkTakeRateLimit() reads the user document before the atomic $inc, and there is no per-user lock across different orders. Two or more concurrent takes by the same user can all pass the check and push the counter past MAX_ORDERS_TAKE, which defeats the anti-spam protection this PR is meant to add.
There is also still a crash window between incrementing take_order_count and writing take_order_cooldown_until, so a failed second write can leave the cap unenforced until the next successful take.
Please gate the counter with an atomic conditional update or a per-user mutex before any order side effects.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/orders/takeOrder.ts`:
- Around line 277-291: The updatedUser === null branch in the order-taking flow
can return without notifying the user when the follow-up User.findById read
lacks take_order_cooldown_until. Add a single retry of the cooldown reservation
or emit a fallback blocked message when current is missing or has no cooldown,
ensuring the branch always provides feedback before returning.
🪄 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: 30cf41d9-f361-476a-8bfa-8c6b5fe30113
📒 Files selected for processing (1)
bot/modules/orders/takeOrder.ts
Luquitasjeffrey
left a comment
There was a problem hiding this comment.
Concept and testing ACK.
Please improve the readibility of mongoose operations
There was a problem hiding this comment.
Code Review Summary
Verdict: Request changes
The readability refactor is a good step, but one correctness blocker remains: the new take-limit state is still reset in util/index.ts without the same per-user serialization used in takeOrder.ts. A successful order completion can race with a concurrent take on the same user and overwrite a newer take_order_count / take_order_cooldown_until state. Please guard those resets with the same user-keyed mutex or make them atomic.
There was a problem hiding this comment.
Code Review Summary
Verdict: Approve
I re-reviewed the current head. The previous blocker in util/index.ts is now serialized with the same per-user mutex used by the take flow, so the counter/cooldown reset can no longer race a concurrent take on the same user. I don't see any blocking issues remaining.
grunch
left a comment
There was a problem hiding this comment.
Strict review
Verdict: request changes — no security/fund-loss bugs found, but one notable design issue plus a few adjustments.
Verified locally: tsc --noEmit clean, all 5 concurrency tests pass, 10 locales present with consistent ${hours}, and the message is sent as plain text (good — avoids the MarkdownV2 escaping pitfall).
🔴 HIGH
H1 — Honest takers get penalized for their counterparty's behavior.
take_order_count is only reset to 0 on SUCCESS (fully paid order, ln/pay_request.ts:167) or on cooldown expiry. It is not decremented when a taken order is later cancelled or expires through the normal flow — releaseTakeSlot only fires when order.save() itself throws (takeOrder.ts:88,124). Confirmed by grep: no other code path touches the counter.
Real-world impact in P2P: a buyer who takes 10 sell orders whose sellers never fund the hold invoice (a very common case) reaches MAX_ORDERS_TAKE and is locked out for 24h without doing anything wrong. The design does not distinguish "victim of flaky counterparties" from "spammer".
Suggestion: reset/decrement the counter when a taken order is cancelled or expires with no fault on the taker (in the cancellation/expiration jobs), or only count takes that never progressed.
🟡 MEDIUM
M2 — Incomplete test coverage. The concurrency tests for reserveTakeSlot/releaseTakeSlot are good, but missing:
- reset-on-completion (
handleReputationItemszeroing both counters), - a test asserting
reserveTakeSlotis actually wired intotakebuy/takesell(regression guard), - an assertion that the message is not sent on an allowed take.
M3 — Misleading comments. The reserveTakeSlot header and both call sites say "Atomically reserve … in a single conditional update" (takeOrder.ts:77,113,151). The implementation is not an atomic conditional update: it is a findById → save read-modify-write guarded by an in-memory mutex. This is leftover wording from the earlier approach (history shows refactor: use per-user mutex instead of atomic mongo pipelines). It misrepresents the concurrency guarantee — please correct it.
🟢 LOW
L1 — Multi-process deployment. PerUserIdMutex is an in-memory (async-mutex) lock; it only serializes within a single process. I verified the bot starts with bot.launch() (long polling), which is inherently single-process → correct today. But if it ever moves to webhooks with replicas, or this logic is moved into a separate job process, reserveTakeSlot's read-modify-write races and the cap can be bypassed. Worth documenting the single-process assumption next to the class.
L2 — releaseTakeSlot clears a legitimate cooldown. If 10 concurrent takes reserve (count=10, cooldown opened) and one save() fails, the release drops count to 9 and clears the cooldown even though 9 real takes remain. Slight under-count / lets the user escape the cooldown early. Self-consistent and low impact, but worth a comment.
L3 — Silent block. In reserveTakeSlot, when dbUser === null it returns true (blocks) without sending any message (takeOrder.ts:227). Unlikely (middleware creates the user), but it's a block with no user feedback.
L4 — i18n wording. The message says "complete an active order to reset the counter", but the reset actually requires a SUCCESS (paid) order, not merely an active one. Minor wording tweak across the 10 locales.
✅ Done well
reserveTakeSlotis called after all validations → a validation rejection never consumes a slot (the comment claims this and it's true).releaseTakeSloton a failedsave()is a thoughtful touch.- Hardened env parsing (
Number.isInteger/isFinite+ fallback). KeyedMutexcleans up the map atlockCount == 0(no memory leak).- Plain-text message, no
parse_mode.
Overall the concurrency core is solid and well-tested; the main thing blocking approval is H1 (fairness for honest takers), with M2/M3 as follow-ups.
…e-limit # Conflicts: # bot/commands.ts
Add a rate limit on order taking to prevent spam: users who exceed
MAX_ORDERS_TAKEconsecutive takes without completing an order are temporarily blocked until the cooldown expires or they complete a trade.Summary
MAX_ORDERS_TAKEandORDER_TAKE_COOLDOWN_HOURSenv vars to control the rate limittake_order_countandtake_order_cooldown_untilon the User modelChanges
models/user.ts— new fields:take_order_count,take_order_cooldown_untilbot/modules/orders/takeOrder.ts— rate limit check intakebuyandtakesellutil/index.ts— reset counter on order completion (SUCCESS)bot/messages.ts— neworderTakeRateLimitMessagewith remaining hourslocales/—order_take_rate_limitkey added in all 10 languages.env-sample— documented new env vars with defaults (MAX_ORDERS_TAKE=10,ORDER_TAKE_COOLDOWN_HOURS=24)Summary by CodeRabbit