From 126bffeb2cfa29ae0e96bd9155ae3524a8b3b1a1 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:55:01 +0200 Subject: [PATCH 1/5] docs(quoter-bot): add loan-as-collateral markets tib TIB-2026-08-27 decides how the ladder quotes both sides of a Midnight loan-as-collateral market (loan asset in the collateral list, constant price-1 oracle) from zero inventory: an opt-in per-market debt config block, a maturity-safe collateral-coverage invariant that makes pre-maturity liquidation impossible by construction, non-reduce-only sells, manual-first collateral management, and penalty-free LLTV=1 post-maturity settlement. Partially supersedes TIB-2026-07-27 (no-debt invariant and the reserved position-health check) and indexes the TIB. Co-Authored-By: Claude Fable 5 --- docs/INDEX.md | 1 + .../TIB-2026-07-27-midnight-quoter-bot.md | 24 +- ...08-27-quoter-loan-as-collateral-markets.md | 483 ++++++++++++++++++ 3 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 10f32d0e..fca4a8d7 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -66,6 +66,7 @@ _None yet — copy [`templates/DATA-FLOW.md`](./templates/DATA-FLOW.md) into a b - [TIB-2026-06-30: Blue liquidation bot — v0](./decisions/TIB-2026-06-30-blue-liquidation-bot.md) — Morpho Blue ecosystem-backstop liquidator (accrual-aware soltag lens, multi-venue swaps, generic Executor, Railway; the TIB's rindexer discovery has since been replaced by Morpho GraphQL API discovery) — implemented - [TIB-2026-07-09: Midnight market whitelist and venue selection](./decisions/TIB-2026-07-09-midnight-market-and-venue-selection.md) — API-sourced market whitelist + best-of-venues probe selection replacing the hand-maintained routing file; Uniswap dropped as a direct venue — implemented - [TIB-2026-08-12: Quoter-bot KMS signing policy middleware](./decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md) — an AWS Lambda behind invoke-only IAM becomes the sole `kms:Sign` principal on the maker key; the bot submits structured revoke/quote intents, the Lambda validates no-crossed-books/price-bounds/no-PnL-drop policy against its own independent chain reads and encodes/derives digests itself (sign-what-you-encode), bounding full bot-host compromise to in-policy quoting loss plus revoke griefing +- [TIB-2026-08-27: Quoter-bot loan-as-collateral markets](./decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md) — on Midnight markets whose collateral list contains the loan asset (constant price-1 oracle, LLTV = 1 expected), the maker posts loan-asset collateral sized by a maturity-safe coverage invariant and quotes both sides from zero inventory: sells turn non-reduce-only (borrowing on fill) behind an opt-in per-market `debt` config block, buys deleverage via protocol netting, bootstrap becomes unnecessary; collateral management is manual-first — partially supersedes TIB-2026-07-27 _Bot-scoped TIBs move under `packages//docs/decisions/` once a bot lands; proposal TIBs for not-yet-built bots sit in `docs/decisions/` alongside their siblings._ diff --git a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md index e43196ab..eb34ecec 100644 --- a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md +++ b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md @@ -1,12 +1,12 @@ # TIB-2026-07-27: Midnight ladder quoter-bot bot — v0 -| Field | Value | -| ----------------- | -------------------------------------------------------- | -| **Status** | Proposed | -| **Date** | 2026-07-27 | -| **Author** | @devatom | -| **Scope** | App: midnight-quoter | -| **Superseded by** | TIB-2026-08-14-quoter-cross-book-clearance _(partially)_ | +| Field | Value | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **Status** | Proposed | +| **Date** | 2026-07-27 | +| **Author** | @devatom | +| **Scope** | App: midnight-quoter | +| **Superseded by** | TIB-2026-08-14-quoter-cross-book-clearance _(partially)_, TIB-2026-08-27-quoter-loan-as-collateral-markets _(partially)_ | --- @@ -17,6 +17,16 @@ > repricing, bound clamping, and same-tick rung merging — see > [TIB-2026-08-14-quoter-cross-book-clearance](./TIB-2026-08-14-quoter-cross-book-clearance.md). > Every other decision in this TIB remains in force. +> +> **Partially superseded (2026-08-27).** For markets that opt into a `debt` configuration block, +> [TIB-2026-08-27-quoter-loan-as-collateral-markets](./TIB-2026-08-27-quoter-loan-as-collateral-markets.md) +> replaces three postures — for those markets only. The §7 invariant that the credit-reducing side +> "cannot create negative credit or unintended debt" and the Security posture "No negative credit" +> now hold only for markets without a `debt` block; on debt-enabled markets they are replaced by a +> maturity-safe collateral-coverage invariant. The §3 position-health `not-required` posture is +> replaced by a real coverage check on debt-enabled markets, as §3 itself anticipated. And the +> Non-Goal "Automatic position-health management in V0" is lifted for those markets. Everything +> else remains in force. ## Context diff --git a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md new file mode 100644 index 00000000..50d0bf7c --- /dev/null +++ b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md @@ -0,0 +1,483 @@ +# TIB-2026-08-27: Quoter-bot loan-as-collateral markets + +| Field | Value | +| -------------- | ------------------------------------------------ | +| **Status** | Proposed | +| **Date** | 2026-08-27 | +| **Author** | @julien | +| **Scope** | Bot: quoter-bot | +| **Supersedes** | TIB-2026-07-27-midnight-quoter-bot _(partially)_ | + +--- + +## Context + +A **loan-as-collateral market** is a Midnight market whose collateral list contains the loan asset +itself — for example loan = USDC, collaterals = {cbBTC, USDC} — with the loan-asset collateral +priced by a constant oracle at exactly 1 and, on the target markets, LLTV = 1. On such a market the +maker can post the loan asset as collateral and quote **both** sides of the book from zero +inventory. Midnight's take netting makes this a single-offer affair: a fill against a maker sell +consumes credit first and books the remainder as debt (`sellerCreditDecrease = min(units, credit)`, +`sellerDebtIncrease = units − sellerCreditDecrease`, L1581–1583), and a fill against a maker buy +repays debt before creating credit (`buyerCreditIncrease = zeroFloorSub(units, buyerDebt)`, L1581, +L1606–1612). Lower-rate sells published without `reduceOnly` therefore borrow on fill; higher-rate +buys lend — and automatically deleverage while debt is outstanding. + +Debt is static face value: `Position` carries `credit` and `debt` with no time term (L1028–1036), +and no code path accrues debt. With a constant oracle and static collateral, `isHealthy` +(L2062–2078) is time-invariant, so collateral sized once for the worst case cannot be liquidated +before maturity even if the bot halts. Position bootstrap — acquiring credit at negative carry so +the sell side has something to reduce — becomes unnecessary. Today's ladder forbids all of this by +construction: every sell is `reduceOnly`, sell capacity is pinned to accrued credit, and +[TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) §3 reserved the position-health check as +"mandatory before any strategy revision can increase collateralized debt". This TIB is that +revision: it decides how the quoter-bot's ladder supports these markets. + +Line references (`L…`) cite the vendored Midnight source, +[midnight-contracts.txt](../context/repos/midnight-contracts.txt), pinned at commit `336b924a` — +the version deployed on Base. + +> **Terminology.** Protocol "units" are face value at maturity, denominated in raw loan-token base +> units; `credit` and `debt` are units. Collateral is raw loan-token assets. At the self-collateral +> price of exactly `1e36` (`ORACLE_PRICE_SCALE`, L851) and LLTV `L`, the protocol's borrowing limit +> is `maxDebt = collateral × L / 1e18`, rounded down (L2062–2078). + +## Goals / Non-Goals + +**Goals** + +- Quote both sides of a qualified loan-as-collateral book from zero inventory, with no bootstrap + entry and no negative-carry credit acquisition. +- Make pre-maturity liquidation of the maker impossible **by construction** — a maturity-safe + collateral requirement covering current debt plus every live sell's worst case — rather than by + continuous monitoring. +- Keep the feature opt-in per market: without the new config block, behavior is byte-for-byte + today's reduce-only, credit-capped ladder. +- Qualify markets fail-closed before quoting: loan-asset self-collateral, allowlisted constant + oracle, disclosed settlement-penalty bound, gate checks. + +**Non-Goals** + +- Automated collateral withdrawal or position unwind. Withdrawal is the one operation that can + strand resting sells — the protocol health-checks it against current debt only (L1751–1776) — so + it stays manual; unwind is an operator runbook. +- A strategy-wide total-debt cap. Collateral is escrowed per market on-chain, so one market's debt + cannot contaminate another; per-market caps bound the risk. A total cap is a capital-allocation + preference (Future Considerations). +- Changing rate sources, ladder shape math, cross-book clearance + ([TIB-2026-08-14](./TIB-2026-08-14-quoter-cross-book-clearance.md)), or buy-side exposure-cap + semantics. +- Rollover into the next maturity — unchanged non-goal from TIB-2026-07-27. + +## Current Solution + +The no-debt invariant is implemented deliberately, not incidentally: + +- [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts) + pins `creditSaleCapacityAssets` to the position's current credit, and + [`ladder-capacity.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-capacity.utils.ts) + caps the lower-rate (sell) side at that credit — a fill can never create debt. +- [`ladder-offer.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts) + (`sideOffers`) hardcodes `reduceOnly: true` and `receiverIfMakerIsSeller: maker` on every sell. +- The ladder's `readMarket` reads only `position.credit` — never debt or collateral — plus wallet + balance, allowance, and cash reservations. The bootstrap snapshot already reads debt — + `MidnightPositionSnapshot` is `{marketId, credit, debt}` in + [`bootstrap-position.service.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-position.service.ts) + — so the ladder read is the one that lags. +- `checkPositionHealth()` in + [`viem-setup-state.service.ts`](../../bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts) + returns `not-required` with reason "V0 does not create collateralized debt" — the reserved port + TIB-2026-07-27 §3 made mandatory for exactly this revision. +- [`setup-check.utils.ts`](../../bots/quoter-bot/src/application/setup/setup-check.utils.ts)'s + `bookProblems` checks allowlist, active, loan asset, tick spacing, and maturity — nothing about + collateral, oracles, or gates — and `marketFromContract` in + [`viem-setup-state.utils.ts`](../../bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.utils.ts) + deliberately projects only `{chainId, midnight, loanToken, maturity}`. +- `start` requires at least one bootstrap entry and one ladder entry; the sell side is funded by + bootstrap's negative-carry credit acquisition. + +## Proposed Solution + +### 1. Central invariant — maturity-safe collateral + +The on-chain loan-asset collateral must always cover the worst case of current debt plus every +live and prospective sell: + +```text +worstCaseDebtUnits = debt + Σ over live and prospective sell groups of + ceil(remainingCapAssets × WAD / tickToPrice(tick)) + +requiredCollateral = ceil(worstCaseDebtUnits × WAD / lltv) × (1 + collateralBufferBps / 10⁴) +``` + +An asset-capped sell accumulates `sellerAssets` against `maxAssets` (L1566–1572); with tick price +below 1, the units a remaining cap can mint are strictly greater than its assets, and +`ceil(remainingCapAssets × WAD / tickToPrice(tick))` bounds them deterministically from the tick. +The requirement is **time-invariant** — debt does not accrue and the oracle is constant — so it is +sized per publication, not monitored. The publish gate: the post-publication requirement must be +`≤` on-chain collateral, otherwise the sell side shrinks through the existing +innermost-rungs-first allocator. + +Consequences, stated explicitly: + +- Pre-maturity liquidation of a covered maker is impossible even if the bot halts. "Nothing to + monitor continuously" holds by construction; the per-cycle health assertion (§6) is a tripwire, + not a control loop. +- A fill that would leave the maker-seller unhealthy **reverts for the taker** + (`SellerIsLiquidatable`, L1679). Undercollateralized sells are un-takeable dead quotes, not a + solvency hazard — the bot's coverage discipline is what keeps its offers takeable. +- The requirement deliberately ignores the position's credit (it assumes every sell unit becomes + debt), so a `lossFactor` slashing of the maker's credit (L1830–1845) cannot invalidate coverage. +- A cap shared by offers at several ticks (per-book group mode) converts at the group's highest-rate + — lowest-price — member tick, since any fill can land there; per-rung caps (shared-rung mode) + convert at each rung's own tick. +- The requirement rounds up; headroom (§3) rounds down. +- `collateralBufferBps` has a validation-enforced floor: small, covering roundings — LLTV = 1 + markets need nothing more. + +### 2. Opt-in per-market configuration + +A ladder-market entry gains an optional `debt` block, a `targetRate`-style nested object following +[`market-collections.ts`](../../bots/quoter-bot/src/config/market-collections.ts) (exact records, +unknown keys rejected, quoted decimal-integer strings, whole-list env replacement): + +```yaml +debt: + maximumDebtAssets: '5000000000' # cap on face debt units, raw loan-token base units, positive + collateralBufferBps: '50' # safety margin over the exact requirement, >= 10 +``` + +An absent block preserves today's behavior byte-for-byte. A `debt` block on a market that fails +qualification (§6) fails startup. `LADDER_MARKETS` takes the same JSON fields as quoted decimal +strings. The cap is per-market only — no strategy-wide total (see Non-Goals). + +### 3. Capacity integration + +`calculateProductionLadderCapacities` gains a debt component: + +```text +lowerRateCapacityAssets = min(credit, creditSaleCapacity) + debtHeadroomAssets + +debtHeadroomUnits = max(0, min(maximumDebtAssets, collateralSupportedUnits) + − debt − liveSellWorstCaseUnits) +``` + +with the headroom converted units → assets conservatively at the innermost — highest-rate, +lowest-price — prospective sell tick, rounding down. +[`generateLadder`](../../bots/quoter-bot/src/domain/ladder/ladder.ts) is capacity-agnostic (it +takes `LadderMarketState` scalar capacities) and needs **no change**; only capacity derivation +changes. After generation, an exact per-rung coverage assertion validates the final tree before +publish — mirroring the existing validate-the-exact-payload ethos alongside +`assertLadderProspectiveSpread` — shrinking or failing closed on violation. The coverage math lives +in a new pure domain module (for example +`bots/quoter-bot/src/domain/ladder/debt-coverage.utils.ts`; utility isolation, arrow constants — +name illustrative). + +### 4. reduceOnly switch and quote identity + +Debt-enabled markets publish sells with `reduceOnly: false`; all other markets keep `true` +(`MakerCreditOrDebtIncreased` requires `sellerDebtIncrease == 0` on reduce-only sells, L1590–1594). +A single side suffices: protocol netting consumes credit before creating debt, so splitting rungs +into a reduce-only tranche plus a debt tranche is pointless (Alternative 1). + +The critical edge case: the flag is part of the published offer (and group hash) but **not** of +`sameLadderQuoteSet`'s rung comparison — +[`ladder-quoter.service.ts`](../../bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts) +decides `rest` on that comparison. The persisted publication and the quote-set identity must carry +the debt mode, so an operator toggling the block forces a `replace` instead of falsely resting on +live offers with the wrong flag. + +### 5. Position read extension + +The ladder `readMarket` additionally reads the position's debt, the loan-asset collateral balance +(locating the loan asset's index in the ascending-sorted `collateralParams`, L974–990), and live +group consumption for sell-cap bounds. `LadderMarketState` gains optional debt/collateral fields, +absent for non-debt markets. `@morpho-org/midnight-sdk` 1.3.0 already exposes the full read +surface (see Dependencies). + +### 6. Setup qualification — per debt-enabled market, fail-closed, read-only + +- The loan asset is in `collateralParams`; resolve and record its index. +- That entry's oracle is an **allowlisted constant-price implementation**: deployed bytecode + matches a pinned constant-oracle artifact **and** a live `price()` read returns exactly `1e36` — + the same pinning pattern as the existing ratifier bytecode check. +- Report `lltv`, `liquidationCursor`, the derived `maxLif` (L873–876), and the implied worst-case + post-maturity settlement penalty `(maxLif − 1) × maximumDebtAssets`. The market ID + content-addresses the entire `Market` struct (`IdLib.toId`, create2/SSTORE2, L286–304), so + allowlisting the ID immutably accepts all of these — no drift monitoring is needed, but the + operator sees what they accepted. Mutable per-market state is only `tickSpacing` (refine-only to + divisors, L1443–1451), settlement fees (bounded per TTM breakpoint, at most 50 bps at 360 days, + L1453–1470, L853–859), and `continuousFee` (at most 1%/year, L1482–1490, L860). +- `enterGate` is zero **or** `canIncreaseDebt(maker)` returns true (L1595–1604) — rechecked every + cycle, because the gate address is immutable but its answer is an external contract call. + `liquidatorGate` is observed and reported: it gates liquidators, not the maker, and a restrictive + one can delay post-maturity settlement. +- `checkPositionHealth` becomes a real check for debt-enabled markets — the coverage invariant over + the current position plus live sells, catching a maker returning after downtime. It stays + `not-required` otherwise. + +### 7. Runtime posture on coverage breach + +| Situation | Posture | +| ----------------------------------- | --------------------------------------------------------- | +| Desired sells do not fit collateral | Capacity shrink — the normal §1/§3 path, no event drama | +| Debt + live sells exceed collateral | Market-local **sell-side** invalidation plus a loud event | + +The second case is external interference — an authorized key withdrew collateral, unexpected +state. Buys **keep quoting** because buy fills repay debt (L1581, L1606) — the only self-repair +channel. Existing hard-halt semantics are untouched: config/reference/decision failures still +request a strategy-wide hard halt, and a market-read failure still invalidates market-locally +while other markets continue +([`ladder-quoter.service.ts`](../../bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts)). +Hard-halting everything on breach was rejected (Alternative 5). + +### 8. Collateral management — manual first + +Phases 1 and 2 ship with the bot **never moving collateral**. `supplyCollateral` requires +`onBehalf == msg.sender` or authorization (anti-collateral-poisoning) and pulls tokens from +`msg.sender` (L1726–1749), so the operator — or an authorized treasury address funding the maker +position from its own wallet — supplies out-of-band. The bot reads, validates, sizes, and reports +the exact remediation transaction without executing it, in the setup-check remediation style. + +Auto-supply (Phase 3) goes through the serialized mutation queue with a transaction policy +assertion pinning target = Midnight, the `supplyCollateral` selector, the market, the loan-asset +collateral index, `onBehalf = maker`, and a bounded amount (the +`assertLadderCancellationTransaction` pattern), plus an allowance-floor extension: setup's +allowance check must then cover lend exposure plus the collateral target. Automated **withdrawal** +stays out of scope entirely — the protocol health-checks `withdrawCollateral` against current debt +only (L1751–1776), never against resting sells, so it is the single operation that can strand the +live book; the bot must gate any future withdrawal below the live-book requirement, and every +phase here simply never withdraws. Unwind — repay, flash-loan repay-then-withdraw, maturity +handling — is an operator runbook plus Future Considerations. + +### 9. Maturity posture + +Sell offers already expire at maturity, and `CannotIncreaseDebtPostMaturity` (L1590) blocks new +debt after it. Outstanding debt at maturity settles by post-maturity liquidation: every debt +position is liquidatable once `block.timestamp > maturity` (L1824–1828) — that **is** the +settlement mechanism — with the LIF ramping linearly from 1 at maturity to `maxLif` over +`TIME_TO_MAX_LIF` = 60 minutes (L1850–1852, L861). At LLTV = 1, `maxLif = 1` and there is **no +liquidation incentive**: liquidators repay at exactly the oracle price plus roundings (contract +header, L1190–1193), so settling a USDC-collateral/USDC-debt position at price 1 is penalty-free +and the position needs no bot action. For `lltv < 1` the worst-case penalty is +`(maxLif − 1) × debt`, bounded and computable at configuration time — market creation enforces +`maxLif ≤ 2` and `lltv × maxLif ≤ 0.999` unless `lltv = 1` (L1974–1976). A covered position +realizes zero bad debt: bad debt is computed against the `maxLif` worst case (L1817–1820). + +The operator runbook documents the voluntary alternative: `repay` (no health check, no maturity +restriction, maker or authorized, L1705–1724) then `withdrawCollateral`, optionally atomic through +Midnight's fee-free `flashLoan` (L1940–1955). Self-take is not an unwind path (`SelfTake` +forbidden, L1549). Rollover into a next maturity remains an explicit operator decision. + +### 10. What this obsoletes and touches + +- **Bootstrap** becomes unnecessary for these markets — the point of the feature. The market's + bootstrap entry is simply omitted, and `start` must accept an empty bootstrap list (today it + requires at least one entry). Cross-book clearance (TIB-2026-08-14) is untouched and still + applies when both are configured. +- **Buy-side capital:** supplying collateral converts wallet cash (buy capacity) into sell + backing. Sells return cash on fill — proceeds go to `receiverIfMakerIsSeller = maker` at the + full offer price, the settlement fee being a taker-side price spread (L1557–1563) — replenishing + buy capacity next cycle; buys with debt outstanding repay debt instead of adding credit + (netting). The README's inventory-movement narrative is extended at implementation time. +- **Exposure caps:** existing lend-exposure caps are unchanged — they cap buys, and are + conservative while debt is outstanding, since those buys repay rather than add credit. + `maximumDebtAssets` is the sell-side symmetric cap. +- **Fees:** the continuous fee applies to credit only — `pendingFee` accrues at buy fill + (L1584–1585), and takes revert when the market fee exceeds the offer's `continuousFeeCap` + (L1545); debt pays no fee, which is part of why debt is static face value. +- **KMS middleware** ([TIB-2026-08-12](./TIB-2026-08-12-quoter-bot-kms-signing-middleware.md)): + needs intents for trees containing non-reduce-only sells and, in Phase 3, `supplyCollateral`; + middleware mode stays fail-closed for these writes until those land. + +### Implementation Phases + +- **Phase 1 — qualification and read surface:** config block plus validation, setup qualification + checks including the real position-health check, ladder position/collateral/debt reads, the pure + coverage-math domain module, read-only rendering. No live-behavior change without the config + block. +- **Phase 2 — debt-capable sell side:** capacity integration, the `reduceOnly` switch with the + mode carried in quote identity and persisted publications, the exact pre-publish coverage + assertion, the breach posture, empty-bootstrap `start`, observability fields; confirm Morpho API + mempool-validation and takeability support (Open Questions). +- **Phase 3 — collateral auto-supply:** the mutation-queue transaction, policy assertions, + allowance extension, and middleware intents. Withdrawal and unwind automation deliberately + excluded. + +## Considered Alternatives + +### Alternative 1: Split sells into a reduce-only tranche plus a debt tranche + +Publish, per rung, one `reduceOnly` sell sized to credit and one debt-creating sell. + +**Why rejected:** Protocol netting makes a single non-reduce-only sell economically identical — +credit is consumed before any debt is created (L1581–1583). Splitting doubles offers and groups +and collides ticks. + +### Alternative 2: Reactive collateral management + +Hold thin collateral and top up as fills land. + +**Why rejected:** It makes bot liveness a solvency dependency and reintroduces the continuous +monitoring obligation that static maturity-safe coverage removes — removing that obligation is the +premise of the feature. Reactive top-ups remain possible later **inside** the invariant as a +capital optimization. + +### Alternative 3: A separate debt-quoting workflow or bot + +**Why rejected:** Same book, same maker, same serialized mutation queue, same whole-book spread +validation. A separate writer would duplicate the reconciliation machinery when the ladder's +capacity seam was designed for exactly this extension. + +### Alternative 4: Keep bootstrap-based inventory for these markets + +**Why rejected:** Negative-carry bootstrap cost, a sell side forever capped by acquired credit, +and a slower path to two-sided quoting. The contract's documented LLTV = 1 special case +(L1190–1193) shows loan-as-collateral is the protocol-intended maker pattern here. + +### Alternative 5: Hard-halt on runtime coverage breach + +**Why rejected:** Exiting removes the deleveraging channel — buys are the only self-repair path — +while the on-chain fill-time check (L1679) already prevents harm from uncovered sells. See §7. + +## Assumptions & Constraints + +- Target markets list the loan asset as a collateral with a code-verifiable constant oracle + returning exactly `1e36`. Expected `lltv = 1e18` (penalty-free settlement); `lltv < 1` markets + are supported by the same math but surface a nonzero disclosed settlement-penalty bound. +- Midnight singleton semantics as pinned (`336b924a`): take netting, the post-fill seller health + requirement, time-invariant `isHealthy`, post-maturity settlement liquidation. The vendored + source is the reference. +- Market IDs immutably commit to all market params; allowlisting an ID is accepting its + collateral, oracle, gate, and maturity surface. +- `@morpho-org/midnight-sdk` ≥ 1.3.0 position/market surface. +- Collateral is escrowed per market; no cross-market debt contagion. +- TIB-2026-07-27's V0/V1 capital gates still apply — posting collateral raises capital at the hot + maker, and a material increase stays gated on the V1 custody/ratifier controls. +- Chain truth on restart is preserved: debt, collateral, and consumption are re-read fresh; the + only new durable state is the debt-mode flag in persisted publication intents. +- Oracle liveness: if an activated collateral's oracle reverts, `take` (when the seller has debt), + `withdrawCollateral`, `isHealthy`, and `liquidate` revert (L1320–1327) — and per the `336b924a` + delta note (L6–13), the deployed revision makes `supplyCollateral` revert on an oracle revert + too. The bytecode allowlist pins a constant implementation with no revert path. +- Activating the loan-asset collateral consumes one of the maker's 16 collateral slots per market + (`MAX_COLLATERALS_PER_BORROWER`, L863) — no practical constraint while the maker activates only + this one. + +## Dependencies + +- `@morpho-org/midnight-sdk` ≥ 1.3.0 (installed): the `Position` entity exposes + `user`/`marketId`/`credit`/`pendingFee`/`debt`/`collateralBitmap`/`collateral[]`; `MarketParams` + exposes `collateralParams` `{token, lltv, liquidationCursor, oracle}`; `midnightAbi` includes + `supplyCollateral`/`withdrawCollateral`/`isHealthy`/`repay`; `TickLib` has `tickToPrice` and + `tickToApr`. The SDK has **no** maxDebt/health helper and **no** collateral or repay transaction + builder — the SDK-first evidence for a local pure coverage-math domain module plus viem + `encodeFunctionData` against the SDK ABI. +- [TIB-2026-08-12](./TIB-2026-08-12-quoter-bot-kms-signing-middleware.md) for middleware-mode + intent coverage; the Phase 2/3 writes stay fail-closed there until the intents land. +- The pinned vendored source [midnight-contracts.txt](../context/repos/midnight-contracts.txt) for + every protocol claim above. + +## Observability + +Per-cycle and verbose fields for debt-enabled markets: collateral, debt, required collateral, +headroom, coverage ratio, and the debt-funded share of the sell side. New events: a collateral +remediation request (manual phase), coverage breach with sell freeze, and — in Phase 3 — +collateral transaction submissions. Alerting guidance: alert on the coverage-breach event; an +approaching-maturity-with-outstanding-debt signal is informational at LLTV = 1, where settlement +is penalty-free and needs no action. BetterStack queries follow the existing README examples. + +## Security + +- **Constant-oracle bytecode allowlist** — a new trust anchor, pinned like the existing ratifier + bytecode check: artifact match plus a live `price() == 1e36` read. +- **Market-ID commitment** — the ID content-addresses every parameter (L286–304); allowlisting is + the acceptance boundary, so there is no post-acceptance parameter drift to monitor. +- **Gate-answer mutability** — `enterGate`'s address is immutable but its answer is an external + call (L1595–1604); the per-cycle `canIncreaseDebt(maker)` recheck fails closed. Residual risk: a + gate flipping mid-cycle makes live sells revert for takers until the next cycle reacts. +- **Withdrawal gating** — the protocol checks only current debt on `withdrawCollateral` + (L1751–1776); bot policy must gate any future withdrawal below the live-book requirement, and + the phases in this TIB never withdraw. +- **Transaction policy** — every new transaction type carries an exact policy assertion (§8); + middleware intents stay fail-closed until scheduled (TIB-2026-08-12). +- **Maker as borrower** — Morpho's own `midnight-liquidation` bot would list the maker as a + candidate if it were ever unhealthy. Pre-maturity it cannot be while covered; post-maturity + seizure at LLTV = 1 is the intended, penalty-free settlement. +- **Slashing** — other borrowers' realized bad debt slashes lenders' credit via `lossFactor` + (L1256–1258, L1830–1845). It touches the maker's credit — the buy side, a pre-existing lender + risk on every market — never its debt or collateral, and the coverage requirement ignores credit + (§1), so coverage survives a slash. +- **"No negative credit" becomes conditional** — TIB-2026-07-27's §7 invariant and Security + posture hold verbatim for markets without a `debt` block; for debt-enabled markets they are + replaced by the coverage invariant. + +## Testing and Verification + +- **Domain:** coverage math — requirement rounds up and headroom down, units ↔ assets conversion + at the tick price, the per-book worst case at the group's highest-rate tick, merged same-tick + rungs, the buffer floor. +- **Application:** a mode flip forces `replace`; capacity shrink; breach produces sell freeze + while buys continue; config validation (absent block, invalid block, block on an unqualified + market). +- **Fork (Anvil, the existing e2e harness):** `touchMarket` is permissionless (L1958–1996), so the + fork creates a loan-as-collateral market — USDC loan, USDC collateral at `lltv = 1e18` with a + deployed constant oracle — and covers `supplyCollateral`, a non-reduce-only sell take creating + debt, a buy take netting debt down, the `SellerIsLiquidatable` revert when uncovered, the + `withdrawCollateral` health gate, post-maturity `repay` plus `withdrawCollateral`, and + post-maturity liquidation at LIF = 1. +- Repository test discipline applies: run each test, temporarily break one assertion to confirm it + fails, restore. + +## Future Considerations + +- A strategy-wide total-debt cap, if operators want one on top of per-market caps (Open + Questions). +- Reactive collateral top-ups **inside** the invariant, as capital optimization (Alternative 2). +- Withdrawal and unwind automation — repay, flash-loan repay-then-withdraw, maturity handling — + once the manual runbook has operational mileage. + +## Open Questions + +1. **Morpho API:** do mempool policy validation and the takeable-offers/books endpoints accept and + surface collateral-backed non-reduce-only sells — maker takeability computed from health, not + just credit? Blocking for the Phase 2 rollout; the on-chain fill-time checks are authoritative + regardless. (Every tree already passes `tree.mempoolValidate` before and after ratification.) +2. Production market parameters for the first target market: exact `lltv` (`1e18` expected), + `liquidationCursor`, and the constant-oracle implementation address to pin. +3. Whether operators want a strategy-wide total-debt cap in addition to per-market caps. +4. The quoter-signer (TIB-2026-08-12) intent schedule for non-reduce-only trees and + `supplyCollateral`. + +## References + +- [TIB-2026-07-27-midnight-quoter-bot](./TIB-2026-07-27-midnight-quoter-bot.md) — §3 (reserved + position-health check), §7 (the "cannot create negative credit or unintended debt" invariant), + the Security "No negative credit" posture, and the position-health Non-Goal: what this TIB + partially supersedes, for debt-enabled markets only. +- [TIB-2026-08-14-quoter-cross-book-clearance](./TIB-2026-08-14-quoter-cross-book-clearance.md) — + untouched; still applies when bootstrap and ladder are both configured. +- [TIB-2026-08-12-quoter-bot-kms-signing-middleware](./TIB-2026-08-12-quoter-bot-kms-signing-middleware.md) + — intent coverage for the new writes. +- [TIB-2026-08-04-extract-quoter-bot-shared-packages](./TIB-2026-08-04-extract-quoter-bot-shared-packages.md) +- [midnight-contracts.txt](../context/repos/midnight-contracts.txt) — the pinned vendored source + behind every line reference. +- [`ladder.ts`](../../bots/quoter-bot/src/domain/ladder/ladder.ts), + [`ladder-capacity.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-capacity.utils.ts), + [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts), + [`ladder-offer.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts), + [`ladder-quoter.service.ts`](../../bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts), + [`setup-check.service.ts`](../../bots/quoter-bot/src/application/setup/setup-check.service.ts), + [`viem-setup-state.service.ts`](../../bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts) + — the seams this TIB extends. +- [Quoter bot Linear project](https://linear.app/morpho-labs/project/market-making-bot-628e80069e52/overview) +- [Initial Base Midnight market](https://markets.morpho.org/fixed/base/0x05959752fdeff325962b9d263edb421efc6e2186a49360dba6c32e86ebf6c84c?orderType=limit) + + From e82a73b69dd3a3d765bc76f42d77b80821f70d9a Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:05:34 +0200 Subject: [PATCH 2/5] docs(quoter-bot): harden loan-as-collateral tib per codex review Address the five codex findings: convert credit units to seller assets before summing sell capacity, pin all coverage inputs to one block, fail readiness when the debt block is removed over outstanding debt, require aggregate debt reservations in the signing middleware, and make maturity settlement an actionable operator runbook (LIF=1 gives third parties no incentive to settle). Co-Authored-By: Claude Fable 5 --- .../TIB-2026-07-27-midnight-quoter-bot.md | 12 +- ...08-27-quoter-loan-as-collateral-markets.md | 106 +++++++++++++----- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md index ecf481c9..6ee49035 100644 --- a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md +++ b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md @@ -1,11 +1,11 @@ # TIB-2026-07-27: Midnight ladder quoter-bot bot — v0 -| Field | Value | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | -| **Status** | Proposed | -| **Date** | 2026-07-27 | -| **Author** | @devatom | -| **Scope** | App: midnight-quoter | +| Field | Value | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | Proposed | +| **Date** | 2026-07-27 | +| **Author** | @devatom | +| **Scope** | App: midnight-quoter | | **Superseded by** | TIB-2026-08-14-quoter-cross-book-clearance _(partially)_, TIB-2026-08-25-quoter-bootstrap-maturity-premium _(partially)_, TIB-2026-08-27-quoter-loan-as-collateral-markets _(partially)_ | --- diff --git a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md index 50d0bf7c..a1149b16 100644 --- a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md +++ b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md @@ -52,7 +52,8 @@ the version deployed on Base. collateral requirement covering current debt plus every live sell's worst case — rather than by continuous monitoring. - Keep the feature opt-in per market: without the new config block, behavior is byte-for-byte - today's reduce-only, credit-capped ladder. + today's reduce-only, credit-capped ladder — with one fail-closed exception: the block cannot be + removed while the position still carries debt (§2). - Qualify markets fail-closed before quoting: loan-asset self-collateral, allowlisted constant oracle, disclosed settlement-penalty bound, gate checks. @@ -147,23 +148,33 @@ debt: collateralBufferBps: '50' # safety margin over the exact requirement, >= 10 ``` -An absent block preserves today's behavior byte-for-byte. A `debt` block on a market that fails -qualification (§6) fails startup. `LADDER_MARKETS` takes the same JSON fields as quoted decimal -strings. The cap is per-market only — no strategy-wide total (see Non-Goals). +An absent block preserves today's behavior byte-for-byte **only while the position carries no +debt**: removing the block while the market's on-chain position still has `debt > 0` fails +readiness (§6), because reverting to reduce-only sells and a `not-required` health check would +leave an unmanaged collateralized position behind the legacy posture. The remediation is explicit — +re-enable the block, or unwind first (repay, or let the buy side deleverage) and then disable. A +`debt` block on a market that fails qualification (§6) fails startup. `LADDER_MARKETS` takes the +same JSON fields as quoted decimal strings. The cap is per-market only — no strategy-wide total +(see Non-Goals). ### 3. Capacity integration `calculateProductionLadderCapacities` gains a debt component: ```text -lowerRateCapacityAssets = min(credit, creditSaleCapacity) + debtHeadroomAssets - -debtHeadroomUnits = max(0, min(maximumDebtAssets, collateralSupportedUnits) - − debt − liveSellWorstCaseUnits) +debtHeadroomUnits = max(0, min(maximumDebtAssets, collateralSupportedUnits) + − debt − liveSellWorstCaseUnits) +sellSideUnits = min(credit, creditSaleCapacityUnits) + debtHeadroomUnits +lowerRateCapacityAssets = floor(sellSideUnits × worstCaseTickPrice / WAD) ``` -with the headroom converted units → assets conservatively at the innermost — highest-rate, -lowest-price — prospective sell tick, rounding down. +Both components are **units** and convert to seller assets together, at the innermost — +highest-rate, lowest-price — prospective sell tick, rounding down. Counting raw credit units as +assets — today's harmless approximation, since a reduce-only sell can never mint debt — becomes +unsafe once sells are non-reduce-only: 100 credit units counted as 100 assets at price 0.9 admit +roughly 111 units of fills, 100 consuming the credit and about 11 minting debt past a zero +headroom, without failing the §1 collateral assertion (which deliberately ignores credit) — +silently exceeding `maximumDebtAssets`. [`generateLadder`](../../bots/quoter-bot/src/domain/ladder/ladder.ts) is capacity-agnostic (it takes `LadderMarketState` scalar capacities) and needs **no change**; only capacity derivation changes. After generation, an exact per-rung coverage assertion validates the final tree before @@ -195,6 +206,17 @@ group consumption for sell-cap bounds. `LadderMarketState` gains optional debt/c absent for non-debt markets. `@morpho-org/midnight-sdk` 1.3.0 already exposes the full read surface (see Dependencies). +Every coverage input — debt, collateral, and each owned sell group's `consumed` — must come from +**one pinned block**, through on-chain reads (the adapter's `consumed(user, group)` read already +takes a block number); the eventually consistent offer-groups API is never a coverage input. The +production adapter today mixes a block-pinned position read with unpinned API group views: a sell +filling between the two undercounts `debt + remaining sell capacity` — the later view has already +removed the filled capacity while the earlier position predates the resulting debt — and the +recovered apparent headroom can push published worst case past `maximumDebtAssets`. Residual skew +is bounded, not dangerous: worst case exceeding coverage makes tail fills revert on-chain (L1679) +rather than realize loss, so incoherent snapshots break the policy cap and offer takeability, never +solvency — but the pinned-block rule removes the breach entirely. + ### 6. Setup qualification — per debt-enabled market, fail-closed, read-only - The loan asset is in `collateralParams`; resolve and record its index. @@ -214,7 +236,9 @@ surface (see Dependencies). one can delay post-maturity settlement. - `checkPositionHealth` becomes a real check for debt-enabled markets — the coverage invariant over the current position plus live sells, catching a maker returning after downtime. It stays - `not-required` otherwise. + `not-required` otherwise. The same check fails readiness when a configured ladder market carries + on-chain debt without a `debt` block (§2), so debt mode cannot be silently disabled around an + outstanding position. ### 7. Runtime posture on coverage breach @@ -258,16 +282,23 @@ position is liquidatable once `block.timestamp > maturity` (L1824–1828) — th settlement mechanism — with the LIF ramping linearly from 1 at maturity to `maxLif` over `TIME_TO_MAX_LIF` = 60 minutes (L1850–1852, L861). At LLTV = 1, `maxLif = 1` and there is **no liquidation incentive**: liquidators repay at exactly the oracle price plus roundings (contract -header, L1190–1193), so settling a USDC-collateral/USDC-debt position at price 1 is penalty-free -and the position needs no bot action. For `lltv < 1` the worst-case penalty is -`(maxLif − 1) × debt`, bounded and computable at configuration time — market creation enforces -`maxLif ≤ 2` and `lltv × maxLif ≤ 0.999` unless `lltv = 1` (L1974–1976). A covered position -realizes zero bad debt: bad debt is computed against the `maxLif` worst case (L1817–1820). - -The operator runbook documents the voluntary alternative: `repay` (no health check, no maturity -restriction, maker or authorized, L1705–1724) then `withdrawCollateral`, optionally atomic through -Midnight's fee-free `flashLoan` (L1940–1955). Self-take is not an unwind path (`SelfTake` -forbidden, L1549). Rollover into a next maturity remains an explicit operator decision. +header, L1190–1193), so settling a USDC-collateral/USDC-debt position at price 1 is penalty-free. +For `lltv < 1` the worst-case penalty is `(maxLif − 1) × debt`, bounded and computable at +configuration time — market creation enforces `maxLif ≤ 2` and `lltv × maxLif ≤ 0.999` unless +`lltv = 1` (L1974–1976). A covered position realizes zero bad debt: bad debt is computed against +the `maxLif` worst case (L1817–1820). + +Penalty-free is not self-settling. Maturity only makes `liquidate` callable, and at LLTV = 1 a +third party would repay the debt for exactly-equal collateral while absorbing gas — zero-incentive +liquidations cannot be assumed, so an unattended position can sit with debt and collateral locked +indefinitely. The split posture is therefore: **solvency** needs no action — debt is static, +collateral is static, debt pays no fee, and nothing degrades while waiting — but **reclaiming the +collateral is an explicit operator action**, and the runbook is the expected settlement path, not a +fallback: `repay` (no health check, no maturity restriction, maker or authorized, L1705–1724) then +`withdrawCollateral`, optionally atomic through Midnight's fee-free `flashLoan` (L1940–1955). The +maturity alert is accordingly actionable, not informational (see Observability). Self-take is not +an unwind path (`SelfTake` forbidden, L1549). Rollover into a next maturity remains an explicit +operator decision. ### 10. What this obsoletes and touches @@ -287,8 +318,16 @@ forbidden, L1549). Rollover into a next maturity remains an explicit operator de (L1584–1585), and takes revert when the market fee exceeds the offer's `continuousFeeCap` (L1545); debt pays no fee, which is part of why debt is static face value. - **KMS middleware** ([TIB-2026-08-12](./TIB-2026-08-12-quoter-bot-kms-signing-middleware.md)): - needs intents for trees containing non-reduce-only sells and, in Phase 3, `supplyCollateral`; - middleware mode stays fail-closed for these writes until those land. + an intent that merely permits non-reduce-only sells is not enough — a compromised bot could + collect several individually covered tree signatures before publishing any, then expose the + signed capacity at once. The middleware today aggregates live plus signed-but-unpublished buy + exposure from its own pinned snapshot and pins the credit-reducing side to `reduceOnly: true` by + policy; the debt extension must mirror that model, not bypass it: the `reduceOnly` pin becomes + per-market conditional, and every debt-capable sell draws from an aggregate reservation — + proposed trees, live sells, and every still-outstanding signed-but-unpublished sell — checked + against independently read debt, collateral, group consumption, and `maximumDebtAssets`, never + per-intent amounts alone. Phase 3 adds a `supplyCollateral` intent. Middleware mode stays + fail-closed for these writes until those land. ### Implementation Phases @@ -383,9 +422,11 @@ while the on-chain fill-time check (L1679) already prevents harm from uncovered Per-cycle and verbose fields for debt-enabled markets: collateral, debt, required collateral, headroom, coverage ratio, and the debt-funded share of the sell side. New events: a collateral remediation request (manual phase), coverage breach with sell freeze, and — in Phase 3 — -collateral transaction submissions. Alerting guidance: alert on the coverage-breach event; an -approaching-maturity-with-outstanding-debt signal is informational at LLTV = 1, where settlement -is penalty-free and needs no action. BetterStack queries follow the existing README examples. +collateral transaction submissions. Alerting guidance: alert on the coverage-breach event, and +alert on maturity-with-outstanding-debt as an **actionable** operator signal — settlement at +LLTV = 1 is penalty-free but not self-executing (§9), so the alert prompts the repay-and-withdraw +runbook; only its urgency is soft, since solvency does not degrade while it waits. BetterStack +queries follow the existing README examples. ## Security @@ -400,7 +441,9 @@ is penalty-free and needs no action. BetterStack queries follow the existing REA (L1751–1776); bot policy must gate any future withdrawal below the live-book requirement, and the phases in this TIB never withdraw. - **Transaction policy** — every new transaction type carries an exact policy assertion (§8); - middleware intents stay fail-closed until scheduled (TIB-2026-08-12). + middleware intents stay fail-closed until scheduled, and the middleware's debt policy is + aggregate-reservation based (§10), so stockpiled individually-covered signatures cannot bypass + the configured debt-loss bound (TIB-2026-08-12). - **Maker as borrower** — Morpho's own `midnight-liquidation` bot would list the maker as a candidate if it were ever unhealthy. Pre-maturity it cannot be while covered; post-maturity seizure at LLTV = 1 is the intended, penalty-free settlement. @@ -417,9 +460,9 @@ is penalty-free and needs no action. BetterStack queries follow the existing REA - **Domain:** coverage math — requirement rounds up and headroom down, units ↔ assets conversion at the tick price, the per-book worst case at the group's highest-rate tick, merged same-tick rungs, the buffer floor. -- **Application:** a mode flip forces `replace`; capacity shrink; breach produces sell freeze - while buys continue; config validation (absent block, invalid block, block on an unqualified - market). +- **Application:** a mode flip forces `replace`; removing the `debt` block with outstanding + on-chain debt fails readiness (§2); capacity shrink; breach produces sell freeze while buys + continue; config validation (absent block, invalid block, block on an unqualified market). - **Fork (Anvil, the existing e2e harness):** `touchMarket` is permissionless (L1958–1996), so the fork creates a loan-as-collateral market — USDC loan, USDC collateral at `lltv = 1e18` with a deployed constant oracle — and covers `supplyCollateral`, a non-reduce-only sell take creating @@ -447,7 +490,8 @@ is penalty-free and needs no action. BetterStack queries follow the existing REA `liquidationCursor`, and the constant-oracle implementation address to pin. 3. Whether operators want a strategy-wide total-debt cap in addition to per-market caps. 4. The quoter-signer (TIB-2026-08-12) intent schedule for non-reduce-only trees and - `supplyCollateral`. + `supplyCollateral`, including the aggregate debt-reservation policy those intents must carry + (§10). ## References From 9bf44c4b70b1e53593beff123e335b0d446bd078 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 3 Sep 2026 07:47:53 +0200 Subject: [PATCH 3/5] docs(quoter-bot): harden loan-as-collateral tib per hayden review Debt obligations survive whole-market removal via a durable debt-market registry that only clears on a pinned-block debt == 0 read; the breach transition becomes a dedicated make-port operation cancelling all owned lower-side groups in one native multicall with stay-in-breach retry semantics; and over-cap debt is an explicit breach state reported distinctly by position-health, never silent zero headroom. Co-Authored-By: Claude Fable 5 --- ...08-27-quoter-loan-as-collateral-markets.md | 65 +++++++++++++++---- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md index a1149b16..24ed5f60 100644 --- a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md +++ b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md @@ -157,6 +157,17 @@ re-enable the block, or unwind first (repay, or let the buy side deleverage) and same JSON fields as quoted decimal strings. The cap is per-market only — no strategy-wide total (see Non-Goals). +The obligation also survives removal of the **entire market entry**. A market enters a durable +**debt-market registry** — alongside the existing maker-and-market-bound ownership state — when its +first debt-capable tree is reserved, and leaves it only when a pinned-block read shows +`debt == 0`. `cleanupRemovedMarkets` may still cancel and forget a removed market's groups +(stopping further fills is always safe) but never forgets its registry entry; readiness fails for a +registry market no longer configured with a `debt` block — the same unwind-first remediation as +above — and maturity alerting (§9, Observability) keys off the registry, not the configured set, so +a removed market still alerts. The registry is a durable pointer, not truth: every check re-reads +the debt it names from chain, and losing the state volume degrades exactly like losing +group-ownership state — `markets.allowlist` and the operator runbook are the backstop. + ### 3. Capacity integration `calculateProductionLadderCapacities` gains a debt component: @@ -236,22 +247,44 @@ solvency — but the pinned-block rule removes the breach entirely. one can delay post-maturity settlement. - `checkPositionHealth` becomes a real check for debt-enabled markets — the coverage invariant over the current position plus live sells, catching a maker returning after downtime. It stays - `not-required` otherwise. The same check fails readiness when a configured ladder market carries - on-chain debt without a `debt` block (§2), so debt mode cannot be silently disabled around an - outstanding position. + `not-required` otherwise. The same check fails readiness when any **debt-market registry** entry + (§2) — still configured or since removed — shows pinned-block `debt > 0` without a `debt` block, + so debt mode cannot be silently disabled, nor the market silently dropped, around an outstanding + position. It also reports **over-cap debt** (§7) distinctly from a covered, ordinarily full cap. ### 7. Runtime posture on coverage breach -| Situation | Posture | -| ----------------------------------- | --------------------------------------------------------- | -| Desired sells do not fit collateral | Capacity shrink — the normal §1/§3 path, no event drama | -| Debt + live sells exceed collateral | Market-local **sell-side** invalidation plus a loud event | +| Situation | Posture | +| ----------------------------------- | ------------------------------------------------------- | +| Desired sells do not fit collateral | Capacity shrink — the normal §1/§3 path, no event drama | +| Debt + live sells exceed collateral | Breach — sell-side freeze plus a loud event | +| Debt exceeds `maximumDebtAssets` | Breach — same transition; never silent zero headroom | The second case is external interference — an authorized key withdrew collateral, unexpected -state. Buys **keep quoting** because buy fills repay debt (L1581, L1606) — the only self-repair -channel. Existing hard-halt semantics are untouched: config/reference/decision failures still -request a strategy-wide hard halt, and a market-read failure still invalidates market-locally -while other markets continue +state. The third can arise without any fault: a cap lowered in configuration below the standing +debt, or out-of-band authorized activity. §3's headroom saturating at zero must never be its only +signal — a violated policy would be indistinguishable from an ordinarily full cap — so the +position-health check reports the over-cap state distinctly (§6) and the market enters the same +breach transition until debt is back within the cap. + +The breach transition is a **dedicated make-port operation**, not a detour through reconciliation +or the hard-halt path (either of which can cancel more than intended): it resolves the market's +owned **lower-side** group IDs from persisted publications — sides never share a group in either +grouping mode — and cancels them in **one** native Midnight `multicall` of +`setConsumed(group, MAX_OFFER_CAP, maker)` inner calls, the exact batch shape the maker-wide +`invalidate` recovery command already uses, leaving the higher-side groups untouched. Failure +semantics: an unconfirmed batch leaves the market in breach state — buy groups retained, the loud +event emitted, the sell cancellation retried on subsequent cycles; there is no partial per-group +cancellation loop. The market exits breach only when a fresh pinned-block read shows the invariant +restored: coverage sufficient and debt at or under the cap. + +Buys **keep quoting** through breach because buy fills repay debt (L1581, L1606) — the only +self-repair channel. For the same reason, a coherent debt-enabled configuration whose position is +already over cap **starts into breach posture** rather than failing readiness closed: refusing to +start would sever the deleveraging channel, while the §2 configuration contradiction — outstanding +debt with no `debt` block — still fails hard. Existing hard-halt semantics are untouched: +config/reference/decision failures still request a strategy-wide hard halt, and a market-read +failure still invalidates market-locally while other markets continue ([`ladder-quoter.service.ts`](../../bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts)). Hard-halting everything on breach was rejected (Alternative 5). @@ -394,7 +427,8 @@ while the on-chain fill-time check (L1679) already prevents harm from uncovered - TIB-2026-07-27's V0/V1 capital gates still apply — posting collateral raises capital at the hot maker, and a material increase stays gated on the V1 custody/ratifier controls. - Chain truth on restart is preserved: debt, collateral, and consumption are re-read fresh; the - only new durable state is the debt-mode flag in persisted publication intents. + only new durable state is the debt-mode flag in persisted publication intents and the + debt-market registry (§2), both pointers into chain truth rather than authoritative balances. - Oracle liveness: if an activated collateral's oracle reverts, `take` (when the seller has debt), `withdrawCollateral`, `isHealthy`, and `liquidate` revert (L1320–1327) — and per the `336b924a` delta note (L6–13), the deployed revision makes `supplyCollateral` revert on an oracle revert @@ -461,8 +495,11 @@ queries follow the existing README examples. at the tick price, the per-book worst case at the group's highest-rate tick, merged same-tick rungs, the buffer floor. - **Application:** a mode flip forces `replace`; removing the `debt` block with outstanding - on-chain debt fails readiness (§2); capacity shrink; breach produces sell freeze while buys - continue; config validation (absent block, invalid block, block on an unqualified market). + on-chain debt fails readiness (§2); removing the **entire market entry** with outstanding debt + fails readiness while its groups are still cancelled and its registry entry survives (§2); + lowering `maximumDebtAssets` below current debt enters the breach transition (§7); the breach + batch cancellation leaves buy groups live, and an unconfirmed batch stays in breach and retries; + capacity shrink; config validation (absent block, invalid block, block on an unqualified market). - **Fork (Anvil, the existing e2e harness):** `touchMarket` is permissionless (L1958–1996), so the fork creates a loan-as-collateral market — USDC loan, USDC collateral at `lltv = 1e18` with a deployed constant oracle — and covers `supplyCollateral`, a non-reduce-only sell take creating From e9072e41ead12f6262baba0b7e6c30c13d1ed750 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 3 Sep 2026 07:49:29 +0200 Subject: [PATCH 4/5] docs(quoter-bot): rename debt cap to units, integer-math buffer Rename maximumDebtAssets to maximumDebtUnits (the cap is protocol face units, the exact confusion the tib warns against) and express the buffered collateral requirement as an explicit mulDivUp chain with tiny-value and uint128-boundary test vectors. Co-Authored-By: Claude Fable 5 --- ...08-27-quoter-loan-as-collateral-markets.md | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md index 24ed5f60..bd622a34 100644 --- a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md +++ b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md @@ -105,15 +105,19 @@ The on-chain loan-asset collateral must always cover the worst case of current d live and prospective sell: ```text -worstCaseDebtUnits = debt + Σ over live and prospective sell groups of - ceil(remainingCapAssets × WAD / tickToPrice(tick)) - -requiredCollateral = ceil(worstCaseDebtUnits × WAD / lltv) × (1 + collateralBufferBps / 10⁴) +worstCaseDebtUnits = debt + Σ over live and prospective sell groups of + mulDivUp(remainingCapAssets, WAD, tickToPrice(tick)) +requiredBaseCollateral = mulDivUp(worstCaseDebtUnits, WAD, lltv) +requiredCollateral = mulDivUp(requiredBaseCollateral, 10_000 + collateralBufferBps, 10_000) ``` +The formula is executable integer math — every step is an explicit `mulDivUp`, so integer division +can never erase or round down a small buffer. + An asset-capped sell accumulates `sellerAssets` against `maxAssets` (L1566–1572); with tick price below 1, the units a remaining cap can mint are strictly greater than its assets, and -`ceil(remainingCapAssets × WAD / tickToPrice(tick))` bounds them deterministically from the tick. +`mulDivUp(remainingCapAssets, WAD, tickToPrice(tick))` bounds them deterministically from the +tick. The requirement is **time-invariant** — debt does not accrue and the oracle is constant — so it is sized per publication, not monitored. The publish gate: the post-publication requirement must be `≤` on-chain collateral, otherwise the sell side shrinks through the existing @@ -144,7 +148,7 @@ unknown keys rejected, quoted decimal-integer strings, whole-list env replacemen ```yaml debt: - maximumDebtAssets: '5000000000' # cap on face debt units, raw loan-token base units, positive + maximumDebtUnits: '5000000000' # cap on face debt units, raw loan-token base units, positive collateralBufferBps: '50' # safety margin over the exact requirement, >= 10 ``` @@ -173,7 +177,7 @@ group-ownership state — `markets.allowlist` and the operator runbook are the b `calculateProductionLadderCapacities` gains a debt component: ```text -debtHeadroomUnits = max(0, min(maximumDebtAssets, collateralSupportedUnits) +debtHeadroomUnits = max(0, min(maximumDebtUnits, collateralSupportedUnits) − debt − liveSellWorstCaseUnits) sellSideUnits = min(credit, creditSaleCapacityUnits) + debtHeadroomUnits lowerRateCapacityAssets = floor(sellSideUnits × worstCaseTickPrice / WAD) @@ -185,7 +189,7 @@ assets — today's harmless approximation, since a reduce-only sell can never mi unsafe once sells are non-reduce-only: 100 credit units counted as 100 assets at price 0.9 admit roughly 111 units of fills, 100 consuming the credit and about 11 minting debt past a zero headroom, without failing the §1 collateral assertion (which deliberately ignores credit) — -silently exceeding `maximumDebtAssets`. +silently exceeding `maximumDebtUnits`. [`generateLadder`](../../bots/quoter-bot/src/domain/ladder/ladder.ts) is capacity-agnostic (it takes `LadderMarketState` scalar capacities) and needs **no change**; only capacity derivation changes. After generation, an exact per-rung coverage assertion validates the final tree before @@ -223,7 +227,7 @@ takes a block number); the eventually consistent offer-groups API is never a cov production adapter today mixes a block-pinned position read with unpinned API group views: a sell filling between the two undercounts `debt + remaining sell capacity` — the later view has already removed the filled capacity while the earlier position predates the resulting debt — and the -recovered apparent headroom can push published worst case past `maximumDebtAssets`. Residual skew +recovered apparent headroom can push published worst case past `maximumDebtUnits`. Residual skew is bounded, not dangerous: worst case exceeding coverage makes tail fills revert on-chain (L1679) rather than realize loss, so incoherent snapshots break the policy cap and offer takeability, never solvency — but the pinned-block rule removes the breach entirely. @@ -235,7 +239,7 @@ solvency — but the pinned-block rule removes the breach entirely. matches a pinned constant-oracle artifact **and** a live `price()` read returns exactly `1e36` — the same pinning pattern as the existing ratifier bytecode check. - Report `lltv`, `liquidationCursor`, the derived `maxLif` (L873–876), and the implied worst-case - post-maturity settlement penalty `(maxLif − 1) × maximumDebtAssets`. The market ID + post-maturity settlement penalty `(maxLif − 1) × maximumDebtUnits`. The market ID content-addresses the entire `Market` struct (`IdLib.toId`, create2/SSTORE2, L286–304), so allowlisting the ID immutably accepts all of these — no drift monitoring is needed, but the operator sees what they accepted. Mutable per-market state is only `tickSpacing` (refine-only to @@ -258,7 +262,7 @@ solvency — but the pinned-block rule removes the breach entirely. | ----------------------------------- | ------------------------------------------------------- | | Desired sells do not fit collateral | Capacity shrink — the normal §1/§3 path, no event drama | | Debt + live sells exceed collateral | Breach — sell-side freeze plus a loud event | -| Debt exceeds `maximumDebtAssets` | Breach — same transition; never silent zero headroom | +| Debt exceeds `maximumDebtUnits` | Breach — same transition; never silent zero headroom | The second case is external interference — an authorized key withdrew collateral, unexpected state. The third can arise without any fault: a cap lowered in configuration below the standing @@ -346,7 +350,7 @@ operator decision. (netting). The README's inventory-movement narrative is extended at implementation time. - **Exposure caps:** existing lend-exposure caps are unchanged — they cap buys, and are conservative while debt is outstanding, since those buys repay rather than add credit. - `maximumDebtAssets` is the sell-side symmetric cap. + `maximumDebtUnits` is the sell-side symmetric cap. - **Fees:** the continuous fee applies to credit only — `pendingFee` accrues at buy fill (L1584–1585), and takes revert when the market fee exceeds the offer's `continuousFeeCap` (L1545); debt pays no fee, which is part of why debt is static face value. @@ -358,7 +362,7 @@ operator decision. policy; the debt extension must mirror that model, not bypass it: the `reduceOnly` pin becomes per-market conditional, and every debt-capable sell draws from an aggregate reservation — proposed trees, live sells, and every still-outstanding signed-but-unpublished sell — checked - against independently read debt, collateral, group consumption, and `maximumDebtAssets`, never + against independently read debt, collateral, group consumption, and `maximumDebtUnits`, never per-intent amounts alone. Phase 3 adds a `supplyCollateral` intent. Middleware mode stays fail-closed for these writes until those land. @@ -493,11 +497,13 @@ queries follow the existing README examples. - **Domain:** coverage math — requirement rounds up and headroom down, units ↔ assets conversion at the tick price, the per-book worst case at the group's highest-rate tick, merged same-tick - rungs, the buffer floor. + rungs, the buffer floor, and explicit tiny-value and `uint128`-boundary vectors proving the + `mulDivUp` chain (§1) never rounds a small buffer away and never overflows position-scale + values. - **Application:** a mode flip forces `replace`; removing the `debt` block with outstanding on-chain debt fails readiness (§2); removing the **entire market entry** with outstanding debt fails readiness while its groups are still cancelled and its registry entry survives (§2); - lowering `maximumDebtAssets` below current debt enters the breach transition (§7); the breach + lowering `maximumDebtUnits` below current debt enters the breach transition (§7); the breach batch cancellation leaves buy groups live, and an unconfirmed batch stays in breach and retries; capacity shrink; config validation (absent block, invalid block, block on an unqualified market). - **Fork (Anvil, the existing e2e harness):** `touchMarket` is permissionless (L1958–1996), so the From 426ddc0c980a11d3e35b70e6202df80ef795be02 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 3 Sep 2026 08:24:13 +0200 Subject: [PATCH 5/5] docs(quoter-bot): atomic registry lifecycle, side-scoped breach mode The debt-market registry entry is written in the same atomic ownership-state write as any debt-capable reservation, and GC requires both no remaining debt-capable reservation and a pinned-block debt == 0 read ordered after the confirmed cancellations. Breach becomes a side-scoped reconciliation mode: sell-freeze retried to confirmation, then higher-side-only generation/recenter/resize replenishing stale buys, never touching the generic hard-halt path. Co-Authored-By: Claude Fable 5 --- ...08-27-quoter-loan-as-collateral-markets.md | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md index bd622a34..7441c5b8 100644 --- a/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md +++ b/docs/decisions/TIB-2026-08-27-quoter-loan-as-collateral-markets.md @@ -161,16 +161,28 @@ re-enable the block, or unwind first (repay, or let the buy side deleverage) and same JSON fields as quoted decimal strings. The cap is per-market only — no strategy-wide total (see Non-Goals). -The obligation also survives removal of the **entire market entry**. A market enters a durable -**debt-market registry** — alongside the existing maker-and-market-bound ownership state — when its -first debt-capable tree is reserved, and leaves it only when a pinned-block read shows -`debt == 0`. `cleanupRemovedMarkets` may still cancel and forget a removed market's groups -(stopping further fills is always safe) but never forgets its registry entry; readiness fails for a -registry market no longer configured with a `debt` block — the same unwind-first remediation as -above — and maturity alerting (§9, Observability) keys off the registry, not the configured set, so -a removed market still alerts. The registry is a durable pointer, not truth: every check re-reads -the debt it names from chain, and losing the state volume degrades exactly like losing -group-ownership state — `markets.allowlist` and the operator runbook are the backstop. +The obligation also survives removal of the **entire market entry**, through a durable +**debt-market registry** whose lifecycle is atomic with sell ownership: + +- **Creation:** the registry entry is written or refreshed in the **same atomic ownership-state + write** that durably reserves any debt-capable publication — before ratification or broadcast, + exactly where group reservations already sit in the reserve → broadcast → confirm sequence. A + crash between separate writes could otherwise leave a signed or live debt-capable tree with no + durable obligation. A reverted or uncertain first publication conservatively leaves the entry in + place. +- **Deletion:** garbage collection requires **both** conditions — (1) no reserved or confirmed + debt-capable lower-side group remains, including API-missing reservations, and (2) a + pinned-block position read taken **at or after** the confirmed cancellations reports + `debt == 0`. A zero-debt read alone can race with a still-live sell that mints debt immediately + afterward. + +`cleanupRemovedMarkets` may still cancel and forget a removed market's groups (stopping further +fills is always safe) but never forgets its registry entry; readiness fails for a registry market +no longer configured with a `debt` block — the same unwind-first remediation as above — and +maturity alerting (§9, Observability) keys off the registry, not the configured set, so a removed +market still alerts. The registry is a durable pointer, not truth: every check re-reads the debt it +names from chain, and losing the state volume degrades exactly like losing group-ownership state — +`markets.allowlist` and the operator runbook are the backstop. ### 3. Capacity integration @@ -277,10 +289,19 @@ owned **lower-side** group IDs from persisted publications — sides never share grouping mode — and cancels them in **one** native Midnight `multicall` of `setConsumed(group, MAX_OFFER_CAP, maker)` inner calls, the exact batch shape the maker-wide `invalidate` recovery command already uses, leaving the higher-side groups untouched. Failure -semantics: an unconfirmed batch leaves the market in breach state — buy groups retained, the loud -event emitted, the sell cancellation retried on subsequent cycles; there is no partial per-group -cancellation loop. The market exits breach only when a fresh pinned-block read shows the invariant -restored: coverage sufficient and debt at or under the cap. +semantics: an unconfirmed batch leaves the market in breach state — existing buy groups retained, +the loud event emitted, the sell cancellation retried on subsequent cycles; there is no partial +per-group cancellation loop. + +Breach is a **mode**, not a one-shot: merely retaining the buys that existed at breach entry would +let the only deleveraging channel evaporate as they fill, expire, or go stale. Every breached cycle +therefore runs a side-scoped reconciliation path that bypasses normal two-sided reconciliation: +first the sell-freeze batch (retried until confirmed), then — once sells are confirmed frozen — +normal generation, recenter, and resize for the **higher side only**, replenishing exhausted or +stale buys while never publishing a lower-side group. The existing allocator supports this +directly: breach pins `lowerRateCapacityAssets` to zero, and a zero-capacity side emits no rungs. +The market exits breach only when a fresh pinned-block read shows the invariant restored — coverage +sufficient and debt at or under the cap — after which two-sided reconciliation resumes. Buys **keep quoting** through breach because buy fills repay debt (L1581, L1606) — the only self-repair channel. For the same reason, a coherent debt-enabled configuration whose position is @@ -503,9 +524,16 @@ queries follow the existing README examples. - **Application:** a mode flip forces `replace`; removing the `debt` block with outstanding on-chain debt fails readiness (§2); removing the **entire market entry** with outstanding debt fails readiness while its groups are still cancelled and its registry entry survives (§2); - lowering `maximumDebtUnits` below current debt enters the breach transition (§7); the breach - batch cancellation leaves buy groups live, and an unconfirmed batch stays in breach and retries; - capacity shrink; config validation (absent block, invalid block, block on an unqualified market). + registry lifecycle vectors (§2) — the entry is written in the same atomic write as the first + debt-capable reservation (a crash between reserve and broadcast still leaves the obligation), a + reverted first publication leaves the entry, GC refuses while any reservation (including an + API-missing one) remains even at `debt == 0`, and the zero-debt read must be ordered at or after + the confirmed cancellations; lowering `maximumDebtUnits` below current debt enters the breach + transition (§7); breach-mode vectors (§7) — an unconfirmed sell batch stays in breach, retains + existing buys, and retries; once sells are confirmed frozen, filled or stale buys are replenished + by higher-side-only reconciliation during a persistent breach, no lower-side group is ever + published, and the generic hard-halt path is never invoked from breach; capacity shrink; config + validation (absent block, invalid block, block on an unqualified market). - **Fork (Anvil, the existing e2e harness):** `touchMarket` is permissionless (L1958–1996), so the fork creates a loan-as-collateral market — USDC loan, USDC collateral at `lltv = 1e18` with a deployed constant oracle — and covers `supplyCollateral`, a non-reduce-only sell take creating