feat(midnight-liquidation): seed loan-as-collateral positions - #208
feat(midnight-liquidation): seed loan-as-collateral positions#208haydenshively wants to merge 3 commits into
Conversation
A loan-as-collateral slot is priced by the identity oracle at exactly ORACLE_PRICE_SCALE and Midnight debt does not accrue, so `isHealthy` is time-invariant and the existing seeder's price-drawdown trigger has no analogue: maturity is the only way such a position becomes liquidatable. The market therefore cannot be minted either, because the bot's whitelist is keyed on exact market id and is fail-closed — it has to be one that is already listed, and these markets have no order book, so the offer is signed here and taken by a second wallet. Adds a sibling seeder for that shape and lifts `txStep`/`confirmPrompt` into scripts/seed/tx.ts now that two callers share them. Fail-closed before it spends: the market must be listed for the chain, `toId(toMarket(id))` must round-trip, both gates must be zero, maturity must be in the future, the loan-collateral slot is derived rather than assumed (it is index 1 in some listed markets), its oracle must read exactly ORACLE_PRICE_SCALE, and the maker's offer group must be unused. It then projects the post-maturity seize and refuses to run when that rounds to zero, which is the skip that silently strands every existing staging fixture. Verified live on Base: seeded market 0xa1312ab8… and staging-bot-8453 liquidated it 3s after maturity across three swap-free liquidations (venue "no-swap", routeCostBps 0, zero-step swap plan), 700004 seized against 699999 repaid. One unit of debt is stranded afterwards by the seize-cap margin shave — filed as BOTS-116, not addressed here. Refs BOTS-113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The markets endpoint decides which deployment's bot will act on the seeded position, and the bot itself takes it from an operator-set variable rather than from the repo. Defaulting it picked an environment on the operator's behalf for a run that spends real funds, and baked a non-public hostname into the tree in the process. Require --markets-api and validate it as a URL, matching how the rest of the script treats ambiguous config. Refs BOTS-113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Devin Review found 5 potential issues.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bcb0fdf40b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ff12c7a to
9cf8cd6
Compare
Why
Loan-as-collateral support shipped on 2026-08-31 (
6e17c519and follow-ups, TIB-2026-08-28) but had never run against a real position — no borrower on any chain used the loan-token collateral slot, so no maturity would have exercised the swap-free path. BOTS-113 asked us to test it before the first production maturity carrying loan collateral.Two constraints shape the tool, and neither applies to the existing seeder:
ORACLE_PRICE_SCALE, Midnight debt is static face value with no accrual path, andtakerefuses to mint an unhealthy seller.isHealthyis therefore time-invariant andisLiquidatableleaves one door open:blockTimestamp > maturity. The--drawdown-bpsmechanism inseed-liquidatable-positions.tshas no analogue.rcfThresholdgets you) is invisible to it. The target has to be one that is already listed — and those markets have no order book, so the offer is signed here and taken by a second wallet.What
scripts/seed-loan-collateral-position.ts— lender posts a bid, borrower supplies loan-token collateral and takes it, becoming the debtor.scripts/seed/tx.ts—txStep/confirmPromptlifted out of the existing seeder now that two callers share them. No behavior change; the retry-on-read-lag loop and its rationale move verbatim.Everything else is reused rather than rewritten:
toId/hashOffer/signOfferTree/encodeRatifierData/isLeaffromscripts/seed/offers.ts,priceToTickfromscripts/seed/price-tick.ts,mulDivUp/mulDivDownfromsrc/sizing/math.ts, andreadMidnightLiquidationLensfor verification.This signs an offer and opens a real debt position with real USDC on live Base. Per
CLAUDE.mdthis needs a review pass before merge.It fails closed before it spends anything:
createListedMarketFilteragainst--markets-api(the target bot'sMARKETS_API_URL), so exact market-id + chain match, no bespoke parsing--face-usdc/--max-spend-usdcassume 6 decimals)toId(toMarket(id))must round-trip, proving the localIdLibport still matches this deployment before anything is signed against that structenterGateandliquidatorGatemust both be zero; maturity must be at leastMIN_SECONDS_TO_MATURITY(600s) away so every tx lands beforetakeORACLE_PRICE_SCALEconsumed == 0), sinceconsumed[maker][group]accumulates across takes--markets-apiis required, not defaulted: that endpoint decides which deployment's bot will act onthe position, and the bot itself takes it from an operator-set variable rather than from the repo, so
defaulting it would pick an environment on the operator's behalf for a run that spends real funds
--max-spend-usdcbounds total outlay;--dry-rundoes the whole read path and every assertion above and sends nothingVerification then goes through the bot's own lens, not a bespoke read, asserting
valid && hasDebt && healthy && gateAllows && !lockedthat only the loan-collateral slot is activated, and thatdebtand collateralamtequal exactly what was sized — so the planner yields exactly one candidate andswapFreeis the only shape under test.Evidence — it worked in production
Seeded market
0xa1312ab8…on Base (staging-listed, maturity 2026-09-09 15:00 UTC).staging-bot-8453liquidated it 3 seconds after maturity, across three swap-free liquidations:no-swap0x8b449f77…no-swap0x40efe773…no-swap0x97dc9a4b…plan.builtcarriedcollateralIndex: 0,postMaturityMode: true,routeCostBps: 0;quote.okcarriedvenue: "no-swap"withexpected == oracle == amountOutMinimum(the zero-step swap plan). On-chain: debt 700000 → 1, collateral 750001 → 49997. 700,004 seized against 699,999 repaid.This is also the first transaction staging has ever broadcast.
Known, not addressed here
One unit of debt is stranded after a successful post-maturity liquidation:
capBoundPlanshaves the cap bySEIZE_CAP_MARGIN_BPSbefore sizing, andmulDivDown(1, 9970, 10000) == 0, so a 1-unit debt is permanently unsizeable on Base at any price scale. Post-maturity repays areceil(seized / lif), and that ceiling is what lands on 1 rather than 0. Filed as BOTS-116 together with the staging fixture sizing gap and the resultingplan.skippedlog spam (~35k lines/47min from ~25 stuck positions).Checks
typecheck,pnpm lint,pnpm knip,pnpm formatclean; 326 midnight-liquidation tests pass. No new unit test: the script is an operator tool whose logic is the fail-closed assertions against live chain state, and it has now been exercised end-to-end in production. The cryptography it depends on is already covered bytest/seed/offers.test.ts.Refs BOTS-113
🤖 Generated with Claude Code
Link to Devin session: https://app.devin.ai/sessions/84fec01ca7ea4d4ca85e9f47fa2a2e3a
Open in Devin Desktop: https://app.devin.ai/desktop/session/84fec01ca7ea4d4ca85e9f47fa2a2e3a?variant=devin
Requested by: @haydenshively