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/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/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 f9ec9f51..2955464f 100644 --- a/bots/blue-liquidation/src/quotes.ts +++ b/bots/blue-liquidation/src/quotes.ts @@ -26,7 +26,6 @@ export function composeQuoting(deps: { chainId: number executor: Address venues: readonly Venue[] - slippageBps: number baseUrls: Partial> maxRouteImpactBps: number unwrappers: readonly Unwrapper[] @@ -57,6 +56,9 @@ export function composeQuoting(deps: { loanToken: out.params.loanToken, amountIn: plan.seizedAssets, referenceAmountOut: expectedLoanOut(plan, out), + // 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/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/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 511fb497..57448390 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -32,8 +32,9 @@ const PARAMS: MarketParams = { lltv: (WAD * 86n) / 100n } -// price = 1e36 → expectedLoanOut = seizedAssets = 1000 (the route-quality reference). -const PLAN: LiquidationPlan = { seizedAssets: 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, @@ -81,15 +82,15 @@ 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, venues: overrides.venues ?? ['0x'], - slippageBps: 100, baseUrls: {}, maxRouteImpactBps: 500, unwrappers: [], @@ -156,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/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/README.md b/bots/midnight-liquidation/README.md index 923ae120..74c0a37c 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). | +| `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. | +| `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). | +| `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. @@ -153,10 +154,15 @@ 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, 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 @@ -356,6 +362,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/config.ts b/bots/midnight-liquidation/src/config.ts index ee88b686..2efeb96a 100644 --- a/bots/midnight-liquidation/src/config.ts +++ b/bots/midnight-liquidation/src/config.ts @@ -53,7 +53,19 @@ 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 +// 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 @@ -71,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'] @@ -93,6 +104,10 @@ 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 backoffMaxBlocks: bigint } @@ -114,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 @@ -418,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, @@ -455,6 +469,14 @@ 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 + }), 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/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/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 new file mode 100644 index 00000000..48e3f955 --- /dev/null +++ b/bots/midnight-liquidation/src/discovery/token-prices.ts @@ -0,0 +1,186 @@ +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' + +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 + +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' + +/** + * 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 } + +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 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}${prefix}${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/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/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/index.ts b/bots/midnight-liquidation/src/index.ts index d671f6e1..2c8a432e 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 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 + }) + // 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 @@ -185,7 +201,6 @@ async function main() { chainId: config.chainId, executor: config.executooorAddress, venues, - slippageBps: config.venues.slippageBps, baseUrls, maxRouteImpactBps: config.quoting.maxRouteImpactBps, unwrappers, @@ -326,6 +341,8 @@ 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, simulate: ({ market, borrower, plan, swapPlan }) => @@ -350,6 +367,7 @@ async function main() { backoff, cooldown, inflightLabels: () => queue.inflightLabels(), + usdValueOf: tokenPrices.usdValueOf, logger }) @@ -391,6 +409,23 @@ async function main() { } void refreshMarketsLoop() + // 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 () => { + 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() + // 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. @@ -401,7 +436,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/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index 61163876..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[] @@ -58,6 +57,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/bots/midnight-liquidation/src/runner/profitability.ts b/bots/midnight-liquidation/src/runner/profitability.ts new file mode 100644 index 00000000..f17c27d7 --- /dev/null +++ b/bots/midnight-liquidation/src/runner/profitability.ts @@ -0,0 +1,69 @@ +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 + /** `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 +} + +/** + * 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, + requiredThreshold: threshold, + shortfallBps: requiredRepay === 0n ? 0n : ((threshold - achievableOut) * BPS) / requiredRepay + } +} 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 2187d7e1..cc4c7d5f 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -3,14 +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 @@ -20,13 +24,14 @@ 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 * ``` * * 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. @@ -48,12 +53,29 @@ type TickCounters = { backoffSkipped: number noSwapPath: number quoteFailed: number + /** + * 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 reverted: number /** Broadcast: the queue reported a transaction actually went out. */ 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 } /** @@ -74,7 +96,106 @@ 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', + // 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' +} + +/** 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 + // 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.headroom + ? { + headroomBps: outcome.headroom.bps, + headroomFloorBps, + lif: outcome.headroom.lif, + postMaturityMode: outcome.headroom.postMaturityMode, + secondsSinceMaturity: out.blockTimestamp - out.market.maturity + } + : {}) + }) + 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 } /** @@ -102,11 +223,20 @@ 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> /** * 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: { @@ -140,6 +270,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 { @@ -147,6 +284,8 @@ export async function runTick(deps: { chainHead, caller, seizeCapMarginBps, + headroomFloorBps, + minSurplusBps, readLens, quoteFor, simulate, @@ -154,6 +293,7 @@ export async function runTick(deps: { backoff, cooldown, inflightLabels, + usdValueOf, logger } = deps @@ -182,51 +322,50 @@ export async function runTick(deps: { backoffSkipped: 0, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, 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 }) - 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 @@ -261,11 +400,49 @@ 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) 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 + // `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, + minSurplusBps + }) + continue + } + swapPlan = quote.plan } diff --git a/bots/midnight-liquidation/src/sizing/plan.ts b/bots/midnight-liquidation/src/sizing/plan.ts index aa40754b..6ca86de5 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' /** @@ -71,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 @@ -87,9 +111,23 @@ 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 }) +const skip = (reason: PlanSkipReason, headroom?: SkippedHeadroom): PlanOutcome => ({ + plan: null, + reason, + headroom +}) const sized = (plan: LiquidationPlan): PlanOutcome => ({ plan }) /** @@ -98,15 +136,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 +213,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 +263,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,24 +287,42 @@ 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. +/** + * 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 + +/** + * Incentive headroom of a sized plan, in bps: `(lif - 1) / lif`. + * + * 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 = (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 @@ -269,7 +352,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 +360,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(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 +404,19 @@ 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 = (outcome: PlanOutcome, headroomFloorBps: number): PlanOutcome => { + if (headroomFloorBps <= 0 || outcome.plan === null) return outcome + const bps = headroomBps(outcome.plan) + if (bps >= BigInt(headroomFloorBps)) return outcome + return skip('insufficient_headroom', { + bps, + lif: outcome.plan.lif, + postMaturityMode: outcome.plan.postMaturityMode + }) +} + /** * {@link planWithReason} projected to just the plan, for callers that do not report the skip reason. */ 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/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/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/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 861feeb7..2afc2195 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -50,7 +50,11 @@ const PLAN: LiquidationPlan = { collateralIndex: 0, seizedAssets: 1000n, repaidUnits: 900n, - postMaturityMode: false + postMaturityMode: false, + // 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 = { @@ -101,15 +105,15 @@ 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, venues: overrides.venues ?? ['0x'], - slippageBps: 100, baseUrls: {}, maxRouteImpactBps: 500, unwrappers: [], @@ -179,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/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 1c89f27b..bbe04b2e 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!) } @@ -45,6 +51,9 @@ 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 WAD_ONE = 10n ** 18n +const MAX_LIF = 1036269430051813471n const SWAP_PLAN: SwapPlan = { steps: [ @@ -104,6 +113,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() @@ -122,13 +143,20 @@ function runWith(opts: { inflight?: ReadonlySet 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 /** 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 @@ -148,9 +176,14 @@ function runWith(opts: { chainHead, caller: CALLER, seizeCapMarginBps: 0, - readLens: stubReadLens(opts.out === undefined ? lensOut() : opts.out), - quoteFor: async () => { + headroomFloorBps: opts.headroomFloorBps ?? 0, + minSurplusBps: opts.minSurplusBps ?? 0, + 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 () => { @@ -165,6 +198,7 @@ function runWith(opts: { backoff, cooldown, inflightLabels: () => opts.inflight ?? new Set(), + usdValueOf: opts.usdValueOf ?? ((_loanToken, loanUnits) => loanUnits), logger }) return result.then(counters => ({ @@ -174,6 +208,7 @@ function runWith(opts: { simulateCalls: () => simulateCalls, submitCalls: () => submitCalls, quoteCalls: () => quoteCalls, + order, events })) } @@ -193,10 +228,12 @@ describe('runTick', () => { backoffSkipped: 0, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, ok: 1, reverted: 0, submitted: 1, - notSent: 0 + notSent: 0, + unpriced: 0 }) expect(simulateCalls()).toBe(1) expect(submitCalls()).toBe(1) @@ -221,6 +258,7 @@ describe('runTick', () => { planned: 1, noSwapPath: 1, quoteFailed: 0, + quoteUnprofitable: 0, submitted: 0 }) expect(simulateCalls()).toBe(0) // skipped before simulating @@ -271,6 +309,7 @@ describe('runTick', () => { planned: 1, noSwapPath: 0, quoteFailed: 0, + quoteUnprofitable: 0, submitted: 1 }) expect(quoteCalls()).toBe(0) // bad-debt realization never quotes @@ -435,6 +474,8 @@ describe('runTick', () => { chainHead: 100n, caller: CALLER, seizeCapMarginBps: 0, + headroomFloorBps: 0, + minSurplusBps: 0, readLens: stubReadLens(lensOut()), quoteFor: async () => ({ kind: 'swap', plan: SWAP_PLAN }), simulate: async () => ({ status: 'ok' }), @@ -447,6 +488,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') @@ -475,6 +517,51 @@ 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', + // 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, + 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) + }) + + 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 @@ -507,6 +594,93 @@ 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('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 + // 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') @@ -526,4 +700,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') + }) + }) }) diff --git a/bots/midnight-liquidation/test/sizing/plan.test.ts b/bots/midnight-liquidation/test/sizing/plan.test.ts index bbc3b933..93d7ced6 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,98 @@ 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', () => { + 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', () => { + 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() + }) +}) 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 a2f6cc84..120d4a8a 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -89,6 +89,27 @@ 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`. + */ +/** + * 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}). + */ +export 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 + : 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). @@ -102,6 +123,19 @@ 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. + * + */ + 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. */ @@ -131,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 @@ -141,18 +176,24 @@ async function firmQuoteVenue(args: { chainId: number executor: Address venueEntry: () => SwapConfigEntry - slippageBps: number tokenIn: Address amountIn: bigint steps: SwapStep[] 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 - const params: QuoteParameters = { + // 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 => ({ chainId, tokenIn, tokenOut: loanToken, @@ -160,20 +201,49 @@ 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(askableFloor, denominator), + minAcceptableAmountOut, 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 } + // 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 + // 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 + } + + // 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, minAcceptableAmountOut)) { + return { kind: 'floor_unmet', swap, floor: minAcceptableAmountOut } } // The reference stays the FULL-PATH oracle value (collateral → loan): the unwrap chain threads its @@ -266,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.info('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: 'floor_unmet' } + } logger.info('quote.ok', { venue: 'unwrap-only', id: request.id, @@ -313,8 +399,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 @@ -334,7 +418,6 @@ export function composeMultiVenueQuoting(deps: { chainId, executor, venues, - slippageBps, baseUrls, maxRouteImpactBps, unwrappers, @@ -348,13 +431,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: @@ -404,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({ @@ -411,7 +496,6 @@ export function composeMultiVenueQuoting(deps: { chainId, executor, venueEntry: () => entryFor(venue), - slippageBps, tokenIn: resolution.token, amountIn: resolution.amountIn, steps: resolution.steps, @@ -429,6 +513,22 @@ export function composeMultiVenueQuoting(deps: { }) continue } + if (outcome.kind === '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, + 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 c8cb6cda..8d50403a 100644 --- a/packages/swaps/src/types.ts +++ b/packages/swaps/src/types.ts @@ -24,10 +24,25 @@ 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. Most venues accept only a percentage; the ones that take an absolute + * minimum read {@link QuoteParameters.minAcceptableAmountOut} instead. + */ slippageBps: number 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 } /** @@ -52,6 +67,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' } /** @@ -111,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/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..cc6e4aa8 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. */ @@ -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, @@ -41,7 +42,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,7 +81,11 @@ export async function quoteOneInch( callData: json.tx.data, amountIn: { source: 'fixed', value: params.amountIn }, expectedAmountOut, - amountOutMinimum: (expectedAmountOut * (BPS - BigInt(params.slippageBps))) / BPS + // 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/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 529cc2bd..1a0dee9f 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: () => {} } @@ -26,7 +26,10 @@ const REQUEST: QuoteRequest = { collateralToken: COLLATERAL, loanToken: LOAN, amountIn: 1000n, - referenceAmountOut: 1000n + referenceAmountOut: 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 @@ -82,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' } } } @@ -143,7 +146,6 @@ function composeMulti( chainId: 8453, executor: EXECUTOR, venues, - slippageBps: 50, baseUrls: {}, maxRouteImpactBps: 500, // floor = 950 unwrappers: options.unwrappers ?? [], @@ -182,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 () => { @@ -239,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: 'floor_unmet' + }) + }) + 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, @@ -346,3 +371,187 @@ describe('composeMultiVenueQuoting', () => { expect(unwrapper.probed).toHaveLength(0) }) }) + +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 }[] = [] + const client: RateLimitedClient = { + getJson: async (args: { searchParams?: Record }) => { + calls.push(args) + return body as T + } + } + 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(['0x'], [{ venue: '0x', expectedOut: 1000n }], client) + await quoteFor(request) + return { calls } + } + + 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 }, + zeroxBody('1000') + ) + 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 }, + zeroxBody('1000') + ) + 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 }, + zeroxBody('1000') + ) + // 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 + // 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 }, + zeroxBody('10000') + ) + const late = await quoteWith( + { ...REQUEST, referenceAmountOut: REFERENCE, minAcceptableAmountOut: 9580n }, + 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?.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?.slippageBps).toBe('420') + }) +}) + +// 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 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) + 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 } + } + + 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 + // 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) + }) + + // 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(sent).toHaveLength(2) + // 9600 · 9877/10000 = 9481, which is 99 units under break-even. + expect(outcome).toEqual({ kind: 'failed', reason: 'floor_unmet' }) + }) + + 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 zeroxBody('9700', '9292') as T + } + } + 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: 'floor_unmet' }) + }) + + 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: REFERENCE + }) + + expect(outcome.kind).toBe('swap') + expect(sent).toHaveLength(1) + }) + + 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(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.