fix(liquidation): v17 pre-submit re-verification must fail closed (#373) - #390
fix(liquidation): v17 pre-submit re-verification must fail closed (#373)#390dcccrypto wants to merge 1 commit into
Conversation
The v17 pre-submit re-verification wrapped its entire block — the
still-undercollateralized margin recheck, the maintenance-margin re-parse and
the oracle-drift guard — in `try { … } catch { /* proceed cautiously */ }` with
an empty catch. Any throw inside it fell straight through to the send with NO
checks applied. The v12.x path directly below is unwrapped and already fails
closed; v17 is the live mainnet layout, so the asymmetry ran the wrong way.
That matters most for the oracle-drift guard, which the code itself documents as
having no on-chain counterpart:
"The on-chain Liquidate instruction carries no price bound, so keeper-side
drift detection is the only available mitigation."
Losing it means submitting at unbounded drift with no backstop anywhere —
keeper-side skipped, on-chain nonexistent. Triggers are not attacker-controlled
(malformed wrapper bytes in parseWrapperConfigV17, a cluster-time RPC error, the
M-6 owner-check failure in fetchSlabWithRetry), but they are reachable.
Narrows the try to the fetch/parse of re-verification data and returns null on
failure. The drift and margin checks now run outside it, so their failure aborts
the submit. Matches the H-8 pattern already applied to the inner reMmBps parse.
Trade-off, stated plainly: during RPC instability the keeper now SKIPS these
liquidations instead of submitting them blind. That is the intended posture and
the one v12.x already had; the next scan cycle retries.
Verification, per the issue: making parseWrapperConfigV17 throw, and separately
making the slab re-fetch throw, must not call keeperSend. Checked against the
ORIGINAL code rather than a hand-rolled mutation — both new tests FAIL on the
unfixed liquidation.ts and pass on the fix.
Also fixes the pre-existing drift test, which was vacuous: its wrapper config had
oracleTargetPublishTime=0n, so resolveV17WrapperPrice treated the EWMA as stale
and returned 0n — the test aborted on the freshPrice===0 guard and never reached
the drift check. It passed with the drift guard entirely disabled. The guard
this issue exists to protect had no working coverage. Now mutation-verified:
disabling the guard fails it.
Full keeper suite: 979 passed, 0 failures. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughV17 liquidation pre-submit re-verification now fails closed on data-fetch, parsing, or price-resolution errors. Tests verify aborted submission on failures and continued drift and margin validation after successful re-verification. ChangesV17 liquidation pre-submit re-verification
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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 |
Closes #373.
The bug
The v17 pre-submit re-verification wrapped its entire block — the still-undercollateralized margin recheck, the maintenance-margin re-parse, and the oracle-drift guard — in
try { … } catch { /* proceed cautiously */ }with an empty catch. Any throw fell straight through to the send with no checks applied.The v12.x path directly below is unwrapped and already fails closed. v17 is the live mainnet layout, so the asymmetry ran the wrong way.
The "proceed cautiously — the on-chain program will reject" justification covers the closed/topped-up case. It does not cover the drift guard, which the code itself documents as having no on-chain counterpart:
So on a throw, a liquidation could be submitted at unbounded drift with no backstop anywhere — keeper-side skipped, on-chain nonexistent. Triggers aren't attacker-controlled (malformed wrapper bytes in
parseWrapperConfigV17, a cluster-time RPC error, the M-6 owner-check failure infetchSlabWithRetry) but they're reachable.The fix
Narrow the
tryto the fetch/parse of re-verification data andreturn nullon failure. The drift and margin checks now run outside it, so their failure aborts the submit. Matches the H-8 pattern already applied to the innerreMmBpsparse.Trade-off, stated plainly: during RPC instability the keeper now skips these liquidations instead of submitting them blind. That's the intended posture and the one v12.x already had — the next scan cycle retries. It trades some liquidation coverage under RPC flakiness for never submitting an unverified liquidation.
Verification
The issue asked for a test where
parseWrapperConfigV17throws andkeeperSendis never called. Added that, plus the slab-re-fetch throw, plus two contrast tests proving the checks still run on the healthy path (otherwise "fail closed" could be implemented as "always return null" and the first two would still pass).I verified these against the original code, not a hand-rolled mutation — my first mutation attempt produced a broken hybrid that passed and would have let a useless test through:
Second finding: the existing drift test was vacuous
While building the contrast case I found the pre-existing test —
aborts v17 liquidation when fresh wrapper price drifts beyond the configured limit— passes with the drift guard entirely commented out.Its wrapper config had
oracleTargetPublishTime: 0n, soresolveV17WrapperPricetreated the EWMA as stale and returned0n. The test aborted on thefreshPrice === 0nguard and never reached the drift check.So the mitigation this whole issue exists to protect had no working test coverage. Fixed by giving it a fresh publish time; the 100% drift (1.0 → 2.0 against a 150bps limit) is now what actually aborts. Mutation-verified: disabling the guard fails it.
Checks
npx tsc --noEmitcleanTouches liquidation safety logic on the live mainnet path — wants a security review before merge, not just a functional one.
🤖 Generated with Claude Code
Summary by CodeRabbit