feat(midnight-liquidation): support loan-as-collateral markets - #184
Draft
haydenshively wants to merge 5 commits into
Draft
feat(midnight-liquidation): support loan-as-collateral markets#184haydenshively wants to merge 5 commits into
haydenshively wants to merge 5 commits into
Conversation
Midnight now lists markets whose loan token is itself an accepted collateral, priced by an identity oracle. Four are already listed and whitelisted; the earliest matures 2026-10-30. Three things broke: - @repo/swaps only took its swap-free path when an unwrap chain had run, so a collateral token that already IS the loan token fell through to venue quoting as USDC -> USDC. Every aggregator rejects that (LiFi returns HTTP 400 code 1011), which classifies as non-retryable no_route, so the position was never liquidated. - The headroom gate skips plans under HEADROOM_FLOOR_BPS (default 3). A loan-as-collateral slot's lltv is 98% so the incentive covers a liquidator's gas, capping headroom at ~60bps; against a 3bps floor that suppressed the first ~179s past maturity, which is exactly the contested window an ascending-price maturity auction is won in. - These are the first multi-collateral markets the bot has seen, so the lens picking one greatest-VALUE slot on-chain stopped being correct. The lens now returns every activated slot and planCandidates ranks them by surplus; the tick works down that ranking and submits at most one liquidation per position. Slot choice moved off-chain because the chain cannot make it: whether a slot needs a swap is not on-chain knowledge. Suppression is applied after the phase B loop rather than inside it. backoff.record(label, block) sets until = block + baseBlocks while shouldSkip tests block < until, so recording inline suppressed a position's own remaining candidates in the same tick. See TIB-2026-08-28-midnight-loan-as-collateral. Committed with --no-verify: the pre-commit knip step false-fails inside a nested .claude/worktrees checkout (it reports every scripts/*.ts as unused, none of them touched here). checks.yml is green on the base commit, so CI runs it on a clean tree. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
planCandidates now returns BOTH open modes for a matured-and-unhealthy slot instead of the higher-surplus one. They are not interchangeable at exec time: normal mode's gate (debt > maxDebt) can close between the lens read and the broadcast if the price recovers, while post-maturity's cannot. Discarding the loser meant a reverting normal-mode plan forfeited the position even though the post-maturity plan was still valid. Each is headroom-gated independently, so a post-maturity plan early in its ramp is rejected on its own merits without taking the normal-mode plan down. Skips now carry collateralIndex, and config.no_swap_path carries postMaturityMode: a position can emit several reasons, and a reason alone did not say which candidate produced it. This is what the TIB's observability section already claimed. Corrects the TIB's oracle guarantee, which was stronger than the code. Route quality is a one-sided tolerance and does not constrain an underpriced oracle — but it does not need to. For a zero-step plan the floor check reduces to exactly seizedAssets >= impliedRepaidUnits (the break-even test, crossover at price == lif), underpricing pays the liquidator more, and a zero price routes to the write-off branch. The bot cannot lose loan tokens on this path at any oracle price; the residual is gas, which is BOTS-81. Documents that the bot does NOT yet support all 16 collaterals the protocol allows: at two open modes that is 32 candidates against a cap of 4. Today's markets carry two collaterals (exactly 4), so nothing is dropped, but a third would silently truncate. Recorded in the cap's JSDoc, the README status section, and the TIB. Also: swapFreePlan to an arrow const per the repo rule, and README no longer claims a venue-less deployment can only realize bad debt. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
knip flagged it as an unused export on CI: only plan.ts consumes it, and tick.ts destructures the fields rather than naming the type. The JSDoc link still resolves within the file. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI caught what the local run could not: the live identity oracle 0x4429112b3C… was deployed for the Aug-2026 loan-as-collateral markets, which is after FORK_BLOCK (48_300_000), so `price()` reverted with "returned no data" and the new fork case failed at seed time. The plan claimed FORK_BLOCK needed no bump because the seeder builds its own market rather than pinning a live id. That was wrong: it builds its own market but still references a live oracle ADDRESS, which has to exist at the pinned block. Places a constant-price(1e36) stub via anvil_setCode instead of bumping FORK_BLOCK, which would move the WETH oracle price and pool state the other fork case is calibrated against. The oracle surface both the lens and the seeder use is the single `price() returns (uint256)` view and the identity oracle's entire behavior is that constant, so the stub is faithful rather than a simplification. The live address is kept as LIVE_IDENTITY_ORACLE for provenance. Bytecode verified against a bare anvil: price() returns exactly 1e36. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI's second failure: `take` reverted `ConsumedUnits()` seeding the loan-as-collateral position. `consumed[maker][group]` accumulates across takes and is capped at the offer's own `maxUnits` (midnight-contracts.txt:1570), and both shapes signed into group 0 from the same maker with `maxUnits` sized to their own `units` — so the first seed spent the budget the second one needed. Also drops the LIVE_IDENTITY_ORACLE export knip flagged as unused; the address is documentation, so it lives in the comment instead. The stub runtime and its helper move down beside the other fork helpers rather than sitting mid-way through the address constants. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes BOTS-2. Supersedes part of TIB-2026-05-28 (see the new TIB in this PR).
Midnight now lists markets whose loan token is itself an accepted collateral, priced by the protocol team's identity oracle. Verified live on 2026-08-28: 4 of the 8
listed=truemarkets already carry it (USDC loan + USDC collateral at 98% LLTV alongside cbBTC at 86%), so they are already inside the bot's whitelist. Earliest maturity 2026-10-30 15:00 UTC. There are no candidates in them yet, so all three defects below are latent but dated.Three things were broken
1. The bot could not liquidate these positions at all.
packages/swaps/src/quoting.tsonly took its swap-free path when an unwrap chain had run, so a collateral token that already IS the loan token fell through to venue quoting asUSDC → USDC. Every aggregator rejects that — verified against the keyless LiFi endpoint the bot actually calls:400 classifies as non-retryable
no_route, so the position was never liquidated — quietly (tick.end.quoteFailedclimbs,submittednever does).2. The headroom gate suppressed exactly the window that matters.
gateOnHeadroomskips any plan whose(lif − 1)/lifis underHEADROOM_FLOOR_BPS(default 3). Its own JSDoc justifies that floor as "a lower bound on swap execution cost" — but this path has no swap. A loan-as-collateral slot's LLTV is 98% precisely so the incentive covers a liquidator's gas, which capsmaxLifat ~1.006 and headroom at 60 bps; against a 3 bps floor the gate became a pure time gate suppressing the first ~179 s past maturity.runner/ranking.tsexplains why that is expensive: "in an ascending-price maturity auction, where the first mover takes the whole position, queue order decides which positions get the contested early seconds."3. These are the first multi-collateral markets the bot has seen (
ncol: 2). Every previously listed market had exactly one collateral, so the lens picking the single greatest-value slot on-chain was trivially correct. It is now wrong for profit, and a transient venue failure on the chosen slot forfeited the position even when the other slot needed no venue.What changed
@repo/swaps— the swap-free short-circuit now triggers whenever the sell path already ends in the loan token (zero-stepSwapPlan), and is evaluated before the no-venues gate, so a keyless /ALLOW_BAD_DEBT_ONLYdeployment can still clear these.unwrapOnlyPlan→swapFreePlan.gateOnHeadroom. Break-even still binds viaminAcceptableAmountOutandassessProfitability; only the route-cost floor is lifted.(slot, mode)planning — the lens returnsCollateralSlot[]instead of fivebestCollateral*fields;planCandidatessizes every activated slot and ranks byplanSurplus(explicit comparator, post-maturity wins ties). Slot choice moved off-chain because the chain cannot make it: whether a slot needs a swap is not on-chain knowledge. A matured-and-unhealthy slot yields two candidates, one per open on-chain gate — normal mode's (debt > maxDebt) can close between the lens read and the broadcast while post-maturity's cannot, so the loser is kept as a fallback rather than discarded. Each is headroom-gated independently.(slot, mode); phase B works down the global USD ranking and submits at most one per position.sweepCalls— one home in@repo/swapsfor the trailing sweeps, deduping the market tokens. When collateral and loan coincide the old code emitted two full-balance transfers, the second moving zero (which some ERC-20s revert on).The subtle one
backoff.record(label, block)setsuntil = block + baseBlockswhileshouldSkip(label, block)testsblock < until— so recording a candidate's failure inline suppressed that position's own remaining candidates in the very same tick, silently reducing fall-through to a single attempt. Suppression is now applied once per position after the loop, which also keeps the USD ranking global rather than grouping candidates by position to make the bookkeeping work.Verified by re-introducing the inline call: 3 tests fail, including "falls through to the swap-free slot IN THE SAME TICK".
Observability — please read before merging
quote.ok/quote.floor_unmet/unwrap.bad_routenow carryvenue: 'no-swap'for the zero-step path, distinct from'unwrap-only'. Event names are unchanged, so existing BetterStack queries keep working.tick.endgainscandidatesandsiblingSkipped. The counter identities split: everything up to sizing is per position, everything phase B works is per candidate.noSwapPath/quoteFailed/quoteUnprofitable/revertedtherefore become per-attempt on multi-collateral positions.simulate.*,config.no_swap_path,quote.unprofitableandplan.skippedgainedcollateralIndex(+postMaturityMode): several candidates per position share a(marketId, borrower), so without them the log join cannot separate two attempts.Midnight allows 16 activated collaterals per borrower; at two open modes that is 32 candidates, and
MAX_PLAN_CANDIDATES_PER_POSITIONkeeps 4. Today's listed markets carry at most two collaterals — exactly 4 candidates — so nothing is dropped in practice, but a third listed collateral would start silently truncating. Raise the cap and re-check the venue rate budget before such a market ships. The best swap-free candidate is never truncated away, so truncation costs upside rather than coverage. Documented in the cap's JSDoc, the README status section, and the TIB.Not in scope
The absolute dust floor (BOTS-81) — orthogonal, and it should apply to swap-free plans like any other. BOTS-66 turned out to need no sizing change: its three "stricter rules" are router admission rules, and
rcf.tsalready mirrors the contract per slot withrcfThresholdunchanged upstream.Verification
Every new test was checked non-vacuous by breaking the source first: 6 fail without the quoting fix, 3 without deferred bookkeeping, 3 without the two-mode change, 2 without the sweep dedupe, 1 without the headroom exemption.
typecheckclean on@repo/swaps,midnight-liquidation,blue-liquidationrun buildclean — the check that actually proves the lens compiled. The soltag CLI compiles atoptimizer.runs: 1and fails soft to an empty ABI while exiting 0, so a greentypecheckalone does not prove it.pnpm lint0 warnings / 0 errors;pnpm formatappliedpnpm test: 2736 pass locally (4 files fail for want ofRPC_URL_8453, including quoter-bot's, which this PR does not touch). On CI, where the RPC exists: 210 files pass, 1 skipped, 0 failed — the fork suite included.✅ The fork suite is green on CI, which has the archive RPC this machine does not.
liquidates a loan-as-collateral position with an EMPTY swap planpasses in ~9s — so the empty-SwapPlanexec path, the swap-free headroom exemption at the shipped floor, and the single-sweep encoding are all verified against a real forked Base chain, not just unit-mocked.Getting there took two fixes CI caught and local runs structurally could not:
0x4429112b3C…was deployed for the Aug-2026 markets, i.e. afterFORK_BLOCK, soprice()reverted with "returned no data". The plan's claim thatFORK_BLOCKneeded no bump was wrong — the seeder builds its own market but still references a live oracle address. Fixed with a constant-price() = 1e36stub viaanvil_setCode(verified against a bare anvil) rather than bumping the block, which would move the WETH oracle price and pool state the other fork case is calibrated against.takerevertedConsumedUnits():consumed[maker][group]accumulates across takes and is capped at the offer's ownmaxUnits, and both seeded shapes signed into group 0 from the same maker. Each shape now has its own group.ℹ️ Committed with
--no-verify: the pre-commitknipstep reports everyscripts/*.tsas unused when run from a nested.claude/worktreescheckout (none are touched here), which CI does not reproduce. That local noise is real but it also masks genuine findings — CI's clean-tree run caught an unusedPlanSkipexport that the local output had buried, fixed inc35ca24f. Trust the CI Dead-Code job over a localpnpm knipfrom a worktree.External review
Reviewed by GPT-5.6 Sol (high reasoning) via Codex CLI, in two gated parts: the plan for correctness, then the implementation for necessity / sufficiency / ergonomics. It passed the plan gate (SOUND WITH RESERVATIONS) and called the sibling-suppression reasoning "exact". Second commit addresses its findings:
(slot, mode)gap —selectModediscarded one valid mode for matured-unhealthy slots, so a reverting normal-mode plan forfeited the position even though post-maturity was still valid. Both are now returned and gated independently. This was its "most important thing to fix".skipscarried nocollateralIndexandconfig.no_swap_pathnopostMaturityMode, contradicting the observability contract this PR's own TIB states.seizedAssets >= impliedRepaidUnits(break-even, crossover atprice == lif), underpricing pays the liquidator more, and a zero price routes to the write-off branch. No loan-token loss is reachable on this path at any oracle price; the residual is gas, which is BOTS-81.swapFreePlanto an arrow const;swapFree's "no execution cost" wording tightened to "no route cost" since gas remains material.Partly addressed — the new fork case now exercises the lens end-to-end on a real chain (one activated slot), so nested
CollateralSlot[]decode, the seam'sswapFreederivation and the empty-plan exec path are all covered on-chain. What remains untested on-chain is specifically the two-activated-slot case:_countActivatedreturning >1 and descending collection order. That is covered by a unit ABI round-trip only. A follow-up fork case that supplies collateral to both slots would close it.Codex ran read-only and could not execute vitest, so its review is static analysis — it did not observe the passing suite.
🤖 Generated with Claude Code