From ad10df553a223ac02e9deb48c5d64841194f471c Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 12:31:42 -0500 Subject: [PATCH 01/10] feat(midnight-liquidation): gate plans on incentive headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seize-exact plan's entire margin is the liquidation incentive `(lif - 1)/lif`, which post-maturity ramps from zero over an hour. Below that, no swap route can fund the repay: the shortfall surfaces either as the router's min-out revert or as Midnight's repay pull failing with `ERC20: transfer amount exceeds allowance`. On the 31 Jul cbBTC/USDC maturity, 824 plans produced 81 quotes and 81 simulation reverts, 1:1 — every quote spent before the incentive crossed our execution cost bought a guaranteed revert. Skip those plans in sizing, where the arithmetic already lives: - `LiquidationPlan` carries the LIF it was sized at and the repay the contract will ceil-derive from it. Neither is recoverable from `postMaturityMode` alone, because the matured-and-unhealthy branch chooses a mode by surplus. `planSurplus` reads both instead of recomputing `lifAt`, so it can no longer disagree with the plan. - `HEADROOM_FLOOR_BPS` (default 3) is a LOWER BOUND on execution cost, not a typical-cost estimate. The gate suppresses until `headroom(t) >= floor`, making it a pure time gate — 3 bps hides roughly the first 25s on a 4.4%-maxLif tier. Higher values blind the earliest, most contested part of a maturity, which costs far more than a wasted quote. - The floor reads the CHOSEN plan's LIF, downstream of mode selection. Normal mode pays the full `maxLif` with no ramp, so a floor derived from a ramping post-maturity LIF would reject matured-and-unhealthy positions the chain funds immediately. Covered by regression tests in both the sizing and tick suites. - `insufficient_headroom` rides `plan.skipped` at `debug`: headroom is scale-invariant, so it fires identically for every candidate in a `(maturity, maxLif, mode)` group. One line per position per block is what buried the post-mortem. Because a sizing skip records neither backoff nor cooldown, a gated position is re-planned every block — I/O-free, and `planSkipped` will be correspondingly large. No current tier can sit permanently under the floor: the ceilings are 420/255/60 bps against 3. `isBadDebtRealization` now takes only the two amounts it reads, so a caller holding wire-verified params need not synthesize a plan. Co-Authored-By: Claude Opus 5 (1M context) --- bots/midnight-liquidation/README.md | 73 ++++---- bots/midnight-liquidation/src/config.ts | 13 ++ .../src/execution/encode-call.ts | 4 +- bots/midnight-liquidation/src/index.ts | 1 + bots/midnight-liquidation/src/runner/tick.ts | 14 +- bots/midnight-liquidation/src/sizing/plan.ts | 163 ++++++++++++++---- .../test/execution/swap-step.test.ts | 12 +- bots/midnight-liquidation/test/quotes.test.ts | 4 +- .../test/runner/tick.test.ts | 39 +++++ .../test/sizing/plan.test.ts | 126 ++++++++++++-- 10 files changed, 354 insertions(+), 95 deletions(-) diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index 923ae120..fc18df93 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -47,42 +47,43 @@ Never commit `.env` files, private keys, or RPC credentials. Environment variables: -| Var | Required | Default | Purpose | -| --------------------------------------------------------- | -------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CHAIN_ID` | yes | — | Must be `8453` for Base. | -| `RPC_URL` | yes | — | Base RPC used for reads, simulation, and sends. Must be a full RPC that relays `eth_sendRawTransaction` — a read-only relay that acks sends without forwarding them to the sequencer would leave every tx unmined. | -| `RPC_URL_FALLBACK` | no | — | Optional fallback RPC for the signer's transport. | -| `LIQUIDATOR_PRIVATE_KEY` | yes | — | `0x`-prefixed 32-byte private key for the sender EOA. | -| `EXECUTOOOR_ADDRESS` | no | derived | Override for the shared Executor address. | -| `LIQUIDATION_CANDIDATES_API_URL` | no | public | Liquidation-candidates endpoint polled for borrower discovery. Defaults to the public Morpho markets API; validated as a URL at startup (fail-loud). | -| `HEALTH_FACTOR_LTE` | no | `1.02` | Health-factor cutoff sent to discovery (`health_factor_lte`); matured positions are always included regardless. Floored at `1.0`. Over-inclusive by design — the on-chain lens is the source of truth. | -| `MARKETS_API_URL` | no | public | Midnight markets endpoint(s) used as the market whitelist (`listed=true`). Accepts a comma-separated list, whose whitelists are unioned per-source (see below). Defaults to the public Morpho markets API; every entry is validated as a URL at startup (fail-loud). ⚠️ Set a list only after an image that supports it is live, and clear it back to one URL before rolling back — older images reject a list. | -| `MARKETS_REFRESH_MS` | no | `60000` | How often the whitelist is refreshed. The endpoint is Morpho's own (not rate-limited); last-known-good is served on a transient failure. | -| `ZEROX_API_KEY` | cond. | — | Enables the `0x` venue when set. Read at point of use; never stored on config or logged. | -| `ONEINCH_API_KEY` | cond. | — | Enables the `1inch` venue when set. Read at point of use; never stored on config or logged. | -| `ENABLE_LIFI` | no | `false` | Enables the keyless `lifi` venue. Also implicitly enabled when `LIFI_API_KEY` is set. | -| `LIFI_API_KEY` | no | — | Optional; LiFi routes keyless, a key only raises its rate limits (and enables the venue). Read at point of use; never logged. | -| `ALLOW_BAD_DEBT_ONLY` | no | `false` | When no venue is enabled, the bot refuses to start unless this is `true` (then it runs bad-debt-only: discovers positions, realizes bad debt, never swap-liquidates). | -| `SLIPPAGE_BPS` | no | `100` | Global max oracle-to-DEX output discount passed to every venue (bakes the on-chain min-out into calldata). Replaces the old per-collateral `slippageBps`. | -| `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | public | Optional venue API host overrides. | -| `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral addresses the bot must never seize/hold — skipped (no quote) even in a listed market. | -| `MAX_FEE_GWEI` | no | `300` | Hard max fee cap used by the pending transaction queue. | -| `PRIORITY_FEE_GWEI` | no | `0.1` | First-send tip. The bump path adds at most 1.42x (3 attempts × 12.5%) over ~15 blocks, so this value, not the ceiling, sets what the bot actually pays for inclusion. Must leave room for one bump under `MAX_FEE_GWEI`. | -| `LOG_LEVEL` | no | `info` | One of `debug`, `info`, `warn`, `error`. | -| `CACHE_DIR` | no | `.cache` | Soltag/deployless cache directory. | -| `QUOTE_TIMEOUT_MS` | no | `2500` | Per-quote HTTP deadline (the firm quote runs inside the per-block tick). | -| `HTTP_RPS` / `HTTP_BURST` | no | `2` / `5` | Per-venue token-bucket refill rate and burst for FIRM quotes. The 1inch free tier is 1 RPS — set `HTTP_RPS=1` if you only use 1inch. | -| `PROBE_HTTP_RPS` | no | `1` | Per-venue token-bucket rate for BACKGROUND probes, on a separate client so probe bursts never queue ahead of a live firm quote. | -| `PROBE_STALE_MS` | no | `600000` | Probe-cache TTL per pair. A pair is re-probed only when a liquidatable position touches it after the cache goes stale — no probe traffic on quiet markets. | -| `PROBE_LADDER` | no | `0.01,0.1,1,10,100` | Comma-separated log-scaled probe sizes in whole collateral tokens; converted per-collateral to base units. Venue rankings are cached per size bucket. | -| `HTTP_MAX_RETRIES` | no | `2` | Retries on 429/5xx/network (honoring `Retry-After`) before a quote fails. | -| `MAX_ROUTE_IMPACT_BPS` | no | `500` | Reject a venue's quoted output more than this far below the oracle reference (route-quality guard). | -| `SEIZE_CAP_MARGIN_BPS` | no | `30` | Headroom shaved off the on-chain repay cap when sizing a cap-binding seize, so a one-block oracle move can't trip the contract's RCF/debt check. `0` sizes right at the cap. | -| `PENDLE_SLIPPAGE_BPS` | no | `50` | Slippage for the Pendle PT → underlying unwrap hop (before the downstream venue sells). | -| `BACKOFF_BASE_BLOCKS` / `BACKOFF_MAX_BLOCKS` | no | `2` / `64` | Exponential per-position cooldown (in blocks) after a failed quote/simulate, bounding API + RPC usage under a backlog. | -| `POSITION_LIQUIDATION_COOLDOWN_MS` | no | `0` | Opt-in per-position cooldown (ms) after a failed liquidation attempt; `0` disables it (re-attempt every tick). | -| `BETTERSTACK_SOURCE_TOKEN` / `BETTERSTACK_INGESTING_HOST` | no | — | Opt-in log shipping; when both are set the bot's in-process loglayer transport ships structured logs to BetterStack (inert otherwise). | -| `BETTERSTACK_HEARTBEAT_URL` | no | — | Optional Better Stack Uptime heartbeat URL, pinged every minute; failures only log a warning and never interrupt liquidations. | +| Var | Required | Default | Purpose | +| --------------------------------------------------------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `CHAIN_ID` | yes | — | Must be `8453` for Base. | +| `RPC_URL` | yes | — | Base RPC used for reads, simulation, and sends. Must be a full RPC that relays `eth_sendRawTransaction` — a read-only relay that acks sends without forwarding them to the sequencer would leave every tx unmined. | +| `RPC_URL_FALLBACK` | no | — | Optional fallback RPC for the signer's transport. | +| `LIQUIDATOR_PRIVATE_KEY` | yes | — | `0x`-prefixed 32-byte private key for the sender EOA. | +| `EXECUTOOOR_ADDRESS` | no | derived | Override for the shared Executor address. | +| `LIQUIDATION_CANDIDATES_API_URL` | no | public | Liquidation-candidates endpoint polled for borrower discovery. Defaults to the public Morpho markets API; validated as a URL at startup (fail-loud). | +| `HEALTH_FACTOR_LTE` | no | `1.02` | Health-factor cutoff sent to discovery (`health_factor_lte`); matured positions are always included regardless. Floored at `1.0`. Over-inclusive by design — the on-chain lens is the source of truth. | +| `MARKETS_API_URL` | no | public | Midnight markets endpoint(s) used as the market whitelist (`listed=true`). Accepts a comma-separated list, whose whitelists are unioned per-source (see below). Defaults to the public Morpho markets API; every entry is validated as a URL at startup (fail-loud). ⚠️ Set a list only after an image that supports it is live, and clear it back to one URL before rolling back — older images reject a list. | +| `MARKETS_REFRESH_MS` | no | `60000` | How often the whitelist is refreshed. The endpoint is Morpho's own (not rate-limited); last-known-good is served on a transient failure. | +| `ZEROX_API_KEY` | cond. | — | Enables the `0x` venue when set. Read at point of use; never stored on config or logged. | +| `ONEINCH_API_KEY` | cond. | — | Enables the `1inch` venue when set. Read at point of use; never stored on config or logged. | +| `ENABLE_LIFI` | no | `false` | Enables the keyless `lifi` venue. Also implicitly enabled when `LIFI_API_KEY` is set. | +| `LIFI_API_KEY` | no | — | Optional; LiFi routes keyless, a key only raises its rate limits (and enables the venue). Read at point of use; never logged. | +| `ALLOW_BAD_DEBT_ONLY` | no | `false` | When no venue is enabled, the bot refuses to start unless this is `true` (then it runs bad-debt-only: discovers positions, realizes bad debt, never swap-liquidates). | +| `SLIPPAGE_BPS` | no | `100` | Global max oracle-to-DEX output discount passed to every venue (bakes the on-chain min-out into calldata). Replaces the old per-collateral `slippageBps`. | +| `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | public | Optional venue API host overrides. | +| `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral addresses the bot must never seize/hold — skipped (no quote) even in a listed market. | +| `MAX_FEE_GWEI` | no | `300` | Hard max fee cap used by the pending transaction queue. | +| `PRIORITY_FEE_GWEI` | no | `0.1` | First-send tip. The bump path adds at most 1.42x (3 attempts × 12.5%) over ~15 blocks, so this value, not the ceiling, sets what the bot actually pays for inclusion. Must leave room for one bump under `MAX_FEE_GWEI`. | +| `LOG_LEVEL` | no | `info` | One of `debug`, `info`, `warn`, `error`. | +| `CACHE_DIR` | no | `.cache` | Soltag/deployless cache directory. | +| `QUOTE_TIMEOUT_MS` | no | `2500` | Per-quote HTTP deadline (the firm quote runs inside the per-block tick). | +| `HTTP_RPS` / `HTTP_BURST` | no | `2` / `5` | Per-venue token-bucket refill rate and burst for FIRM quotes. The 1inch free tier is 1 RPS — set `HTTP_RPS=1` if you only use 1inch. | +| `PROBE_HTTP_RPS` | no | `1` | Per-venue token-bucket rate for BACKGROUND probes, on a separate client so probe bursts never queue ahead of a live firm quote. | +| `PROBE_STALE_MS` | no | `600000` | Probe-cache TTL per pair. A pair is re-probed only when a liquidatable position touches it after the cache goes stale — no probe traffic on quiet markets. | +| `PROBE_LADDER` | no | `0.01,0.1,1,10,100` | Comma-separated log-scaled probe sizes in whole collateral tokens; converted per-collateral to base units. Venue rankings are cached per size bucket. | +| `HTTP_MAX_RETRIES` | no | `2` | Retries on 429/5xx/network (honoring `Retry-After`) before a quote fails. | +| `MAX_ROUTE_IMPACT_BPS` | no | `500` | Reject a venue's quoted output more than this far below the oracle reference (route-quality guard). | +| `SEIZE_CAP_MARGIN_BPS` | no | `30` | Headroom shaved off the on-chain repay cap when sizing a cap-binding seize, so a one-block oracle move can't trip the contract's RCF/debt check. `0` sizes right at the cap. | +| `HEADROOM_FLOOR_BPS` | no | `3` | **Lower bound** on swap execution cost — the cheapest route you would ever expect, NOT a typical cost. A seize-exact plan's whole margin is the incentive `(lif - 1)/lif`, so a plan below this floor cannot fund its own repay by any route and is skipped as `plan.skipped` / `insufficient_headroom` before it costs a quote, a simulation or a gas estimate. Post-maturity the incentive ramps from zero over an hour, so this acts as a pure time gate: `3` suppresses roughly the first 25s on a 4.4%-maxLif tier. Set it too high and it blinds the earliest, most contested part of a maturity. `0` disables the gate. | +| `PENDLE_SLIPPAGE_BPS` | no | `50` | Slippage for the Pendle PT → underlying unwrap hop (before the downstream venue sells). | +| `BACKOFF_BASE_BLOCKS` / `BACKOFF_MAX_BLOCKS` | no | `2` / `64` | Exponential per-position cooldown (in blocks) after a failed quote/simulate, bounding API + RPC usage under a backlog. | +| `POSITION_LIQUIDATION_COOLDOWN_MS` | no | `0` | Opt-in per-position cooldown (ms) after a failed liquidation attempt; `0` disables it (re-attempt every tick). | +| `BETTERSTACK_SOURCE_TOKEN` / `BETTERSTACK_INGESTING_HOST` | no | — | Opt-in log shipping; when both are set the bot's in-process loglayer transport ships structured logs to BetterStack (inert otherwise). | +| `BETTERSTACK_HEARTBEAT_URL` | no | — | Optional Better Stack Uptime heartbeat URL, pinged every minute; failures only log a warning and never interrupt liquidations. | The bot **refuses to start** if no venue is enabled, unless `ALLOW_BAD_DEBT_ONLY=true` — a rotated or forgotten key (or a missing `ENABLE_LIFI`) must not silently disable liquidations. diff --git a/bots/midnight-liquidation/src/config.ts b/bots/midnight-liquidation/src/config.ts index ee88b686..06908e47 100644 --- a/bots/midnight-liquidation/src/config.ts +++ b/bots/midnight-liquidation/src/config.ts @@ -53,6 +53,13 @@ const DEFAULT_MAX_ROUTE_IMPACT_BPS = 500 // reject an aggregator route >5% below // entry (the underlying's entry isn't known until after resolution). Keep well under // MAX_ROUTE_IMPACT_BPS — it also haircuts the amount the downstream venue sells. const DEFAULT_PENDLE_SLIPPAGE_BPS = 50 +// Lower bound on swap execution cost. Skips a plan whose incentive headroom `(lif - 1)/lif` cannot +// cover even the cheapest route, before it costs a quote, a simulation and a gas estimate. Kept LOW +// deliberately: the floor is a pure time gate (3 bps suppresses until ~t+25s post-maturity on a +// 438bps-maxLif tier), and blinding the earliest seconds of a maturity is far more costly than a +// wasted quote. The 31 Jul archive implies (8.52, 15.83] for that maturity's basis regime; that is one +// observation and deliberately NOT the default. +const DEFAULT_HEADROOM_FLOOR_BPS = 3 const DEFAULT_SEIZE_CAP_MARGIN_BPS = 30 // shave the repay cap when sizing a cap-binding seize — one-block oracle-drift headroom; calibratable const DEFAULT_BACKOFF_BASE_BLOCKS = 2n const DEFAULT_BACKOFF_MAX_BLOCKS = 64n @@ -93,6 +100,8 @@ export type QuotingConfig = { pendleSlippageBps: number /** Headroom (bps) shaved off the on-chain repay cap when sizing a cap-binding seize-exact plan. */ seizeCapMarginBps: number + /** Lower bound (bps) on swap execution cost; skips plans whose incentive headroom cannot cover it. */ + headroomFloorBps: number backoffBaseBlocks: bigint backoffMaxBlocks: bigint } @@ -455,6 +464,10 @@ export function loadConfig( min: 0, max: 10_000 }), + headroomFloorBps: intEnv(env, 'HEADROOM_FLOOR_BPS', DEFAULT_HEADROOM_FLOOR_BPS, { + min: 0, + max: 10_000 + }), backoffBaseBlocks: bigintEnv(env, 'BACKOFF_BASE_BLOCKS', DEFAULT_BACKOFF_BASE_BLOCKS), backoffMaxBlocks: bigintEnv(env, 'BACKOFF_MAX_BLOCKS', DEFAULT_BACKOFF_MAX_BLOCKS) } diff --git a/bots/midnight-liquidation/src/execution/encode-call.ts b/bots/midnight-liquidation/src/execution/encode-call.ts index ea9ee456..fa3ad596 100644 --- a/bots/midnight-liquidation/src/execution/encode-call.ts +++ b/bots/midnight-liquidation/src/execution/encode-call.ts @@ -64,10 +64,8 @@ export function encodeLiquidationExec(params: { if ( isBadDebtRealization({ - collateralIndex: params.collateralIndex, seizedAssets: params.seizedAssets, - repaidUnits: params.repaidUnits, - postMaturityMode: params.postMaturityMode + repaidUnits: params.repaidUnits }) ) { const liquidateData = encodeFunctionData({ diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index d671f6e1..8ec1ea86 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -326,6 +326,7 @@ async function main() { chainHead, caller: config.executooorAddress, seizeCapMarginBps: config.quoting.seizeCapMarginBps, + headroomFloorBps: config.quoting.headroomFloorBps, readLens: pairs => readMidnightLiquidationLens(client, config.midnight, pairs), quoteFor, simulate: ({ market, borrower, plan, swapPlan }) => diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index 2187d7e1..fabf1ef8 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -74,7 +74,11 @@ const LEVEL_BY_REASON: Record = { seize_rounds_to_zero: 'info', // Unliquidatable in normal mode rather than transient: it clears only when the oracle moves or the // position matures into post-maturity mode, where the RCF cap does not apply. - writeoff_below_max_debt: 'info' + writeoff_below_max_debt: 'info', + // Group property, not a per-position one: headroom is `(lif - 1)/lif`, so every candidate sharing a + // (maturity, maxLif, chosen mode) group is skipped together. At `info` that is one line per position + // per block — the shape that gave the 31 Jul post-mortem hundreds of identical warnings. + insufficient_headroom: 'debug' } /** @@ -102,6 +106,8 @@ export async function runTick(deps: { caller: Address /** Headroom (bps) shaved off a cap-binding seize for one-block oracle-drift; passed to sizing. */ seizeCapMarginBps: number + /** Lower bound (bps) on swap execution cost; passed to sizing. `0` disables the headroom gate. */ + headroomFloorBps: number readLens: (pairs: LensInput[]) => Promise> /** * Fetches ONE executable swap for a liquidatable position from its configured venue (Uniswap is @@ -147,6 +153,7 @@ export async function runTick(deps: { chainHead, caller, seizeCapMarginBps, + headroomFloorBps, readLens, quoteFor, simulate, @@ -206,7 +213,10 @@ export async function runTick(deps: { continue } - const outcome = planWithReason(planInputFromLens(out), { seizeCapMarginBps }) + const outcome = planWithReason(planInputFromLens(out), { + seizeCapMarginBps, + headroomFloorBps + }) if (outcome.plan === null) { // Deliberately no backoff and no cooldown: a sizing skip is not a failure, and several // reasons clear on their own as chain time advances (see PlanOutcome). diff --git a/bots/midnight-liquidation/src/sizing/plan.ts b/bots/midnight-liquidation/src/sizing/plan.ts index aa40754b..5ea0b941 100644 --- a/bots/midnight-liquidation/src/sizing/plan.ts +++ b/bots/midnight-liquidation/src/sizing/plan.ts @@ -35,6 +35,18 @@ export type LiquidationPlan = { seizedAssets: bigint repaidUnits: bigint postMaturityMode: boolean + /** + * LIF this plan was sized at — {@link lifAt} for `postMaturityMode` at `input.blockTimestamp`. + * Surfaced rather than recomputed because it is NOT derivable from `postMaturityMode` alone: the + * matured-and-unhealthy branch picks a mode by surplus, so only the chosen plan knows its own LIF. + */ + lif: bigint + /** + * The repay the contract will ceil-derive for `seizedAssets` at {@link LiquidationPlan.lif} — i.e. + * the swap's break-even output in loan units. `repaidUnits` stays `0n` (seize-exact); this is what + * the chain computes from it. + */ + impliedRepaidUnits: bigint } /** @@ -52,6 +64,9 @@ export type LiquidationPlan = { * `(0, 0)` plan that {@link isBadDebtRealization} would misclassify as a write-off against a * still-solvent position. * - `seize_rounds_to_zero`: a cap-binding seize rounded down to zero collateral. + * - `insufficient_headroom`: the chosen plan's incentive headroom is below + * {@link PlanOptions.headroomFloorBps}, so no swap route could fund the repay. Clears on its own as + * the post-maturity LIF ramps, which is why a skip must not record backoff (see {@link PlanOutcome}). */ export type PlanSkipReason = | 'no_debt' @@ -60,6 +75,7 @@ export type PlanSkipReason = | 'cap_not_positive' | 'nothing_to_seize' | 'seize_rounds_to_zero' + | 'insufficient_headroom' | 'writeoff_below_max_debt' /** @@ -87,6 +103,16 @@ type PlanOptions = { * `cap·(1 - margin)` keeps headroom for ordinary one-block moves. `0` reproduces the unmargined cap. */ seizeCapMarginBps?: number + /** + * A **lower bound** on swap execution cost in bps — the cheapest route the operator would ever + * expect — NOT a typical-cost estimate. A seize-exact plan's entire margin is `(lif - 1)/lif`, so a + * plan whose headroom is under this floor cannot fund its own repay by any route and is skipped + * before it costs a quote, a simulation and a gas estimate. + * + * Set it too high and the gate blinds the earliest, most contested part of a maturity: the floor is + * a pure time gate, suppressing until `headroom(t) >= floor`. `0` disables the gate. + */ + headroomFloorBps?: number } const skip = (reason: PlanSkipReason): PlanOutcome => ({ plan: null, reason }) @@ -98,15 +124,51 @@ const sized = (plan: LiquidationPlan): PlanOutcome => ({ plan }) * A `(0, 0)` plan is the encoding — seizing no collateral for no repay. Callers must branch on this * before treating a plan as a swap-funded liquidation, since a write-off needs neither a quote nor * loan-token funding. Pure predicate: no failures, no side effects. + * + * Takes only the two amounts it reads, not a whole {@link LiquidationPlan}, so a caller holding + * wire-verified params need not synthesize the plan's derived fields to ask the question. */ -export const isBadDebtRealization = (plan: LiquidationPlan): boolean => - plan.seizedAssets === 0n && plan.repaidUnits === 0n +export const isBadDebtRealization = ( + plan: Pick +): boolean => plan.seizedAssets === 0n && plan.repaidUnits === 0n -// Repaid units the contract derives when the caller passes `seizedAssets` (midnight-contracts.txt:2369): -// two chained ceil-divisions, collateral → loan units → repaid units. +/** + * Repaid units the contract derives when the caller passes `seizedAssets` + * (midnight-contracts.txt:2369): two chained ceil-divisions, collateral → loan units → repaid units. + * Both round up, i.e. against the liquidator, so this is the swap's break-even output. Every sized + * plan already carries its own value as {@link LiquidationPlan.impliedRepaidUnits}, so read that + * rather than recomputing; export this only alongside a consumer that cannot. + */ const impliedRepaidUnits = (seizedAssets: bigint, price: bigint, lif: bigint): bigint => mulDivUp(mulDivUp(seizedAssets, price, ORACLE_PRICE_SCALE), WAD, lif) +/** + * The seized slot's oracle value in loan units — `seizedAssets · price / ORACLE_PRICE_SCALE`. Floors, + * so a dust position can value to zero; callers dividing by it must guard that. + */ +const seizedValueOf = (seizedAssets: bigint, price: bigint): bigint => + mulDivDown(seizedAssets, price, ORACLE_PRICE_SCALE) + +// Assembles a seize-exact plan with the two derived fields downstream consumers would otherwise +// recompute: the LIF it was sized at and the repay the chain will derive from it. +const buildPlan = (args: { + input: PlanInput + seizedAssets: bigint + lif: bigint + postMaturityMode: boolean +}): LiquidationPlan => ({ + collateralIndex: args.input.bestCollateralIndex, + seizedAssets: args.seizedAssets, + repaidUnits: 0n, + postMaturityMode: args.postMaturityMode, + lif: args.lif, + impliedRepaidUnits: impliedRepaidUnits( + args.seizedAssets, + args.input.bestCollateralPrice, + args.lif + ) +}) + /** * The largest seize `S` whose contract-derived repaid (`impliedRepaidUnits(S, price, lif)`) stays * within `cap`. This is exactly the contract's own `repaidUnits → seizedAssets` derivation @@ -139,21 +201,12 @@ const capBoundPlan = ( const capEff = mulDivDown(cap, BPS - BigInt(marginBps), BPS) const seizedAssets = maxSeizeForCap(capEff, input.bestCollateralPrice, lif) if (seizedAssets === 0n) return skip('seize_rounds_to_zero') - return sized({ - collateralIndex: input.bestCollateralIndex, - seizedAssets, - repaidUnits: 0n, - postMaturityMode - }) + return sized(buildPlan({ input, seizedAssets, lif, postMaturityMode })) } // The whole-slot seize-exact plan in the given mode (the no-cap-binding case for both modes). -const wholeSlotPlan = (input: PlanInput, postMaturityMode: boolean): LiquidationPlan => ({ - collateralIndex: input.bestCollateralIndex, - seizedAssets: input.bestCollateralAmt, - repaidUnits: 0n, - postMaturityMode -}) +const wholeSlotPlan = (input: PlanInput, lif: bigint, postMaturityMode: boolean): LiquidationPlan => + buildPlan({ input, seizedAssets: input.bestCollateralAmt, lif, postMaturityMode }) // Normal-mode sizing (gated on-chain by `debt > maxDebt`, before or after maturity alike): LIF is the // slot's full `maxLif` immediately. The contract subtracts `repaidUnits` from the post-writeoff debt @@ -198,7 +251,7 @@ const normalModePlan = (input: PlanInput, marginBps: number): PlanOutcome => { const repayCap = exempt ? effectiveDebt : min(maxRepaid, effectiveDebt) if (repayCap <= 0n) return skip('cap_not_positive') - if (wholeSlotRepaid <= repayCap) return sized(wholeSlotPlan(input, false)) + if (wholeSlotRepaid <= repayCap) return sized(wholeSlotPlan(input, lif, false)) return capBoundPlan(input, repayCap, lif, marginBps, false) } @@ -222,23 +275,29 @@ const postMaturityPlan = (input: PlanInput, marginBps: number): PlanOutcome => { input.bestCollateralPrice, lif ) - if (wholeSlotRepaid <= effectiveDebt) return sized(wholeSlotPlan(input, true)) + if (wholeSlotRepaid <= effectiveDebt) return sized(wholeSlotPlan(input, lif, true)) return capBoundPlan(input, effectiveDebt, lif, marginBps, true) } // Expected surplus of a seize-exact plan, in loan units: the seized slot's oracle value minus the -// repaid units the contract will ceil-derive under that plan's mode/LIF. Used only to CHOOSE between -// two plans whose gates are both open — absolute profitability (gas, slippage, route quality) stays -// the quoting/simulate layer's job. -const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint => { - const lif = lifAt({ - now: input.blockTimestamp, - maturity: input.maturity, - maxLif: input.bestCollateralMaxLif, - postMaturityMode: chosen.postMaturityMode - }) - const seizedValue = mulDivDown(chosen.seizedAssets, input.bestCollateralPrice, ORACLE_PRICE_SCALE) - return seizedValue - impliedRepaidUnits(chosen.seizedAssets, input.bestCollateralPrice, lif) +// repay the contract will ceil-derive. Both terms are already on the plan, so this neither recomputes +// `lifAt` nor can disagree with the LIF the plan was sized at. Used to CHOOSE between two plans whose +// gates are both open; absolute profitability (gas, route quality) stays the quoting layer's job. +const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint => + seizedValueOf(chosen.seizedAssets, input.bestCollateralPrice) - chosen.impliedRepaidUnits + +/** + * Incentive headroom of a sized plan, in bps: `surplus / seizedValue`, which for a seize-exact plan + * reduces to `(lif - 1) / lif` and is therefore **scale-invariant** — a $10k and a $1 plan at the same + * LIF read identically. That is why this cannot reject dust; only an absolute floor can. + * + * Returns `0n` when the seized slot's oracle value floors to zero, so a valueless plan fails any + * non-zero floor rather than dividing by zero. + */ +const headroomBps = (input: PlanInput, chosen: LiquidationPlan): bigint => { + const seizedValue = seizedValueOf(chosen.seizedAssets, input.bestCollateralPrice) + if (seizedValue <= 0n) return 0n + return ((seizedValue - chosen.impliedRepaidUnits) * BPS) / seizedValue } /** @@ -269,7 +328,7 @@ const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint => { * Side-effect free. Callers must not treat a skip as a failure — see {@link PlanOutcome}. */ export const planWithReason = (input: PlanInput, options: PlanOptions = {}): PlanOutcome => { - const { seizeCapMarginBps = 0 } = options + const { seizeCapMarginBps = 0, headroomFloorBps = 0 } = options if (!input.hasDebt) return skip('no_debt') if (input.locked) return skip('locked') @@ -277,19 +336,37 @@ export const planWithReason = (input: PlanInput, options: PlanOptions = {}): Pla const matured = input.blockTimestamp > input.maturity if (!matured && input.healthy) return skip('healthy_pre_maturity') + // Bad-debt realization: a pure write-off, no assets move and no swap funds it, so the headroom gate + // below must not see it — hence the early return rather than a `(0, 0)` plan falling through. if (input.badDebt >= input.debt) { - return sized({ - collateralIndex: input.bestCollateralIndex, - seizedAssets: 0n, - repaidUnits: 0n, - postMaturityMode: matured - }) + return sized( + buildPlan({ + input, + seizedAssets: 0n, + lif: lifAt({ + now: input.blockTimestamp, + maturity: input.maturity, + maxLif: input.bestCollateralMaxLif, + postMaturityMode: matured + }), + postMaturityMode: matured + }) + ) } // Below here every plan seizes collateral, so an empty best slot cannot produce one: a whole-slot // seize of nothing is the `(0, 0)` shape reserved for bad-debt realization. if (input.bestCollateralAmt === 0n) return skip('nothing_to_seize') + return gateOnHeadroom(input, selectMode(input, seizeCapMarginBps), headroomFloorBps) +} + +// The mode policy of `liquidate(...)` — see {@link planWithReason}'s JSDoc. Split out so the headroom +// gate is provably DOWNSTREAM of mode selection: normal mode pays the full `maxLif` with no ramp, so a +// gate reading a ramping post-maturity LIF would reject matured-and-unhealthy positions that normal +// mode funds immediately. +const selectMode = (input: PlanInput, seizeCapMarginBps: number): PlanOutcome => { + const matured = input.blockTimestamp > input.maturity if (!matured) return normalModePlan(input, seizeCapMarginBps) const post = postMaturityPlan(input, seizeCapMarginBps) @@ -303,6 +380,18 @@ export const planWithReason = (input: PlanInput, options: PlanOptions = {}): Pla return planSurplus(input, normal.plan) > planSurplus(input, post.plan) ? normal : post } +// Rejects a sized plan whose incentive headroom cannot cover the operator's floor on execution cost. +// Reads the CHOSEN plan's own `lif`, so it cannot disagree with the mode `selectMode` picked. +const gateOnHeadroom = ( + input: PlanInput, + outcome: PlanOutcome, + headroomFloorBps: number +): PlanOutcome => { + if (headroomFloorBps <= 0 || outcome.plan === null) return outcome + if (headroomBps(input, outcome.plan) >= BigInt(headroomFloorBps)) return outcome + return skip('insufficient_headroom') +} + /** * {@link planWithReason} projected to just the plan, for callers that do not report the skip reason. */ diff --git a/bots/midnight-liquidation/test/execution/swap-step.test.ts b/bots/midnight-liquidation/test/execution/swap-step.test.ts index 255a510e..e668ddaf 100644 --- a/bots/midnight-liquidation/test/execution/swap-step.test.ts +++ b/bots/midnight-liquidation/test/execution/swap-step.test.ts @@ -56,7 +56,9 @@ describe('expectedLoanOut', () => { collateralIndex: 0, seizedAssets: 1000n, repaidUnits: 0n, - postMaturityMode: false + postMaturityMode: false, + lif: WAD, + impliedRepaidUnits: 1000n } // 1000 collateral × price(2) = 2000 loan. expect(expectedLoanOut(plan, out)).toBe(2000n) @@ -69,7 +71,9 @@ describe('expectedLoanOut', () => { collateralIndex: 0, seizedAssets: 366n, repaidUnits: 0n, - postMaturityMode: false + postMaturityMode: false, + lif: WAD, + impliedRepaidUnits: 1098n } expect(expectedLoanOut(plan, { ...out, bestCollateralPrice: ORACLE_PRICE_SCALE * 3n })).toBe( 1098n @@ -81,7 +85,9 @@ describe('expectedLoanOut', () => { collateralIndex: 0, seizedAssets: 1000n, repaidUnits: 0n, - postMaturityMode: false + postMaturityMode: false, + lif: WAD, + impliedRepaidUnits: 1000n } expect(expectedLoanOut(plan, { ...out, bestCollateralPrice: 0n })).toBe(0n) }) diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index 861feeb7..6ba96416 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -50,7 +50,9 @@ const PLAN: LiquidationPlan = { collateralIndex: 0, seizedAssets: 1000n, repaidUnits: 900n, - postMaturityMode: false + postMaturityMode: false, + lif: WAD, + impliedRepaidUnits: 1000n } const OUT: LensOut = { diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index 1c89f27b..dcab3f05 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -45,6 +45,8 @@ const ROUTER: Address = getAddress('0x5555555555555555555555555555555555555555') const ZERO = '0x0000000000000000000000000000000000000000' as const const MARKET: Hex = `0x${'a'.repeat(64)}` const LABEL = lensKey(MARKET, BORROWER) +// 3.63% incentive → a 349bps headroom ceiling; the ramp reaches 3bps about 30s past maturity. +const MAX_LIF = 1036269430051813471n const SWAP_PLAN: SwapPlan = { steps: [ @@ -122,6 +124,7 @@ function runWith(opts: { inflight?: ReadonlySet noSwap?: boolean seedBackoffAt?: bigint + headroomFloorBps?: number cooldown?: CooldownStore /** Models the queue's outcome; the two no-broadcast reasons are NOT interchangeable. */ submitOutcome?: SubmitOutcome @@ -148,6 +151,7 @@ function runWith(opts: { chainHead, caller: CALLER, seizeCapMarginBps: 0, + headroomFloorBps: opts.headroomFloorBps ?? 0, readLens: stubReadLens(opts.out === undefined ? lensOut() : opts.out), quoteFor: async () => { quoteCalls += 1 @@ -435,6 +439,7 @@ describe('runTick', () => { chainHead: 100n, caller: CALLER, seizeCapMarginBps: 0, + headroomFloorBps: 0, readLens: stubReadLens(lensOut()), quoteFor: async () => ({ kind: 'swap', plan: SWAP_PLAN }), simulate: async () => ({ status: 'ok' }), @@ -475,6 +480,40 @@ describe('runTick', () => { expectCounterIdentities(counters) }) + it('reports insufficient_headroom at debug, spending no quote and recording no backoff', async () => { + // Past maturity and healthy, 20s into the LIF ramp: ~2bps of headroom against a 3bps floor. The + // point of the gate is that this costs no aggregator call, no simulation and no gas estimate. + const cooldown = createCooldownStore({ cooldownMs: 60_000 }) + const { counters, events, backoff, quoteCalls, simulateCalls } = await runWith({ + cooldown, + headroomFloorBps: 3, + out: lensOut({ healthy: true, blockTimestamp: 2020n, bestCollateralMaxLif: MAX_LIF }) + }) + expect(counters).toMatchObject({ liquidatable: 1, planSkipped: 1, planned: 0 }) + expect(quoteCalls()).toBe(0) + expect(simulateCalls()).toBe(0) + const skipped = events.find(e => e.event === 'plan.skipped') + // debug, not info: headroom is a group property, so this fires identically for every candidate + // in the group — one line per position per block is the shape that buried the 31 Jul post-mortem. + expect(skipped?.level).toBe('debug') + expect(skipped?.fields).toMatchObject({ reason: 'insufficient_headroom' }) + expect(backoff.shouldSkip(LABEL, 100n)).toBe(false) + expect(cooldown.shouldSkip(LABEL)).toBe(false) + expectCounterIdentities(counters) + }) + + it('does not gate a matured-and-unhealthy position that normal mode funds at maxLif', async () => { + // Regression companion to the sizing test: same instant as above but UNHEALTHY, so both on-chain + // gates are open and normal mode wins with the full maxLif. It must be worked, not skipped. + const { counters, quoteCalls } = await runWith({ + headroomFloorBps: 100, + out: lensOut({ healthy: false, blockTimestamp: 2020n, bestCollateralMaxLif: MAX_LIF }) + }) + expect(counters).toMatchObject({ liquidatable: 1, planSkipped: 0, planned: 1 }) + expect(quoteCalls()).toBe(1) + expectCounterIdentities(counters) + }) + it('reports writeoff_below_max_debt when the write-off pushes effective debt under maxDebt', async () => { // debt 1000 - badDebt 200 = 800 effective, under maxDebt 900, while debt > maxDebt keeps normal // mode open. maxRepaidNormalMode's numerator goes negative, and a negative cap used to propagate diff --git a/bots/midnight-liquidation/test/sizing/plan.test.ts b/bots/midnight-liquidation/test/sizing/plan.test.ts index bbc3b933..3cbc1b11 100644 --- a/bots/midnight-liquidation/test/sizing/plan.test.ts +++ b/bots/midnight-liquidation/test/sizing/plan.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { LiquidationPlan, PlanInput } from '../../src/sizing/plan' import { ORACLE_PRICE_SCALE, WAD } from '../../src/constants' -import { maxSeizeForCap, plan } from '../../src/sizing/plan' +import { maxSeizeForCap, plan, planWithReason } from '../../src/sizing/plan' const MAX_LIF = 1036269430051813471n const LLTV = 860000000000000000n @@ -43,7 +43,7 @@ describe('plan', () => { }) it('seizes the whole slot in normal mode when the RCF cap does not bind', () => { - expect(plan(baseInput({ bestCollateralAmt: 100n * WAD }))).toEqual({ + expect(plan(baseInput({ bestCollateralAmt: 100n * WAD }))).toMatchObject({ collateralIndex: 3, seizedAssets: 100n * WAD, repaidUnits: 0n, @@ -55,11 +55,15 @@ describe('plan', () => { // The RCF cap is ~919 WAD of repaid units; seize-exact pins the largest seize whose contract-derived // repaid stays within it (`maxSeizeForCap`), and lets the contract ceil-derive `repaidUnits`. const maxRepaid = 919047619047619043969n + // The one full-shape assertion in this file: every field pinned, so an unexpected addition to + // LiquidationPlan fails here rather than passing silently through the toMatchObject cases. const expected: LiquidationPlan = { collateralIndex: 3, seizedAssets: maxSeizeForCap(maxRepaid, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, - postMaturityMode: false + postMaturityMode: false, + lif: MAX_LIF, + impliedRepaidUnits: maxRepaid } expect(plan(baseInput({ bestCollateralAmt: 2000n * WAD, rcfThreshold: WAD }))).toEqual(expected) }) @@ -67,7 +71,9 @@ describe('plan', () => { it('seizes the whole slot when rcf-exempt and the slot fits within the debt', () => { // Exemption waives the RCF cap, so a slot whose implied repaid units (~965 WAD) still fit within // the 1000-WAD debt is seized whole — the cap would otherwise have bound it at ~919 WAD. - expect(plan(baseInput({ bestCollateralAmt: 1000n * WAD, rcfThreshold: 2000n * WAD }))).toEqual({ + expect( + plan(baseInput({ bestCollateralAmt: 1000n * WAD, rcfThreshold: 2000n * WAD })) + ).toMatchObject({ collateralIndex: 3, seizedAssets: 1000n * WAD, repaidUnits: 0n, @@ -80,7 +86,9 @@ describe('plan', () => { // whole makes the contract derive repaidUnits > debt and revert (Panic 0x11 underflow) — a real // bot run hit exactly this. Seize-exact instead pins the largest seize whose derived repaid stays // within the post-writeoff debt. - expect(plan(baseInput({ bestCollateralAmt: 2000n * WAD, rcfThreshold: 2000n * WAD }))).toEqual({ + expect( + plan(baseInput({ bestCollateralAmt: 2000n * WAD, rcfThreshold: 2000n * WAD })) + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(1000n * WAD, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, @@ -102,7 +110,7 @@ describe('plan', () => { bestCollateralAmt: 2000n * WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(1000n * WAD, ORACLE_PRICE_SCALE, MAX_LIF), // cap = debt - badDebt repaidUnits: 0n, @@ -121,7 +129,7 @@ describe('plan', () => { badDebt: 1000n * WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: 0n, repaidUnits: 0n, @@ -141,7 +149,7 @@ describe('plan', () => { bestCollateralAmt: 500n * WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: 500n * WAD, repaidUnits: 0n, @@ -157,7 +165,7 @@ describe('plan', () => { plan(baseInput({ bestCollateralAmt: 2000n * WAD, rcfThreshold: WAD }), { seizeCapMarginBps: 100 }) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(capEff, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, @@ -184,7 +192,7 @@ describe('plan', () => { rcfThreshold: 2000n * WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(1000n * WAD, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, @@ -197,7 +205,7 @@ describe('plan', () => { // derive fewer repaid units for it (~482 vs ~500 WAD) — same seize, higher surplus. expect( plan(baseInput({ blockTimestamp: 2060n, maturity: 2000n, bestCollateralAmt: 500n * WAD })) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: 500n * WAD, repaidUnits: 0n, @@ -218,7 +226,7 @@ describe('plan', () => { rcfThreshold: 2000n * WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(1000n * WAD, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, @@ -238,7 +246,7 @@ describe('plan', () => { rcfThreshold: WAD }) ) - ).toEqual({ + ).toMatchObject({ collateralIndex: 3, seizedAssets: maxSeizeForCap(1000n * WAD, ORACLE_PRICE_SCALE, MAX_LIF), repaidUnits: 0n, @@ -310,3 +318,95 @@ describe('maxSeizeForCap', () => { } }) }) + +describe('derived plan fields', () => { + it('carries the full maxLif in normal mode, where the LIF does not ramp', () => { + const built = plan(baseInput()) + expect(built?.lif).toBe(MAX_LIF) + }) + + it('carries the RAMPED lif past maturity, not maxLif', () => { + // 60s into a 3600s ramp on a 0.0362694 incentive: lif - 1 is ~1/60th of the way up. + const built = plan(baseInput({ blockTimestamp: 2060n, maturity: 2000n, healthy: true })) + expect(built?.postMaturityMode).toBe(true) + expect(built?.lif).toBe(WAD + ((MAX_LIF - WAD) * 60n) / 3600n) + expect(built!.lif).toBeLessThan(MAX_LIF) + }) + + it('pins the derived repay to the cap, exactly, for a cap-binding seize', () => { + // maxSeizeForCap is exact rather than conservative: the contract-derived repay lands ON the RCF + // cap to the unit. Hand-computed literal so a rounding regression fails here rather than silently + // shrinking every cap-bound plan. + const built = plan(baseInput({ bestCollateralAmt: 2000n * WAD, rcfThreshold: WAD })) + expect(built?.impliedRepaidUnits).toBe(919047619047619043969n) + }) + + it('derives a zero repay for a bad-debt realization, which seizes nothing', () => { + const built = plan( + baseInput({ blockTimestamp: 3000n, maturity: 2000n, healthy: true, badDebt: 1000n * WAD }) + ) + expect(built).toMatchObject({ seizedAssets: 0n, repaidUnits: 0n, impliedRepaidUnits: 0n }) + }) +}) + +describe('headroom floor', () => { + // Past maturity and HEALTHY, so post-maturity mode is the only open gate and the LIF is still + // ramping — 20s in, headroom is ~2bps against a 349bps ceiling. + const earlyRamp = { blockTimestamp: 2020n, maturity: 2000n, healthy: true } + + it('skips a plan whose headroom is under the floor, with insufficient_headroom', () => { + expect(planWithReason(baseInput(earlyRamp), { headroomFloorBps: 3 })).toEqual({ + plan: null, + reason: 'insufficient_headroom' + }) + }) + + it('allows the same position once the ramp clears the floor', () => { + const later = planWithReason(baseInput({ ...earlyRamp, blockTimestamp: 2040n }), { + headroomFloorBps: 3 + }) + expect(later.reason).toBeUndefined() + expect(later.plan).not.toBeNull() + }) + + it('is disabled at a floor of 0, reproducing the ungated plan exactly', () => { + const input = baseInput(earlyRamp) + expect(planWithReason(input, { headroomFloorBps: 0 })).toEqual(planWithReason(input)) + }) + + it('pins the shipped default: suppressed at t+20s, allowed at t+40s', () => { + // The default is a LOWER BOUND on execution cost, not a typical cost — a change to it alters prod + // timing, so it breaks a test rather than sliding through. + expect(plan(baseInput(earlyRamp), { headroomFloorBps: 3 })).toBeNull() + expect( + plan(baseInput({ ...earlyRamp, blockTimestamp: 2040n }), { headroomFloorBps: 3 }) + ).not.toBeNull() + }) + + it('does NOT skip a matured-and-unhealthy position that normal mode funds at the full maxLif', () => { + // Regression: the floor must read the CHOSEN plan's lif. Both gates are open here, and normal mode + // wins early in the ramp because it pays maxLif immediately — ~349bps of headroom. A floor derived + // from the ramping post-maturity LIF (~2bps at t+20s) would reject a position the chain funds + // immediately, which is the opposite of the gate's purpose and, default-ON, a prod regression. + const bothGatesOpen = baseInput({ blockTimestamp: 2020n, maturity: 2000n, healthy: false }) + const outcome = planWithReason(bothGatesOpen, { headroomFloorBps: 100 }) + expect(outcome.reason).toBeUndefined() + expect(outcome.plan).toMatchObject({ postMaturityMode: false, lif: MAX_LIF }) + }) + + it('skips every candidate sharing a (maturity, maxLif, mode) group together', () => { + // Headroom is (lif - 1)/lif, so it is scale-invariant: it cannot separate a large position from a + // dust one. A per-candidate threshold test would pass vacuously; assert the GROUP property. + const sizes = [1n, 1000n, 100n * WAD, 2000n * WAD] + const reasons = sizes.map( + bestCollateralAmt => + planWithReason(baseInput({ ...earlyRamp, bestCollateralAmt }), { headroomFloorBps: 3 }) + .reason + ) + expect(reasons).toEqual(sizes.map(() => 'insufficient_headroom')) + }) + + it('never skips a normal-mode (pre-maturity) plan, whose LIF is maxLif from the start', () => { + expect(plan(baseInput(), { headroomFloorBps: 300 })).not.toBeNull() + }) +}) From 4b94c6522ebf90b75b8fe3a34998f8d9222a9b37 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 12:53:21 -0500 Subject: [PATCH 02/10] feat(midnight-liquidation): gate quotes on contract-derived repay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `liquidate` ends by pulling its own re-derived `repaidUnits` from the payer, while the Executor's callback approves only its live balance. A route returning less than that repay therefore reverts as `ERC20: transfer amount exceeds allowance` — a balance shortfall wearing an allowance error's clothes, with no amounts attached. On the 31 Jul cbBTC/USDC maturity that string was emitted 131 times and the approval encoding was suspected for a month. Compare the quote against `plan.impliedRepaidUnits` before simulating, and skip with `quote.unprofitable` carrying requiredRepay / achievableOut / shortfallBps. Break-even is read off the plan rather than recomputed: the matured-and-unhealthy branch picks a mode by surplus, so the LIF a plan was sized at is not recoverable from `postMaturityMode` or from chain time, and recomputing it would overstate the repay for a normal-mode plan. Deliberately no backoff and no cooldown on this exit. Both sides of the comparison move on a ten-second scale — the ramp lifts break-even while route cost is itself volatile — so the outcome says almost nothing about the next attempt. Quote volume is bounded by the pre-quote headroom gate instead. `MIN_SURPLUS_BPS` defaults to 0, i.e. pure break-even: both sides then come from the contract's own formula with no tuned value, so the gate can only reject plans that would have reverted on-chain. Co-Authored-By: Claude Opus 5 (1M context) --- bots/midnight-liquidation/src/config.ts | 11 +++ bots/midnight-liquidation/src/index.ts | 1 + .../src/runner/profitability.ts | 62 ++++++++++++++ bots/midnight-liquidation/src/runner/tick.ts | 42 +++++++++- .../test/runner/tick.test.ts | 81 ++++++++++++++++++- 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 bots/midnight-liquidation/src/runner/profitability.ts diff --git a/bots/midnight-liquidation/src/config.ts b/bots/midnight-liquidation/src/config.ts index 06908e47..9a86e164 100644 --- a/bots/midnight-liquidation/src/config.ts +++ b/bots/midnight-liquidation/src/config.ts @@ -61,6 +61,11 @@ const DEFAULT_PENDLE_SLIPPAGE_BPS = 50 // observation and deliberately NOT the default. const DEFAULT_HEADROOM_FLOOR_BPS = 3 const DEFAULT_SEIZE_CAP_MARGIN_BPS = 30 // shave the repay cap when sizing a cap-binding seize — one-block oracle-drift headroom; calibratable +// Pure break-even by default: at 0 the profitability gate compares two contract-derived quantities and +// carries no tuned value, so it can only reject plans that would have reverted on-chain. Raising it +// trades captured liquidations for margin against gas and sim→exec drift, and wants a measured basis +// distribution rather than a guess — one maturity implies only a wide, unhelpful interval. +const DEFAULT_MIN_SURPLUS_BPS = 0 const DEFAULT_BACKOFF_BASE_BLOCKS = 2n const DEFAULT_BACKOFF_MAX_BLOCKS = 64n // Opt-in per-position cooldown (ms) after a liquidation attempt fails to produce a submittable tx @@ -100,6 +105,8 @@ export type QuotingConfig = { pendleSlippageBps: number /** Headroom (bps) shaved off the on-chain repay cap when sizing a cap-binding seize-exact plan. */ seizeCapMarginBps: number + /** Surplus over the plan's contract-derived repay a quoted route must clear to be simulated. */ + minSurplusBps: number /** Lower bound (bps) on swap execution cost; skips plans whose incentive headroom cannot cover it. */ headroomFloorBps: number backoffBaseBlocks: bigint @@ -464,6 +471,10 @@ export function loadConfig( min: 0, max: 10_000 }), + minSurplusBps: intEnv(env, 'MIN_SURPLUS_BPS', DEFAULT_MIN_SURPLUS_BPS, { + min: 0, + max: 10_000 + }), headroomFloorBps: intEnv(env, 'HEADROOM_FLOOR_BPS', DEFAULT_HEADROOM_FLOOR_BPS, { min: 0, max: 10_000 diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index 8ec1ea86..be04c5f0 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -326,6 +326,7 @@ async function main() { chainHead, caller: config.executooorAddress, seizeCapMarginBps: config.quoting.seizeCapMarginBps, + minSurplusBps: config.quoting.minSurplusBps, headroomFloorBps: config.quoting.headroomFloorBps, readLens: pairs => readMidnightLiquidationLens(client, config.midnight, pairs), quoteFor, diff --git a/bots/midnight-liquidation/src/runner/profitability.ts b/bots/midnight-liquidation/src/runner/profitability.ts new file mode 100644 index 00000000..19abddfb --- /dev/null +++ b/bots/midnight-liquidation/src/runner/profitability.ts @@ -0,0 +1,62 @@ +import type { SwapPlan } from '@repo/swaps' + +import type { LiquidationPlan } from '../sizing/plan' + +import { BPS } from '../constants' +import { mulDivUp } from '../sizing/math' + +type ProfitabilityAssessment = { + viable: boolean + /** Loan units `liquidate` will pull from the Executor — {@link LiquidationPlan.impliedRepaidUnits}. */ + requiredRepay: bigint + /** Loan units the quoted route is expected to deliver. */ + achievableOut: bigint + /** How far the route falls short of `requiredRepay`, in bps of it. Negative when profitable. */ + shortfallBps: bigint +} + +/** + * Whether a quoted route covers the repay `liquidate` will pull, evaluated BEFORE simulating. + * + * Midnight ends `liquidate` with `safeTransferFrom(loanToken, payer, this, repaidUnits)`, re-deriving + * `repaidUnits` on-chain, while the Executor's callback approves only its own live balance. A route + * returning less than that derived repay therefore reverts as + * `ERC20: transfer amount exceeds allowance` — a balance shortfall wearing an allowance error's + * clothes, which is why the failure reads as an approval bug and is not one. Gating here turns the + * revert into a reported skip carrying the numbers the revert string omits. + * + * Break-even is read off {@link LiquidationPlan.impliedRepaidUnits} rather than recomputed. The + * matured-and-unhealthy branch opens both on-chain gates and picks a mode by surplus, so the LIF a + * plan was sized at is not recoverable from `postMaturityMode` or from chain time — recomputing it + * here would silently apply the post-maturity ramp to a normal-mode plan and overstate the repay by + * the whole un-ramped incentive. + * + * The comparison uses `expectedAmountOut`, not `amountOutMinimum`. The latter embeds the venue's + * slippage allowance — 1% by default — which dwarfs the post-maturity incentive (a few bps for the + * first minutes) and would suppress every viable liquidation for most of the ramp, and permanently on + * high-LLTV tiers whose entire `maxLif` sits under that allowance. + * + * Pure — no RPC, no I/O. + */ +export const assessProfitability = ({ + plan, + swapPlan, + minSurplusBps +}: { + plan: LiquidationPlan + swapPlan: SwapPlan + /** Surplus over break-even required to pass, in bps of `requiredRepay`. `0` is pure break-even. */ + minSurplusBps: number +}): ProfitabilityAssessment => { + const requiredRepay = plan.impliedRepaidUnits + const achievableOut = swapPlan.expectedAmountOut + const threshold = requiredRepay + mulDivUp(requiredRepay, BigInt(minSurplusBps), BPS) + + return { + viable: achievableOut >= threshold, + requiredRepay, + achievableOut, + shortfallBps: + requiredRepay === 0n ? 0n : ((requiredRepay - achievableOut) * BPS) / requiredRepay + } +} diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index fabf1ef8..44088f3a 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -11,6 +11,7 @@ import type { LensInput, LensOut } from '../state/lens.sol' import { isBadDebtRealization, planWithReason } from '../sizing/plan' import { isLiquidatable, planInputFromLens } from './eligibility' +import { assessProfitability } from './profitability' /** * Per-tick outcome tally, emitted as `tick.end`, ordered as the pipeline runs. On a tick that ran to @@ -20,7 +21,7 @@ import { isLiquidatable, planInputFromLens } from './eligibility' * ```text * pairs >= liquidatable * liquidatable === inflightSkipped + planSkipped + planned - * planned === cooledDown + backoffSkipped + noSwapPath + quoteFailed + ok + reverted + * planned === cooledDown + backoffSkipped + noSwapPath + quoteFailed + quoteUnprofitable + ok + reverted * ok === submitted + notSent * ``` * @@ -48,6 +49,13 @@ type TickCounters = { backoffSkipped: number noSwapPath: number quoteFailed: number + /** + * Quoted successfully, but the route could not cover the repay `liquidate` would pull. An economic + * skip, not a failure: deliberately no backoff and no cooldown, because both sides of the comparison + * move on a ten-second scale — the LIF ramp lifts break-even while route cost is itself volatile — + * so this outcome says almost nothing about the next attempt. + */ + quoteUnprofitable: number ok: number reverted: number /** Broadcast: the queue reported a transaction actually went out. */ @@ -106,6 +114,12 @@ export async function runTick(deps: { caller: Address /** Headroom (bps) shaved off a cap-binding seize for one-block oracle-drift; passed to sizing. */ seizeCapMarginBps: number + /** + * Surplus over break-even a quoted route must clear to be simulated, in bps of the plan's + * contract-derived repay. `0` is pure break-even — both sides then come from the contract's own + * formula with no tuned value, so the gate can only reject plans that would have reverted. + */ + minSurplusBps: number /** Lower bound (bps) on swap execution cost; passed to sizing. `0` disables the headroom gate. */ headroomFloorBps: number readLens: (pairs: LensInput[]) => Promise> @@ -154,6 +168,7 @@ export async function runTick(deps: { caller, seizeCapMarginBps, headroomFloorBps, + minSurplusBps, readLens, quoteFor, simulate, @@ -189,6 +204,7 @@ export async function runTick(deps: { backoffSkipped: 0, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, ok: 0, reverted: 0, submitted: 0, @@ -276,6 +292,30 @@ export async function runTick(deps: { cooldown.mark(label) continue } + + // Economic gate, before spending a simulation: `liquidate` ends by pulling its own re-derived + // repay from the Executor, which approves only its live balance — so a route short of that + // repay reverts as an allowance error instead of reporting a shortfall. + const economics = assessProfitability({ + plan: liquidationPlan, + swapPlan: quote.plan, + minSurplusBps + }) + if (!economics.viable) { + // No backoff, no cooldown — see `quoteUnprofitable` on TickCounters. Quote volume is bounded + // by the pre-quote headroom gate in sizing, not by suppressing a position that may be one + // block of LIF ramp away from being fundable. + counters.quoteUnprofitable += 1 + logger.info('quote.unprofitable', { + marketId: pair.id, + borrower: pair.borrower, + requiredRepay: economics.requiredRepay, + achievableOut: economics.achievableOut, + shortfallBps: economics.shortfallBps + }) + continue + } + swapPlan = quote.plan } diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index dcab3f05..5e885f2c 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -32,7 +32,13 @@ const expectCounterIdentities = (c: Record) => { expect(c.pairs).toBeGreaterThanOrEqual(c.liquidatable!) expect(c.liquidatable).toBe(c.inflightSkipped! + c.planSkipped! + c.planned!) expect(c.planned).toBe( - c.cooledDown! + c.backoffSkipped! + c.noSwapPath! + c.quoteFailed! + c.ok! + c.reverted! + c.cooledDown! + + c.backoffSkipped! + + c.noSwapPath! + + c.quoteFailed! + + c.quoteUnprofitable! + + c.ok! + + c.reverted! ) expect(c.ok).toBe(c.submitted! + c.notSent!) } @@ -125,6 +131,7 @@ function runWith(opts: { noSwap?: boolean seedBackoffAt?: bigint headroomFloorBps?: number + minSurplusBps?: number cooldown?: CooldownStore /** Models the queue's outcome; the two no-broadcast reasons are NOT interchangeable. */ submitOutcome?: SubmitOutcome @@ -152,6 +159,7 @@ function runWith(opts: { caller: CALLER, seizeCapMarginBps: 0, headroomFloorBps: opts.headroomFloorBps ?? 0, + minSurplusBps: opts.minSurplusBps ?? 0, readLens: stubReadLens(opts.out === undefined ? lensOut() : opts.out), quoteFor: async () => { quoteCalls += 1 @@ -197,6 +205,7 @@ describe('runTick', () => { backoffSkipped: 0, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, ok: 1, reverted: 0, submitted: 1, @@ -225,6 +234,7 @@ describe('runTick', () => { planned: 1, noSwapPath: 1, quoteFailed: 0, + quoteUnprofitable: 0, submitted: 0 }) expect(simulateCalls()).toBe(0) // skipped before simulating @@ -275,6 +285,7 @@ describe('runTick', () => { planned: 1, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, submitted: 1 }) expect(quoteCalls()).toBe(0) // bad-debt realization never quotes @@ -440,6 +451,7 @@ describe('runTick', () => { caller: CALLER, seizeCapMarginBps: 0, headroomFloorBps: 0, + minSurplusBps: 0, readLens: stubReadLens(lensOut()), quoteFor: async () => ({ kind: 'swap', plan: SWAP_PLAN }), simulate: async () => ({ status: 'ok' }), @@ -546,6 +558,73 @@ describe('runTick', () => { }) }) + describe('profitability gate', () => { + // The default fixture sizes a cap-bound normal-mode plan: seize 1100 at LIF 1.1, so the contract + // ceil-derives a 1000-unit repay. That is the swap's break-even, and SWAP_PLAN clears it at 2000. + const REQUIRED_REPAY = 1000n + const quoting = (expectedAmountOut: bigint): QuoteOutcome => ({ + kind: 'swap', + plan: { ...SWAP_PLAN, expectedAmountOut } + }) + + it('skips before simulating when the route cannot cover the derived repay', async () => { + const { counters, simulateCalls, submitCalls, events } = await runWith({ + quoteOutcome: quoting(REQUIRED_REPAY - 1n) + }) + expect(counters).toMatchObject({ planned: 1, quoteUnprofitable: 1, ok: 0, reverted: 0 }) + // The whole point: the shortfall is reported instead of being discovered as an allowance revert. + expect(simulateCalls()).toBe(0) + expect(submitCalls()).toBe(0) + const skipped = events.find(e => e.event === 'quote.unprofitable') + expect(skipped?.fields).toMatchObject({ + requiredRepay: REQUIRED_REPAY, + achievableOut: REQUIRED_REPAY - 1n, + shortfallBps: 10n + }) + expectCounterIdentities(counters) + }) + + it('does not back off or cool down an unprofitable quote', async () => { + const { counters, backoff, cooldown } = await runWith({ + quoteOutcome: quoting(REQUIRED_REPAY - 1n), + cooldown: createCooldownStore({ cooldownMs: 60_000 }) + }) + // Asserted so the suppression checks below cannot pass by the gate simply never firing. + expect(counters.quoteUnprofitable).toBe(1) + // Economic non-viability is not a failure: break-even falls as the LIF ramps and route cost is + // itself volatile, so suppressing the position would delay re-checking it precisely as it + // becomes fundable. + expect(backoff.shouldSkip(LABEL, 100n)).toBe(false) + expect(cooldown.shouldSkip(LABEL)).toBe(false) + }) + + it('passes a route at exact break-even, and fails it once a surplus is required', async () => { + const exact = await runWith({ quoteOutcome: quoting(REQUIRED_REPAY) }) + expect(exact.counters).toMatchObject({ quoteUnprofitable: 0, ok: 1, submitted: 1 }) + + const withSurplus = await runWith({ + quoteOutcome: quoting(REQUIRED_REPAY), + minSurplusBps: 1 + }) + expect(withSurplus.counters).toMatchObject({ quoteUnprofitable: 1, ok: 0 }) + expect(withSurplus.simulateCalls()).toBe(0) + }) + + it('uses the LIF the plan was sized at, not the one chain time implies', async () => { + // Matured AND unhealthy opens both on-chain gates, and sizing picks by surplus: one second past + // maturity the post-maturity ramp is still ~WAD, so normal mode wins with the full maxLif and a + // 1000-unit break-even. Deriving the LIF here from `blockTimestamp > maturity` instead would use + // the ramping value, put break-even at 1100, and reject a route the chain funds. + const { counters, simulateCalls } = await runWith({ + out: lensOut({ blockTimestamp: 2001n, healthy: false }), + quoteOutcome: quoting(1050n) + }) + expect(counters).toMatchObject({ planned: 1, quoteUnprofitable: 0, ok: 1, submitted: 1 }) + expect(simulateCalls()).toBe(1) + expectCounterIdentities(counters) + }) + }) + describe('counter identities', () => { it('holds across a mixed batch with an in-flight position', async () => { const second = getAddress('0x6666666666666666666666666666666666666666') From 518eb58db257648092c7e39702341e844b6a12b1 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 12:45:34 -0500 Subject: [PATCH 03/10] feat(midnight-liquidation): rank candidates by expected usd profit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tick works candidates serially — one quote and one simulation each — in whatever order discovery returned them, which is ascending checksummed address order. At the 31 Jul cbBTC/USDC maturity the largest position sat fifth of fourteen, behind three worth under $1.10 combined, in an auction where the first mover takes the whole position. Split the tick in two. Phase A is pure: the lens batch is already in memory, so building plans and scoring them costs nothing and ordering the result is free. Phase B runs the expensive serial stages — cooldown, quote, simulate, submit — over that order. `plan.built` moves into phase B and gains `rank`, `surplus` and `surplusUsd`, emitted per candidate so the timestamp sequence remains the record of what was worked and when; that sequence is how this maturity was reconstructed at all. The sort key is oracle surplus converted to USD. Loan units alone are not comparable across markets — an 18-decimal loan token would systematically starve a 6-decimal USDC market — so `discovery/token-prices.ts` keeps a snapshot of loan-token prices from the markets tokens endpoint, whose typed client is already generated and already consumed by borrower discovery. Ranking only, never gating, which is what makes an indexed price acceptable: - `usdValueOf` is synchronous and reads the snapshot, so no candidate waits on HTTP before being planned. Awaiting a price mid-burst would add exactly the latency this ordering exists to remove. - An unpriced token sorts last rather than as zero — absent price means unrankable, not worthless. The sort is stable, so a total outage degrades to the previous discovery order rather than to an untested fallback. - The snapshot has no max-age ceiling, unlike the listed-markets whitelist: a stale whitelist can keep a delisted market in scope, whereas a stale price only misorders work. It fails open and reports its age instead. - Prices refresh on their own timer. `fetchWithRetry` is a 5s deadline times three retries, so folding this into the whitelist loop could stall a fail-closed safety refresh for ~22s on behalf of a cosmetic one. USD figures use the 1e8 scale the Blue profitability-gate design settled on for this same endpoint, so the two can converge without a rescale. Conversion goes through `parseUnits` on fixed-notation text, so no float arithmetic survives the API boundary and a price with no usable 1e8 precision reads as unpriced rather than as zero. `planSurplus` is exported and reads the plan's own recorded `impliedRepaidUnits`, so it cannot disagree with the LIF the plan was sized at. Its docstring states what it is not: oracle-only, excluding DEX cost and gas, and structurally positive for every post-maturity candidate — a ranking key, not a profitability measure. `unpriced` joins TickCounters as an attribute rather than a loop exit, so it deliberately participates in none of the sum identities: an unpriced candidate is still worked, just ordered last. A persistently high value means the snapshot is not covering the loan tokens actually being liquidated. Ordering is necessary but not sufficient. Early in the post-maturity LIF ramp every candidate is uniformly unviable regardless of size, so ordering only decides anything once more than one candidate clears a viability threshold. Co-Authored-By: Claude Opus 5 (1M context) --- bots/midnight-liquidation/README.md | 10 + bots/midnight-liquidation/src/constants.ts | 8 + .../src/discovery/token-prices.ts | 173 +++++++++++++++ bots/midnight-liquidation/src/index.ts | 36 ++- .../src/runner/ranking.ts | 26 +++ bots/midnight-liquidation/src/runner/tick.ts | 169 ++++++++++---- bots/midnight-liquidation/src/sizing/plan.ts | 12 +- .../test/discovery/token-prices.test.ts | 207 ++++++++++++++++++ .../test/runner/ranking.test.ts | 55 +++++ .../test/runner/tick.test.ts | 107 ++++++++- 10 files changed, 760 insertions(+), 43 deletions(-) create mode 100644 bots/midnight-liquidation/src/discovery/token-prices.ts create mode 100644 bots/midnight-liquidation/src/runner/ranking.ts create mode 100644 bots/midnight-liquidation/test/discovery/token-prices.test.ts create mode 100644 bots/midnight-liquidation/test/runner/ranking.test.ts diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index fc18df93..cdfb7b4a 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -357,6 +357,16 @@ zero new candidates so the pending queue (confirmations / fee bumps) is still dr runaway paginated response is capped at `MAX_DISCOVERY_PAGES` and logs `discover.max_pages` rather than silently truncating (which would be under-inclusion — a liquidation missed). +Separately, [src/discovery/token-prices.ts](./src/discovery/token-prices.ts) keeps a snapshot of loan +token USD prices from the markets tokens endpoint, refreshed on its own timer (independent of +`MARKETS_REFRESH_MS`, so a slow tokens fetch cannot stall the fail-closed whitelist refresh) and served +last-known-good. It is used **only to order** the tick's candidates by expected profit, never to decide +whether a liquidation is attempted, so it fails **open**: a token with no usable price sorts last and a +total outage degrades ordering to discovery order. Watch `prices.tokens` for the snapshot size and the +`unpriced` counter on `tick.end` — a persistently high `unpriced` means the snapshot is not covering the +loan tokens actually being liquidated. Note the endpoint prices plain assets but not Midnight's +synthetic collateral wrappers; only the loan token is needed here. + ### State Lens [src/state/lens.sol.ts](./src/state/lens.sol.ts) defines a deployless Solidity lens. For each diff --git a/bots/midnight-liquidation/src/constants.ts b/bots/midnight-liquidation/src/constants.ts index 9ee04a60..6b823c98 100644 --- a/bots/midnight-liquidation/src/constants.ts +++ b/bots/midnight-liquidation/src/constants.ts @@ -44,6 +44,14 @@ export const SETTLED_COOLDOWN_BLOCKS = 20n */ export const LISTED_MARKETS_MAX_AGE_MS = 10 * 60_000 +/** + * How often the token USD-price snapshot is refetched. Build-time rather than an env var, like + * {@link LISTED_MARKETS_MAX_AGE_MS}: the snapshot only orders work, so an operator has no reason to + * tune it. Matched to the endpoint's own `max-age=30, stale-while-revalidate=60` cache, and refreshed + * on its own timer so a slow tokens fetch cannot delay the fail-closed whitelist refresh. + */ +export const TOKEN_PRICES_REFRESH_MS = 60_000 + /** * Basis-point denominator (100% = 10_000 bps) for the sizing layer's `seizeCapMarginBps` math. * `@repo/swaps` carries its own copy for slippage/route-quality math — kept separate so protocol diff --git a/bots/midnight-liquidation/src/discovery/token-prices.ts b/bots/midnight-liquidation/src/discovery/token-prices.ts new file mode 100644 index 00000000..ff1dc951 --- /dev/null +++ b/bots/midnight-liquidation/src/discovery/token-prices.ts @@ -0,0 +1,173 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address } from 'viem' + +import { delay, fetchWithRetry, mulDivDown, tryCatch } from '@repo/utils' +import createClient from 'openapi-fetch' +import { getAddress, isAddress, parseUnits } from 'viem' + +import type { paths } from '../generated/markets-api' + +/** The `fetch` shape `openapi-fetch` calls — a single `Request`. The global `fetch` satisfies it. */ +type FetchLike = (request: Request) => Promise + +const REQUEST_TIMEOUT_MS = 5_000 + +/** + * Fixed-point scale for USD figures: `1e8`, matching the `USD_PRICE_SCALE` the Blue profitability-gate + * design settled on for this same endpoint, so the two can converge without a rescale. + */ +export const USD_PRICE_SCALE_DECIMALS = 8 + +/** + * The tokens operation path — a literal key of the generated {@link paths}, so `client.GET(PATH)` is + * type-checked against the spec. Shares a base with the liquidation-candidates endpoint, so the base + * URL is derived from the configured candidates URL rather than a second env var: pointing the bot at + * a staging host moves both endpoints together. + */ +const PATH = '/markets/midnight/tokens' + +/** A token's price, normalized once at the API boundary so no float arithmetic survives it. */ +type PricedToken = { priceE8: bigint; decimals: number } + +type TokenPriceSource = { + /** + * USD value of `loanUnits` of `token`, scaled by `10 ** USD_PRICE_SCALE_DECIMALS`; `null` when the + * token has no usable price or decimals in the last snapshot. Synchronous and side-effect free — it + * reads the in-memory snapshot and never performs I/O, so a tick can call it per candidate without + * adding latency. + * + * `null` means "unrankable", never "worthless": callers must order unpriced candidates last rather + * than treating them as zero-value. This is a RANKING input only — the price is the API's latest + * indexed value with no freshness guarantee, so it must not gate whether a liquidation is attempted. + */ + usdValueOf: (token: Address, loanUnits: bigint) => bigint | null + /** + * Refetches the snapshot. **Contractually non-throwing**: an API failure is reported as + * `prices.refresh_failed` and the previous snapshot is retained, because ranking degrades to + * discovery order rather than failing closed. Callers therefore never need to handle a rejection. + */ + refresh: () => Promise + snapshot: () => { source: string; tokens: number; updatedAt: number | null } +} + +// A JSON-number USD price converted to 1e8 fixed point, or null when it carries no usable precision. +// `toFixed` first so viem never sees exponential notation (which `parseUnits` rejects) and no float +// multiply happens; a price below 1e-8 rounds to zero, which is absence of precision rather than a +// zero valuation, so it reads as unpriced. +const toPriceE8 = (usd: number): bigint | null => { + if (!Number.isFinite(usd) || usd <= 0) return null + const scaled = parseUnits(usd.toFixed(USD_PRICE_SCALE_DECIMALS), USD_PRICE_SCALE_DECIMALS) + return scaled > 0n ? scaled : null +} + +// ERC-20 decimals are nullable in the spec and unbounded in principle; 36 is far above any real token +// and keeps `10n ** decimals` from becoming an absurd bigint on a malformed row. +const MAX_TOKEN_DECIMALS = 36 + +/** + * Builds a {@link TokenPriceSource} over `GET /markets/midnight/tokens`, via a typed `openapi-fetch` + * client generated from the Markets API spec — the same spec `discovery/borrowers.ts` consumes. + * Mirrors {@link createListedMarketFilter}: last-known-good on failure, `fetchWithRetry`'s + * 429/5xx/network policy, a {@link REQUEST_TIMEOUT_MS} deadline, and injectable + * `fetchImpl`/`sleep`/`now` for tests. + * + * Deliberately has **no max-age ceiling**, unlike the listed-markets whitelist. A stale whitelist is a + * safety problem — it can keep a delisted market in scope — whereas a stale price only misorders work, + * so this fails **open** and `snapshot().updatedAt` makes the age observable instead. + * + * Queried by `chain_ids` alone: not by `markets` (its 100-id cap is a truncation risk and it would + * couple pricing to the whitelist) and not by `listed` (the whitelist already decides which markets + * are acted on). Token cardinality is small and the response is unpaginated. + */ +export const createTokenPriceSource = (deps: { + /** The configured liquidation-candidates URL; only its base is used. */ + apiUrl: string + chainId: number + logger: Logger + fetchImpl?: FetchLike + sleep?: (ms: number) => Promise + now?: () => number +}): TokenPriceSource => { + const sleep = deps.sleep ?? delay + const now = deps.now ?? (() => Date.now()) + const url = new URL(deps.apiUrl) + const baseUrl = deps.apiUrl.endsWith(PATH) ? deps.apiUrl.slice(0, -PATH.length) : url.origin + const client = createClient({ baseUrl, fetch: deps.fetchImpl ?? fetch }) + // Host + path only; the query string is excluded so nothing can ride along into a log line. + const source = `${url.host}${PATH}` + + // Last-known-good: replaced only by a fully-successful refresh. Keyed by checksummed address so a + // lookup cannot miss on casing. + let priced = new Map() + let updatedAt: number | null = null + + const fetchTokens = async () => { + const body = await fetchWithRetry( + () => + client.GET(PATH, { + params: { query: { chain_ids: [deps.chainId] } }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }), + { label: 'tokens', sleep } + ) + return Array.isArray(body.data) ? body.data : [] + } + + const refresh = async () => { + // Validation runs strictly AFTER the fetch: `fetchWithRetry` treats every throw from its callback + // as retryable, so a parse error inside it would be retried as though it were a network blip. + const fetched = await tryCatch(fetchTokens()) + if (fetched.error) { + deps.logger.warn('prices.refresh_failed', { + chainId: deps.chainId, + source, + detail: fetched.error.message + }) + return + } + + const next = new Map() + for (const token of fetched.data) { + if (token.chain_id !== deps.chainId) continue + if (typeof token.address !== 'string' || !isAddress(token.address, { strict: false })) + continue + const { decimals } = token + if ( + typeof decimals !== 'number' || + !Number.isInteger(decimals) || + decimals < 0 || + decimals > MAX_TOKEN_DECIMALS + ) { + continue + } + const priceE8 = token.price ? toPriceE8(token.price.usd) : null + if (priceE8 === null) continue + next.set(getAddress(token.address), { priceE8, decimals }) + } + + // A successful-but-empty response is not a transient failure, so it legitimately replaces + // last-known-good — but it silently un-ranks every candidate. Schema drift looks exactly like this, + // so a nonempty→empty transition is called out rather than left to be inferred from a count. + if (priced.size > 0 && next.size === 0) { + deps.logger.warn('prices.tokens_empty', { + chainId: deps.chainId, + source, + previous: priced.size, + detail: 'tokens source returned zero usable prices where it previously returned some' + }) + } + priced = next + updatedAt = now() + deps.logger.info('prices.tokens', { chainId: deps.chainId, source, tokens: priced.size }) + } + + return { + usdValueOf: (token, loanUnits) => { + const entry = priced.get(getAddress(token)) + if (!entry) return null + return mulDivDown(loanUnits, entry.priceE8, 10n ** BigInt(entry.decimals)) + }, + refresh, + snapshot: () => ({ source, tokens: priced.size, updatedAt }) + } +} diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index be04c5f0..e67e3ad5 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -34,13 +34,18 @@ import type { Market } from './execution/encode-call' import type { LiquidationPlan } from './sizing/plan' import { loadConfig } from './config' -import { LISTED_MARKETS_MAX_AGE_MS, SETTLED_COOLDOWN_BLOCKS } from './constants' +import { + LISTED_MARKETS_MAX_AGE_MS, + SETTLED_COOLDOWN_BLOCKS, + TOKEN_PRICES_REFRESH_MS +} from './constants' import { createApiCandidateSource, discoverBorrowers, MAX_DISCOVERY_PAGES } from './discovery/borrowers' import { createListedMarketFilter, createUnionListedMarketFilter } from './discovery/markets' +import { createTokenPriceSource } from './discovery/token-prices' import { encodeLiquidationExec } from './execution/encode-call' import { composeQuoting } from './quotes' import { runTick } from './runner/tick' @@ -163,6 +168,17 @@ async function main() { }) await tryCatch(listedMarkets.refresh()) + // Loan-token USD prices, used ONLY to order the tick's candidates by expected profit. Fails open: + // an unpriced token sorts last, so a fetch failure degrades ordering to discovery order rather than + // suppressing work. Shares the candidates endpoint's base URL, so pointing the bot at a staging host + // moves both. The boot fetch is non-fatal — the timer below retries. + const tokenPrices = createTokenPriceSource({ + apiUrl: config.discovery.apiUrl, + chainId: config.chainId, + logger + }) + await tryCatch(tokenPrices.refresh()) + // Pre-swap converters for exotic collateral (ERC4626 shares, Pendle PTs → underlying). // Auto-detecting with per-process memoization. erc4626 first: a memoized eth_call beats consulting // the markets list. Pendle is only constructed on chains it is deployed to — elsewhere a @@ -352,6 +368,7 @@ async function main() { backoff, cooldown, inflightLabels: () => queue.inflightLabels(), + usdValueOf: tokenPrices.usdValueOf, logger }) @@ -393,6 +410,20 @@ async function main() { } void refreshMarketsLoop() + // Prices refresh on their own timer rather than inside `refreshMarketsLoop`. `fetchWithRetry` is a + // 5s deadline times three retries plus backoff, so a hanging tokens fetch could stall ~22s — and the + // whitelist refresh it would stall is fail-closed and safety-critical, while this one only orders + // work. `refresh` is contractually non-throwing (it reports `prices.refresh_failed` itself), so the + // tryCatch is belt-and-braces and an error here is a bug rather than an API blip. + const refreshTokenPricesLoop = async () => { + await delay(TOKEN_PRICES_REFRESH_MS) + if (stopped) return + const { error } = await tryCatch(tokenPrices.refresh()) + if (error) logger.error('prices.refresh_error', { detail: error.message }) + void refreshTokenPricesLoop() + } + void refreshTokenPricesLoop() + // Graceful shutdown: stop the loops and log the pending set (hashes + nonces) plus the venue / // whitelist state for observability. Sends are fire-and-forget and chain truth wins on restart, so // there is nothing to persist or await-drain — a redeploy re-derives from chain. @@ -403,7 +434,8 @@ async function main() { signal, pending: queue.snapshot(), venues: venueSelector.snapshot(), - listedMarkets: listedMarkets.snapshot() + listedMarkets: listedMarkets.snapshot(), + tokenPrices: tokenPrices.snapshot() }) void runner.stop().finally(() => process.exit(0)) } diff --git a/bots/midnight-liquidation/src/runner/ranking.ts b/bots/midnight-liquidation/src/runner/ranking.ts new file mode 100644 index 00000000..2e0e2b70 --- /dev/null +++ b/bots/midnight-liquidation/src/runner/ranking.ts @@ -0,0 +1,26 @@ +/** + * Orders sized candidates by expected USD profit, descending, with unpriced candidates last. + * + * The tick works candidates serially — one quote plus one simulation each — so in an ascending-price + * maturity auction, where the first mover takes the whole position, queue order decides which + * positions get the contested early seconds. Discovery returns candidates in ascending checksummed + * address order, which is arbitrary. + * + * `null` sorts last rather than as zero: an absent price means "unrankable", not "worthless". The sort + * is stable and non-mutating, so ties and the entire unpriced group keep discovery order — a total + * price outage therefore degrades to exactly the previous behaviour, not to an untested fallback. + * + * Comparison is by bigint predicate, never `Number(b - a)`: these are 1e8-scaled USD figures over + * token amounts, and their differences routinely exceed `Number.MAX_SAFE_INTEGER`. + * + * Generic over the score field so it carries no dependency on the lens or sizing types. + */ +export const rankByUsdSurplus = ( + candidates: readonly T[] +): T[] => + candidates.toSorted((a, b) => { + if (a.surplusUsd === null) return b.surplusUsd === null ? 0 : 1 + if (b.surplusUsd === null) return -1 + if (a.surplusUsd === b.surplusUsd) return 0 + return a.surplusUsd > b.surplusUsd ? -1 : 1 + }) diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index 44088f3a..d6c595ef 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -3,15 +3,18 @@ import type { QuoteOutcome, SwapPlan } from '@repo/swaps' import type { Address } from 'viem' import { assertNever, lensKey, tryCatch } from '@repo/utils' +import { formatUnits } from 'viem' import type { BorrowerCandidate } from '../discovery/borrowers' import type { Market } from '../execution/encode-call' import type { LiquidationPlan, PlanSkipReason } from '../sizing/plan' import type { LensInput, LensOut } from '../state/lens.sol' -import { isBadDebtRealization, planWithReason } from '../sizing/plan' +import { USD_PRICE_SCALE_DECIMALS } from '../discovery/token-prices' +import { isBadDebtRealization, planSurplus, planWithReason } from '../sizing/plan' import { isLiquidatable, planInputFromLens } from './eligibility' import { assessProfitability } from './profitability' +import { rankByUsdSurplus } from './ranking' /** * Per-tick outcome tally, emitted as `tick.end`, ordered as the pipeline runs. On a tick that ran to @@ -27,7 +30,8 @@ import { assessProfitability } from './profitability' * * A new loop **exit** must join one of these sums; a new **attribute** of a position that is still * worked must not (it would double-count). Any future pre-quote skip therefore belongs in the - * `planned` sum, and any future plan-stage skip rides `planSkipped`. + * `planned` sum, and any future plan-stage skip rides `planSkipped`. `unpriced` is an attribute: an + * unpriced candidate is still worked, just ordered last, so it deliberately joins no sum. * * On `complete: false` the last identity is short by one: an aborting `submit` throws after `ok` was * counted. @@ -62,6 +66,12 @@ type TickCounters = { submitted: number /** The queue returned without broadcasting (a send failure, or a queue-wide refusal). */ notSent: number + /** + * Planned candidates whose loan token had no usable USD price, so they were ordered last rather than + * ranked. An attribute of a worked position, not a loop exit — it joins no identity. A persistently + * high value means the price snapshot is not covering the loan tokens we actually liquidate. + */ + unpriced: number } /** @@ -89,6 +99,88 @@ const LEVEL_BY_REASON: Record = { insufficient_headroom: 'debug' } +/** One candidate that produced a plan in phase A, carrying its score so phase B can be ordered. */ +type SizedCandidate = { + pair: LensInput + label: string + out: LensOut + plan: LiquidationPlan + /** Oracle-only surplus in loan units — see {@link planSurplus}. Logged for forensics. */ + surplus: bigint + /** {@link SizedCandidate.surplus} in USD at `10 ** USD_PRICE_SCALE_DECIMALS`; `null` when unpriced. */ + surplusUsd: bigint | null +} + +/** + * Phase A of a tick: turn the fresh lens batch into sized, scored candidates. Filters to positions the + * chain says are liquidatable and not already in flight, sizes each one, and scores it by oracle + * surplus converted to USD. + * + * **Deliberately synchronous, and the type signature is the enforcement.** `await` here would add + * latency to exactly the maturity burst the ordering exists to win, and — less obviously — it would + * break the ranking's internal consistency: the price snapshot is replaced wholesale by an independent + * refresh loop, so yielding mid-phase would score some candidates against one snapshot and the rest + * against the next. Because this function is not `async`, both hazards are compile errors rather than + * review comments. Keep it that way; if a future stage genuinely needs I/O, it belongs in phase B. + * + * Side effect: increments `counters` in place (`liquidatable`, `inflightSkipped`, `planSkipped`, + * `planned`, `unpriced`) and emits `plan.skipped` for each position sizing rejected. A sizing skip + * records neither backoff nor cooldown — see {@link PlanOutcome}. + */ +const sizeCandidates = (deps: { + pairs: readonly LensInput[] + lensOut: Map + inflight: ReadonlySet + seizeCapMarginBps: number + headroomFloorBps: number + usdValueOf: (loanToken: Address, loanUnits: bigint) => bigint | null + counters: TickCounters + logger: Logger +}): SizedCandidate[] => { + const { + pairs, + lensOut, + inflight, + seizeCapMarginBps, + headroomFloorBps, + usdValueOf, + counters, + logger + } = deps + const sized: SizedCandidate[] = [] + for (const pair of pairs) { + const label = lensKey(pair.id, pair.borrower) + const out = lensOut.get(label) + if (!out || !isLiquidatable(out)) continue + counters.liquidatable += 1 + + // Backpressure: a tx for this position is already pending — don't re-plan/simulate/submit it + // every block while it confirms. + if (inflight.has(label)) { + counters.inflightSkipped += 1 + continue + } + + const input = planInputFromLens(out) + const outcome = planWithReason(input, { seizeCapMarginBps, headroomFloorBps }) + if (outcome.plan === null) { + counters.planSkipped += 1 + logger[LEVEL_BY_REASON[outcome.reason]]('plan.skipped', { + marketId: pair.id, + borrower: pair.borrower, + reason: outcome.reason + }) + continue + } + counters.planned += 1 + const surplus = planSurplus(input, outcome.plan) + const surplusUsd = usdValueOf(out.market.loanToken, surplus) + if (surplusUsd === null) counters.unpriced += 1 + sized.push({ pair, label, out, plan: outcome.plan, surplus, surplusUsd }) + } + return sized +} + /** * One tick: enumerate the over-inclusive (id, borrower) candidate universe from discovery, read the * liquidation lens fresh for the whole batch (one deployless `eth_call`), and for each liquidatable @@ -160,6 +252,13 @@ export async function runTick(deps: { cooldown: CooldownStore /** Labels (`${id}:${borrower}`) already in flight — skipped to avoid re-submitting each block. */ inflightLabels: () => ReadonlySet + /** + * USD value of a loan-token amount at `10 ** USD_PRICE_SCALE_DECIMALS`, or `null` when unpriced. + * Synchronous by contract: it reads an out-of-band snapshot and must never perform I/O, because + * awaiting a price before planning would add latency to exactly the maturity burst this ordering + * exists to win. Ranking only — never a gate on whether to attempt a liquidation. + */ + usdValueOf: (loanToken: Address, loanUnits: bigint) => bigint | null logger: Logger }): Promise { const { @@ -176,6 +275,7 @@ export async function runTick(deps: { backoff, cooldown, inflightLabels, + usdValueOf, logger } = deps @@ -208,51 +308,46 @@ export async function runTick(deps: { ok: 0, reverted: 0, submitted: 0, - notSent: 0 + notSent: 0, + unpriced: 0 } - // 3. Compose liquidatability off-chain → plan → simulate → submit. `inflight` is captured once; - // discovery yields distinct (id, borrower) pairs, so no label repeats within a single tick. - const inflight = inflightLabels() + // 3. Phase A — sizing only, and synchronous by construction (see `sizeCandidates`). `inflight` is + // captured once; discovery yields distinct (id, borrower) pairs, so no label repeats in one tick. + const sized = sizeCandidates({ + pairs, + lensOut, + inflight: inflightLabels(), + seizeCapMarginBps, + headroomFloorBps, + usdValueOf, + counters, + logger + }) + + // 4. Phase B — the expensive serial stages (one quote and one simulation each), worked in descending + // expected-USD-profit order so the most valuable position gets the contested early seconds rather + // than whichever borrower sorts first by address. let complete = false try { - for (const pair of pairs) { - const label = lensKey(pair.id, pair.borrower) - const out = lensOut.get(label) - if (!out || !isLiquidatable(out)) continue - counters.liquidatable += 1 - - // Backpressure: a tx for this position is already pending — don't re-plan/simulate/submit it - // every block while it confirms. - if (inflight.has(label)) { - counters.inflightSkipped += 1 - continue - } - - const outcome = planWithReason(planInputFromLens(out), { - seizeCapMarginBps, - headroomFloorBps - }) - if (outcome.plan === null) { - // Deliberately no backoff and no cooldown: a sizing skip is not a failure, and several - // reasons clear on their own as chain time advances (see PlanOutcome). - counters.planSkipped += 1 - logger[LEVEL_BY_REASON[outcome.reason]]('plan.skipped', { - marketId: pair.id, - borrower: pair.borrower, - reason: outcome.reason - }) - continue - } - const liquidationPlan = outcome.plan - counters.planned += 1 + let rank = 0 + for (const { pair, label, out, plan: liquidationPlan, surplus, surplusUsd } of rankByUsdSurplus( + sized + )) { + rank += 1 + // Emitted here, per candidate, rather than batched in phase A: the timestamp sequence of these + // lines IS the record of what the bot worked and in what order, which is how the 31 Jul maturity + // was reconstructed at all. logger.info('plan.built', { marketId: pair.id, borrower: pair.borrower, + rank, collateralIndex: liquidationPlan.collateralIndex, seizedAssets: liquidationPlan.seizedAssets, repaidUnits: liquidationPlan.repaidUnits, - postMaturityMode: liquidationPlan.postMaturityMode + postMaturityMode: liquidationPlan.postMaturityMode, + surplus, + surplusUsd: surplusUsd === null ? null : formatUnits(surplusUsd, USD_PRICE_SCALE_DECIMALS) }) // Opt-in cooldown (complementary to backoff): a position whose last attempt produced no diff --git a/bots/midnight-liquidation/src/sizing/plan.ts b/bots/midnight-liquidation/src/sizing/plan.ts index 5ea0b941..2165e768 100644 --- a/bots/midnight-liquidation/src/sizing/plan.ts +++ b/bots/midnight-liquidation/src/sizing/plan.ts @@ -283,7 +283,17 @@ const postMaturityPlan = (input: PlanInput, marginBps: number): PlanOutcome => { // repay the contract will ceil-derive. Both terms are already on the plan, so this neither recomputes // `lifAt` nor can disagree with the LIF the plan was sized at. Used to CHOOSE between two plans whose // gates are both open; absolute profitability (gas, route quality) stays the quoting layer's job. -const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint => +/** + * Expected surplus of a sized plan, in loan units: the seized slot's oracle value minus the repay the + * contract will ceil-derive for it. Reads the plan's own recorded `impliedRepaidUnits`, so it cannot + * disagree with the LIF the plan was sized at. + * + * **Oracle-only, and a ranking key rather than a profitability measure.** It excludes DEX execution + * cost and gas, and post-maturity `lif > WAD` makes it structurally positive for every candidate — so + * a positive surplus does not mean a liquidation is worth attempting. Use it to order work, and leave + * viability to the headroom floor and the quoting/simulate layer. + */ +export const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint => seizedValueOf(chosen.seizedAssets, input.bestCollateralPrice) - chosen.impliedRepaidUnits /** diff --git a/bots/midnight-liquidation/test/discovery/token-prices.test.ts b/bots/midnight-liquidation/test/discovery/token-prices.test.ts new file mode 100644 index 00000000..daaa9613 --- /dev/null +++ b/bots/midnight-liquidation/test/discovery/token-prices.test.ts @@ -0,0 +1,207 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address } from 'viem' + +import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' + +import { createTokenPriceSource } from '../../src/discovery/token-prices' + +const BASE_URL = 'https://api.example.test/markets/midnight/liquidation-candidates' +const USDC: Address = getAddress('0x1111111111111111111111111111111111111111') +const WETH: Address = getAddress('0x2222222222222222222222222222222222222222') +const UNLISTED: Address = getAddress('0x3333333333333333333333333333333333333333') + +const NOOP_LOGGER: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} +} + +function capturingLogger() { + const events: { level: string; event: string; fields?: Record }[] = [] + const make = (level: string) => (event: string, fields?: Record) => + events.push({ level, event, fields }) + return { + logger: { debug: make('debug'), info: make('info'), warn: make('warn'), error: make('error') }, + find: (event: string) => events.find(e => e.event === event) + } +} + +const jsonResponse = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers } + }) + +// `address`/`decimals`/`price` are `unknown` so malformed rows type-check in the bad-input cases. +const row = (address: unknown, decimals: unknown, usd: unknown, chainId = 8453) => ({ + chain_id: chainId, + address, + name: 'Token', + symbol: 'TKN', + decimals, + logo_uri: null, + tags: null, + is_listed: true, + price: usd === null ? null : { usd, timestamp: 1_700_000_000 } +}) + +const sourceWith = (body: unknown, status = 200, logger: Logger = NOOP_LOGGER) => + createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger, + fetchImpl: async () => jsonResponse(body, status) + }) + +describe('createTokenPriceSource', () => { + it('requests the tokens path for the configured chain', async () => { + let requestedUrl = '' + const source = createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger: NOOP_LOGGER, + fetchImpl: async request => { + requestedUrl = request.url + return jsonResponse({ data: [] }) + } + }) + await source.refresh() + const url = new URL(requestedUrl) + expect(url.pathname).toBe('/markets/midnight/tokens') + expect(url.searchParams.get('chain_ids')).toBe('8453') + // Not narrowed by market or listing status — the whitelist already gates what we act on. + expect(url.searchParams.has('markets')).toBe(false) + expect(url.searchParams.has('listed')).toBe(false) + }) + + it('values loan amounts at the 1e8 USD scale across differing decimals', async () => { + const source = sourceWith({ + data: [row(USDC, 6, 1), row(WETH, 18, 2500)] + }) + await source.refresh() + // 1 USDC at $1.00 → 1.00000000 at 1e8 + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + // 1 WETH at $2500 → 2500.00000000 at 1e8 + expect(source.usdValueOf(WETH, 10n ** 18n)).toBe(250_000_000_000n) + // Scales linearly, and floors rather than rounding up. + expect(source.usdValueOf(USDC, 1n)).toBe(100n) + expect(source.snapshot()).toMatchObject({ tokens: 2 }) + }) + + it('is case-insensitive on the token address', async () => { + const source = sourceWith({ data: [row(USDC.toLowerCase(), 6, 1)] }) + await source.refresh() + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + }) + + it('returns null for a token absent from the snapshot', async () => { + const source = sourceWith({ data: [row(USDC, 6, 1)] }) + await source.refresh() + expect(source.usdValueOf(UNLISTED, 1n)).toBeNull() + }) + + it('drops rows with no price, no decimals, a non-positive price, or a bad address', async () => { + const source = sourceWith({ + data: [ + row(USDC, 6, 1), // ok + row(WETH, 18, null), // price: null — the collateral wrappers really do come back this way + row(UNLISTED, null, 1), // decimals: null + row(getAddress('0x4444444444444444444444444444444444444444'), 18, 0), // usd: 0 + row(getAddress('0x5555555555555555555555555555555555555555'), 18, -1), // usd: negative + row('not-an-address', 18, 1), + row(getAddress('0x6666666666666666666666666666666666666666'), 18, 1, 1) // wrong chain + ] + }) + await source.refresh() + expect(source.snapshot()).toMatchObject({ tokens: 1 }) + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + expect(source.usdValueOf(WETH, 1n)).toBeNull() + expect(source.usdValueOf(UNLISTED, 1n)).toBeNull() + }) + + it('treats a price with no usable 1e8 precision as unpriced rather than as zero', async () => { + const source = sourceWith({ data: [row(USDC, 6, 1e-12)] }) + await source.refresh() + expect(source.usdValueOf(USDC, 1_000_000n)).toBeNull() + }) + + it('keeps last-known-good and does not reject when the API fails', async () => { + let status = 200 + const logs = capturingLogger() + const source = createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger: logs.logger, + sleep: async () => {}, + fetchImpl: async () => jsonResponse({ data: [row(USDC, 6, 1)] }, status) + }) + await source.refresh() + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + + status = 500 + // Contractually non-throwing: ranking degrades, it never fails closed. + await expect(source.refresh()).resolves.toBeUndefined() + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + expect(logs.find('prices.refresh_failed')?.level).toBe('warn') + }) + + it('retries a 429 honoring retry-after', async () => { + let attempts = 0 + let slept = 0 + const source = createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger: NOOP_LOGGER, + sleep: async () => { + slept += 1 + }, + fetchImpl: async () => { + attempts += 1 + if (attempts === 1) { + return jsonResponse({ error: 'slow down' }, 429, { 'retry-after': '0' }) + } + return jsonResponse({ data: [row(USDC, 6, 1)] }) + } + }) + await source.refresh() + expect(attempts).toBe(2) + expect(slept).toBe(1) + expect(source.usdValueOf(USDC, 1_000_000n)).toBe(100_000_000n) + }) + + it('warns when a previously-populated snapshot comes back empty', async () => { + let body: unknown = { data: [row(USDC, 6, 1)] } + const logs = capturingLogger() + const source = createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger: logs.logger, + fetchImpl: async () => jsonResponse(body) + }) + await source.refresh() + body = { data: [] } + await source.refresh() + expect(logs.find('prices.tokens_empty')?.fields).toMatchObject({ previous: 1 }) + expect(source.usdValueOf(USDC, 1n)).toBeNull() + }) + + it('reports an unfetched snapshot as empty with no timestamp', () => { + const source = sourceWith({ data: [] }) + expect(source.snapshot()).toMatchObject({ tokens: 0, updatedAt: null }) + expect(source.usdValueOf(USDC, 1n)).toBeNull() + }) + + it('stamps updatedAt from the injected clock on a successful refresh', async () => { + const source = createTokenPriceSource({ + apiUrl: BASE_URL, + chainId: 8453, + logger: NOOP_LOGGER, + now: () => 1_234, + fetchImpl: async () => jsonResponse({ data: [row(USDC, 6, 1)] }) + }) + await source.refresh() + expect(source.snapshot().updatedAt).toBe(1_234) + }) +}) diff --git a/bots/midnight-liquidation/test/runner/ranking.test.ts b/bots/midnight-liquidation/test/runner/ranking.test.ts new file mode 100644 index 00000000..cd802696 --- /dev/null +++ b/bots/midnight-liquidation/test/runner/ranking.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import { rankByUsdSurplus } from '../../src/runner/ranking' + +const at = (id: string, surplusUsd: bigint | null) => ({ id, surplusUsd }) + +describe('rankByUsdSurplus', () => { + it('orders priced candidates by descending USD surplus', () => { + const ranked = rankByUsdSurplus([at('small', 50n), at('large', 200n), at('medium', 100n)]) + expect(ranked.map(c => c.id)).toEqual(['large', 'medium', 'small']) + }) + + it('orders unpriced candidates after every priced one, whatever their position', () => { + const ranked = rankByUsdSurplus([ + at('unpriced-first', null), + at('small', 50n), + at('unpriced-last', null), + at('large', 200n) + ]) + expect(ranked.map(c => c.id)).toEqual(['large', 'small', 'unpriced-first', 'unpriced-last']) + }) + + it('preserves input order among unpriced candidates', () => { + const ranked = rankByUsdSurplus([at('c', null), at('a', null), at('b', null)]) + expect(ranked.map(c => c.id)).toEqual(['c', 'a', 'b']) + }) + + it('preserves input order on ties', () => { + const ranked = rankByUsdSurplus([at('c', 10n), at('a', 10n), at('b', 10n)]) + expect(ranked.map(c => c.id)).toEqual(['c', 'a', 'b']) + }) + + it('does not mutate its input', () => { + const input = [at('small', 1n), at('large', 2n)] + rankByUsdSurplus(input) + expect(input.map(c => c.id)).toEqual(['small', 'large']) + }) + + it('returns an empty array unchanged', () => { + expect(rankByUsdSurplus([])).toEqual([]) + }) + + it('treats a zero surplus as priced, so it still outranks an unpriced candidate', () => { + const ranked = rankByUsdSurplus([at('unpriced', null), at('zero', 0n)]) + expect(ranked.map(c => c.id)).toEqual(['zero', 'unpriced']) + }) + + it('orders correctly when the difference exceeds Number.MAX_SAFE_INTEGER', () => { + // Regression guard for a `Number(b - a)` comparator: these differ by ~1e21, so coercing the + // difference to a double loses the distinction entirely. + const huge = 10n ** 30n + const ranked = rankByUsdSurplus([at('lo', huge), at('hi', huge + 10n ** 21n)]) + expect(ranked.map(c => c.id)).toEqual(['hi', 'lo']) + }) +}) diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index 5e885f2c..bf836cb6 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -112,6 +112,18 @@ function lensOut(overrides: Partial = {}): LensOut { const candidates = (...borrowers: Address[]): BorrowerCandidate[] => borrowers.map(borrower => ({ marketId: MARKET, borrower })) +// Per-borrower readings, so one tick can hold candidates with different surpluses / loan tokens. +function stubReadLensByBorrower(byBorrower: Map) { + return async (pairs: LensInput[]) => { + const map = new Map() + for (const pair of pairs) { + const out = byBorrower.get(pair.borrower) + if (out) map.set(lensKey(pair.id, pair.borrower), out) + } + return map + } +} + function stubReadLens(out: LensOut | null) { return async (pairs: LensInput[]) => { const map = new Map() @@ -137,8 +149,13 @@ function runWith(opts: { submitOutcome?: SubmitOutcome /** Models a send that claimed a nonce but produced no hash, which aborts the tick. */ submitThrows?: Error + /** Distinct readings per borrower, for ordering cases. Overrides `out`. */ + outsByBorrower?: Map + /** Defaults to an identity valuation, so surplusUsd tracks surplus and ordering is deterministic. */ + usdValueOf?: (loanToken: Address, loanUnits: bigint) => bigint | null }) { const { logger, events } = spyLogger() + const order: Address[] = [] let simulateCalls = 0 let submitCalls = 0 let quoteCalls = 0 @@ -160,9 +177,12 @@ function runWith(opts: { seizeCapMarginBps: 0, headroomFloorBps: opts.headroomFloorBps ?? 0, minSurplusBps: opts.minSurplusBps ?? 0, - readLens: stubReadLens(opts.out === undefined ? lensOut() : opts.out), - quoteFor: async () => { + readLens: opts.outsByBorrower + ? stubReadLensByBorrower(opts.outsByBorrower) + : stubReadLens(opts.out === undefined ? lensOut() : opts.out), + quoteFor: async (_plan, _out, label) => { quoteCalls += 1 + order.push(getAddress(`0x${label.slice(-40)}`)) return opts.quoteOutcome ?? defaultOutcome }, simulate: async () => { @@ -177,6 +197,7 @@ function runWith(opts: { backoff, cooldown, inflightLabels: () => opts.inflight ?? new Set(), + usdValueOf: opts.usdValueOf ?? ((_loanToken, loanUnits) => loanUnits), logger }) return result.then(counters => ({ @@ -186,6 +207,7 @@ function runWith(opts: { simulateCalls: () => simulateCalls, submitCalls: () => submitCalls, quoteCalls: () => quoteCalls, + order, events })) } @@ -209,7 +231,8 @@ describe('runTick', () => { ok: 1, reverted: 0, submitted: 1, - notSent: 0 + notSent: 0, + unpriced: 0 }) expect(simulateCalls()).toBe(1) expect(submitCalls()).toBe(1) @@ -464,6 +487,7 @@ describe('runTick', () => { backoff: createBackoff({ baseBlocks: 2n, maxBlocks: 64n }), cooldown: createCooldownStore({ cooldownMs: 0 }), inflightLabels: () => new Set(), + usdValueOf: (_loanToken, loanUnits) => loanUnits, logger }) ).rejects.toThrow('nonce claimed, no hash') @@ -644,4 +668,81 @@ describe('runTick', () => { expectCounterIdentities(counters) }) }) + describe('profit ordering', () => { + // The repay cap binds at `debt - badDebt`, so debt sets the seize and therefore the surplus: + // seize = cap * lif / WAD, surplus = seizedValue - ceil(seize * WAD / lif) = cap * (lif - 1)/WAD. + // debt 500 -> 50, debt 1000 -> 100, debt 2000 -> 200, at maxLif 1.1. + const SMALL = getAddress('0x0000000000000000000000000000000000000a11') + const LARGE = getAddress('0x0000000000000000000000000000000000000b22') + const MEDIUM = getAddress('0x0000000000000000000000000000000000000c33') + + it('works the highest-USD-surplus position first, whatever order discovery returned', async () => { + const outs = new Map([ + [SMALL, lensOut({ debt: 500n, maxDebt: 450n })], + [LARGE, lensOut({ debt: 2000n, maxDebt: 1800n })], + [MEDIUM, lensOut({ debt: 1000n, maxDebt: 900n })] + ]) + const { counters, order } = await runWith({ + borrowers: [SMALL, LARGE, MEDIUM], + outsByBorrower: outs + }) + expect(counters).toMatchObject({ liquidatable: 3, planned: 3, unpriced: 0, submitted: 3 }) + expect(order).toEqual([LARGE, MEDIUM, SMALL]) + expectCounterIdentities(counters) + }) + + it('orders an unpriced candidate last even when its surplus is the largest', async () => { + const UNPRICED_TOKEN = getAddress('0x9999999999999999999999999999999999999999') + const outs = new Map([ + // The bigger position is the unpriced one, so discovery order and surplus order both put it + // first; only the unpriced-last rule can move it. + [ + LARGE, + lensOut({ + debt: 2000n, + maxDebt: 1800n, + market: { ...lensOut().market, loanToken: UNPRICED_TOKEN } + }) + ], + [MEDIUM, lensOut({ debt: 1000n, maxDebt: 900n })] + ]) + const { counters, order } = await runWith({ + borrowers: [LARGE, MEDIUM], + outsByBorrower: outs, + usdValueOf: (loanToken, loanUnits) => (loanToken === UNPRICED_TOKEN ? null : loanUnits) + }) + expect(counters).toMatchObject({ liquidatable: 2, planned: 2, unpriced: 1 }) + expect(order).toEqual([MEDIUM, LARGE]) + expectCounterIdentities(counters) + }) + + it('falls back to discovery order when nothing is priced', async () => { + const outs = new Map([ + [SMALL, lensOut({ debt: 500n, maxDebt: 450n })], + [LARGE, lensOut({ debt: 2000n, maxDebt: 1800n })], + [MEDIUM, lensOut({ debt: 1000n, maxDebt: 900n })] + ]) + const { counters, order } = await runWith({ + borrowers: [SMALL, LARGE, MEDIUM], + outsByBorrower: outs, + usdValueOf: () => null + }) + expect(counters.unpriced).toBe(3) + expect(order).toEqual([SMALL, LARGE, MEDIUM]) + }) + + it('logs rank and surplus on plan.built in the order worked', async () => { + const outs = new Map([ + [SMALL, lensOut({ debt: 500n, maxDebt: 450n })], + [LARGE, lensOut({ debt: 2000n, maxDebt: 1800n })] + ]) + const { events } = await runWith({ borrowers: [SMALL, LARGE], outsByBorrower: outs }) + const built = events.filter(e => e.event === 'plan.built') + expect(built.map(e => e.fields?.rank)).toEqual([1, 2]) + expect(built.map(e => e.fields?.borrower)).toEqual([LARGE, SMALL]) + expect(built.map(e => e.fields?.surplus)).toEqual([200n, 50n]) + // Rendered at the USD scale rather than as a raw 1e8-scaled bigint. + expect(built[0]?.fields?.surplusUsd).toBe('0.000002') + }) + }) }) From e72a1304e9d2e06099c48c23caa145115560bb2a Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 13:49:16 -0500 Subject: [PATCH 04/10] feat(swaps): derive the min-out floor from break-even MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A liquidation's entire margin is the protocol's liquidation incentive, so break-even — not a percentage — is the economically correct min-out floor. `SLIPPAGE_BPS` at its 100 default is wrong in BOTH directions, and which one depends on where the incentive sits, so no single value is right: midnight cbBTC/USDC, maxLif 1.043841, oracle value 10_000 t incentive break-even minOut @1% the fixed guard is +123s 15.0bp 9985.04 9883.17 too loose +600s 72.5bp 9927.46 9883.17 too loose +1800s 214.5bp 9785.50 9883.17 too tight +3600s 420.0bp 9580.00 9883.17 too tight Below break-even the router accepts a route that cannot settle, and the shortfall surfaces at the repay as `ERC20: transfer amount exceeds allowance` — which reads as an approval bug and is not one. Above break-even the router rejects fills that would have settled profitably. `QuoteParameters`/`QuoteRequest` gain an optional `minAcceptableAmountOut`. When set, the allowance is derived from it instead of from `slippageBps`. It is expressed as a percentage of the oracle reference rather than as an absolute because the aggregators' only lever IS a slippage parameter — they bake the min-out themselves from it. Uniswap derives its min-out as `reference · (1 - slippage)`, so the same percentage lands it on the absolute floor exactly, and no venue needed changing. An aggregator lands slightly below the floor, by its quote's shortfall against the oracle (the execution cost). That is the safe direction: it can never reject a fill that would have settled. Wired in both liquidators, and omitting the field reproduces today's behavior exactly: - midnight reads `plan.impliedRepaidUnits` straight off the plan. The matured-and-unhealthy branch picks a mode by surplus, so the LIF is recoverable from neither `postMaturityMode` nor chain time. - blue derives it at the seam from `lifFromLltv(lltv)`. Its LIF is a pure function of the market — no maturity ramp, no mode choice — so there is nothing about it a plan could know that the market does not. This also makes the knob monotone. Raising `SLIPPAGE_BPS` previously loosened the router's protection and pushed shortfalls onto the repay; an economic floor cannot be widened below break-even. Co-Authored-By: Claude Opus 5 (1M context) --- bots/blue-liquidation/src/quotes.ts | 11 ++++ bots/midnight-liquidation/src/quotes.ts | 4 ++ packages/swaps/src/quoting.ts | 34 +++++++++++- packages/swaps/src/types.ts | 6 ++ packages/swaps/test/quoting.test.ts | 73 +++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/bots/blue-liquidation/src/quotes.ts b/bots/blue-liquidation/src/quotes.ts index f9ec9f51..764fc147 100644 --- a/bots/blue-liquidation/src/quotes.ts +++ b/bots/blue-liquidation/src/quotes.ts @@ -8,7 +8,10 @@ import { isAddressEqual } from 'viem' import type { LiquidationPlan } from './sizing/plan' import type { LensOut } from './state/lens.sol' +import { WAD } from './constants' import { expectedLoanOut } from './execution/swap-step' +import { lifFromLltv } from './sizing/lif' +import { mulDivUp } from './sizing/math' /** * The Blue-shaped adapter over `@repo/swaps`' {@link composeMultiVenueQuoting}: keeps the @@ -57,6 +60,14 @@ export function composeQuoting(deps: { loanToken: out.params.loanToken, amountIn: plan.seizedAssets, referenceAmountOut: expectedLoanOut(plan, out), + // Break-even: the loan assets Blue will pull for this seize. Derived here rather than carried + // on the plan because Blue's LIF is a pure function of the market's `lltv` — no maturity ramp + // and no mode choice — so there is nothing about it a plan could know that the market doesn't. + minAcceptableAmountOut: mulDivUp( + expectedLoanOut(plan, out), + WAD, + lifFromLltv(out.params.lltv) + ), // The tick's position label (`${id}:${borrower}`) — the correlation id join across quote logs. id: label }) diff --git a/bots/midnight-liquidation/src/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index 61163876..3a8f6ece 100644 --- a/bots/midnight-liquidation/src/quotes.ts +++ b/bots/midnight-liquidation/src/quotes.ts @@ -58,6 +58,10 @@ export function composeQuoting(deps: { loanToken: out.market.loanToken, amountIn: plan.seizedAssets, referenceAmountOut: expectedLoanOut(plan, out), + // Break-even, straight off the plan: the repay `liquidate` will pull for this seize at the LIF + // the plan was sized at. Read rather than recomputed — the matured-and-unhealthy branch picks a + // mode by surplus, so the LIF is not recoverable from `postMaturityMode` or from chain time. + minAcceptableAmountOut: plan.impliedRepaidUnits, // The tick's position label (`${id}:${borrower}`) — the correlation id join across quote logs. id: label }) diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index a2f6cc84..f60b0d73 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -102,6 +102,20 @@ export type QuoteRequest = { amountIn: bigint /** Oracle-priced expected output (no DEX slippage) — the route-quality reference. */ referenceAmountOut: bigint + /** + * Break-even output: the loan-token amount the protocol will pull to settle the repay. When set, the + * min-out floor is derived from it instead of from the operator's `slippageBps`. + * + * A liquidation's entire margin is the protocol's liquidation incentive, so break-even — not a + * percentage — is the economically correct floor. A fixed allowance is wrong in both directions and + * crosses over as the incentive changes: below break-even it lets a shortfall through to fail at the + * repay instead (surfacing as a misleading allowance error), and above break-even it makes the + * router reject fills that would have settled profitably. Deriving the allowance from break-even is + * right at every point, and cannot be tuned wrong. + * + * Omit it to keep the pre-existing `slippageBps` behavior exactly. + */ + minAcceptableAmountOut?: bigint /** `collateralToken` decimals — required only for decimal-denominated venues (LiquidSwap). */ tokenInDecimals?: number /** The position's correlation id — threaded into log events only, never parsed. */ @@ -148,9 +162,24 @@ async function firmQuoteVenue(args: { request: QuoteRequest maxRouteImpactBps: number }): Promise { - const { httpClient, chainId, executor, venueEntry, slippageBps } = args + const { httpClient, chainId, executor, venueEntry } = args const { tokenIn, amountIn, steps, request, maxRouteImpactBps } = args - const { loanToken, referenceAmountOut } = request + const { loanToken, referenceAmountOut, minAcceptableAmountOut } = request + + // An economic floor beats the operator percentage when one is supplied. Expressed as a percentage of + // the oracle reference rather than as an absolute, because the aggregators' only lever IS a slippage + // parameter — they bake the min-out themselves from it. Uniswap derives its min-out as + // `reference · (1 - slippage)`, so the same percentage lands it on `minAcceptableAmountOut` exactly; + // an aggregator lands slightly below it (its own quote is under the oracle by the execution cost), + // which is the safe direction — it can never reject a fill that would have settled. + const economicFloor = + minAcceptableAmountOut !== undefined && minAcceptableAmountOut > referenceAmountOut + ? referenceAmountOut // a floor above the oracle reference would imply negative slippage + : minAcceptableAmountOut + const slippageBps = + economicFloor === undefined || referenceAmountOut <= 0n + ? args.slippageBps + : Number(((referenceAmountOut - economicFloor) * BPS) / referenceAmountOut) const params: QuoteParameters = { chainId, @@ -163,6 +192,7 @@ async function firmQuoteVenue(args: { slippageBps, executor, referenceAmountOut, + minAcceptableAmountOut, // The request's decimals describe the RAW collateral; after an unwrap they would mislabel // the underlying, so they are only forwarded on the direct (no-unwrap) path. tokenInDecimals: steps.length === 0 ? request.tokenInDecimals : undefined diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index c8cb6cda..0f380562 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -28,6 +28,12 @@ export type QuoteParameters = TokenInDecimals & { executor: Address /** Oracle-priced expected output (no DEX slippage) — the no-route-quality reference. */ referenceAmountOut: bigint + /** + * Break-even output: the loan-token amount the protocol will pull to settle the repay. When set, it + * REPLACES `slippageBps` as the min-out floor — see {@link QuoteRequest.minAcceptableAmountOut} for + * why an economic floor beats an operator-chosen percentage. + */ + minAcceptableAmountOut?: bigint } /** diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 529cc2bd..f2b5685a 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -346,3 +346,76 @@ describe('composeMultiVenueQuoting', () => { expect(unwrapper.probed).toHaveLength(0) }) }) + +describe('economic min-out floor (minAcceptableAmountOut)', () => { + // Captures the slippage each venue was asked for, which is the aggregators' ONLY min-out lever. + const capturingHttp = (body: unknown) => { + const calls: { searchParams?: Record }[] = [] + const client: RateLimitedClient = { + getJson: async (args: { searchParams?: Record }) => { + calls.push(args) + return body as T + } + } + return { client, calls } + } + + const quoteWith = async (request: QuoteRequest, body: unknown) => { + const { client, calls } = capturingHttp(body) + const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 1000n }], client) + await quoteFor(request) + return { calls } + } + + it('leaves the operator slippage untouched when no floor is supplied', async () => { + const { calls } = await quoteWith(REQUEST, oneInchBody('1000')) + // composeMulti configures slippageBps: 50, sent as a percentage. + expect(calls[0]?.searchParams?.slippage).toBe('0.5') + }) + + it('derives the allowance from break-even, replacing the operator percentage', async () => { + // reference 1000, break-even 900 -> the route may give up 100/1000 = 1000bps = 10%. + const { calls } = await quoteWith( + { ...REQUEST, minAcceptableAmountOut: 900n }, + oneInchBody('1000') + ) + expect(calls[0]?.searchParams?.slippage).toBe('10') + }) + + it('asks for zero slippage when break-even is the whole reference', async () => { + const { calls } = await quoteWith( + { ...REQUEST, minAcceptableAmountOut: 1000n }, + oneInchBody('1000') + ) + expect(calls[0]?.searchParams?.slippage).toBe('0') + }) + + it('clamps a floor above the reference rather than asking for negative slippage', async () => { + const { calls } = await quoteWith( + { ...REQUEST, minAcceptableAmountOut: 1200n }, + oneInchBody('1000') + ) + expect(calls[0]?.searchParams?.slippage).toBe('0') + }) + + // The finding this change exists for: a FIXED allowance is wrong in both directions and crosses + // over as the protocol's incentive grows, while a break-even-derived one is right at both ends. + it('tracks the incentive across the ramp where a fixed percentage cannot', async () => { + const REFERENCE = 10_000n + const early = await quoteWith( + { ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: 9985n }, + oneInchBody('10000') + ) + const late = await quoteWith( + { ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: 9580n }, + oneInchBody('10000') + ) + + // Early (15bps of incentive) the allowance is FAR tighter than the operator's 1%: a fixed 1% + // would sit below break-even and let a shortfall through to fail at the repay instead. + expect(early.calls[0]?.searchParams?.slippage).toBe('0.15') + // Late (420bps) it is FAR looser: a fixed 1% would sit above break-even and reject fills that + // would have settled profitably. + expect(late.calls[0]?.searchParams?.slippage).toBe('4.2') + }) +}) From 40b09451a963044bfd2a170d94bdfcb07a841f78 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 14:01:39 -0500 Subject: [PATCH 05/10] refactor(swaps): delete SLIPPAGE_BPS, now unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both consumers of `@repo/swaps` supply an economic min-out floor, and the package is private/unpublished, so nothing could reach the operator percentage any more. Deleting it rather than leaving a knob that silently does nothing: - `QuoteRequest.minAcceptableAmountOut` is required, and the derivation is unconditional. - `composeMultiVenueQuoting` no longer takes `slippageBps`, and the entries it synthesizes no longer carry one. Venues already read `params.slippageBps` — the derived value — never the entry's, so `entry.slippageBps` was vestigial before this too. - `SLIPPAGE_BPS` and `VenueConfig.slippageBps` are gone from both bots. - `parseSwapConfig` keeps accepting `slippageBps`, now optional: the seed script reads only `venue`/`router`/`fee`, so existing operator JSON still parses. Prod's stale `SWAP_CONFIG_PATH` is likewise inert. Leaving the env var unread rather than rejected is deliberate — a deployment that still sets it must keep starting, since an unknown env var is not a misconfiguration. Both bots have a test pinning that. `minAcceptableAmountOut` also comes off `QuoteParameters`: no venue reads it, only the quoting layer that derives the percentage from it, so it has no business on the venue-facing type. `slippageBps` stays there as the derived value a venue consumes — which is why the fork suites, which call `quoteUniswapV3` directly with their own percentage, are untouched. A ceiling would have been the wrong repair. Capping the allowance below the incentive reintroduces the too-tight failure late in the ramp, which is exactly what the previous commit fixed. `PENDLE_SLIPPAGE_BPS` is unrelated and unaffected — it bounds the PT → underlying unwrap hop, not a venue's min-out. Co-Authored-By: Claude Opus 5 (1M context) --- bots/blue-liquidation/README.md | 1 - bots/blue-liquidation/src/config.ts | 10 +++---- bots/blue-liquidation/src/index.ts | 1 - bots/blue-liquidation/src/quotes.ts | 1 - bots/blue-liquidation/test/config.test.ts | 7 ++--- bots/blue-liquidation/test/quotes.test.ts | 1 - bots/midnight-liquidation/README.md | 12 +++++---- bots/midnight-liquidation/src/config.ts | 10 +++---- bots/midnight-liquidation/src/index.ts | 1 - bots/midnight-liquidation/src/quotes.ts | 1 - bots/midnight-liquidation/test/config.test.ts | 10 +++---- bots/midnight-liquidation/test/quotes.test.ts | 1 - packages/swaps/src/config.ts | 5 +++- packages/swaps/src/quoting.ts | 27 +++++++------------ packages/swaps/src/types.ts | 12 ++++----- packages/swaps/test/quoting.test.ts | 14 ++++------ 16 files changed, 48 insertions(+), 66 deletions(-) diff --git a/bots/blue-liquidation/README.md b/bots/blue-liquidation/README.md index e9739cdc..f358fd79 100644 --- a/bots/blue-liquidation/README.md +++ b/bots/blue-liquidation/README.md @@ -46,7 +46,6 @@ Env vars (fail-loud on a missing required var, an unknown chain, or a malformed | `ZEROX_API_KEY` / `ONEINCH_API_KEY` / `LIFI_API_KEY` | no | — | Each key **enables** its venue; LiFi also via `ENABLE_LIFI` (keyless) | | `ENABLE_LIFI` | no | `false` | Enable LiFi without a key | | `ALLOW_DETECTION_ONLY` | no | `false` | Opt-in: boot with zero venues (discover + log only, skip every liquidation). Without it, zero venues is a startup error | -| `SLIPPAGE_BPS` | no | `100` | Global venue slippage (routing is no longer per-collateral) | | `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral deny-list (skipped with `config.no_swap_path`) | | `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | — | Optional per-venue API host overrides | | `PROBE_STALE_MS` / `PROBE_HTTP_RPS` / `PROBE_LADDER` | no | see `config.ts` | Venue-probe cache staleness, isolated probe rate, and whole-token ladder sizes | diff --git a/bots/blue-liquidation/src/config.ts b/bots/blue-liquidation/src/config.ts index 3a93d6fa..74946832 100644 --- a/bots/blue-liquidation/src/config.ts +++ b/bots/blue-liquidation/src/config.ts @@ -85,7 +85,6 @@ const DEFAULT_POSITION_LIQUIDATION_COOLDOWN_MS = 0 // queue ahead of a time-sensitive firm quote; log-scaled ladder sizes are whole collateral tokens // (converted per-collateral to base units). `PROBE_STALE_MS` caps probe cadence per pair; a pair is // re-probed only when a liquidatable position touches it after the cache goes stale. -const DEFAULT_SLIPPAGE_BPS = 100 const DEFAULT_PROBE_STALE_MS = 600_000 const DEFAULT_PROBE_HTTP_RPS = 1 const DEFAULT_PROBE_LADDER = ['0.01', '0.1', '1', '10', '100'] @@ -122,13 +121,13 @@ export type DiscoveryConfig = { } /** - * Enabled swap venues + global routing knobs. Venues are enabled by the PRESENCE of their API key in - * env (secrets themselves are read at the point of use in index.ts, never stored here). `slippageBps` - * is global now that routing is not per-collateral; `baseUrl` overrides are optional per-venue hosts. + * Enabled swap venues + their optional host overrides. Venues are enabled by the PRESENCE of their API + * key in env (secrets themselves are read at the point of use in index.ts, never stored here). There is + * no slippage knob: the quoting layer derives each venue's allowance from the liquidation's break-even + * output, so the min-out floor is economic rather than operator-chosen. */ export type VenueConfig = { enabled: Venue[] - slippageBps: number zeroxBaseUrl: string | undefined oneinchBaseUrl: string | undefined lifiBaseUrl: string | undefined @@ -365,7 +364,6 @@ export function loadConfig( } const venues: VenueConfig = { enabled: enabledVenues, - slippageBps: intEnv(env, 'SLIPPAGE_BPS', DEFAULT_SLIPPAGE_BPS, { min: 0, max: 10_000 }), zeroxBaseUrl, oneinchBaseUrl, lifiBaseUrl, diff --git a/bots/blue-liquidation/src/index.ts b/bots/blue-liquidation/src/index.ts index 66182ed4..68473e10 100644 --- a/bots/blue-liquidation/src/index.ts +++ b/bots/blue-liquidation/src/index.ts @@ -165,7 +165,6 @@ async function main() { chainId: config.chainId, executor: config.executooorAddress, venues, - slippageBps: config.venues.slippageBps, baseUrls, maxRouteImpactBps: config.quoting.maxRouteImpactBps, unwrappers, diff --git a/bots/blue-liquidation/src/quotes.ts b/bots/blue-liquidation/src/quotes.ts index 764fc147..9726a49f 100644 --- a/bots/blue-liquidation/src/quotes.ts +++ b/bots/blue-liquidation/src/quotes.ts @@ -29,7 +29,6 @@ export function composeQuoting(deps: { chainId: number executor: Address venues: readonly Venue[] - slippageBps: number baseUrls: Partial> maxRouteImpactBps: number unwrappers: readonly Unwrapper[] diff --git a/bots/blue-liquidation/test/config.test.ts b/bots/blue-liquidation/test/config.test.ts index 9b9a814b..c408411e 100644 --- a/bots/blue-liquidation/test/config.test.ts +++ b/bots/blue-liquidation/test/config.test.ts @@ -34,7 +34,6 @@ describe('loadConfig', () => { // Executor address is derived from the deterministic CREATE2 factory when not overridden. expect(config.executooorAddress).toMatch(/^0x[0-9a-fA-F]{40}$/) expect(config.venues.enabled).toEqual(['0x']) - expect(config.venues.slippageBps).toBe(100) expect(config.venues.excludeCollaterals).toEqual([]) expect(config.venues.zeroxBaseUrl).toBeUndefined() }) @@ -113,8 +112,10 @@ describe('loadConfig', () => { ) }) - it('parses SLIPPAGE_BPS and EXCLUDE_COLLATERALS, failing loud on a bad address', () => { - expect(loadConfig(baseEnv({ SLIPPAGE_BPS: '250' })).venues.slippageBps).toBe(250) + it('parses EXCLUDE_COLLATERALS, failing loud on a bad address', () => { + // A stale SLIPPAGE_BPS must not fail startup: the knob was removed when the min-out floor became + // break-even-derived, and an unknown env var is not a misconfiguration. + expect(() => loadConfig(baseEnv({ SLIPPAGE_BPS: '250' }))).not.toThrow() const config = loadConfig(baseEnv({ EXCLUDE_COLLATERALS: ` ${COLLATERAL} , ${MORPHO}` })) expect(config.venues.excludeCollaterals).toEqual([getAddress(COLLATERAL), MORPHO]) expect(() => loadConfig(baseEnv({ EXCLUDE_COLLATERALS: '0x123' }))).toThrow( diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index 511fb497..abb71b00 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -89,7 +89,6 @@ function compose( chainId: 8453, executor: EXECUTOR, venues: overrides.venues ?? ['0x'], - slippageBps: 100, baseUrls: {}, maxRouteImpactBps: 500, unwrappers: [], diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index cdfb7b4a..2b8ac6da 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -63,7 +63,6 @@ Environment variables: | `ENABLE_LIFI` | no | `false` | Enables the keyless `lifi` venue. Also implicitly enabled when `LIFI_API_KEY` is set. | | `LIFI_API_KEY` | no | — | Optional; LiFi routes keyless, a key only raises its rate limits (and enables the venue). Read at point of use; never logged. | | `ALLOW_BAD_DEBT_ONLY` | no | `false` | When no venue is enabled, the bot refuses to start unless this is `true` (then it runs bad-debt-only: discovers positions, realizes bad debt, never swap-liquidates). | -| `SLIPPAGE_BPS` | no | `100` | Global max oracle-to-DEX output discount passed to every venue (bakes the on-chain min-out into calldata). Replaces the old per-collateral `slippageBps`. | | `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | public | Optional venue API host overrides. | | `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral addresses the bot must never seize/hold — skipped (no quote) even in a listed market. | | `MAX_FEE_GWEI` | no | `300` | Hard max fee cap used by the pending transaction queue. | @@ -154,10 +153,13 @@ There is no swap config file. Instead: venue only on failure — never fanning out firm quotes across venues at once. A pair not yet probed (e.g. newly listed) falls back to a deterministic default venue for that one quote. -`SLIPPAGE_BPS` is the global max oracle-to-DEX output discount passed to every venue (which bakes the -on-chain min-out into its calldata); the bot additionally rejects any quoted route more than -`MAX_ROUTE_IMPACT_BPS` below the oracle reference. API keys come from `ZEROX_API_KEY` / -`ONEINCH_API_KEY` and are never logged. +The min-out floor is **derived, not configured**: each venue's slippage allowance is computed from the +liquidation's break-even output — the repay `liquidate` will pull — so the floor is economic rather +than a percentage someone picked. A fixed allowance is wrong in both directions and crosses over as +the incentive ramps: below break-even it lets a shortfall through to fail at the repay instead, above +it the router rejects fills that would have settled. There is no `SLIPPAGE_BPS`. A route is still +rejected when its quote is more than `MAX_ROUTE_IMPACT_BPS` below the oracle reference. API keys come +from `ZEROX_API_KEY` / ## Running Locally diff --git a/bots/midnight-liquidation/src/config.ts b/bots/midnight-liquidation/src/config.ts index 9a86e164..2efeb96a 100644 --- a/bots/midnight-liquidation/src/config.ts +++ b/bots/midnight-liquidation/src/config.ts @@ -83,7 +83,6 @@ const DEFAULT_POSITION_LIQUIDATION_COOLDOWN_MS = 0 // will touch, so it must be opted into explicitly per deployment rather than shipped in the default. const DEFAULT_MARKETS_API_URLS = ['https://api.morpho.org/v0/midnight/markets'] const DEFAULT_MARKETS_REFRESH_MS = 60_000 -const DEFAULT_SLIPPAGE_BPS = 100 const DEFAULT_PROBE_STALE_MS = 600_000 const DEFAULT_PROBE_HTTP_RPS = 1 const DEFAULT_PROBE_LADDER = ['0.01', '0.1', '1', '10', '100'] @@ -130,13 +129,13 @@ export type DiscoveryConfig = { } /** - * Enabled swap venues + global routing knobs. Venues are enabled by the PRESENCE of their API key in - * env (secrets themselves are read at the point of use in index.ts, never stored here). `slippageBps` - * is global now that routing is not per-collateral; `baseUrl` overrides are optional per-venue hosts. + * Enabled swap venues + their optional host overrides. Venues are enabled by the PRESENCE of their API + * key in env (secrets themselves are read at the point of use in index.ts, never stored here). There is + * no slippage knob: the quoting layer derives each venue's allowance from the liquidation's break-even + * output, so the min-out floor is economic rather than operator-chosen. */ export type VenueConfig = { enabled: Venue[] - slippageBps: number zeroxBaseUrl: string | undefined oneinchBaseUrl: string | undefined lifiBaseUrl: string | undefined @@ -434,7 +433,6 @@ export function loadConfig( } const venues: VenueConfig = { enabled: enabledVenues, - slippageBps: intEnv(env, 'SLIPPAGE_BPS', DEFAULT_SLIPPAGE_BPS, { min: 0, max: 10_000 }), zeroxBaseUrl, oneinchBaseUrl, lifiBaseUrl, diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index e67e3ad5..3704d283 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -201,7 +201,6 @@ async function main() { chainId: config.chainId, executor: config.executooorAddress, venues, - slippageBps: config.venues.slippageBps, baseUrls, maxRouteImpactBps: config.quoting.maxRouteImpactBps, unwrappers, diff --git a/bots/midnight-liquidation/src/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index 3a8f6ece..f3a33b99 100644 --- a/bots/midnight-liquidation/src/quotes.ts +++ b/bots/midnight-liquidation/src/quotes.ts @@ -25,7 +25,6 @@ export function composeQuoting(deps: { chainId: number executor: Address venues: readonly Venue[] - slippageBps: number baseUrls: Partial> maxRouteImpactBps: number unwrappers: readonly Unwrapper[] diff --git a/bots/midnight-liquidation/test/config.test.ts b/bots/midnight-liquidation/test/config.test.ts index 4af0e0fd..8e29617d 100644 --- a/bots/midnight-liquidation/test/config.test.ts +++ b/bots/midnight-liquidation/test/config.test.ts @@ -52,7 +52,6 @@ describe('loadConfig', () => { // Venue enablement is inferred from the present API key; global routing knobs take their defaults. expect(config.venues.enabled).toEqual(['0x']) - expect(config.venues.slippageBps).toBe(100) expect(config.venues.excludeCollaterals).toEqual([]) expect(config.venues.zeroxBaseUrl).toBeUndefined() @@ -239,11 +238,10 @@ describe('loadConfig', () => { ) }) - it('parses SLIPPAGE_BPS and rejects an out-of-range value', () => { - expect(loadConfig(baseEnv({ SLIPPAGE_BPS: '250' }), deps).venues.slippageBps).toBe(250) - expect(() => loadConfig(baseEnv({ SLIPPAGE_BPS: '20000' }), deps)).toThrow( - /SLIPPAGE_BPS must be <= 10000/ - ) + it('ignores a stale SLIPPAGE_BPS rather than failing loud on it', () => { + // The knob was removed when the min-out floor became break-even-derived. A deployment that still + // sets it must keep starting — an unknown env var is not a misconfiguration. + expect(() => loadConfig(baseEnv({ SLIPPAGE_BPS: '250' }), deps)).not.toThrow() }) it('parses EXCLUDE_COLLATERALS into checksummed addresses and rejects a malformed entry', () => { diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index 6ba96416..26d61e6f 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -111,7 +111,6 @@ function compose( chainId: 8453, executor: EXECUTOR, venues: overrides.venues ?? ['0x'], - slippageBps: 100, baseUrls: {}, maxRouteImpactBps: 500, unwrappers: [], diff --git a/packages/swaps/src/config.ts b/packages/swaps/src/config.ts index a5a57e77..e5e5fe55 100644 --- a/packages/swaps/src/config.ts +++ b/packages/swaps/src/config.ts @@ -16,7 +16,10 @@ import { z } from 'zod' // still the shape the venue adapters dispatch on, and `parseSwapConfig` still validates the JSON the // operator tooling (midnight's seed script) consumes. API keys NEVER live here — they come from env. -const slippageBps = z.number().int().min(0).max(10_000) +// Optional: the live quote path derives the min-out allowance from break-even +// (`QuoteRequest.minAcceptableAmountOut`), so no venue reads this. Retained so existing operator +// JSON keeps parsing. +const slippageBps = z.number().int().min(0).max(10_000).optional() const uniswapV3Venue = z .object({ diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index f60b0d73..36004a64 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -113,9 +113,8 @@ export type QuoteRequest = { * router reject fills that would have settled profitably. Deriving the allowance from break-even is * right at every point, and cannot be tuned wrong. * - * Omit it to keep the pre-existing `slippageBps` behavior exactly. */ - minAcceptableAmountOut?: bigint + minAcceptableAmountOut: bigint /** `collateralToken` decimals — required only for decimal-denominated venues (LiquidSwap). */ tokenInDecimals?: number /** The position's correlation id — threaded into log events only, never parsed. */ @@ -155,7 +154,6 @@ async function firmQuoteVenue(args: { chainId: number executor: Address venueEntry: () => SwapConfigEntry - slippageBps: number tokenIn: Address amountIn: bigint steps: SwapStep[] @@ -172,13 +170,13 @@ async function firmQuoteVenue(args: { // `reference · (1 - slippage)`, so the same percentage lands it on `minAcceptableAmountOut` exactly; // an aggregator lands slightly below it (its own quote is under the oracle by the execution cost), // which is the safe direction — it can never reject a fill that would have settled. + // A floor above the oracle reference would imply negative slippage; clamp rather than reject, since + // rounding can put break-even a unit over the reference on a dust seize. const economicFloor = - minAcceptableAmountOut !== undefined && minAcceptableAmountOut > referenceAmountOut - ? referenceAmountOut // a floor above the oracle reference would imply negative slippage - : minAcceptableAmountOut + minAcceptableAmountOut > referenceAmountOut ? referenceAmountOut : minAcceptableAmountOut const slippageBps = - economicFloor === undefined || referenceAmountOut <= 0n - ? args.slippageBps + referenceAmountOut <= 0n + ? 0 : Number(((referenceAmountOut - economicFloor) * BPS) / referenceAmountOut) const params: QuoteParameters = { @@ -192,7 +190,6 @@ async function firmQuoteVenue(args: { slippageBps, executor, referenceAmountOut, - minAcceptableAmountOut, // The request's decimals describe the RAW collateral; after an unwrap they would mislabel // the underlying, so they are only forwarded on the direct (no-unwrap) path. tokenInDecimals: steps.length === 0 ? request.tokenInDecimals : undefined @@ -343,8 +340,6 @@ export function composeMultiVenueQuoting(deps: { executor: Address /** Enabled venues, in deterministic default order (used when a pair has no cached probe yet). */ venues: readonly Venue[] - /** Global slippage (bps) applied to every venue — no per-collateral routing anymore. */ - slippageBps: number /** Optional per-venue API host overrides. */ baseUrls: Partial> maxRouteImpactBps: number @@ -364,7 +359,6 @@ export function composeMultiVenueQuoting(deps: { chainId, executor, venues, - slippageBps, baseUrls, maxRouteImpactBps, unwrappers, @@ -378,13 +372,13 @@ export function composeMultiVenueQuoting(deps: { function entryFor(venue: Venue): SwapConfigEntry { switch (venue) { case '0x': - return { venue: '0x', baseUrl: baseUrls['0x'], slippageBps } + return { venue: '0x', baseUrl: baseUrls['0x'] } case '1inch': - return { venue: '1inch', baseUrl: baseUrls['1inch'], slippageBps } + return { venue: '1inch', baseUrl: baseUrls['1inch'] } case 'lifi': - return { venue: 'lifi', baseUrl: baseUrls.lifi, slippageBps } + return { venue: 'lifi', baseUrl: baseUrls.lifi } case 'liquidswap': - return { venue: 'liquidswap', baseUrl: baseUrls.liquidswap, slippageBps } + return { venue: 'liquidswap', baseUrl: baseUrls.liquidswap } case 'uniswap-v3': throw new QuoteError('api_error', 'uniswap-v3 is not a multi-venue candidate') default: @@ -441,7 +435,6 @@ export function composeMultiVenueQuoting(deps: { chainId, executor, venueEntry: () => entryFor(venue), - slippageBps, tokenIn: resolution.token, amountIn: resolution.amountIn, steps: resolution.steps, diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index 0f380562..38e5d2f7 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -24,16 +24,16 @@ export type QuoteParameters = TokenInDecimals & { tokenIn: Address // seized collateral tokenOut: Address // loan token amountIn: bigint // the seized collateral the Executor will hold — exactly `plan.seizedAssets` (seize-exact) + /** + * Max output discount the venue may accept, in bps. **Derived**, not operator-set: the quoting layer + * computes it from the liquidation's break-even output (`QuoteRequest.minAcceptableAmountOut`) so the + * resulting floor is economic. A percentage is all the aggregators accept, which is why this — and + * not the absolute floor — is the shape a venue sees. + */ slippageBps: number executor: Address /** Oracle-priced expected output (no DEX slippage) — the no-route-quality reference. */ referenceAmountOut: bigint - /** - * Break-even output: the loan-token amount the protocol will pull to settle the repay. When set, it - * REPLACES `slippageBps` as the min-out floor — see {@link QuoteRequest.minAcceptableAmountOut} for - * why an economic floor beats an operator-chosen percentage. - */ - minAcceptableAmountOut?: bigint } /** diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index f2b5685a..86a3570c 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -26,7 +26,10 @@ const REQUEST: QuoteRequest = { collateralToken: COLLATERAL, loanToken: LOAN, amountIn: 1000n, - referenceAmountOut: 1000n + referenceAmountOut: 1000n, + // Break-even equal to the reference means zero derived slippage — the strictest floor, so a venue + // stub returning exactly the reference still passes and existing expectations are unchanged. + minAcceptableAmountOut: 1000n } // The plan's final step is the venue swap; its approvalSpender is the venue's approve target — the @@ -143,7 +146,6 @@ function composeMulti( chainId: 8453, executor: EXECUTOR, venues, - slippageBps: 50, baseUrls: {}, maxRouteImpactBps: 500, // floor = 950 unwrappers: options.unwrappers ?? [], @@ -347,7 +349,7 @@ describe('composeMultiVenueQuoting', () => { }) }) -describe('economic min-out floor (minAcceptableAmountOut)', () => { +describe('economic min-out floor', () => { // Captures the slippage each venue was asked for, which is the aggregators' ONLY min-out lever. const capturingHttp = (body: unknown) => { const calls: { searchParams?: Record }[] = [] @@ -367,12 +369,6 @@ describe('economic min-out floor (minAcceptableAmountOut)', () => { return { calls } } - it('leaves the operator slippage untouched when no floor is supplied', async () => { - const { calls } = await quoteWith(REQUEST, oneInchBody('1000')) - // composeMulti configures slippageBps: 50, sent as a percentage. - expect(calls[0]?.searchParams?.slippage).toBe('0.5') - }) - it('derives the allowance from break-even, replacing the operator percentage', async () => { // reference 1000, break-even 900 -> the route may give up 100/1000 = 1000bps = 10%. const { calls } = await quoteWith( From 54d090b6cf24a322ac1eac35262d380da8e8edb5 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 15:06:58 -0500 Subject: [PATCH 06/10] fix(bots): repair the min-out floor and its two false claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external review (gpt-5.6-sol) found three defects in the preceding commits. All three verified; all three were places a claim was made and the code did not deliver it. **1. The aggregator min-out sat below break-even — the whole point of the floor.** The percentage was derived against the oracle reference `R`, but aggregators apply it to their own quote `Q < R`, landing their min-out at `Q·F/R`. With `R=10000, F=9580, Q=9700` a 420bps request floors at 9292: a fill of 9342 clears the router and then reverts at the protocol's repay pull, which is the exact failure the floor exists to prevent. Calling that "the safe direction" conflated safety-against-false-rejection with an economic floor. `firmQuoteVenue` now re-derives against the venue's own quoted output when the first pass comes back short, so the floor lands where it belongs. One extra call, taken only when needed and only for the already-chosen venue; Uniswap applies the percentage to the reference itself and never takes the branch. A failed re-quote keeps the first quote rather than losing the position — it is still executable, just with a looser on-chain floor, and simulation plus the pre-broadcast check both still stand in front of it. The old tests asserted only the percentage sent, never the resulting min-out. That is why this escaped them, so the new ones assert the min-out. **2. Blue's break-even understated what Blue pulls.** It reused `expectedLoanOut`, which floors, where Blue ceils, and skipped the shares round-trip entirely. Blue settles the repay in shares — `toSharesUp` then `toAssetsUp` — and both conversions round up, so the estimate was short and the floor derived from it did not protect the repay. `LiquidationPlan` now carries `impliedRepaidAssets`, mirroring the contract chain step for step. Checked against `contractRepaidShares`, an independent reimplementation already in the test file for the underflow sweep, so the assertion is not circular. **3. Headroom was not scale-invariant, as claimed.** `seizedValueOf` floors while `impliedRepaidUnits` double-ceils, so the amount-wise ratio was neither exact nor monotone in size: at one LIF, 167 units reported 0bps where 168 reported 59, and 1000 units reported 50. It is now derived from `lif` directly — `(lif - WAD)·BPS/lif` — which is exactly scale-invariant by construction, since no amount enters it. Also from the review: `quote.unprofitable` measured its shortfall against break-even while viability compared against break-even plus the configured surplus, so a rejected route could report a NEGATIVE shortfall and omit the bar that rejected it. It now reports `requiredThreshold` and `minSurplusBps` and measures against the threshold. `plan.skipped/insufficient_headroom` gained the floor, `maxLif` and seconds-since-maturity; the realized headroom is deliberately absent, since a skip discards the plan and the chosen mode's LIF is not recoverable. `expectedLoanOut` narrows to `Pick` in both bots — it reads only that, and requiring the derived fields would force callers to synthesize them. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/execution/swap-step.ts | 2 +- bots/blue-liquidation/src/quotes.ts | 14 +--- bots/blue-liquidation/src/sizing/plan.ts | 65 +++++++++++++++- bots/blue-liquidation/test/quotes.test.ts | 4 +- .../blue-liquidation/test/sizing/plan.test.ts | 52 ++++++++++++- .../src/execution/swap-step.ts | 2 +- .../src/runner/profitability.ts | 13 +++- bots/midnight-liquidation/src/runner/tick.ts | 28 +++++-- bots/midnight-liquidation/src/sizing/plan.ts | 32 ++++---- .../test/runner/tick.test.ts | 30 +++++++- packages/swaps/src/quoting.ts | 57 +++++++++----- packages/swaps/test/quoting.test.ts | 77 +++++++++++++++++++ 12 files changed, 314 insertions(+), 62 deletions(-) diff --git a/bots/blue-liquidation/src/execution/swap-step.ts b/bots/blue-liquidation/src/execution/swap-step.ts index 2057ada5..21bbac96 100644 --- a/bots/blue-liquidation/src/execution/swap-step.ts +++ b/bots/blue-liquidation/src/execution/swap-step.ts @@ -12,6 +12,6 @@ import { mulDivDown } from '../sizing/math' * ORACLE_PRICE_SCALE`), with token decimals already baked in. This reference feeds Uniswap min-out * construction and aggregator route-quality checks. */ -export function expectedLoanOut(plan: LiquidationPlan, out: LensOut): bigint { +export function expectedLoanOut(plan: Pick, out: LensOut): bigint { return mulDivDown(plan.seizedAssets, out.collateralPrice, ORACLE_PRICE_SCALE) } diff --git a/bots/blue-liquidation/src/quotes.ts b/bots/blue-liquidation/src/quotes.ts index 9726a49f..2955464f 100644 --- a/bots/blue-liquidation/src/quotes.ts +++ b/bots/blue-liquidation/src/quotes.ts @@ -8,10 +8,7 @@ import { isAddressEqual } from 'viem' import type { LiquidationPlan } from './sizing/plan' import type { LensOut } from './state/lens.sol' -import { WAD } from './constants' import { expectedLoanOut } from './execution/swap-step' -import { lifFromLltv } from './sizing/lif' -import { mulDivUp } from './sizing/math' /** * The Blue-shaped adapter over `@repo/swaps`' {@link composeMultiVenueQuoting}: keeps the @@ -59,14 +56,9 @@ export function composeQuoting(deps: { loanToken: out.params.loanToken, amountIn: plan.seizedAssets, referenceAmountOut: expectedLoanOut(plan, out), - // Break-even: the loan assets Blue will pull for this seize. Derived here rather than carried - // on the plan because Blue's LIF is a pure function of the market's `lltv` — no maturity ramp - // and no mode choice — so there is nothing about it a plan could know that the market doesn't. - minAcceptableAmountOut: mulDivUp( - expectedLoanOut(plan, out), - WAD, - lifFromLltv(out.params.lltv) - ), + // Break-even, straight off the plan: the loan assets `liquidate` will pull for this seize, + // including the shares round-trip Blue settles through. + minAcceptableAmountOut: plan.impliedRepaidAssets, // The tick's position label (`${id}:${borrower}`) — the correlation id join across quote logs. id: label }) diff --git a/bots/blue-liquidation/src/sizing/plan.ts b/bots/blue-liquidation/src/sizing/plan.ts index 7b88fcfa..5e672664 100644 --- a/bots/blue-liquidation/src/sizing/plan.ts +++ b/bots/blue-liquidation/src/sizing/plan.ts @@ -1,6 +1,47 @@ import { ORACLE_PRICE_SCALE } from '../constants' import { lifFromLltv } from './lif' -import { min, mulDivDown, toAssetsDown, wMulDown } from './math' +import { + min, + mulDivDown, + mulDivUp, + toAssetsDown, + toAssetsUp, + toSharesUp, + wDivUp, + wMulDown +} from './math' + +/** + * The loan assets `liquidate` pulls from the liquidator for a given seize — the swap's break-even + * output. Mirrors Morpho Blue's own chain, every step of which rounds UP against the liquidator: + * + * ```text + * quoted = seizedAssets.mulDivUp(collateralPrice, ORACLE_PRICE_SCALE) + * shares = quoted.wDivUp(lif).toSharesUp(totalBorrowAssets, totalBorrowShares) + * assets = shares.toAssetsUp(totalBorrowAssets, totalBorrowShares) + * ``` + * + * The shares round-trip is not decorative: Blue settles the repay in shares, so an assets-only + * estimate is short by the two rounding steps and understates what the callback must produce. + * + * Module-private: every sized plan carries its own value as + * {@link LiquidationPlan.impliedRepaidAssets}, which is the surface callers want. + */ +const impliedRepaidAssets = (args: { + seizedAssets: bigint + collateralPrice: bigint + lltv: bigint + accruedTotalBorrowAssets: bigint + totalBorrowShares: bigint +}): bigint => { + const quoted = mulDivUp(args.seizedAssets, args.collateralPrice, ORACLE_PRICE_SCALE) + const shares = toSharesUp( + wDivUp(quoted, lifFromLltv(args.lltv)), + args.accruedTotalBorrowAssets, + args.totalBorrowShares + ) + return toAssetsUp(shares, args.accruedTotalBorrowAssets, args.totalBorrowShares) +} /** * The fresh, lens-derived inputs the sizing decision depends on. All fields come from one `eth_call` @@ -22,7 +63,16 @@ export type PlanInput = { } /** A seize-exact plan: pin `seizedAssets` and pass `repaidShares = 0`, letting Blue ceil-derive it. */ -export type LiquidationPlan = { seizedAssets: bigint } +export type LiquidationPlan = { + seizedAssets: bigint + /** + * The loan assets Blue will pull for `seizedAssets` — the swap's break-even output. Mirrors + * `liquidate`'s own derivation, including the shares round-trip: Blue converts the quoted seize to + * shares and back, and BOTH conversions round up, so an assets-only estimate understates what is + * actually transferred. + */ + impliedRepaidAssets: bigint +} /** * Turns a fresh lens reading into a seize-exact liquidation plan, or `null` when the position is not @@ -67,5 +117,14 @@ export function plan(input: PlanInput): LiquidationPlan | null { const seizedAssets = min(input.collateral, seizeForFullDebt) // Rounds to nothing (dust position, or price ≫ debt): can't pass 0 to `liquidate`, so skip it. if (seizedAssets === 0n) return null - return { seizedAssets } + return { + seizedAssets, + impliedRepaidAssets: impliedRepaidAssets({ + seizedAssets, + collateralPrice: input.collateralPrice, + lltv: input.lltv, + accruedTotalBorrowAssets: input.accruedTotalBorrowAssets, + totalBorrowShares: input.totalBorrowShares + }) + } } diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index abb71b00..f2302bd7 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -33,7 +33,9 @@ const PARAMS: MarketParams = { } // price = 1e36 → expectedLoanOut = seizedAssets = 1000 (the route-quality reference). -const PLAN: LiquidationPlan = { seizedAssets: 1000n } +// break-even at lif 1.0 with no shares skew — the strictest floor, so the first quote pass +// already clears it and these projection cases spend no second call. +const PLAN: LiquidationPlan = { seizedAssets: 1000n, impliedRepaidAssets: 1000n } const OUT: LensOut = { params: PARAMS, diff --git a/bots/blue-liquidation/test/sizing/plan.test.ts b/bots/blue-liquidation/test/sizing/plan.test.ts index 50d797a4..ae3a30b2 100644 --- a/bots/blue-liquidation/test/sizing/plan.test.ts +++ b/bots/blue-liquidation/test/sizing/plan.test.ts @@ -10,7 +10,8 @@ import { toSharesUp, wDivUp, wMulDown, - mulDivUp + mulDivUp, + toAssetsUp } from '../../src/sizing/math' import { plan } from '../../src/sizing/plan' @@ -141,3 +142,52 @@ describe('plan — repaidShares ≤ borrowShares (no on-chain underflow)', () => expect(checked).toBeGreaterThan(100) }) }) + +describe('impliedRepaidAssets', () => { + // Checked against `contractRepaidShares` above — an independent reimplementation of Blue's round-up + // chain, written for the underflow sweep before this field existed, so this is not circular. + it('matches what liquidate pulls, across share prices and sizes', () => { + const cases: Partial[] = [ + {}, + { accruedTotalBorrowAssets: 1_000_000n * WAD, totalBorrowShares: 999_999n * WAD }, + { accruedTotalBorrowAssets: 7_777_777n, totalBorrowShares: 3_333_331n }, + { collateralPrice: (ORACLE_PRICE_SCALE * 3n) / 7n }, + { collateralPrice: ORACLE_PRICE_SCALE * 41n, collateral: 5n * WAD }, + { borrowShares: 1n, collateral: 1n } + ] + let checked = 0 + for (const overrides of cases) { + const input = baseInput(overrides) + const built = plan(input) + if (!built) continue + checked += 1 + const expected = toAssetsUp( + contractRepaidShares(input, built.seizedAssets), + input.accruedTotalBorrowAssets, + input.totalBorrowShares + ) + expect(built.impliedRepaidAssets).toBe(expected) + } + expect(checked).toBeGreaterThan(3) + }) + + it('is not the assets-only estimate, which understates the repay', () => { + // The bug this field replaced: flooring the quoted value and skipping the shares round-trip. Both + // errors round the wrong way, so the estimate came in UNDER what Blue actually pulls — a min-out + // floor derived from it does not protect the repay. + const input = baseInput({ + collateralPrice: (ORACLE_PRICE_SCALE * 3n) / 7n, + accruedTotalBorrowAssets: 7_777_777n, + totalBorrowShares: 3_333_331n + }) + const built = plan(input) + expect(built).not.toBeNull() + if (!built) return + const assetsOnly = mulDivUp( + mulDivDown(built.seizedAssets, input.collateralPrice, ORACLE_PRICE_SCALE), + WAD, + lifFromLltv(input.lltv) + ) + expect(built.impliedRepaidAssets).toBeGreaterThan(assetsOnly) + }) +}) diff --git a/bots/midnight-liquidation/src/execution/swap-step.ts b/bots/midnight-liquidation/src/execution/swap-step.ts index b31d0308..f989bfa2 100644 --- a/bots/midnight-liquidation/src/execution/swap-step.ts +++ b/bots/midnight-liquidation/src/execution/swap-step.ts @@ -16,6 +16,6 @@ import { mulDivDown } from '../sizing/math' * token's native units. This is the venue-agnostic reference output: a Uniswap min-out is derived * from it, and an aggregator's quoted output is sanity-checked against it. */ -export function expectedLoanOut(plan: LiquidationPlan, out: LensOut): bigint { +export function expectedLoanOut(plan: Pick, out: LensOut): bigint { return mulDivDown(plan.seizedAssets, out.bestCollateralPrice, ORACLE_PRICE_SCALE) } diff --git a/bots/midnight-liquidation/src/runner/profitability.ts b/bots/midnight-liquidation/src/runner/profitability.ts index 19abddfb..f17c27d7 100644 --- a/bots/midnight-liquidation/src/runner/profitability.ts +++ b/bots/midnight-liquidation/src/runner/profitability.ts @@ -11,7 +11,14 @@ type ProfitabilityAssessment = { requiredRepay: bigint /** Loan units the quoted route is expected to deliver. */ achievableOut: bigint - /** How far the route falls short of `requiredRepay`, in bps of it. Negative when profitable. */ + /** `requiredRepay` plus the configured surplus — the value `viable` actually compares against. */ + requiredThreshold: bigint + /** + * How far the route falls short of {@link ProfitabilityAssessment.requiredThreshold}, in bps of + * `requiredRepay`. Measured against the threshold, not against break-even, so a rejected route never + * reports a non-positive shortfall: with `minSurplusBps > 0` a route can clear the repay and still + * miss the bar, and a "shortfall" of -12 bps on a rejection is not diagnosable. + */ shortfallBps: bigint } @@ -56,7 +63,7 @@ export const assessProfitability = ({ viable: achievableOut >= threshold, requiredRepay, achievableOut, - shortfallBps: - requiredRepay === 0n ? 0n : ((requiredRepay - achievableOut) * BPS) / requiredRepay + requiredThreshold: threshold, + shortfallBps: requiredRepay === 0n ? 0n : ((threshold - achievableOut) * BPS) / requiredRepay } } diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index d6c595ef..40148fb9 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -93,9 +93,9 @@ const LEVEL_BY_REASON: Record = { // Unliquidatable in normal mode rather than transient: it clears only when the oracle moves or the // position matures into post-maturity mode, where the RCF cap does not apply. writeoff_below_max_debt: 'info', - // Group property, not a per-position one: headroom is `(lif - 1)/lif`, so every candidate sharing a - // (maturity, maxLif, chosen mode) group is skipped together. At `info` that is one line per position - // per block — the shape that gave the 31 Jul post-mortem hundreds of identical warnings. + // A rate, not a per-position quantity: headroom is `(lif - 1)/lif`, so every candidate sharing a + // (maturity, maxLif, chosen mode) group evaluates identically. At `info` that is one line per + // position per block — the shape that gave the 31 Jul post-mortem hundreds of identical warnings. insufficient_headroom: 'debug' } @@ -165,10 +165,23 @@ const sizeCandidates = (deps: { const outcome = planWithReason(input, { seizeCapMarginBps, headroomFloorBps }) if (outcome.plan === null) { counters.planSkipped += 1 + // `insufficient_headroom` is a threshold decision, so it carries the inputs behind it — the + // reason alone says a position was skipped without saying whether the floor is mis-set or the + // ramp is simply early. The realized headroom itself is NOT here: it is `(lif - 1)/lif` of the + // CHOSEN plan, and a skip discards the plan, so the tick cannot recover which mode won. `maxLif` + // plus `secondsSinceMaturity` lets an operator reconstruct the post-maturity case, which is the + // one this gate fires in. logger[LEVEL_BY_REASON[outcome.reason]]('plan.skipped', { marketId: pair.id, borrower: pair.borrower, - reason: outcome.reason + reason: outcome.reason, + ...(outcome.reason === 'insufficient_headroom' + ? { + headroomFloorBps, + maxLif: out.bestCollateralMaxLif, + secondsSinceMaturity: out.blockTimestamp - out.market.maturity + } + : {}) }) continue } @@ -401,12 +414,17 @@ export async function runTick(deps: { // by the pre-quote headroom gate in sizing, not by suppressing a position that may be one // block of LIF ramp away from being fundable. counters.quoteUnprofitable += 1 + // `requiredThreshold` and `minSurplusBps` are both here on purpose: with a nonzero buffer + // the route can clear `requiredRepay` and still be rejected, and an operator cannot tell + // which rule fired without seeing the bar that was applied. logger.info('quote.unprofitable', { marketId: pair.id, borrower: pair.borrower, requiredRepay: economics.requiredRepay, + requiredThreshold: economics.requiredThreshold, achievableOut: economics.achievableOut, - shortfallBps: economics.shortfallBps + shortfallBps: economics.shortfallBps, + minSurplusBps }) continue } diff --git a/bots/midnight-liquidation/src/sizing/plan.ts b/bots/midnight-liquidation/src/sizing/plan.ts index 2165e768..952dc936 100644 --- a/bots/midnight-liquidation/src/sizing/plan.ts +++ b/bots/midnight-liquidation/src/sizing/plan.ts @@ -297,18 +297,20 @@ export const planSurplus = (input: PlanInput, chosen: LiquidationPlan): bigint = seizedValueOf(chosen.seizedAssets, input.bestCollateralPrice) - chosen.impliedRepaidUnits /** - * Incentive headroom of a sized plan, in bps: `surplus / seizedValue`, which for a seize-exact plan - * reduces to `(lif - 1) / lif` and is therefore **scale-invariant** — a $10k and a $1 plan at the same - * LIF read identically. That is why this cannot reject dust; only an absolute floor can. + * Incentive headroom of a sized plan, in bps: `(lif - 1) / lif`. * - * Returns `0n` when the seized slot's oracle value floors to zero, so a valueless plan fails any - * non-zero floor rather than dividing by zero. + * Computed from the LIF, NOT from the plan's amounts. The amount-wise ratio + * `(seizedValue - impliedRepaidUnits) / seizedValue` is the same quantity in exact arithmetic, but the + * implementation cannot be: `seizedValueOf` floors while `impliedRepaidUnits` double-ceils, so at + * sub-dollar sizes the two roundings disagree and the ratio is neither exact nor monotone in size — + * 167 units reported 0 bps where 168 reported 59, at one LIF. Deriving from `lif` makes this + * **exactly scale-invariant** by construction: no amount enters it, so two candidates at the same LIF + * cannot disagree. + * + * Being a rate, it is blind to position size — it cannot reject dust, whose surplus is real but + * smaller than the gas to collect it. That needs an absolute floor (BOTS-81), not this. */ -const headroomBps = (input: PlanInput, chosen: LiquidationPlan): bigint => { - const seizedValue = seizedValueOf(chosen.seizedAssets, input.bestCollateralPrice) - if (seizedValue <= 0n) return 0n - return ((seizedValue - chosen.impliedRepaidUnits) * BPS) / seizedValue -} +const headroomBps = (chosen: LiquidationPlan): bigint => ((chosen.lif - WAD) * BPS) / chosen.lif /** * Turns a fresh lens reading into a liquidation plan, or a {@link PlanSkipReason} when the position @@ -368,7 +370,7 @@ export const planWithReason = (input: PlanInput, options: PlanOptions = {}): Pla // seize of nothing is the `(0, 0)` shape reserved for bad-debt realization. if (input.bestCollateralAmt === 0n) return skip('nothing_to_seize') - return gateOnHeadroom(input, selectMode(input, seizeCapMarginBps), headroomFloorBps) + return gateOnHeadroom(selectMode(input, seizeCapMarginBps), headroomFloorBps) } // The mode policy of `liquidate(...)` — see {@link planWithReason}'s JSDoc. Split out so the headroom @@ -392,13 +394,9 @@ const selectMode = (input: PlanInput, seizeCapMarginBps: number): PlanOutcome => // Rejects a sized plan whose incentive headroom cannot cover the operator's floor on execution cost. // Reads the CHOSEN plan's own `lif`, so it cannot disagree with the mode `selectMode` picked. -const gateOnHeadroom = ( - input: PlanInput, - outcome: PlanOutcome, - headroomFloorBps: number -): PlanOutcome => { +const gateOnHeadroom = (outcome: PlanOutcome, headroomFloorBps: number): PlanOutcome => { if (headroomFloorBps <= 0 || outcome.plan === null) return outcome - if (headroomBps(input, outcome.plan) >= BigInt(headroomFloorBps)) return outcome + if (headroomBps(outcome.plan) >= BigInt(headroomFloorBps)) return outcome return skip('insufficient_headroom') } diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index bf836cb6..d0d640cc 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -532,7 +532,15 @@ describe('runTick', () => { // debug, not info: headroom is a group property, so this fires identically for every candidate // in the group — one line per position per block is the shape that buried the 31 Jul post-mortem. expect(skipped?.level).toBe('debug') - expect(skipped?.fields).toMatchObject({ reason: 'insufficient_headroom' }) + expect(skipped?.fields).toMatchObject({ + reason: 'insufficient_headroom', + // The inputs behind the decision: without these an operator cannot tell a mis-set floor from + // an early ramp. The realized headroom is absent by design — a skip discards the plan, so the + // chosen mode's LIF is not recoverable here. + headroomFloorBps: 3, + maxLif: MAX_LIF, + secondsSinceMaturity: 20n + }) expect(backoff.shouldSkip(LABEL, 100n)).toBe(false) expect(cooldown.shouldSkip(LABEL)).toBe(false) expectCounterIdentities(counters) @@ -634,6 +642,26 @@ describe('runTick', () => { expect(withSurplus.simulateCalls()).toBe(0) }) + it('reports the threshold it applied, so a rejection above break-even is diagnosable', async () => { + // With a surplus required, a route can clear `requiredRepay` and still be rejected. Measuring the + // shortfall against break-even would then report a NEGATIVE shortfall on a rejection, which tells + // an operator nothing; it is measured against the threshold that actually fired. + const { events, counters } = await runWith({ + quoteOutcome: quoting(REQUIRED_REPAY), + minSurplusBps: 100 + }) + expect(counters).toMatchObject({ quoteUnprofitable: 1, ok: 0 }) + const skipped = events.find(e => e.event === 'quote.unprofitable') + expect(skipped?.fields).toMatchObject({ + requiredRepay: REQUIRED_REPAY, + achievableOut: REQUIRED_REPAY, + minSurplusBps: 100 + }) + // The bar that fired is above break-even, and the shortfall is positive against it. + expect(skipped?.fields?.requiredThreshold as bigint).toBeGreaterThan(REQUIRED_REPAY) + expect(skipped?.fields?.shortfallBps as bigint).toBeGreaterThan(0n) + }) + it('uses the LIF the plan was sized at, not the one chain time implies', async () => { // Matured AND unhealthy opens both on-chain gates, and sizing picks by surplus: one second past // maturity the post-maturity ramp is still ~WAD, so normal mode wins with the full maxLif and a diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index 36004a64..89ab41a8 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -89,6 +89,19 @@ export function passesRouteQuality(args: { return args.expected >= floor } +/** + * The slippage percentage (bps) that puts a venue's min-out at `floor`, given that the venue applies + * the percentage to `denominator`. + * + * Which denominator is correct depends on the venue: Uniswap applies the percentage to the oracle + * reference we hand it, while the aggregators apply it to their own quoted output. Passing the wrong + * one silently lands the floor below break-even — see the second pass in `firmQuoteVenue`. + */ +const slippageForFloor = (floor: bigint, denominator: bigint): number => + denominator <= 0n || floor >= denominator + ? 0 + : Number(((denominator - floor) * BPS) / denominator) + /** * One liquidatable position's swap request, already projected out of the protocol's lens shape by * the calling bot (which knows how its markets address collateral). @@ -164,22 +177,12 @@ async function firmQuoteVenue(args: { const { tokenIn, amountIn, steps, request, maxRouteImpactBps } = args const { loanToken, referenceAmountOut, minAcceptableAmountOut } = request - // An economic floor beats the operator percentage when one is supplied. Expressed as a percentage of - // the oracle reference rather than as an absolute, because the aggregators' only lever IS a slippage - // parameter — they bake the min-out themselves from it. Uniswap derives its min-out as - // `reference · (1 - slippage)`, so the same percentage lands it on `minAcceptableAmountOut` exactly; - // an aggregator lands slightly below it (its own quote is under the oracle by the execution cost), - // which is the safe direction — it can never reject a fill that would have settled. // A floor above the oracle reference would imply negative slippage; clamp rather than reject, since // rounding can put break-even a unit over the reference on a dust seize. const economicFloor = minAcceptableAmountOut > referenceAmountOut ? referenceAmountOut : minAcceptableAmountOut - const slippageBps = - referenceAmountOut <= 0n - ? 0 - : Number(((referenceAmountOut - economicFloor) * BPS) / referenceAmountOut) - const params: QuoteParameters = { + const paramsFor = (denominator: bigint): QuoteParameters => ({ chainId, tokenIn, tokenOut: loanToken, @@ -187,20 +190,38 @@ async function firmQuoteVenue(args: { // before the callback. After unwraps it is the chain's worst-case output — a fixed-amount // venue can only leave skimmable surplus, never revert on shortfall. amountIn, - slippageBps, + slippageBps: slippageForFloor(economicFloor, denominator), executor, referenceAmountOut, // The request's decimals describe the RAW collateral; after an unwrap they would mislabel // the underlying, so they are only forwarded on the direct (no-unwrap) path. tokenInDecimals: steps.length === 0 ? request.tokenInDecimals : undefined + }) + + const quote = async (params: QuoteParameters) => + tryCatch((async () => quoteByVenue(httpClient, venueEntry(), params))()) + + const first = await quote(paramsFor(referenceAmountOut)) + if (first.error || !first.data) { + const reason = first.error instanceof QuoteError ? first.error.reason : 'api_error' + return { kind: 'quote_failed', reason, detail: ensureError(first.error).message } } - const { data: swap, error } = await tryCatch( - (async () => quoteByVenue(httpClient, venueEntry(), params))() - ) - if (error || !swap) { - const reason = error instanceof QuoteError ? error.reason : 'api_error' - return { kind: 'quote_failed', reason, detail: ensureError(error).message } + // Second pass, and the reason it exists: the aggregators apply the slippage percentage to THEIR OWN + // quote, which sits under the oracle reference by the execution cost. A percentage derived against + // the reference therefore lands their min-out at `quote · floor / reference` — strictly BELOW + // break-even — so a drifted fill would clear the router and then revert at the protocol's repay + // pull, which is exactly the misleading failure this floor exists to prevent. Re-deriving against + // the venue's own quoted output puts the floor where it belongs. Uniswap already lands at or above + // break-even on the first pass (it applies the percentage to the reference itself), so it never + // takes this branch. + let swap = first.data + if (swap.amountOutMinimum < economicFloor && swap.expectedAmountOut > 0n) { + const second = await quote(paramsFor(swap.expectedAmountOut)) + // A failed re-quote is not fatal: the first quote is still executable, just with a looser on-chain + // floor than break-even. Simulation and the caller's pre-broadcast profitability check both still + // stand between it and a broadcast. + if (second.data) swap = second.data } // The reference stays the FULL-PATH oracle value (collateral → loan): the unwrap chain threads its diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 86a3570c..85aad74f 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -415,3 +415,80 @@ describe('economic min-out floor', () => { expect(late.calls[0]?.searchParams?.slippage).toBe('4.2') }) }) + +// The defect these cover: the aggregators apply the slippage percentage to THEIR OWN quote, which sits +// under the oracle reference by the execution cost. A percentage derived against the reference lands +// their min-out BELOW break-even, so a drifted fill clears the router and then reverts at the +// protocol's repay pull — the exact failure the floor exists to prevent. The earlier tests asserted +// only the percentage sent, which is why this escaped them. +describe('aggregator min-out actually clears break-even', () => { + const countingHttp = (bodies: unknown[]) => { + const sent: (Record | undefined)[] = [] + const client: RateLimitedClient = { + getJson: async (args: { searchParams?: Record }) => { + sent.push(args.searchParams) + return (bodies[sent.length - 1] ?? bodies.at(-1)) as T + } + } + return { client, sent } + } + + // reference 10_000, break-even 9_580 (the incident tier at full ramp), venue quote 9_700. + const REFERENCE = 10_000n + const FLOOR = 9_580n + const QUOTE = '9700' + + it('re-derives against the venue quote so the floor is not undercut', async () => { + const { client, sent } = countingHttp([oneInchBody(QUOTE), oneInchBody(QUOTE)]) + const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 9700n }], client) + const outcome = await quoteFor({ + ...REQUEST, + referenceAmountOut: REFERENCE, + minAcceptableAmountOut: FLOOR + }) + + expect(outcome.kind).toBe('swap') + if (outcome.kind !== 'swap') return + // First pass asks 420bps against the reference; applied to 9700 that would floor at ~9292 — 288 + // units BELOW break-even. The second pass asks against the quote instead. + expect(sent[0]?.slippage).toBe('4.2') + expect(sent[1]?.slippage).toBe('1.23') + expect(outcome.plan.amountOutMinimum).toBeGreaterThanOrEqual(FLOOR) + }) + + it('spends the second call only when the first floor is short', async () => { + // Break-even equal to the reference asks for zero slippage, so the first min-out already clears it. + const { client, sent } = countingHttp([oneInchBody('10000')]) + const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 10_000n }], client) + const outcome = await quoteFor({ + ...REQUEST, + referenceAmountOut: REFERENCE, + minAcceptableAmountOut: REFERENCE + }) + + expect(outcome.kind).toBe('swap') + expect(sent).toHaveLength(1) + }) + + it('keeps the first quote when the re-quote fails rather than losing the position', async () => { + let call = 0 + const client: RateLimitedClient = { + getJson: async () => { + call += 1 + if (call === 2) throw new QuoteError('rate_limited', 'second pass throttled') + return oneInchBody(QUOTE) as T + } + } + const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 9700n }], client) + const outcome = await quoteFor({ + ...REQUEST, + referenceAmountOut: REFERENCE, + minAcceptableAmountOut: FLOOR + }) + + // Executable, just with a looser on-chain floor than break-even. Simulation and the caller's + // pre-broadcast profitability check both still stand between it and a broadcast. + expect(call).toBe(2) + expect(outcome.kind).toBe('swap') + }) +}) From 1e1c3ac9de110ea3551ac739b9118b82f306c05e Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 15:24:34 -0500 Subject: [PATCH 07/10] fix(swaps): enforce the min-out floor as a postcondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round found the retry did not actually guarantee the floor. Three ways it could still ship a quote whose encoded minimum sits under break-even: - The retry derives its percentage from the FIRST quote's output, so a second quote that comes back lower lands under the floor again. Floor 9580, first quote 9700 -> ask 123bps, second quote 9600 -> minimum 9481, which is 99 units short. The previous test held both responses at 9700, so it never exercised drift. - A failed retry knowingly kept the first, under-floor quote. - 1inch returns opaque calldata and we RECONSTRUCT its minimum arithmetically, so comparing that value against the floor compares our arithmetic with itself and always agrees. The retry is now only an attempt to satisfy a postcondition, and the postcondition is the guarantee: after the final response, a quote whose encoded minimum is not known to clear break-even is refused, and the caller falls through to the next venue with `quote.floor_unmet`. Refusing is correct — a floor that does not protect the repay is the original defect, and simulation does not restore the invariant, since it proves one state rather than preventing a later fill between the router minimum and the protocol pull. `Swap` gains `minOutSource: 'venue' | 'derived'` so this is decidable at all. uniswap-v3 encodes the calldata here; 0x, LiFi and LiquidSwap report their own minimum; 1inch does not, so it can never satisfy an enforced floor until its `minReturn` parameter is wired. It is not enabled in prod (no ONEINCH_API_KEY) and it still falls through rather than failing the tick, but an operator enabling it will see every attempt refused with `minOutSource: 'derived'` in the event. Also: the claim that a skip's realized headroom was unrecoverable was wrong. `gateOnHeadroom` holds the chosen plan when it rejects it; the value was being discarded by `PlanOutcome`'s shape, not lost. The skip now carries `{ bps, lif, postMaturityMode }`, and `plan.skipped` logs all three. `maxLif` plus chain time cannot substitute: a matured-and-unhealthy position may be sized in either mode, so neither identifies the LIF that was applied. Test fixtures across three packages had break-even set at or above their stubs' reported minimums, which the postcondition correctly refuses. They now sit below it, so those cases exercise what they are about — lens projection and venue routing — rather than the floor. The 0x stub in the floor tests now applies the requested slippage to its own quote, which is what the real API does and what a fixed-minimum stub could not model. Co-Authored-By: Claude Opus 5 (1M context) --- bots/blue-liquidation/test/quotes.test.ts | 4 +- bots/midnight-liquidation/src/runner/tick.ts | 16 +-- bots/midnight-liquidation/src/sizing/plan.ts | 27 +++- bots/midnight-liquidation/test/quotes.test.ts | 6 +- .../test/runner/tick.test.ts | 12 +- .../test/sizing/plan.test.ts | 11 +- packages/swaps/src/quoting.ts | 50 +++++-- packages/swaps/src/types.ts | 10 ++ packages/swaps/src/venues/lifi.ts | 3 +- packages/swaps/src/venues/liquidswap.ts | 3 +- packages/swaps/src/venues/oneinch.ts | 7 +- packages/swaps/src/venues/uniswap-v3.ts | 4 +- packages/swaps/src/venues/zerox.ts | 3 +- packages/swaps/test/quoting.test.ts | 124 +++++++++++------- 14 files changed, 195 insertions(+), 85 deletions(-) diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index f2302bd7..efdbd41b 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -35,7 +35,9 @@ const PARAMS: MarketParams = { // price = 1e36 → expectedLoanOut = seizedAssets = 1000 (the route-quality reference). // break-even at lif 1.0 with no shares skew — the strictest floor, so the first quote pass // already clears it and these projection cases spend no second call. -const PLAN: LiquidationPlan = { seizedAssets: 1000n, impliedRepaidAssets: 1000n } +// Break-even at 800, under the 0x stub's reported min-out of 995, so these cases exercise the lens +// projection rather than the economic floor (which is covered in @repo/swaps). +const PLAN: LiquidationPlan = { seizedAssets: 1000n, impliedRepaidAssets: 800n } const OUT: LensOut = { params: PARAMS, diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index 40148fb9..b179a3ec 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -165,20 +165,20 @@ const sizeCandidates = (deps: { const outcome = planWithReason(input, { seizeCapMarginBps, headroomFloorBps }) if (outcome.plan === null) { counters.planSkipped += 1 - // `insufficient_headroom` is a threshold decision, so it carries the inputs behind it — the - // reason alone says a position was skipped without saying whether the floor is mis-set or the - // ramp is simply early. The realized headroom itself is NOT here: it is `(lif - 1)/lif` of the - // CHOSEN plan, and a skip discards the plan, so the tick cannot recover which mode won. `maxLif` - // plus `secondsSinceMaturity` lets an operator reconstruct the post-maturity case, which is the - // one this gate fires in. + // A threshold decision carries the numbers behind it, including the LIF and mode actually + // chosen: `maxLif` and chain time do NOT identify them, because a matured-and-unhealthy position + // may be sized in either mode. Without these an operator cannot tell a mis-set floor from an + // early ramp, nor which mode the sizer picked. logger[LEVEL_BY_REASON[outcome.reason]]('plan.skipped', { marketId: pair.id, borrower: pair.borrower, reason: outcome.reason, - ...(outcome.reason === 'insufficient_headroom' + ...(outcome.headroom ? { + headroomBps: outcome.headroom.bps, headroomFloorBps, - maxLif: out.bestCollateralMaxLif, + lif: outcome.headroom.lif, + postMaturityMode: outcome.headroom.postMaturityMode, secondsSinceMaturity: out.blockTimestamp - out.market.maturity } : {}) diff --git a/bots/midnight-liquidation/src/sizing/plan.ts b/bots/midnight-liquidation/src/sizing/plan.ts index 952dc936..6ca86de5 100644 --- a/bots/midnight-liquidation/src/sizing/plan.ts +++ b/bots/midnight-liquidation/src/sizing/plan.ts @@ -87,8 +87,16 @@ export type PlanSkipReason = * so suppressing a skipped position would delay re-evaluating it precisely when it becomes viable. */ type PlanOutcome = - | { plan: LiquidationPlan; reason?: undefined } - | { plan: null; reason: PlanSkipReason } + | { plan: LiquidationPlan; reason?: undefined; headroom?: undefined } + | { plan: null; reason: PlanSkipReason; headroom?: SkippedHeadroom } + +/** + * The numbers behind an `insufficient_headroom` skip. Carried on the outcome because the gate holds the + * chosen plan when it rejects it, and a caller cannot reconstruct these afterwards: a + * matured-and-unhealthy position may be sized in EITHER mode, so neither `maxLif` nor chain time + * identifies the LIF that was actually applied. + */ +type SkippedHeadroom = { bps: bigint; lif: bigint; postMaturityMode: boolean } /** * Operator sizing knobs that are NOT lens-derived (so they live here, not on `PlanInput`). Sourced @@ -115,7 +123,11 @@ type PlanOptions = { headroomFloorBps?: number } -const skip = (reason: PlanSkipReason): PlanOutcome => ({ plan: null, reason }) +const skip = (reason: PlanSkipReason, headroom?: SkippedHeadroom): PlanOutcome => ({ + plan: null, + reason, + headroom +}) const sized = (plan: LiquidationPlan): PlanOutcome => ({ plan }) /** @@ -396,8 +408,13 @@ const selectMode = (input: PlanInput, seizeCapMarginBps: number): PlanOutcome => // Reads the CHOSEN plan's own `lif`, so it cannot disagree with the mode `selectMode` picked. const gateOnHeadroom = (outcome: PlanOutcome, headroomFloorBps: number): PlanOutcome => { if (headroomFloorBps <= 0 || outcome.plan === null) return outcome - if (headroomBps(outcome.plan) >= BigInt(headroomFloorBps)) return outcome - return skip('insufficient_headroom') + const bps = headroomBps(outcome.plan) + if (bps >= BigInt(headroomFloorBps)) return outcome + return skip('insufficient_headroom', { + bps, + lif: outcome.plan.lif, + postMaturityMode: outcome.plan.postMaturityMode + }) } /** diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index 26d61e6f..6b3383d9 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -51,8 +51,10 @@ const PLAN: LiquidationPlan = { seizedAssets: 1000n, repaidUnits: 900n, postMaturityMode: false, - lif: WAD, - impliedRepaidUnits: 1000n + // lif 1.25 puts break-even at exactly 800, under the 0x stub's reported min-out of 995, so these + // projection cases exercise the lens mapping rather than the economic floor. + lif: (WAD * 5n) / 4n, + impliedRepaidUnits: 800n } const OUT: LensOut = { diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index d0d640cc..bbe04b2e 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -52,6 +52,7 @@ const ZERO = '0x0000000000000000000000000000000000000000' as const const MARKET: Hex = `0x${'a'.repeat(64)}` const LABEL = lensKey(MARKET, BORROWER) // 3.63% incentive → a 349bps headroom ceiling; the ramp reaches 3bps about 30s past maturity. +const WAD_ONE = 10n ** 18n const MAX_LIF = 1036269430051813471n const SWAP_PLAN: SwapPlan = { @@ -534,13 +535,16 @@ describe('runTick', () => { expect(skipped?.level).toBe('debug') expect(skipped?.fields).toMatchObject({ reason: 'insufficient_headroom', - // The inputs behind the decision: without these an operator cannot tell a mis-set floor from - // an early ramp. The realized headroom is absent by design — a skip discards the plan, so the - // chosen mode's LIF is not recoverable here. + // The realized headroom AND the LIF/mode it came from: `maxLif` plus chain time do not + // identify them, because a matured-and-unhealthy position may be sized in either mode. headroomFloorBps: 3, - maxLif: MAX_LIF, + postMaturityMode: true, secondsSinceMaturity: 20n }) + // ~2bps at 20s into a 3600s ramp on a 3.63% incentive — under the 3bps floor that rejected it. + expect(skipped?.fields?.headroomBps as bigint).toBeLessThan(3n) + expect(skipped?.fields?.lif as bigint).toBeGreaterThan(WAD_ONE) + expect(skipped?.fields?.lif as bigint).toBeLessThan(MAX_LIF) expect(backoff.shouldSkip(LABEL, 100n)).toBe(false) expect(cooldown.shouldSkip(LABEL)).toBe(false) expectCounterIdentities(counters) diff --git a/bots/midnight-liquidation/test/sizing/plan.test.ts b/bots/midnight-liquidation/test/sizing/plan.test.ts index 3cbc1b11..93d7ced6 100644 --- a/bots/midnight-liquidation/test/sizing/plan.test.ts +++ b/bots/midnight-liquidation/test/sizing/plan.test.ts @@ -355,10 +355,13 @@ describe('headroom floor', () => { const earlyRamp = { blockTimestamp: 2020n, maturity: 2000n, healthy: true } it('skips a plan whose headroom is under the floor, with insufficient_headroom', () => { - expect(planWithReason(baseInput(earlyRamp), { headroomFloorBps: 3 })).toEqual({ - plan: null, - reason: 'insufficient_headroom' - }) + const outcome = planWithReason(baseInput(earlyRamp), { headroomFloorBps: 3 }) + expect(outcome.plan).toBeNull() + expect(outcome.reason).toBe('insufficient_headroom') + // The skip carries the numbers behind it, so a caller can report WHY without re-deriving a LIF it + // cannot recover (the chosen mode is not implied by chain time). + expect(outcome.headroom).toMatchObject({ postMaturityMode: true }) + expect(outcome.headroom!.bps).toBeLessThan(3n) }) it('allows the same position once the ramp clears the floor', () => { diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index 89ab41a8..28157228 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -97,6 +97,14 @@ export function passesRouteQuality(args: { * reference we hand it, while the aggregators apply it to their own quoted output. Passing the wrong * one silently lands the floor below break-even — see the second pass in `firmQuoteVenue`. */ +/** + * Whether a quote's ENCODED min-out is known to sit at or above `floor`. A `'derived'` min-out never + * qualifies, however large it looks: it is our reconstruction, so checking it against the floor checks + * our arithmetic against itself (see {@link Swap.minOutSource}). + */ +const clearsFloor = (swap: Swap, floor: bigint): boolean => + swap.minOutSource === 'venue' && swap.amountOutMinimum >= floor + const slippageForFloor = (floor: bigint, denominator: bigint): number => denominator <= 0n || floor >= denominator ? 0 @@ -157,6 +165,7 @@ type FirmQuoteOutcome = | { kind: 'swap'; swap: Swap; plan: SwapPlan } | { kind: 'quote_failed'; reason: QuoteFailureReason; detail: string } | { kind: 'bad_route'; swap: Swap } + | { kind: 'floor_unmet'; swap: Swap; floor: bigint } // One firm venue quote shared by both composers: build the venue params, dispatch under `tryCatch`, // then oracle-sanity the quoted output and fold a success into the plan's final step. `venueEntry` is @@ -207,23 +216,27 @@ async function firmQuoteVenue(args: { return { kind: 'quote_failed', reason, detail: ensureError(first.error).message } } - // Second pass, and the reason it exists: the aggregators apply the slippage percentage to THEIR OWN - // quote, which sits under the oracle reference by the execution cost. A percentage derived against - // the reference therefore lands their min-out at `quote · floor / reference` — strictly BELOW - // break-even — so a drifted fill would clear the router and then revert at the protocol's repay - // pull, which is exactly the misleading failure this floor exists to prevent. Re-deriving against - // the venue's own quoted output puts the floor where it belongs. Uniswap already lands at or above - // break-even on the first pass (it applies the percentage to the reference itself), so it never - // takes this branch. + // The aggregators apply the slippage percentage to THEIR OWN quote, which sits under the oracle + // reference by the execution cost, so a percentage derived against the reference lands their min-out + // at `quote · floor / reference` — strictly BELOW break-even. Re-deriving against the venue's own + // quoted output puts it back. Uniswap applies the percentage to the reference itself and is already + // at or above the floor, so it never takes this branch. let swap = first.data - if (swap.amountOutMinimum < economicFloor && swap.expectedAmountOut > 0n) { + if (!clearsFloor(swap, economicFloor) && swap.expectedAmountOut > 0n) { const second = await quote(paramsFor(swap.expectedAmountOut)) - // A failed re-quote is not fatal: the first quote is still executable, just with a looser on-chain - // floor than break-even. Simulation and the caller's pre-broadcast profitability check both still - // stand between it and a broadcast. if (second.data) swap = second.data } + // POSTCONDITION, and the actual guarantee — the retry above is only an attempt to satisfy it. The + // second quote's own output can come back lower than the first's, which puts its min-out back under + // the floor; the retry can fail outright; and a venue that only lets us RECONSTRUCT its min-out + // cannot be checked at all. In every one of those cases the encoded bound does not protect the + // repay, so the venue is refused and the caller falls through to the next one. Keeping a + // known-underfloor quote here is what the earlier revision got wrong. + if (!clearsFloor(swap, economicFloor)) { + return { kind: 'floor_unmet', swap, floor: economicFloor } + } + // The reference stays the FULL-PATH oracle value (collateral → loan): the unwrap chain threads its // worst-case amounts into `amountIn`, so the venue's quoted output is directly comparable. if ( @@ -473,6 +486,19 @@ export function composeMultiVenueQuoting(deps: { }) continue } + if (outcome.kind === 'floor_unmet') { + lastReason = 'bad_route' + logger.warn('quote.floor_unmet', { + venue, + id, + collateral: collateralToken, + expected: outcome.swap.expectedAmountOut, + amountOutMinimum: outcome.swap.amountOutMinimum, + minOutSource: outcome.swap.minOutSource, + floor: outcome.floor + }) + continue + } if (outcome.kind === 'bad_route') { lastReason = 'bad_route' logger.warn('quote.route_quality_failed', { diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index 38e5d2f7..6856b50b 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -58,6 +58,16 @@ export type Swap = { expectedAmountOut: bigint /** The min-out floor encoded in `callData` — logging/observability. */ amountOutMinimum: bigint + /** + * Whether {@link Swap.amountOutMinimum} is the floor the venue actually encoded in `callData` + * (`'venue'`) or our own reconstruction of what it probably encoded (`'derived'`). + * + * Load-bearing, not bookkeeping: an economic floor can only be *checked* against a `'venue'` value. + * Comparing a `'derived'` one against the floor compares our arithmetic with itself and always + * agrees, whatever the venue actually baked — so a caller enforcing a floor must reject `'derived'` + * rather than trust it. + */ + minOutSource: 'venue' | 'derived' } /** diff --git a/packages/swaps/src/venues/lifi.ts b/packages/swaps/src/venues/lifi.ts index 9ebbbb4b..e00bd7d2 100644 --- a/packages/swaps/src/venues/lifi.ts +++ b/packages/swaps/src/venues/lifi.ts @@ -74,7 +74,8 @@ export async function quoteLifi( callData: json.transactionRequest.data, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut: BigInt(json.estimate.toAmount ?? '0'), - amountOutMinimum: BigInt(json.estimate.toAmountMin ?? '0') + amountOutMinimum: BigInt(json.estimate.toAmountMin ?? '0'), + minOutSource: 'venue' } } diff --git a/packages/swaps/src/venues/liquidswap.ts b/packages/swaps/src/venues/liquidswap.ts index 789be535..894e75f9 100644 --- a/packages/swaps/src/venues/liquidswap.ts +++ b/packages/swaps/src/venues/liquidswap.ts @@ -90,7 +90,8 @@ export async function quoteLiquidSwap( callData: json.execution.calldata, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut, - amountOutMinimum: BigInt(minAmountOut) + amountOutMinimum: BigInt(minAmountOut), + minOutSource: 'venue' } } diff --git a/packages/swaps/src/venues/oneinch.ts b/packages/swaps/src/venues/oneinch.ts index f4f75ddc..a7eb4af7 100644 --- a/packages/swaps/src/venues/oneinch.ts +++ b/packages/swaps/src/venues/oneinch.ts @@ -77,7 +77,12 @@ export async function quoteOneInch( callData: json.tx.data, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut, - amountOutMinimum: (expectedAmountOut * (BPS - BigInt(params.slippageBps))) / BPS + // RECONSTRUCTED, not read: 1inch returns opaque calldata with its own bound baked in from the + // `slippage` percentage, and nothing here verifies this matches it. Its `minReturn` parameter would + // let us set an absolute floor and report it faithfully; until that is wired, a caller enforcing an + // economic floor must not use this venue. See {@link Swap.minOutSource}. + amountOutMinimum: (expectedAmountOut * (BPS - BigInt(params.slippageBps))) / BPS, + minOutSource: 'derived' } } diff --git a/packages/swaps/src/venues/uniswap-v3.ts b/packages/swaps/src/venues/uniswap-v3.ts index 6b6a8d05..aee0210e 100644 --- a/packages/swaps/src/venues/uniswap-v3.ts +++ b/packages/swaps/src/venues/uniswap-v3.ts @@ -70,6 +70,8 @@ export function quoteUniswapV3(entry: UniswapV3Entry, params: QuoteParameters): callData, amountIn: { source: 'balance', offset: SWAP_AMOUNT_IN_OFFSET }, expectedAmountOut: params.referenceAmountOut, - amountOutMinimum + amountOutMinimum, + // We encode the calldata here, so this IS the on-chain bound. + minOutSource: 'venue' } } diff --git a/packages/swaps/src/venues/zerox.ts b/packages/swaps/src/venues/zerox.ts index 4d2aced3..7f6e29d1 100644 --- a/packages/swaps/src/venues/zerox.ts +++ b/packages/swaps/src/venues/zerox.ts @@ -61,7 +61,8 @@ export async function quoteZerox( callData: json.transaction.data, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut: BigInt(json.buyAmount ?? '0'), - amountOutMinimum: BigInt(json.minBuyAmount ?? '0') + amountOutMinimum: BigInt(json.minBuyAmount ?? '0'), + minOutSource: 'venue' } } diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 85aad74f..05b54b1c 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -27,9 +27,9 @@ const REQUEST: QuoteRequest = { loanToken: LOAN, amountIn: 1000n, referenceAmountOut: 1000n, - // Break-even equal to the reference means zero derived slippage — the strictest floor, so a venue - // stub returning exactly the reference still passes and existing expectations are unchanged. - minAcceptableAmountOut: 1000n + // Break-even below the route-quality floor (950), so these cases exercise routing rather than the + // economic floor. The floor itself is exercised in its own describe blocks. + minAcceptableAmountOut: 900n } // The plan's final step is the venue swap; its approvalSpender is the venue's approve target — the @@ -85,11 +85,11 @@ describe('passesRouteQuality', () => { }) // A 0x-shaped firm-quote body (AllowanceHolder) with the given buyAmount. -function zeroxBody(buyAmount: string) { +function zeroxBody(buyAmount: string, minBuyAmount = buyAmount) { return { liquidityAvailable: true, buyAmount, - minBuyAmount: buyAmount, + minBuyAmount, transaction: { to: ROUTER, data: '0xabc', value: '0' } } } @@ -184,17 +184,19 @@ describe('composeMultiVenueQuoting', () => { it('falls through to the runner-up venue when the top one fails route quality', async () => { const { quoteFor } = composeMulti( - ['0x', '1inch'], + ['0x', 'lifi'], [ { venue: '0x', expectedOut: 900n }, - { venue: '1inch', expectedOut: 990n } + { venue: 'lifi', expectedOut: 990n } ], - // 0x quotes 900 (< floor 950) → fall through; 1inch quotes 990 (≥ 950) → win. - multiHttp({ '0x': zeroxBody('900'), '1inch': oneInchBody('990') }) + // 0x quotes 900 (< floor 950) → fall through; lifi quotes 990 (≥ 950) → win. The runner-up is + // lifi rather than 1inch because 1inch only RECONSTRUCTS its min-out, so it can never satisfy an + // enforced economic floor (see Swap.minOutSource). + multiHttp({ '0x': zeroxBody('900'), lifi: lifiBody('990') }) ) const outcome = await quoteFor(REQUEST) expect(outcome.kind).toBe('swap') - expect(finalSpender(outcome)).toBe(ONEINCH_ROUTER[8453]) + expect(finalSpender(outcome)).toBe(LIFI_SPENDER) }) it('uses the deterministic enabled-venue order when the pair is not yet probed (cold cache)', async () => { @@ -422,73 +424,107 @@ describe('economic min-out floor', () => { // protocol's repay pull — the exact failure the floor exists to prevent. The earlier tests asserted // only the percentage sent, which is why this escaped them. describe('aggregator min-out actually clears break-even', () => { - const countingHttp = (bodies: unknown[]) => { + const REFERENCE = 10_000n + const FLOOR = 9_580n + + /** + * A 0x stub that behaves like the real thing: it applies the slippage WE send to ITS OWN quote. That + * is precisely why deriving the percentage against the oracle reference put the floor too low, so a + * stub returning a fixed minimum could not have caught it. + */ + const aggregator = (quotes: string[]) => { const sent: (Record | undefined)[] = [] const client: RateLimitedClient = { getJson: async (args: { searchParams?: Record }) => { sent.push(args.searchParams) - return (bodies[sent.length - 1] ?? bodies.at(-1)) as T + const buy = BigInt(quotes[sent.length - 1] ?? quotes.at(-1)!) + const bps = BigInt(args.searchParams?.slippageBps ?? '0') + return zeroxBody(buy.toString(), ((buy * (10_000n - bps)) / 10_000n).toString()) as T } } return { client, sent } } - // reference 10_000, break-even 9_580 (the incident tier at full ramp), venue quote 9_700. - const REFERENCE = 10_000n - const FLOOR = 9_580n - const QUOTE = '9700' - - it('re-derives against the venue quote so the floor is not undercut', async () => { - const { client, sent } = countingHttp([oneInchBody(QUOTE), oneInchBody(QUOTE)]) - const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 9700n }], client) - const outcome = await quoteFor({ + const quoteVia = async (client: RateLimitedClient, expectedOut: bigint) => + composeMulti(['0x'], [{ venue: '0x', expectedOut }], client).quoteFor({ ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: FLOOR }) + it('re-derives against the venue quote so the floor is not undercut', async () => { + const { client, sent } = aggregator(['9700', '9700']) + const outcome = await quoteVia(client, 9700n) + expect(outcome.kind).toBe('swap') if (outcome.kind !== 'swap') return - // First pass asks 420bps against the reference; applied to 9700 that would floor at ~9292 — 288 - // units BELOW break-even. The second pass asks against the quote instead. - expect(sent[0]?.slippage).toBe('4.2') - expect(sent[1]?.slippage).toBe('1.23') + // 420bps against the reference, applied to 9700, floors at 9292 — 288 units BELOW break-even. The + // second pass asks 123bps against the quote instead. + expect(sent[0]?.slippageBps).toBe('420') + expect(sent[1]?.slippageBps).toBe('123') expect(outcome.plan.amountOutMinimum).toBeGreaterThanOrEqual(FLOOR) }) - it('spends the second call only when the first floor is short', async () => { - // Break-even equal to the reference asks for zero slippage, so the first min-out already clears it. - const { client, sent } = countingHttp([oneInchBody('10000')]) - const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 10_000n }], client) - const outcome = await quoteFor({ - ...REQUEST, - referenceAmountOut: REFERENCE, - minAcceptableAmountOut: REFERENCE - }) + // The case the previous revision missed: the retry is derived from the FIRST quote's output, so a + // second quote that comes back lower puts its minimum back under the floor. The retry is only an + // attempt — the postcondition is the guarantee. + it('refuses the venue when the re-quote drifts down and still misses the floor', async () => { + const { client, sent } = aggregator(['9700', '9600']) + const outcome = await quoteVia(client, 9700n) - expect(outcome.kind).toBe('swap') - expect(sent).toHaveLength(1) + expect(sent).toHaveLength(2) + // 9600 · 9877/10000 = 9481, which is 99 units under break-even. + expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) }) - it('keeps the first quote when the re-quote fails rather than losing the position', async () => { + it('refuses the venue when the re-quote fails outright', async () => { let call = 0 const client: RateLimitedClient = { getJson: async () => { call += 1 if (call === 2) throw new QuoteError('rate_limited', 'second pass throttled') - return oneInchBody(QUOTE) as T + return zeroxBody('9700', '9292') as T } } - const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 9700n }], client) - const outcome = await quoteFor({ + const outcome = await quoteVia(client, 9700n) + + expect(call).toBe(2) + // The earlier revision kept the first, known-underfloor quote here, which preserved the bug. + expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) + }) + + it('spends the second call only when the first floor is short', async () => { + // Break-even equal to the reference asks zero slippage, so the first minimum already clears it. + const { client, sent } = aggregator(['10000']) + const outcome = await composeMulti( + ['0x'], + [{ venue: '0x', expectedOut: 10_000n }], + client + ).quoteFor({ ...REQUEST, referenceAmountOut: REFERENCE, - minAcceptableAmountOut: FLOOR + minAcceptableAmountOut: REFERENCE }) - // Executable, just with a looser on-chain floor than break-even. Simulation and the caller's - // pre-broadcast profitability check both still stand between it and a broadcast. - expect(call).toBe(2) expect(outcome.kind).toBe('swap') + expect(sent).toHaveLength(1) + }) + + it('refuses a venue whose min-out is only RECONSTRUCTED, however large it looks', async () => { + // 1inch returns opaque calldata and we rebuild its floor arithmetically, so checking that value + // against break-even checks our own arithmetic against itself. A floor that cannot be read cannot + // be enforced, so the venue is refused rather than trusted. + const client: RateLimitedClient = { getJson: async () => oneInchBody('10000') as T } + const outcome = await composeMulti( + ['1inch'], + [{ venue: '1inch', expectedOut: 10_000n }], + client + ).quoteFor({ + ...REQUEST, + referenceAmountOut: REFERENCE, + minAcceptableAmountOut: FLOOR + }) + + expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) }) }) From 5eed272f2b8225eee94454d0a0d19215b32fedd4 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 15:37:52 -0500 Subject: [PATCH 08/10] fix(swaps): close the last two holes in the min-out floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round. Both holes let a quote through whose encoded minimum sits under break-even. **The postcondition was checking a weakened floor.** A floor above the oracle reference was clamped, and the check then used the clamped value — so a request for 1001 against a reference of 1000 could return a swap whose minimum was 1000, and a test preserved that. The clamp exists only because a percentage cannot express a floor above its own denominator (at `lif == WAD` the double-ceil can land a unit over the floored reference); it is a bound on what we can ASK for, never on what we accept. The postcondition now uses the requested `minAcceptableAmountOut`, so that case is refused rather than silently downgraded. **The unwrap-only path skipped the floor entirely.** A chain ending in the loan token returns before any venue is quoted, and only route quality was checked — an unrelated threshold. Reference 1000, route-quality floor 950, unwrap worst case 970, break-even 990: passed, with a bound 20 units short. Live for Blue in particular, which has no pre-simulation profitability gate. It now enforces the same floor and logs `quote.floor_unmet`. **1inch now asks for an absolute `minReturn`** rather than a percentage the API applies to its own quote, so its bound can be reported instead of reconstructed, and it stops being permanently unusable under a floor. Two reviews independently cited the v6.1 docs for `minReturn` being an absolute base-unit minimum. If the parameter is rejected the quote fails and falls through to the next venue, which is the same outcome as before, so the downside is bounded. That required restoring `minAcceptableAmountOut` on `QuoteParameters`, which an earlier commit removed on the argument that no venue reads it. A venue that takes an absolute minimum does; venues that take a percentage still get `slippageBps` derived from it. Also: the retry is skipped for a `derived` minimum, which could never clear the floor however it was asked for — it was costing a second API call guaranteed not to help. `clearsFloor` is exported and unit-tested on both branches, because no shipped venue reports a `derived` minimum any more and the rule would otherwise be untested. Two new tests pin each bot's projection of plan break-even into the slippage it asks for; both fail if the adapter stops threading it, which the loosened fixtures no longer prove on their own. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/fork/liquidation.test.ts | 3 + bots/blue-liquidation/test/quotes.test.ts | 24 +++++- .../test/fork/liquidation.test.ts | 3 + bots/midnight-liquidation/test/quotes.test.ts | 21 ++++- packages/swaps/src/quoting.ts | 41 +++++++-- packages/swaps/src/types.ts | 9 ++ packages/swaps/src/venues/oneinch.ts | 18 ++-- packages/swaps/test/quoting.test.ts | 83 ++++++++++++------- packages/swaps/test/venues/lifi.test.ts | 3 +- packages/swaps/test/venues/liquidswap.test.ts | 1 + packages/swaps/test/venues/oneinch.test.ts | 9 +- packages/swaps/test/venues/uniswap-v3.test.ts | 1 + packages/swaps/test/venues/zerox.test.ts | 3 +- 13 files changed, 165 insertions(+), 54 deletions(-) diff --git a/bots/blue-liquidation/test/fork/liquidation.test.ts b/bots/blue-liquidation/test/fork/liquidation.test.ts index da0e3adc..854da321 100644 --- a/bots/blue-liquidation/test/fork/liquidation.test.ts +++ b/bots/blue-liquidation/test/fork/liquidation.test.ts @@ -120,6 +120,9 @@ describe.skipIf(!FORK_URL || !FIXTURE)( tokenOut: out.params.loanToken, amountIn: liquidationPlan.seizedAssets, slippageBps: SLIPPAGE_BPS, + // The fork suite drives the venue directly, so it sets its own floor rather than deriving + // one; 0 means "no economic floor", which is what a raw exec-path test wants. + minAcceptableAmountOut: 0n, executor: executooor, referenceAmountOut: expectedLoanOut(liquidationPlan, out) } diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index efdbd41b..57448390 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -32,9 +32,6 @@ const PARAMS: MarketParams = { lltv: (WAD * 86n) / 100n } -// price = 1e36 → expectedLoanOut = seizedAssets = 1000 (the route-quality reference). -// break-even at lif 1.0 with no shares skew — the strictest floor, so the first quote pass -// already clears it and these projection cases spend no second call. // Break-even at 800, under the 0x stub's reported min-out of 995, so these cases exercise the lens // projection rather than the economic floor (which is covered in @repo/swaps). const PLAN: LiquidationPlan = { seizedAssets: 1000n, impliedRepaidAssets: 800n } @@ -85,10 +82,11 @@ function compose( venues?: ('0x' | '1inch')[] excludeCollaterals?: `0x${string}`[] logger?: Logger + httpClient?: RateLimitedClient } = {} ) { return composeQuoting({ - httpClient: httpStub, + httpClient: overrides.httpClient ?? httpStub, selector, chainId: 8453, executor: EXECUTOR, @@ -159,4 +157,22 @@ describe('composeQuoting (Blue lens-projection adapter)', () => { const selectOk = events.find(e => e.event === 'select.ok') expect(selectOk?.fields?.id).toBe(LABEL) }) + + it('projects the plan break-even into the venue slippage it asks for', () => { + // seizedAssets 1000 at price 1e36 -> reference 1000; break-even 800 -> the route may give up + // 200/1000 = 2000bps. If the adapter stopped threading `impliedRepaidAssets` this would read 0. + const calls: (Record | undefined)[] = [] + const capturing: RateLimitedClient = { + getJson: async (args: { searchParams?: Record }) => { + calls.push(args.searchParams) + return OK_ZEROX_BODY as T + } + } + const { selector } = fakeSelector([{ venue: '0x', expectedOut: 1000n }]) + return compose(selector, { httpClient: capturing }) + .quoteFor(PLAN, OUT, LABEL) + .then(() => { + expect(calls[0]?.slippageBps).toBe('2000') + }) + }) }) diff --git a/bots/midnight-liquidation/test/fork/liquidation.test.ts b/bots/midnight-liquidation/test/fork/liquidation.test.ts index 4839f43f..8e0e4f5c 100644 --- a/bots/midnight-liquidation/test/fork/liquidation.test.ts +++ b/bots/midnight-liquidation/test/fork/liquidation.test.ts @@ -128,6 +128,9 @@ describe('fork: end-to-end liquidation against a real Base position', () => { tokenOut: out.market.loanToken, amountIn: liquidationPlan.seizedAssets, slippageBps: SLIPPAGE_BPS, + // The fork suite drives the venue directly, so it sets its own floor rather than deriving one; + // 0 means "no economic floor", which is what a raw exec-path test wants. + minAcceptableAmountOut: 0n, executor: executooor, referenceAmountOut: expectedLoanOut(liquidationPlan, out) } diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index 6b3383d9..2afc2195 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -105,10 +105,11 @@ function compose( venues?: ('0x' | '1inch')[] excludeCollaterals?: `0x${string}`[] logger?: Logger + httpClient?: RateLimitedClient } = {} ) { return composeQuoting({ - httpClient: httpStub, + httpClient: overrides.httpClient ?? httpStub, selector, chainId: 8453, executor: EXECUTOR, @@ -182,4 +183,22 @@ describe('composeQuoting (Midnight lens-projection adapter)', () => { const selectOk = events.find(e => e.event === 'select.ok') expect(selectOk?.fields?.id).toBe(LABEL) }) + + it('projects the plan break-even into the venue slippage it asks for', () => { + // seizedAssets 1000 at price 1e36 -> reference 1000; break-even 800 -> 2000bps of allowance. Pins + // that the adapter threads `impliedRepaidUnits` rather than leaving the floor unset. + const calls: (Record | undefined)[] = [] + const capturing: RateLimitedClient = { + getJson: async (args: { searchParams?: Record }) => { + calls.push(args.searchParams) + return OK_ZEROX_BODY as T + } + } + const { selector } = fakeSelector([{ venue: '0x', expectedOut: 1000n }]) + return compose(selector, { httpClient: capturing }) + .quoteFor(PLAN, OUT, LABEL) + .then(() => { + expect(calls[0]?.slippageBps).toBe('2000') + }) + }) }) diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index 28157228..9258a314 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -102,7 +102,7 @@ export function passesRouteQuality(args: { * qualifies, however large it looks: it is our reconstruction, so checking it against the floor checks * our arithmetic against itself (see {@link Swap.minOutSource}). */ -const clearsFloor = (swap: Swap, floor: bigint): boolean => +export const clearsFloor = (swap: Swap, floor: bigint): boolean => swap.minOutSource === 'venue' && swap.amountOutMinimum >= floor const slippageForFloor = (floor: bigint, denominator: bigint): number => @@ -186,9 +186,11 @@ async function firmQuoteVenue(args: { const { tokenIn, amountIn, steps, request, maxRouteImpactBps } = args const { loanToken, referenceAmountOut, minAcceptableAmountOut } = request - // A floor above the oracle reference would imply negative slippage; clamp rather than reject, since - // rounding can put break-even a unit over the reference on a dust seize. - const economicFloor = + // The clamp exists only because a percentage cannot express a floor above its own denominator: at + // `lif == WAD` the double-ceil in break-even can land a unit over the floored oracle reference. It is + // an ARITHMETIC bound on what we can ask a venue for — never a relaxation of what we accept, which is + // always the requested `minAcceptableAmountOut` (see the postcondition below). + const askableFloor = minAcceptableAmountOut > referenceAmountOut ? referenceAmountOut : minAcceptableAmountOut const paramsFor = (denominator: bigint): QuoteParameters => ({ @@ -199,7 +201,8 @@ async function firmQuoteVenue(args: { // before the callback. After unwraps it is the chain's worst-case output — a fixed-amount // venue can only leave skimmable surplus, never revert on shortfall. amountIn, - slippageBps: slippageForFloor(economicFloor, denominator), + slippageBps: slippageForFloor(askableFloor, denominator), + minAcceptableAmountOut, executor, referenceAmountOut, // The request's decimals describe the RAW collateral; after an unwrap they would mislabel @@ -222,7 +225,13 @@ async function firmQuoteVenue(args: { // quoted output puts it back. Uniswap applies the percentage to the reference itself and is already // at or above the floor, so it never takes this branch. let swap = first.data - if (!clearsFloor(swap, economicFloor) && swap.expectedAmountOut > 0n) { + // A `derived` minimum can never clear the floor however it is asked for, so a retry there is a second + // API call that is guaranteed not to help. + if ( + swap.minOutSource === 'venue' && + !clearsFloor(swap, minAcceptableAmountOut) && + swap.expectedAmountOut > 0n + ) { const second = await quote(paramsFor(swap.expectedAmountOut)) if (second.data) swap = second.data } @@ -233,8 +242,8 @@ async function firmQuoteVenue(args: { // cannot be checked at all. In every one of those cases the encoded bound does not protect the // repay, so the venue is refused and the caller falls through to the next one. Keeping a // known-underfloor quote here is what the earlier revision got wrong. - if (!clearsFloor(swap, economicFloor)) { - return { kind: 'floor_unmet', swap, floor: economicFloor } + if (!clearsFloor(swap, minAcceptableAmountOut)) { + return { kind: 'floor_unmet', swap, floor: minAcceptableAmountOut } } // The reference stays the FULL-PATH oracle value (collateral → loan): the unwrap chain threads its @@ -327,6 +336,22 @@ function unwrapOnlyPlan(args: { }) return { kind: 'failed', reason: 'bad_route' } } + // The same economic floor the venue path enforces. `resolution.amountIn` is the chain's threaded + // WORST-CASE output, and every hop encodes its own min-out, so it is an on-chain bound rather than an + // estimate — but route quality alone does not check it against break-even, and the two thresholds are + // unrelated: a chain can clear `maxRouteImpactBps` and still land under the repay. + if (resolution.amountIn < request.minAcceptableAmountOut) { + logger.warn('quote.floor_unmet', { + venue: 'unwrap-only', + id: request.id, + collateral: request.collateralToken, + expected: resolution.amountIn, + amountOutMinimum: resolution.amountIn, + minOutSource: 'venue', + floor: request.minAcceptableAmountOut + }) + return { kind: 'failed', reason: 'bad_route' } + } logger.info('quote.ok', { venue: 'unwrap-only', id: request.id, diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index 6856b50b..18c8ef26 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -34,6 +34,15 @@ export type QuoteParameters = TokenInDecimals & { executor: Address /** Oracle-priced expected output (no DEX slippage) — the no-route-quality reference. */ referenceAmountOut: bigint + /** + * The break-even output the quote must clear, in `tokenOut` units. + * + * Carried alongside {@link QuoteParameters.slippageBps} because venues express a floor differently: + * most accept only a percentage, which the quoting layer derives from this, while 1inch takes an + * ABSOLUTE `minReturn` and so needs the value itself. A venue that can pass this straight through can + * report the bound faithfully instead of reconstructing it — see {@link Swap.minOutSource}. + */ + minAcceptableAmountOut: bigint } /** diff --git a/packages/swaps/src/venues/oneinch.ts b/packages/swaps/src/venues/oneinch.ts index a7eb4af7..44a07d34 100644 --- a/packages/swaps/src/venues/oneinch.ts +++ b/packages/swaps/src/venues/oneinch.ts @@ -3,7 +3,7 @@ import { getAddress, isAddressEqual, isHex } from 'viem' import type { RateLimitedClient } from '../http-client' import type { PriceParameters, PriceQuote, QuoteParameters, Swap } from '../types' -import { BPS, ONEINCH_BASE_URL, ONEINCH_ROUTER } from '../constants' +import { ONEINCH_BASE_URL, ONEINCH_ROUTER } from '../constants' import { QuoteError } from '../types' /** The 1inch arm of the per-collateral swap config. */ @@ -41,7 +41,10 @@ export async function quoteOneInch( from: params.executor, origin: params.executor, receiver: params.executor, - slippage: (params.slippageBps / 100).toString(), + // `minReturn` is an ABSOLUTE base-unit minimum, unlike `slippage` which is a percentage the API + // applies to its own quote. Asking for the absolute floor is what lets the returned bound be + // reported faithfully rather than reconstructed — see {@link Swap.minOutSource}. + minReturn: params.minAcceptableAmountOut.toString(), disableEstimate: 'true' } }) @@ -77,12 +80,11 @@ export async function quoteOneInch( callData: json.tx.data, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut, - // RECONSTRUCTED, not read: 1inch returns opaque calldata with its own bound baked in from the - // `slippage` percentage, and nothing here verifies this matches it. Its `minReturn` parameter would - // let us set an absolute floor and report it faithfully; until that is wired, a caller enforcing an - // economic floor must not use this venue. See {@link Swap.minOutSource}. - amountOutMinimum: (expectedAmountOut * (BPS - BigInt(params.slippageBps))) / BPS, - minOutSource: 'derived' + // The absolute `minReturn` we asked for, which the router enforces — the same trust boundary as + // 0x's `minBuyAmount` or LiFi's `toAmountMin`, and unlike the old `slippage` path this is not a + // reconstruction of a percentage the API applied to its own quote. + amountOutMinimum: params.minAcceptableAmountOut, + minOutSource: 'venue' } } diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 05b54b1c..1c798110 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -5,11 +5,11 @@ import { describe, expect, it } from 'vitest' import type { HttpVenue, RateLimitedClient } from '../src/http-client' import type { QuoteLogger, QuoteRequest } from '../src/quoting' -import type { QuoteOutcome, Venue } from '../src/types' +import type { QuoteOutcome, Swap, Venue } from '../src/types' import type { Unwrapper } from '../src/unwrappers/resolve' import { ONEINCH_ROUTER, ZEROX_ALLOWANCE_HOLDER } from '../src/constants' -import { composeMultiVenueQuoting, passesRouteQuality } from '../src/quoting' +import { clearsFloor, composeMultiVenueQuoting, passesRouteQuality } from '../src/quoting' import { QuoteError } from '../src/types' const NOOP_LOGGER: QuoteLogger = { info: () => {}, warn: () => {} } @@ -243,6 +243,27 @@ describe('composeMultiVenueQuoting', () => { expect(await quoteFor(REQUEST)).toEqual({ kind: 'failed', reason: 'bad_route' }) }) + // The reviewer's counterexample: an unwrap chain can clear route quality and still land under + // break-even, because the two thresholds are unrelated. This path never touches a venue, so the + // venue-side postcondition does not cover it. + it('holds an unwrap-only plan to the economic floor, not just route quality', async () => { + // Reference 1000, route-quality floor 950, unwrap worst case 970 — passes route quality. + const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: LOAN, out: 970n }) + const { quoteFor } = composeMulti(['0x'], [], multiHttp({}), { unwrappers: [unwrapper] }) + + // Break-even 960: the chain clears it. + expect(await quoteFor({ ...REQUEST, minAcceptableAmountOut: 960n })).toMatchObject({ + kind: 'swap' + }) + + // Break-even 990: it does not, and must be refused rather than broadcast with a bound that cannot + // fund the repay. + expect(await quoteFor({ ...REQUEST, minAcceptableAmountOut: 990n })).toEqual({ + kind: 'failed', + reason: 'bad_route' + }) + }) + it('drops the request tokenInDecimals after an unwrap (they described the raw collateral)', async () => { const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: UNDERLYING, out: 1000n }) // LiquidSwap requires tokenInDecimals; the post-unwrap quote must NOT reuse the share token's, @@ -364,9 +385,11 @@ describe('economic min-out floor', () => { return { client, calls } } + // 0x is the venue under test here because it takes a PERCENTAGE, which is what this derivation + // produces. 1inch takes an absolute `minReturn` and so never exercises it. const quoteWith = async (request: QuoteRequest, body: unknown) => { const { client, calls } = capturingHttp(body) - const { quoteFor } = composeMulti(['1inch'], [{ venue: '1inch', expectedOut: 1000n }], client) + const { quoteFor } = composeMulti(['0x'], [{ venue: '0x', expectedOut: 1000n }], client) await quoteFor(request) return { calls } } @@ -375,25 +398,27 @@ describe('economic min-out floor', () => { // reference 1000, break-even 900 -> the route may give up 100/1000 = 1000bps = 10%. const { calls } = await quoteWith( { ...REQUEST, minAcceptableAmountOut: 900n }, - oneInchBody('1000') + zeroxBody('1000') ) - expect(calls[0]?.searchParams?.slippage).toBe('10') + expect(calls[0]?.searchParams?.slippageBps).toBe('1000') }) it('asks for zero slippage when break-even is the whole reference', async () => { const { calls } = await quoteWith( { ...REQUEST, minAcceptableAmountOut: 1000n }, - oneInchBody('1000') + zeroxBody('1000') ) - expect(calls[0]?.searchParams?.slippage).toBe('0') + expect(calls[0]?.searchParams?.slippageBps).toBe('0') }) it('clamps a floor above the reference rather than asking for negative slippage', async () => { const { calls } = await quoteWith( { ...REQUEST, minAcceptableAmountOut: 1200n }, - oneInchBody('1000') + zeroxBody('1000') ) - expect(calls[0]?.searchParams?.slippage).toBe('0') + // The clamp is arithmetic only — a percentage cannot express a floor above its own denominator. It + // does NOT lower what is accepted: the postcondition still requires the requested 1200. + expect(calls[0]?.searchParams?.slippageBps).toBe('0') }) // The finding this change exists for: a FIXED allowance is wrong in both directions and crosses @@ -402,19 +427,19 @@ describe('economic min-out floor', () => { const REFERENCE = 10_000n const early = await quoteWith( { ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: 9985n }, - oneInchBody('10000') + zeroxBody('10000') ) const late = await quoteWith( { ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: 9580n }, - oneInchBody('10000') + zeroxBody('10000') ) // Early (15bps of incentive) the allowance is FAR tighter than the operator's 1%: a fixed 1% // would sit below break-even and let a shortfall through to fail at the repay instead. - expect(early.calls[0]?.searchParams?.slippage).toBe('0.15') + expect(early.calls[0]?.searchParams?.slippageBps).toBe('15') // Late (420bps) it is FAR looser: a fixed 1% would sit above break-even and reject fills that // would have settled profitably. - expect(late.calls[0]?.searchParams?.slippage).toBe('4.2') + expect(late.calls[0]?.searchParams?.slippageBps).toBe('420') }) }) @@ -510,21 +535,23 @@ describe('aggregator min-out actually clears break-even', () => { expect(sent).toHaveLength(1) }) - it('refuses a venue whose min-out is only RECONSTRUCTED, however large it looks', async () => { - // 1inch returns opaque calldata and we rebuild its floor arithmetically, so checking that value - // against break-even checks our own arithmetic against itself. A floor that cannot be read cannot - // be enforced, so the venue is refused rather than trusted. - const client: RateLimitedClient = { getJson: async () => oneInchBody('10000') as T } - const outcome = await composeMulti( - ['1inch'], - [{ venue: '1inch', expectedOut: 10_000n }], - client - ).quoteFor({ - ...REQUEST, - referenceAmountOut: REFERENCE, - minAcceptableAmountOut: FLOOR + it('refuses a min-out that is only RECONSTRUCTED, however large it looks', () => { + // No shipped venue reports a `derived` minimum any more — 1inch moved to an absolute `minReturn` — + // so this guards the rule directly rather than through a venue. A reconstruction cannot be checked + // against the floor: doing so compares our own arithmetic with itself. + const swap = (minOutSource: 'venue' | 'derived'): Swap => ({ + spender: ROUTER, + target: ROUTER, + value: 0n, + callData: '0xabc', + amountIn: { source: 'fixed', value: 1000n }, + expectedAmountOut: 10_000n, + amountOutMinimum: 10_000n, + minOutSource }) - - expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) + expect(clearsFloor(swap('venue'), 9_580n)).toBe(true) + expect(clearsFloor(swap('derived'), 9_580n)).toBe(false) + // ...and a venue-reported minimum below the floor is still refused. + expect(clearsFloor({ ...swap('venue'), amountOutMinimum: 9_579n }, 9_580n)).toBe(false) }) }) diff --git a/packages/swaps/test/venues/lifi.test.ts b/packages/swaps/test/venues/lifi.test.ts index 89f772cc..719560ed 100644 --- a/packages/swaps/test/venues/lifi.test.ts +++ b/packages/swaps/test/venues/lifi.test.ts @@ -21,7 +21,8 @@ const params: QuoteParameters = { amountIn: 100n, slippageBps: 50, executor: EXECUTOR, - referenceAmountOut: 2000n + referenceAmountOut: 2000n, + minAcceptableAmountOut: 0n } // A fake client that returns a fixed JSON body and records the request args. diff --git a/packages/swaps/test/venues/liquidswap.test.ts b/packages/swaps/test/venues/liquidswap.test.ts index 512627e0..4c63d8c1 100644 --- a/packages/swaps/test/venues/liquidswap.test.ts +++ b/packages/swaps/test/venues/liquidswap.test.ts @@ -21,6 +21,7 @@ const params: QuoteParameters = { slippageBps: 150, executor: EXECUTOR, referenceAmountOut: 3_000_000_000n, + minAcceptableAmountOut: 0n, tokenInDecimals: 18 } diff --git a/packages/swaps/test/venues/oneinch.test.ts b/packages/swaps/test/venues/oneinch.test.ts index a09bad24..3bb58317 100644 --- a/packages/swaps/test/venues/oneinch.test.ts +++ b/packages/swaps/test/venues/oneinch.test.ts @@ -23,7 +23,8 @@ const params: QuoteParameters = { amountIn: 100n, slippageBps: 50, // 0.5% executor: EXECUTOR, - referenceAmountOut: 2000n + referenceAmountOut: 2000n, + minAcceptableAmountOut: 1990n } function fakeClient(body: unknown) { @@ -53,15 +54,17 @@ describe('quoteOneInch', () => { expect(swap.expectedAmountOut).toBe(2000n) // 2000 × (10000 - 50) / 10000 = 1990. expect(swap.amountOutMinimum).toBe(1990n) + // Reported, not reconstructed: the absolute floor we asked the router to enforce. + expect(swap.minOutSource).toBe('venue') - // slippage is sent as a percentage (bps / 100); output goes to the Executor. + // `minReturn` is an ABSOLUTE base-unit minimum, not a percentage; output goes to the Executor. expect(calls[0]?.searchParams).toMatchObject({ src: COLLATERAL, dst: LOAN, amount: '100', from: EXECUTOR, receiver: EXECUTOR, - slippage: '0.5' + minReturn: '1990' }) }) diff --git a/packages/swaps/test/venues/uniswap-v3.test.ts b/packages/swaps/test/venues/uniswap-v3.test.ts index 358cbe66..f7495a35 100644 --- a/packages/swaps/test/venues/uniswap-v3.test.ts +++ b/packages/swaps/test/venues/uniswap-v3.test.ts @@ -44,6 +44,7 @@ function params(overrides: Partial = {}): QuoteParameters { slippageBps: 50, executor: EXECUTOR, referenceAmountOut: 2000n, + minAcceptableAmountOut: 0n, ...overrides } } diff --git a/packages/swaps/test/venues/zerox.test.ts b/packages/swaps/test/venues/zerox.test.ts index 366bcfff..dd05858a 100644 --- a/packages/swaps/test/venues/zerox.test.ts +++ b/packages/swaps/test/venues/zerox.test.ts @@ -21,7 +21,8 @@ const params: QuoteParameters = { amountIn: 100n, slippageBps: 50, executor: EXECUTOR, - referenceAmountOut: 2000n + referenceAmountOut: 2000n, + minAcceptableAmountOut: 0n } // A fake client that returns a fixed JSON body and records the request args. From 3001d76917eb476efcc98639a149b8dca5e14840 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 27 Aug 2026 15:54:50 -0500 Subject: [PATCH 09/10] docs(swaps): correct the 1inch floor docs to match minReturn Both doc blocks still described the pre-minReturn shape: one said the on-chain min-out is an oracle-derived floor recorded for observability, the other that a percentage is all any aggregator accepts and so the absolute floor never reaches a venue. The adapter now asks for an absolute minimum and reports the router's own bound, which is what makes it usable under an enforced floor. --- packages/swaps/src/types.ts | 4 ++-- packages/swaps/src/venues/oneinch.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index 18c8ef26..f56d50e5 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -27,8 +27,8 @@ export type QuoteParameters = TokenInDecimals & { /** * Max output discount the venue may accept, in bps. **Derived**, not operator-set: the quoting layer * computes it from the liquidation's break-even output (`QuoteRequest.minAcceptableAmountOut`) so the - * resulting floor is economic. A percentage is all the aggregators accept, which is why this — and - * not the absolute floor — is the shape a venue sees. + * resulting floor is economic. Most venues accept only a percentage; the ones that take an absolute + * minimum read {@link QuoteParameters.minAcceptableAmountOut} instead. */ slippageBps: number executor: Address diff --git a/packages/swaps/src/venues/oneinch.ts b/packages/swaps/src/venues/oneinch.ts index 44a07d34..cc6e4aa8 100644 --- a/packages/swaps/src/venues/oneinch.ts +++ b/packages/swaps/src/venues/oneinch.ts @@ -18,9 +18,10 @@ type OneInchSwap = { /** * Quotes 1inch via the one-step Classic Swap `/swap` endpoint, which returns ready-to-use `tx` * calldata. Approval is a plain ERC20 `approve` to the static AggregationRouterV6 (no Permit2). Output - * is sent to `receiver` (the Executor). `slippage` is a percentage (bps / 100). `amount` is committed - * off-chain, so the {@link Swap} carries `amountIn: { source: 'fixed' }`; the on-chain min-out is the - * router's own bound (we record an oracle-derived floor for observability). + * is sent to `receiver` (the Executor). The floor is requested as an absolute `minReturn` rather than a + * `slippage` percentage, so the returned {@link Swap} reports the router's own bound + * (`minOutSource: 'venue'`) instead of reconstructing one. `amount` is committed off-chain, so the + * {@link Swap} carries `amountIn: { source: 'fixed' }`. */ export async function quoteOneInch( client: RateLimitedClient, From 1b6f8c72958e122fa282e71957f1883b62d0368f Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Fri, 28 Aug 2026 08:16:37 -0500 Subject: [PATCH 10/10] fix(midnight-liquidation): retry economic floor misses without backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 5. A venue whose guaranteed output missed break-even collapsed into `bad_route`, so the tick recorded backoff and cooldown on what the design calls an economic skip — sampling the LIF ramp exponentially and potentially skipping the block where a position first becomes fundable. - `QuoteFailureReason` gains `floor_unmet`, split from the transport/route failures it is not; midnight counts it as `quoteUnprofitable` and retries the next block. The per-venue log drops to `info`, since every venue misses the floor for every candidate through the early ramp. Blue keeps today's backoff: its incentive is static, so a miss there does carry information. - Token prices derive their base URL from the *candidates* path, not the tokens path — the old comparison could never match, so a gateway prefix silently collapsed to the origin and left every candidate unpriced. - The boot price fetch moves into its refresh loop: a hanging tokens endpoint delayed the first tick by ~22s on a source that is ranking-only and fail-open. - README: document `MIN_SURPLUS_BPS`, including that it gates the expected output and not the min-out encoded in calldata, and restore the venue section's truncated sentence. Co-Authored-By: Claude Opus 5 (1M context) --- bots/midnight-liquidation/README.md | 7 ++++-- .../src/discovery/borrowers.ts | 16 ++++++++----- .../src/discovery/token-prices.ts | 17 +++++++++++-- bots/midnight-liquidation/src/index.ts | 19 ++++++++------- bots/midnight-liquidation/src/runner/tick.ts | 24 +++++++++++++++---- packages/swaps/src/quoting.ts | 13 ++++++---- packages/swaps/src/types.ts | 22 +++++++++++++---- packages/swaps/test/quoting.test.ts | 6 ++--- 8 files changed, 90 insertions(+), 34 deletions(-) diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index 2b8ac6da..74c0a37c 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -78,6 +78,7 @@ Environment variables: | `MAX_ROUTE_IMPACT_BPS` | no | `500` | Reject a venue's quoted output more than this far below the oracle reference (route-quality guard). | | `SEIZE_CAP_MARGIN_BPS` | no | `30` | Headroom shaved off the on-chain repay cap when sizing a cap-binding seize, so a one-block oracle move can't trip the contract's RCF/debt check. `0` sizes right at the cap. | | `HEADROOM_FLOOR_BPS` | no | `3` | **Lower bound** on swap execution cost — the cheapest route you would ever expect, NOT a typical cost. A seize-exact plan's whole margin is the incentive `(lif - 1)/lif`, so a plan below this floor cannot fund its own repay by any route and is skipped as `plan.skipped` / `insufficient_headroom` before it costs a quote, a simulation or a gas estimate. Post-maturity the incentive ramps from zero over an hour, so this acts as a pure time gate: `3` suppresses roughly the first 25s on a 4.4%-maxLif tier. Set it too high and it blinds the earliest, most contested part of a maturity. `0` disables the gate. | +| `MIN_SURPLUS_BPS` | no | `0` | Surplus over break-even a quoted route's **expected** output must clear before the bot spends a simulation on it, in bps of the plan's contract-derived repay. `0` is pure break-even: both sides then come from the contract's own formula with no tuned value, so the gate can only reject plans that would have reverted anyway. It gates the expected output only — the min-out actually encoded in the swap calldata stays at break-even — so raising it buys margin against a route that underperforms its quote, not against oracle drift between simulation and inclusion. | | `PENDLE_SLIPPAGE_BPS` | no | `50` | Slippage for the Pendle PT → underlying unwrap hop (before the downstream venue sells). | | `BACKOFF_BASE_BLOCKS` / `BACKOFF_MAX_BLOCKS` | no | `2` / `64` | Exponential per-position cooldown (in blocks) after a failed quote/simulate, bounding API + RPC usage under a backlog. | | `POSITION_LIQUIDATION_COOLDOWN_MS` | no | `0` | Opt-in per-position cooldown (ms) after a failed liquidation attempt; `0` disables it (re-attempt every tick). | @@ -158,8 +159,10 @@ liquidation's break-even output — the repay `liquidate` will pull — so the f than a percentage someone picked. A fixed allowance is wrong in both directions and crosses over as the incentive ramps: below break-even it lets a shortfall through to fail at the repay instead, above it the router rejects fills that would have settled. There is no `SLIPPAGE_BPS`. A route is still -rejected when its quote is more than `MAX_ROUTE_IMPACT_BPS` below the oracle reference. API keys come -from `ZEROX_API_KEY` / +rejected when its quote is more than `MAX_ROUTE_IMPACT_BPS` below the oracle reference, and every venue +missing the floor is reported as `quote.floor_unmet` — an economic verdict, so the position is retried +on the next block rather than backed off. API keys come from `ZEROX_API_KEY` / `ONEINCH_API_KEY` and +are never logged. ## Running Locally diff --git a/bots/midnight-liquidation/src/discovery/borrowers.ts b/bots/midnight-liquidation/src/discovery/borrowers.ts index b1746f63..ccea60b0 100644 --- a/bots/midnight-liquidation/src/discovery/borrowers.ts +++ b/bots/midnight-liquidation/src/discovery/borrowers.ts @@ -36,11 +36,15 @@ const PAGE_LIMIT = 100 export const MAX_DISCOVERY_PAGES = 100 /** - * The candidates operation path — a literal key of the generated {@link paths}, so `client.GET(PATH)` + * The candidates operation path — a literal key of the generated {@link paths}, so `client.GET(LIQUIDATION_CANDIDATES_PATH)` * is type-checked against the spec. The runtime base URL is derived by stripping this suffix from the * configured endpoint URL (see {@link createApiCandidateSource}). + * + * Exported because `LIQUIDATION_CANDIDATES_API_URL` is the base for the sibling tokens endpoint too: + * that suffix, not the tokens one, is what the configured URL ends with, so stripping it is the only + * way to recover a gateway prefix (see `createTokenPriceSource`). */ -const PATH = '/markets/midnight/liquidation-candidates' +export const LIQUIDATION_CANDIDATES_PATH = '/markets/midnight/liquidation-candidates' // Validates and normalizes one raw response row into a candidate, or `null` if malformed. Only // `market_id` + `borrower` feed the pipeline — the lens re-derives everything else (debt, health, @@ -113,7 +117,7 @@ type FetchLike = (request: Request) => Promise * pending queue is still driven that block. `fetchImpl`/`sleep` are injectable for tests. * * `deps.url` is the fully-qualified endpoint URL from config; the client base URL is it minus the - * fixed {@link PATH} suffix (falling back to the origin). An operator override of + * fixed {@link LIQUIDATION_CANDIDATES_PATH} suffix (falling back to the origin). An operator override of * `LIQUIDATION_CANDIDATES_API_URL` therefore changes host/prefix, but the request path is fixed by * the typed client. */ @@ -126,15 +130,15 @@ export function createApiCandidateSource(deps: { sleep?: (ms: number) => Promise }): FetchCandidatePage { const sleep = deps.sleep ?? delay - const baseUrl = deps.url.endsWith(PATH) - ? deps.url.slice(0, -PATH.length) + const baseUrl = deps.url.endsWith(LIQUIDATION_CANDIDATES_PATH) + ? deps.url.slice(0, -LIQUIDATION_CANDIDATES_PATH.length) : new URL(deps.url).origin const client = createClient({ baseUrl, fetch: deps.fetchImpl ?? fetch }) return async cursor => { const body = await fetchWithRetry( () => - client.GET(PATH, { + client.GET(LIQUIDATION_CANDIDATES_PATH, { params: { query: { chain_ids: [deps.chainId], diff --git a/bots/midnight-liquidation/src/discovery/token-prices.ts b/bots/midnight-liquidation/src/discovery/token-prices.ts index ff1dc951..48e3f955 100644 --- a/bots/midnight-liquidation/src/discovery/token-prices.ts +++ b/bots/midnight-liquidation/src/discovery/token-prices.ts @@ -7,6 +7,8 @@ import { getAddress, isAddress, parseUnits } from 'viem' import type { paths } from '../generated/markets-api' +import { LIQUIDATION_CANDIDATES_PATH } from './borrowers' + /** The `fetch` shape `openapi-fetch` calls — a single `Request`. The global `fetch` satisfies it. */ type FetchLike = (request: Request) => Promise @@ -26,6 +28,16 @@ export const USD_PRICE_SCALE_DECIMALS = 8 */ const PATH = '/markets/midnight/tokens' +/** + * The path prefix a gateway may mount the API under, recovered from the configured CANDIDATES url — + * stripping this path would never match it. `pathname` rather than the raw string so a configured + * query string cannot defeat the suffix test. Empty when the URL is not the candidates endpoint. + */ +const gatewayPrefix = (url: URL): string => + url.pathname.endsWith(LIQUIDATION_CANDIDATES_PATH) + ? url.pathname.slice(0, -LIQUIDATION_CANDIDATES_PATH.length) + : '' + /** A token's price, normalized once at the API boundary so no float arithmetic survives it. */ type PricedToken = { priceE8: bigint; decimals: number } @@ -91,10 +103,11 @@ export const createTokenPriceSource = (deps: { const sleep = deps.sleep ?? delay const now = deps.now ?? (() => Date.now()) const url = new URL(deps.apiUrl) - const baseUrl = deps.apiUrl.endsWith(PATH) ? deps.apiUrl.slice(0, -PATH.length) : url.origin + const prefix = gatewayPrefix(url) + const baseUrl = `${url.origin}${prefix}` const client = createClient({ baseUrl, fetch: deps.fetchImpl ?? fetch }) // Host + path only; the query string is excluded so nothing can ride along into a log line. - const source = `${url.host}${PATH}` + const source = `${url.host}${prefix}${PATH}` // Last-known-good: replaced only by a fully-successful refresh. Keyed by checksummed address so a // lookup cannot miss on casing. diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index 3704d283..2c8a432e 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -171,13 +171,13 @@ async function main() { // Loan-token USD prices, used ONLY to order the tick's candidates by expected profit. Fails open: // an unpriced token sorts last, so a fetch failure degrades ordering to discovery order rather than // suppressing work. Shares the candidates endpoint's base URL, so pointing the bot at a staging host - // moves both. The boot fetch is non-fatal — the timer below retries. + // moves both. The first fetch runs in the refresh loop below rather than being awaited here — see + // there for why. const tokenPrices = createTokenPriceSource({ apiUrl: config.discovery.apiUrl, chainId: config.chainId, logger }) - await tryCatch(tokenPrices.refresh()) // Pre-swap converters for exotic collateral (ERC4626 shares, Pendle PTs → underlying). // Auto-detecting with per-process memoization. erc4626 first: a memoized eth_call beats consulting @@ -409,16 +409,19 @@ async function main() { } void refreshMarketsLoop() - // Prices refresh on their own timer rather than inside `refreshMarketsLoop`. `fetchWithRetry` is a - // 5s deadline times three retries plus backoff, so a hanging tokens fetch could stall ~22s — and the - // whitelist refresh it would stall is fail-closed and safety-critical, while this one only orders - // work. `refresh` is contractually non-throwing (it reports `prices.refresh_failed` itself), so the - // tryCatch is belt-and-braces and an error here is a bug rather than an API blip. + // Prices refresh on their own timer rather than inside `refreshMarketsLoop`, and the FIRST fetch runs + // here rather than at construction. `fetchWithRetry` is a 5s deadline times three retries plus + // backoff, so a hanging tokens fetch could stall ~22s — which must delay neither the fail-closed, + // safety-critical whitelist refresh nor, since the runner is already started, the first tick of a + // redeploy landing mid-maturity. Until it lands every candidate is simply unpriced and worked in + // discovery order, which is the source's own fail-open contract. `refresh` is contractually + // non-throwing (it reports `prices.refresh_failed` itself), so the tryCatch is belt-and-braces and an + // error here is a bug rather than an API blip. const refreshTokenPricesLoop = async () => { - await delay(TOKEN_PRICES_REFRESH_MS) if (stopped) return const { error } = await tryCatch(tokenPrices.refresh()) if (error) logger.error('prices.refresh_error', { detail: error.message }) + await delay(TOKEN_PRICES_REFRESH_MS) void refreshTokenPricesLoop() } void refreshTokenPricesLoop() diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index b179a3ec..cc4c7d5f 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -54,10 +54,14 @@ type TickCounters = { noSwapPath: number quoteFailed: number /** - * Quoted successfully, but the route could not cover the repay `liquidate` would pull. An economic - * skip, not a failure: deliberately no backoff and no cooldown, because both sides of the comparison - * move on a ten-second scale — the LIF ramp lifts break-even while route cost is itself volatile — - * so this outcome says almost nothing about the next attempt. + * Quoted successfully, but the route could not cover the repay `liquidate` would pull. Two producers, + * counted together because they are the same verdict at different strictness: the quoting layer + * refusing every venue whose GUARANTEED output misses break-even (`floor_unmet`), and + * {@link assessProfitability} refusing an EXPECTED output under the configured threshold. + * + * An economic skip, not a failure: deliberately no backoff and no cooldown, because both sides of the + * comparison move on a ten-second scale — the LIF ramp lifts break-even while route cost is itself + * volatile — so this outcome says almost nothing about the next attempt. */ quoteUnprofitable: number ok: number @@ -231,7 +235,8 @@ export async function runTick(deps: { /** * Fetches ONE executable swap for a liquidatable position from its configured venue (Uniswap is * local; aggregators make a single API call). `no_config` → skip with `config.no_swap_path` (no - * backoff); `failed` → skip and back the position off. + * backoff); `failed` → skip, backing the position off unless the reason is the economic + * `floor_unmet`. */ quoteFor: (plan: LiquidationPlan, out: LensOut, label: string) => Promise simulate: (args: { @@ -395,6 +400,15 @@ export async function runTick(deps: { continue } if (quote.kind === 'failed') { + // An economic refusal is not a failure signal: every venue's guaranteed output missed the + // break-even repay, which is the normal state of the early LIF ramp and clears on its own as + // the incentive grows. Backing off here would sample the ramp exponentially and skip the + // contested block where the position first becomes fundable — see `quoteUnprofitable`. The + // quoting layer already logged `quote.floor_unmet` per venue with the numbers. + if (quote.reason === 'floor_unmet') { + counters.quoteUnprofitable += 1 + continue + } counters.quoteFailed += 1 backoff.record(label, chainHead) cooldown.mark(label) diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index 9258a314..120d4a8a 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -341,7 +341,7 @@ function unwrapOnlyPlan(args: { // estimate — but route quality alone does not check it against break-even, and the two thresholds are // unrelated: a chain can clear `maxRouteImpactBps` and still land under the repay. if (resolution.amountIn < request.minAcceptableAmountOut) { - logger.warn('quote.floor_unmet', { + logger.info('quote.floor_unmet', { venue: 'unwrap-only', id: request.id, collateral: request.collateralToken, @@ -350,7 +350,7 @@ function unwrapOnlyPlan(args: { minOutSource: 'venue', floor: request.minAcceptableAmountOut }) - return { kind: 'failed', reason: 'bad_route' } + return { kind: 'failed', reason: 'floor_unmet' } } logger.info('quote.ok', { venue: 'unwrap-only', @@ -487,6 +487,8 @@ export function composeMultiVenueQuoting(deps: { // Try the ranked venues in order; a quote or route-quality failure falls through to the next // (coverage-first). Only the CHOSEN venue is firm-quoted per step — never all venues at once. + // `lastReason` is last-venue-wins, so a transport failure after a floor miss reports the failure + // and the caller still backs off — the conservative direction of the two. let lastReason: QuoteFailureReason = 'no_route' for (const venue of order) { const outcome = await firmQuoteVenue({ @@ -512,8 +514,11 @@ export function composeMultiVenueQuoting(deps: { continue } if (outcome.kind === 'floor_unmet') { - lastReason = 'bad_route' - logger.warn('quote.floor_unmet', { + lastReason = 'floor_unmet' + // `info`, not `warn`: the floor IS the break-even repay, so during the post-maturity ramp + // every venue misses it for every candidate on every block until the incentive catches up. + // That is the ordinary early-ramp shape, not an anomaly to page on. + logger.info('quote.floor_unmet', { venue, id, collateral: collateralToken, diff --git a/packages/swaps/src/types.ts b/packages/swaps/src/types.ts index f56d50e5..8d50403a 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -136,16 +136,30 @@ export type SwapPlan = { amountOutMinimum: bigint } -/** Why an executable quote could not be produced (for logging + backoff). */ -export type QuoteFailureReason = 'timeout' | 'rate_limited' | 'no_route' | 'api_error' | 'bad_route' +/** + * Why an executable quote could not be produced. Two classes, and callers must not conflate them: + * `timeout`/`rate_limited`/`api_error`/`no_route`/`bad_route` are failures, and suppressing a position + * that keeps producing them bounds API + RPC usage. `floor_unmet` is an economic verdict — the venue + * quoted fine, its guaranteed output just did not clear the liquidation's break-even repay — and both + * sides of that comparison move on a ten-second scale, so it says almost nothing about the next + * attempt and must not drive backoff. + */ +export type QuoteFailureReason = + | 'timeout' + | 'rate_limited' + | 'no_route' + | 'api_error' + | 'bad_route' + | 'floor_unmet' /** * The result of resolving a swap for one liquidatable position: * - `swap` — an executable {@link SwapPlan} to encode + simulate; * - `no_config` — the operator has not configured this collateral (a coverage gap, not a failure; no * API call was made) → skip with `config.no_swap_path`; - * - `failed` — a transient quote/route failure (API down, no route, or the route fails the oracle - * sanity check) → skip and back the position off. + * - `failed` — no executable quote: a transient quote/route failure (API down, no route, or the route + * fails the oracle sanity check) → skip and back the position off, or an economic `floor_unmet` + * verdict → skip WITHOUT backing off (see {@link QuoteFailureReason}). */ export type QuoteOutcome = | { kind: 'swap'; plan: SwapPlan } diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 1c798110..1a0dee9f 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -260,7 +260,7 @@ describe('composeMultiVenueQuoting', () => { // fund the repay. expect(await quoteFor({ ...REQUEST, minAcceptableAmountOut: 990n })).toEqual({ kind: 'failed', - reason: 'bad_route' + reason: 'floor_unmet' }) }) @@ -499,7 +499,7 @@ describe('aggregator min-out actually clears break-even', () => { expect(sent).toHaveLength(2) // 9600 · 9877/10000 = 9481, which is 99 units under break-even. - expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) + expect(outcome).toEqual({ kind: 'failed', reason: 'floor_unmet' }) }) it('refuses the venue when the re-quote fails outright', async () => { @@ -515,7 +515,7 @@ describe('aggregator min-out actually clears break-even', () => { expect(call).toBe(2) // The earlier revision kept the first, known-underfloor quote here, which preserved the bug. - expect(outcome).toEqual({ kind: 'failed', reason: 'bad_route' }) + expect(outcome).toEqual({ kind: 'failed', reason: 'floor_unmet' }) }) it('spends the second call only when the first floor is short', async () => {