Skip to content

feat(midnight-liquidation): support loan-as-collateral markets - #184

Draft
haydenshively wants to merge 5 commits into
mainfrom
worktree-midnight-loan-as-collateral
Draft

feat(midnight-liquidation): support loan-as-collateral markets#184
haydenshively wants to merge 5 commits into
mainfrom
worktree-midnight-loan-as-collateral

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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=true markets 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.ts 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 — verified against the keyless LiFi endpoint the bot actually calls:

HTTP 400 {"code":1011,"message":"The same token cannot be used as both the source and destination token."}

400 classifies as non-retryable no_route, so the position was never liquidated — quietly (tick.end.quoteFailed climbs, submitted never does).

2. The headroom gate suppressed exactly the window that matters. gateOnHeadroom skips any plan whose (lif − 1)/lif is under HEADROOM_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 caps maxLif at ~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.ts explains 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-step SwapPlan), and is evaluated before the no-venues gate, so a keyless / ALLOW_BAD_DEBT_ONLY deployment can still clear these. unwrapOnlyPlanswapFreePlan.
  • Headroom exemption — swap-free plans bypass gateOnHeadroom. Break-even still binds via minAcceptableAmountOut and assessProfitability; only the route-cost floor is lifted.
  • Per-(slot, mode) planning — the lens returns CollateralSlot[] instead of five bestCollateral* fields; planCandidates sizes every activated slot and ranks by planSurplus (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.
  • Tick fall-through — phase A emits one candidate per (slot, mode); phase B works down the global USD ranking and submits at most one per position.
  • sweepCalls — one home in @repo/swaps for 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) sets until = block + baseBlocks while shouldSkip(label, block) tests block < 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_route now carry venue: 'no-swap' for the zero-step path, distinct from 'unwrap-only'. Event names are unchanged, so existing BetterStack queries keep working.
  • tick.end gains candidates and siblingSkipped. The counter identities split: everything up to sizing is per position, everything phase B works is per candidate. noSwapPath / quoteFailed / quoteUnprofitable / reverted therefore become per-attempt on multi-collateral positions.
  • simulate.*, config.no_swap_path, quote.unprofitable and plan.skipped gained collateralIndex (+ postMaturityMode): several candidates per position share a (marketId, borrower), so without them the log join cannot separate two attempts.

⚠️ Known limitation: not all 16 collaterals

Midnight allows 16 activated collaterals per borrower; at two open modes that is 32 candidates, and MAX_PLAN_CANDIDATES_PER_POSITION keeps 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.ts already mirrors the contract per slot with rcfThreshold unchanged 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.

  • typecheck clean on @repo/swaps, midnight-liquidation, blue-liquidation
  • run build clean — the check that actually proves the lens compiled. The soltag CLI compiles at optimizer.runs: 1 and fails soft to an empty ABI while exiting 0, so a green typecheck alone does not prove it.
  • pnpm lint 0 warnings / 0 errors; pnpm format applied
  • pnpm test: 2736 pass locally (4 files fail for want of RPC_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 plan passes in ~9s — so the empty-SwapPlan exec 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:

  • The live identity oracle 0x4429112b3C… was deployed for the Aug-2026 markets, i.e. after FORK_BLOCK, so price() reverted with "returned no data". The plan's claim that FORK_BLOCK needed no bump was wrong — the seeder builds its own market but still references a live oracle address. Fixed with a constant-price() = 1e36 stub via anvil_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.
  • take reverted ConsumedUnits(): consumed[maker][group] accumulates across takes and is capped at the offer's own maxUnits, 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-commit knip step reports every scripts/*.ts as unused when run from a nested .claude/worktrees checkout (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 unused PlanSkip export that the local output had buried, fixed in c35ca24f. Trust the CI Dead-Code job over a local pnpm knip from 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) gapselectMode discarded 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".
  • Attributionskips carried no collateralIndex and config.no_swap_path no postMaturityMode, contradicting the observability contract this PR's own TIB states.
  • Oracle guarantee overstated — the TIB claimed route quality fails closed on any non-1:1 price. It does not: it is a one-sided tolerance and does not constrain underpricing. Corrected to state what actually holds — the floor check reduces to exactly seizedAssets >= impliedRepaidUnits (break-even, crossover at price == 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.
  • README contradictions — two places still said a venue-less deployment can only realize bad debt.
  • swapFreePlan to 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's swapFree derivation and the empty-plan exec path are all covered on-chain. What remains untested on-chain is specifically the two-activated-slot case: _countActivated returning >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

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]>
@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

BOTS-2

@haydenshively haydenshively self-assigned this Aug 28, 2026
haydenshively and others added 4 commits August 28, 2026 12:04
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant