fix(keeper): back off retries for a position that keeps failing liquidation - #365
Conversation
…dation _cycleSeenPositions/_inFlightPositions only prevent concurrent double-submission within or across one cycle -- neither remembers that a liquidate() attempt for a given position failed. A position whose liquidate() keeps failing (e.g. an owner racing a cheap top-up between the keeper's scan and submit to flip stillLiquidatable just before send) gets re-attempted at full tx-fee cost on every single polling cycle and LaserStream event, forever, with zero increasing cost to the owner -- an asymmetric-cost DoS on the keeper's wallet that no existing breaker catches (the budget circuit breaker is global, not per-position). Adds a per-position failure-backoff map in LiquidationService. The first failure is free (immediate retry permitted -- a single recheck-abort is routine and must not delay a position that's still genuinely liquidatable, matching the existing in-flight-guard test's documented intent). From the second consecutive failure onward, the retry cooldown escalates (5s, 10s, 20s, ... capped at 5 minutes, mirroring the existing cycle-level maxBackoffMs), bounding how often a sustained-failure position can be retried without ever permanently giving up on it. A successful liquidation clears the backoff history. BUG-103 from a clean-room Phase 4 audit pass.
|
Warning Review limit reached
More reviews will be available in 23 minutes and 38 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 To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✨ 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 |
|
Independent verification — not an approval (QA/Security own that), just evidence for whoever reviews. Verdict: genuine. Plus a merge-compatibility check with a sibling PR, since both touch Method: ran the PR's tests first (35 passed), then removed only the backoff skip: if (backoff && Date.now() < backoff.retryAfter) { → if (false) {Result: That's the behaviour the PR exists for, and it's the only test that moves — the other 34 cover paths that legitimately shouldn't change. Merge compatibility with #390
So they're independent in practice — #365 sits around A correction to my own first attempt, since it produced a scary answer: I initially reported a conflict to myself because I ran the second One design noteThe backoff key is per-position, and Worth being aware of (not a change request): the map has no eviction. A keeper running for a long time across many markets accumulates one entry per position that has ever failed. Entries are tiny and only added on failure, so it's unlikely to matter — but if positions churn heavily it grows unboundedly, and the natural fix is to drop entries once No changes requested from me. |
Problem
LiquidationService's dedup state is purely about concurrency, not history:_cycleSeenPositions/_cycleOwnerCountsare cleared every polling cycle (scanAndLiquidateAll)._inFlightPositionsonly guards true concurrent execution.Nothing remembers that a
liquidate()call for a given position failed. A position whose liquidation keeps failing — for example, an owner racing a cheap top-up timed to land between the keeper's scan and its pre-submit recheck, repeatedly flippingstillLiquidatableto false right before send — gets re-attempted at full transaction-fee cost on every single polling cycle and LaserStream event, forever, with zero increasing cost to the owner.This is an asymmetric-cost DoS surface: the attacker's action (a tiny, cheap on-chain state change) costs far less than the keeper's resulting paid transaction attempt, and no existing breaker catches it —
KeeperBudget's circuit breaker is global (cycle/hour/day spend + success-rate), not keyed per-position, so a slow background drain across several flapping accounts can sit under the global threshold indefinitely.Production Impact
An attacker can keep N marginal accounts permanently flapping at the liquidation boundary, each costing the keeper a real priority-fee-bearing transaction every ~60s cycle (or faster via LaserStream-triggered re-evaluation), draining the keeper's hot wallet at a steady rate with no alert and no escalating cost to the attacker.
Fix
Added a per-position failure-backoff map (
_positionBackoff) inLiquidationService, checked ingatedLiquidate()(the single entry point for both the polling and LaserStream paths) before the existing in-flight/cycle-dedup checks:H-1test's documented intent ("a null (aborted) resolution must release the in-flight guard... otherwise every legitimate recheck-abort would permanently wedge the position").maxBackoffMsconstant already used for whole-cycle failures).This also saves the RPC cost of the expensive pre-submit recheck (oracle drift guard,
stillLiquidatable, fresh slab fetch) for backed-off positions, not just the final send.Proof of Fix
New tests in
tests/services/liquidation.test.ts(BUG-103: per-position failure backoff):liquidate()Test Output
Full suite: 979 passed, 33 skipped, 1 pre-existing unrelated failure (
tests/v17-risk-params.poc.test.ts— stale assertion from #345, out of scope here).pnpm build— clean, zero errors.