From b760271d7e920f6f75ec4c2a7409756a02d1a093 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 31 Aug 2026 22:15:25 -0500 Subject: [PATCH 1/3] fix(bots): close regressions found reviewing the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression sweep over #184/#187/#188/#189 found four behaviors the stack changed without meaning to. None are new functionality; each restores what the bots did on main. Detection-only blue could broadcast. The no-venues gate moved to AFTER unwrap resolution and the swap-free branch lost its `steps.length > 0` guard, so a venue-less deployment returned `kind: 'swap'` — which the tick takes straight to simulate+submit. docker-compose defaults Robinhood (4663) to ALLOW_DETECTION_ONLY with a funded key, documented as skipping every routed liquidation. `swapFreeWithoutVenues` (default false) restores the immediate refusal; midnight opts in, because its loan-as-collateral slots need no route and ALLOW_BAD_DEBT_ONLY is a supported posture there. That same reorder downgraded a transient unwrapper RPC failure from `no_config` (skip) to `failed` (arms backoff), pushing a deliberately unarmed deployment into a suppression state machine it never entered. Refusing before the unwrap chain fixes both at once, and spends no reads doing it. A send REJECTION now arms backoff even when a sibling execution-reverted. Both sets are keyed by position, so `backoffExempt` was cancelling the backoff a broken nonce/funds/RPC send earned; the position then re-sent every block while the send machinery was still broken. Phase A.5 no longer resolves routes for suppressed positions. It ran ahead of the cooldown/backoff gates, so a backed-off position spent an uncached read per candidate per tick — breaking backoff's contract that it bounds API and RPC usage under a backlog, and putting that latency in front of the first send of a maturity burst. Each of the 8 new tests was verified to fail against the pre-fix source. Co-Authored-By: Claude Opus 5 (1M context) --- bots/blue-liquidation/test/quotes.test.ts | 41 +++++++++++- bots/midnight-liquidation/src/quotes.ts | 4 ++ bots/midnight-liquidation/src/runner/tick.ts | 39 ++++++++++-- .../test/runner/tick.test.ts | 61 ++++++++++++++++++ packages/swaps/src/quoting.ts | 33 +++++++--- packages/swaps/test/quoting.test.ts | 62 ++++++++++++++++--- 6 files changed, 218 insertions(+), 22 deletions(-) diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index fa6bceb6..50ac7c86 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -1,5 +1,5 @@ import type { Logger } from '@repo/bot-kit' -import type { RateLimitedClient, Venue, VenuePair, VenueSelector } from '@repo/swaps' +import type { RateLimitedClient, Unwrapper, Venue, VenuePair, VenueSelector } from '@repo/swaps' import { getAddress } from 'viem' import { describe, expect, it } from 'vitest' @@ -96,6 +96,7 @@ function compose( excludeCollaterals?: `0x${string}`[] logger?: Logger httpClient?: RateLimitedClient + unwrappers?: readonly Unwrapper[] } = {} ) { return composeQuoting({ @@ -106,12 +107,48 @@ function compose( venues: overrides.venues ?? ['0x'], baseUrls: {}, maxRouteImpactBps: 500, - unwrappers: [], + unwrappers: overrides.unwrappers ?? [], excludeCollaterals: overrides.excludeCollaterals ?? [], logger: overrides.logger ?? NOOP_LOGGER }) } +describe('detection-only posture (no venues enabled)', () => { + // ALLOW_DETECTION_ONLY exists so an operator can run Blue unarmed — docker-compose defaults + // Robinhood (4663) to it with a funded LIQUIDATOR_PRIVATE_KEY. `kind: 'swap'` goes straight to + // simulate+submit in the tick, so the adapter must never produce one, whatever the collateral is. + // Blue has no swap-free liquidation path, which is why it leaves `swapFreeWithoutVenues` off. + const unwrapToLoan: Unwrapper & { probed: `0x${string}`[] } = { + kind: 'fake-erc4626', + probed: [], + async resolve({ token }) { + unwrapToLoan.probed.push(token) + return { + step: { + tokenIn: COLLATERAL, + tokenOut: LOAN, + target: TARGET, + value: 0n, + callData: '0x12345678', + amountIn: { source: 'balance', offset: 4n } + }, + expectedAmountOut: 1000n, + amountOutMinimum: 1000n + } + } + } + + it('never returns a broadcastable plan for a collateral that unwraps to the loan token', async () => { + unwrapToLoan.probed.length = 0 + const { selector, refreshed } = fakeSelector(['0x']) + const { quoteFor } = compose(selector, { venues: [], unwrappers: [unwrapToLoan] }) + expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 }) + // And it costs nothing: the refusal precedes unwrap resolution, so no detection read is spent. + expect(unwrapToLoan.probed).toHaveLength(0) + expect(refreshed).toHaveLength(0) + }) +}) + describe('composeQuoting (Blue lens-projection adapter)', () => { it('returns no_config (and never probes) for an excluded collateral', async () => { const { selector, refreshed } = fakeSelector(['0x']) diff --git a/bots/midnight-liquidation/src/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index faa6adf7..647d1a73 100644 --- a/bots/midnight-liquidation/src/quotes.ts +++ b/bots/midnight-liquidation/src/quotes.ts @@ -76,6 +76,10 @@ export function composeQuoting(deps: { ...rest, executor, unwrappers, + // Midnight liquidates loan-as-collateral slots with no route at all, and `ALLOW_BAD_DEBT_ONLY` + // is a supported posture, so a venue-less deployment must still resolve the unwrap chain to find + // those. Blue leaves this off: it has no such mode, and staying unarmed is the point there. + swapFreeWithoutVenues: true, // The composer owns the probe refresh now: it runs after unwrap resolution so probes price the // tradable underlying, not an exotic collateral the venues can't quote. refresh: selector.refresh, diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index 617ff666..70a138ac 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -372,7 +372,7 @@ type TickRouting = { * **Pairs are deduplicated before anything is warmed**, keyed exactly as the probe cache keys them, so * several slots on one pair — or several positions in one market — trigger one sweep; and no pair * without a sized candidate is ever probed. A candidate that needs no route at all (a swap-free slot, a - * bad-debt write-off) resolves nothing and warms nothing. + * bad-debt write-off) — or one phase B will suppress anyway — resolves nothing and warms nothing. * * Failure is non-fatal in both stages: an unresolved candidate is left `unknown` and a failed warm * simply leaves the curve cold, which fails the affected positions open to gross-surplus ordering. @@ -383,13 +383,26 @@ type TickRouting = { const prepareRoutes = async (deps: { sized: readonly SizedCandidate[] routing: TickRouting + /** + * Whether phase B will skip this candidate outright. Suppression is position-keyed and constant + * within a tick, so consulting it here is safe and keeps backoff's contract — a suppressed position + * costs no RPC and no venue traffic — which resolving routes ahead of the gate would have broken. + */ + suppressed: (candidate: SizedCandidate) => boolean logger: Logger }): Promise> => { - const { sized, routing, logger } = deps + const { sized, routing, suppressed, logger } = deps const states = new Map() const pairs = new Map() for (const candidate of sized) { + // `unknown` rather than `no_route`: this route's cost is genuinely unmeasured, and claiming it + // needs none would price it at zero and let it outrank a costed sibling. Every candidate of a + // suppressed position is suppressed, so the fail-open ordering this triggers is moot for it. + if (suppressed(candidate)) { + states.set(candidate, { kind: 'unknown' }) + continue + } // Two shapes need no route, and they must not be costed as `unknown`: that is a per-POSITION // fail-open signal (see {@link RouteState}), so reporting it here would drop a whole write-off // position back to gross ordering. A write-off also never reaches `quoteFor` at all — phase B @@ -713,7 +726,16 @@ export async function runTick(deps: { // 5. Phase A.5 — the async step between sizing and the expensive work: resolve each candidate's // route and start warming the (deduplicated) probe curves for those pairs only. The warm is NOT // awaited; this tick reads whatever the cache already holds (see {@link prepareRoutes}). - const states = await prepareRoutes({ sized: probeable, routing, logger }) + const states = await prepareRoutes({ + sized: probeable, + routing, + // The same two gates phase B applies at its cooldown/backoff checks, read here so a suppressed + // position spends nothing. `needsSwap` mirrors phase B exactly: a write-off is never backed off. + suppressed: candidate => + cooldown.shouldSkip(candidate.label) || + (!isBadDebtRealization(candidate.plan) && backoff.shouldSkip(candidate.label, chainHead)), + logger + }) // 6. Rank net of route cost, THEN truncate. Both halves matter: charging the route makes a swap-free // slot beat a nominally larger swap slot the incentive cannot fund, and capping afterwards means the @@ -750,6 +772,10 @@ export async function runTick(deps: { // Positions an execution-reverted send exempts from `pendingBackoff` — see the `executionRevert` // arm for why the exemption has to be a latch. const backoffExempt = new Set() + // Positions a send REJECTION arms unconditionally, overriding that exemption. Both sets are keyed + // by position, so without this a sibling's execution revert would cancel the backoff a broken + // nonce/funds/RPC send earned — see the `sendRejected` arm. + const backoffForced = new Set() const pendingCooldown = new Set() let complete = false // Firm venue calls this tick, `null` while no quote reported any: an absent `firmCalls` is unknown, @@ -1003,8 +1029,11 @@ export async function runTick(deps: { } else { counters.sendRejected += 1 // The send machinery failed (nonce, funds, RPC): a fact about this position that says nothing - // about the chain's view of the plan, so it arms backoff and ends the streak. + // about the chain's view of the plan, so it arms backoff and ends the streak. FORCED, because + // a sibling's execution revert is a verdict on that sibling's plan and refutes nothing about + // broken send machinery — without the override the position would re-send every block. pendingBackoff.add(label) + backoffForced.add(label) revertStreaks.reset(label) } } @@ -1016,7 +1045,7 @@ export async function runTick(deps: { // preamble). In the `finally` so an aborting `submit` still records what the tick learned. for (const label of pendingCooldown) cooldown.mark(label) for (const label of pendingBackoff) { - if (!backoffExempt.has(label)) backoff.record(label, chainHead) + if (backoffForced.has(label) || !backoffExempt.has(label)) backoff.record(label, chainHead) } logger.info('tick.end', { ...counters, diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index 54d31718..7c3c261d 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -246,6 +246,8 @@ function runWith(opts: { // Collateral tokens phase A.5 asked to be warmed, in call order — one entry per refresh actually // issued, so a duplicate here is a duplicated probe burst. const warmed: Address[] = [] + // Candidate slots phase A.5 resolved a route for — the RPC a suppressed position must not spend. + const resolved: number[] = [] // Every streak call in order, so a test can pin that the tick records the chain's declines and ends // the streak on everything else — the store's own arithmetic is covered in revert-streak.test.ts. const streakCalls: { kind: 'record' | 'reset'; label: string; selector?: Hex }[] = [] @@ -268,6 +270,7 @@ function runWith(opts: { } const routing = { resolveRoute: async (plan: LiquidationPlan, out: LensOut) => { + resolved.push(plan.collateralIndex) const collateral = out.market.collateralParams[plan.collateralIndex] if (!collateral || collateral.token === out.market.loanToken) return null return { @@ -343,6 +346,7 @@ function runWith(opts: { quoteCalls: () => quoteCalls, order, warmed, + resolved, streakCalls, events })) @@ -429,6 +433,28 @@ describe('runTick', () => { expect(simulateCalls()).toBe(0) }) + it('spends no route RPC or probe warm on a suppressed position', async () => { + // Backoff exists to bound API + RPC usage under a backlog, so the suppression gate has to be read + // in phase A.5 too: resolving routes there would spend an uncached read per candidate per tick on + // exactly the positions the gate exists to stop, and put that latency ahead of the first send. + const { counters, resolved, warmed } = await runWith({ + chainHead: 101n, + seedBackoffAt: 100n + }) + expect(counters).toMatchObject({ backoffSkipped: 1 }) + expect(resolved).toHaveLength(0) + expect(warmed).toHaveLength(0) + }) + + it('spends no route RPC on a cooled-down position', async () => { + const cooldown = createCooldownStore({ cooldownMs: 60_000 }) + cooldown.mark(LABEL) + const { counters, resolved, warmed } = await runWith({ cooldown }) + expect(counters).toMatchObject({ cooledDown: 1 }) + expect(resolved).toHaveLength(0) + expect(warmed).toHaveLength(0) + }) + it('clears backoff on a successful submit', async () => { const { backoff } = await runWith({ seedBackoffAt: 1n, simulateResult: { status: 'ok' } }) // Seeded at block 1 (cooldown until 3) so it didn't suppress this tick at 100; the submit clears it. @@ -808,6 +834,41 @@ describe('runTick', () => { expectCounterIdentities(counters) }) + it('a send REJECTION still suppresses, even when a sibling execution-reverted', async () => { + // The exemption is an economic verdict on ONE candidate's plan; it refutes nothing about + // broken send machinery, which is a fact about the position. Without the override the + // position would re-send into a broken nonce/funds/RPC path every block. + let calls = 0 + const { counters, backoff } = await runWith({ + out: twoSlots(), + submitWith: async () => { + calls += 1 + return calls === 1 + ? { sent: false, reason: 'send_failed', executionRevert: false } + : declined + } + }) + expect(counters).toMatchObject({ candidates: 2, sendRejected: 1, sendReverted: 1 }) + expect(backoff.shouldSkip(LABEL, 101n)).toBe(true) + expectCounterIdentities(counters) + }) + + it('suppresses on a rejection that lands AFTER the sibling execution revert', async () => { + let calls = 0 + const { counters, backoff } = await runWith({ + out: twoSlots(), + submitWith: async () => { + calls += 1 + return calls === 1 + ? declined + : { sent: false, reason: 'send_failed', executionRevert: false } + } + }) + expect(counters).toMatchObject({ candidates: 2, sendRejected: 1, sendReverted: 1 }) + expect(backoff.shouldSkip(LABEL, 101n)).toBe(true) + expectCounterIdentities(counters) + }) + it('an execution revert then a quote failure leaves the position unsuppressed', async () => { const { counters, backoff } = await runWith({ out: twoSlots(), diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index e8f32a93..f15e207a 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -580,11 +580,11 @@ export type QuoteLogger = { * cost is reported as {@link QuoteOutcome.firmCalls}. Quotes are made ONLY for liquidatable positions, * so API + probe usage is bounded by the (small) liquidatable set, never the full candidate universe. * - * No enabled venues → `no_config` (no API call, no probe), preserving the caller's no-swap posture — - * but the {@link swapFreePlan} short-circuit is evaluated FIRST, so a sell path already ending in the - * loan token still resolves. The unwrap chain therefore runs even with no venues enabled, since that - * is what determines whether a venue is needed at all; unwrappers memoize their negatives, so a plain - * ERC-20 collateral costs one read per token for the process lifetime. + * No enabled venues → `no_config`, preserving the caller's no-swap posture. Whether that refusal is + * immediate depends on {@link composeMultiVenueQuoting}'s `swapFreeWithoutVenues`: a caller with a + * swap-free liquidation path resolves the unwrap chain first, because that chain is what decides + * whether a venue is needed at all; a caller without one refuses before spending any RPC, since + * nothing downstream could succeed. */ export function composeMultiVenueQuoting(deps: { httpClient: RateLimitedClient @@ -597,6 +597,16 @@ export function composeMultiVenueQuoting(deps: { maxRouteImpactBps: number /** Pre-swap converters, tried in order each hop. Pass `[]` for venue-only quoting. */ unwrappers: readonly Unwrapper[] + /** + * Whether a venue-less deployment may still act on a plan that needs no venue (the sell path + * already ends in the loan token). Only for callers whose protocol has such a mode — Midnight's + * loan-as-collateral slots, which must stay liquidatable in `ALLOW_BAD_DEBT_ONLY` mode. + * + * Defaults to `false`, which is what keeps a deliberately unarmed deployment unarmed: with no + * venues the quote refuses immediately, before any unwrap RPC, so the bot can neither broadcast + * nor arm per-position backoff off a transient read failure. + */ + swapFreeWithoutVenues?: boolean /** * Probe-cache refresh for the (post-unwrap) pair — runs here, after unwrap resolution, so the * probes price the tradable underlying. Failures are non-fatal (cold-default venue order). @@ -621,6 +631,7 @@ export function composeMultiVenueQuoting(deps: { baseUrls, maxRouteImpactBps, unwrappers, + swapFreeWithoutVenues = false, refresh, select, logger @@ -695,14 +706,20 @@ export function composeMultiVenueQuoting(deps: { const { collateralToken, loanToken, referenceAmountOut } = request const correlation = correlationOf(request) + // A caller with no swap-free path cannot act on ANY outcome once the venues are gone, so the + // refusal comes before the unwrap chain rather than after it. Resolving first would both spend + // reads that provably cannot lead to a broadcast and downgrade a transient read failure from + // `no_config` (skip) to `failed` (arms backoff) — see `swapFreeWithoutVenues`. + if (venues.length === 0 && !swapFreeWithoutVenues) return { kind: 'no_config', firmCalls: 0 } + const unwrapped = await tryResolveUnwraps(unwrappers, request, executor, logger) if ('outcome' in unwrapped) return { ...unwrapped.outcome, firmCalls: 0 } const { resolution } = unwrapped // Nothing left to sell, so this resolves WITHOUT a venue — deliberately ahead of the - // no-venues gate below. A collateral token that is already the loan token needs no route at - // all, so a keyless / bad-debt-only deployment must still be able to liquidate it; gating on - // `venues` first would refuse the one case that provably does not need one. + // no-venues gate below. A sell path already ending in the loan token needs no route at all, so + // a caller that opted into `swapFreeWithoutVenues` must still be able to liquidate it; gating + // on `venues` first would refuse the one case that provably does not need one. if (isAddressEqual(resolution.token, loanToken)) { return { ...swapFreePlan({ resolution, request, maxRouteImpactBps, logger }), firmCalls: 0 } } diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 23b4ad0c..2bcd5797 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -65,6 +65,20 @@ function fakeUnwrapper(args: { from: Address; to: Address; out: bigint }): Unwra } } +// An unwrapper whose detection read fails, standing in for a transient RPC failure inside erc4626 / +// Pendle resolution. Records whether it was reached at all. +function throwingUnwrapper(): Unwrapper & { probed: Address[] } { + const probed: Address[] = [] + return { + kind: 'throwing', + probed, + async resolve({ token }) { + probed.push(token) + throw new QuoteError('api_error', 'detection read failed') + } + } +} + const throwingHttp: RateLimitedClient = { getJson: async () => { throw new QuoteError('rate_limited', 'boom') @@ -157,7 +171,12 @@ function composeMulti( venues: Venue[], order: { venue: Venue; expectedOut: bigint }[], httpClient: RateLimitedClient, - options: { unwrappers?: readonly Unwrapper[]; clamped?: boolean; ageMs?: number } = {} + options: { + unwrappers?: readonly Unwrapper[] + clamped?: boolean + ageMs?: number + swapFreeWithoutVenues?: boolean + } = {} ) { const refreshed: { collateral: Address; loan: Address }[] = [] const selected: { @@ -178,6 +197,7 @@ function composeMulti( baseUrls: {}, maxRouteImpactBps: 500, // floor = 950 unwrappers: options.unwrappers ?? [], + swapFreeWithoutVenues: options.swapFreeWithoutVenues ?? false, refresh: async pair => { refreshed.push(pair) }, @@ -416,13 +436,33 @@ describe('composeMultiVenueQuoting', () => { expect(refreshed).toHaveLength(0) }) - it('still resolves unwraps with no venues, then reports no_config for a chain needing one', async () => { - // The no-venues gate now sits AFTER the swap-free short-circuit, so the unwrap chain runs first — - // it is what decides whether a venue is needed at all. A chain ending somewhere other than the - // loan token still needs one, so the outcome is unchanged; only the probing is. + it('skips unwrap resolution entirely with no venues and no swap-free path', async () => { + // The refusal must come BEFORE the unwrap chain: a caller with no swap-free path cannot act on + // any outcome, so resolving first would spend reads that provably cannot lead to a broadcast. const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: UNDERLYING, out: 970n }) const { quoteFor } = composeMulti([], [], multiHttp({}), { unwrappers: [unwrapper] }) expect(await quoteFor(REQUEST)).toEqual({ kind: 'no_config', firmCalls: 0 }) + expect(unwrapper.probed).toHaveLength(0) + }) + + it('reports no_config, never failed, when an unwrapper throws with no venues', async () => { + // `failed` arms per-position backoff where `no_config` does not, so a transient read failure must + // not push a deliberately unarmed deployment into a suppression state machine it never enters. + const unwrapper = throwingUnwrapper() + const { quoteFor } = composeMulti([], [], multiHttp({}), { unwrappers: [unwrapper] }) + expect(await quoteFor(REQUEST)).toEqual({ kind: 'no_config', firmCalls: 0 }) + }) + + it('still resolves unwraps with no venues when the caller has a swap-free path', async () => { + // Midnight's posture: the unwrap chain is what decides whether a venue is needed at all, so a + // bad-debt-only deployment resolves it. A chain ending elsewhere than the loan token still needs + // a venue, so the outcome is unchanged; only the probing is. + const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: UNDERLYING, out: 970n }) + const { quoteFor } = composeMulti([], [], multiHttp({}), { + unwrappers: [unwrapper], + swapFreeWithoutVenues: true + }) + expect(await quoteFor(REQUEST)).toEqual({ kind: 'no_config', firmCalls: 0 }) expect(unwrapper.probed.length).toBeGreaterThan(0) }) @@ -447,14 +487,22 @@ describe('composeMultiVenueQuoting', () => { expect(selected).toHaveLength(0) }) - it('resolves even with NO venues enabled — no route is needed, so none is required', async () => { + it('resolves with NO venues enabled when the caller opted into a swap-free path', async () => { // The point of moving the gate: a keyless / bad-debt-only deployment must still clear these. - const { quoteFor } = composeMulti([], [], throwingHttp) + const { quoteFor } = composeMulti([], [], throwingHttp, { swapFreeWithoutVenues: true }) const outcome = await quoteFor(SELF) expect(outcome.kind).toBe('swap') if (outcome.kind === 'swap') expect(outcome.plan.steps).toHaveLength(0) }) + it('refuses with NO venues enabled when the caller has no swap-free path', async () => { + // The safety invariant behind a detection-only deployment: with the venues gone it must hand + // back nothing actionable, because its caller takes `kind: 'swap'` straight to simulate+submit. + // Opting in is what arms this path, so the default must never produce a broadcastable plan. + const { quoteFor } = composeMulti([], [], throwingHttp) + expect(await quoteFor(SELF)).toEqual({ kind: 'no_config', firmCalls: 0 }) + }) + it('does not consult the unwrappers — the sell path is already over', async () => { const unwrapper = fakeUnwrapper({ from: LOAN, to: UNDERLYING, out: 1000n }) const { quoteFor } = composeMulti(['0x'], [], throwingHttp, { unwrappers: [unwrapper] }) From 93b35977afa81d584058ff9bc7e0425fbfceeec1 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 31 Aug 2026 22:25:54 -0500 Subject: [PATCH 2/3] address review of the stack fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The midnight opt-in was untested: deleting `swapFreeWithoutVenues: true` broke no test, because the existing no-venues case uses a collateral that is not the loan token. Adds the loan-as-collateral case that pins it, verified to fail when the flag is removed. TIB-2026-08-28 asserted the swap-free short-circuit runs before the no-venues gate as an unconditional package property. It is now opt-in, and the record says so — including why, since that ordering is what let a venue-less blue return a broadcastable plan. Also: `Address` over an inline hex template in the blue fixture; one home for the `swapFreeWithoutVenues` rationale instead of four; `isCooled`/`isBackedOff` defined once rather than open-coded in both phase A.5 and phase B (their only guarantee of agreement was a comment, which referenced a symbol not in scope); `probe.warmed` counts candidates that actually resolved, so its ratio no longer drifts under a backlog; README's probe-gating claim narrowed to match. Co-Authored-By: Claude Opus 5 (1M context) --- bots/blue-liquidation/test/quotes.test.ts | 67 ++++++++++--------- bots/midnight-liquidation/README.md | 3 +- bots/midnight-liquidation/src/quotes.ts | 4 +- bots/midnight-liquidation/src/runner/tick.ts | 41 +++++++----- bots/midnight-liquidation/test/quotes.test.ts | 19 ++++++ ...-2026-08-28-midnight-loan-as-collateral.md | 7 +- packages/swaps/src/quoting.ts | 22 ++---- packages/swaps/test/quoting.test.ts | 4 +- 8 files changed, 95 insertions(+), 72 deletions(-) diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index 50ac7c86..b330267d 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -1,5 +1,6 @@ import type { Logger } from '@repo/bot-kit' import type { RateLimitedClient, Unwrapper, Venue, VenuePair, VenueSelector } from '@repo/swaps' +import type { Address } from 'viem' import { getAddress } from 'viem' import { describe, expect, it } from 'vitest' @@ -89,6 +90,31 @@ function fakeSelector( return { selector, refreshed } } +// An unwrapper converting the collateral straight to the loan token, recording what it was asked +// about — so a test can assert the read was never spent. +const unwrapsToLoan = (): Unwrapper & { probed: Address[] } => { + const probed: Address[] = [] + return { + kind: 'fake-erc4626', + probed, + async resolve({ token }) { + probed.push(token) + return { + step: { + tokenIn: COLLATERAL, + tokenOut: LOAN, + target: TARGET, + value: 0n, + callData: '0x12345678', + amountIn: { source: 'balance', offset: 4n } + }, + expectedAmountOut: 1000n, + amountOutMinimum: 1000n + } + } + } +} + function compose( selector: VenueSelector, overrides: { @@ -113,43 +139,20 @@ function compose( }) } -describe('detection-only posture (no venues enabled)', () => { - // ALLOW_DETECTION_ONLY exists so an operator can run Blue unarmed — docker-compose defaults - // Robinhood (4663) to it with a funded LIQUIDATOR_PRIVATE_KEY. `kind: 'swap'` goes straight to - // simulate+submit in the tick, so the adapter must never produce one, whatever the collateral is. - // Blue has no swap-free liquidation path, which is why it leaves `swapFreeWithoutVenues` off. - const unwrapToLoan: Unwrapper & { probed: `0x${string}`[] } = { - kind: 'fake-erc4626', - probed: [], - async resolve({ token }) { - unwrapToLoan.probed.push(token) - return { - step: { - tokenIn: COLLATERAL, - tokenOut: LOAN, - target: TARGET, - value: 0n, - callData: '0x12345678', - amountIn: { source: 'balance', offset: 4n } - }, - expectedAmountOut: 1000n, - amountOutMinimum: 1000n - } - } - } - - it('never returns a broadcastable plan for a collateral that unwraps to the loan token', async () => { - unwrapToLoan.probed.length = 0 +describe('composeQuoting (Blue lens-projection adapter)', () => { + it('passes no swap-free opt-in, so zero venues never yields a broadcastable plan', async () => { + // `kind: 'swap'` goes straight to simulate+submit in the tick, and Blue supports running unarmed + // (ALLOW_DETECTION_ONLY). It has no swap-free liquidation path, so it leaves the package's + // `swapFreeWithoutVenues` off — even a collateral that unwraps straight to the loan token. + const unwrapper = unwrapsToLoan() const { selector, refreshed } = fakeSelector(['0x']) - const { quoteFor } = compose(selector, { venues: [], unwrappers: [unwrapToLoan] }) + const { quoteFor } = compose(selector, { venues: [], unwrappers: [unwrapper] }) expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 }) - // And it costs nothing: the refusal precedes unwrap resolution, so no detection read is spent. - expect(unwrapToLoan.probed).toHaveLength(0) + // The refusal precedes unwrap resolution, so it also spends no detection read. + expect(unwrapper.probed).toHaveLength(0) expect(refreshed).toHaveLength(0) }) -}) -describe('composeQuoting (Blue lens-projection adapter)', () => { it('returns no_config (and never probes) for an excluded collateral', async () => { const { selector, refreshed } = fakeSelector(['0x']) const { quoteFor } = compose(selector, { excludeCollaterals: [COLLATERAL] }) diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index d1474ec9..00a5467d 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -164,7 +164,8 @@ There is no swap config file. Instead: collateral→loan pair, a background job requests indicative quotes from every enabled venue at the `PROBE_LADDER` sizes and caches each venue's rate curve, which is then interpolated at the actual seize size. This probe is **gated** to pairs that - have a liquidatable position and cached for `PROBE_STALE_MS`, and runs on its own `PROBE_HTTP_RPS` + have a liquidatable position that is neither cooled-down nor backed off, is cached for + `PROBE_STALE_MS`, and runs on its own `PROBE_HTTP_RPS` budget — so venues' tight rate limits (~1 req/sec) are respected and quiet markets cost nothing. It is started, never awaited: a pair is warmed for the next tick rather than delaying this one. - **When a position is liquidatable**, the bot firm-quotes once against the pre-chosen best venue and diff --git a/bots/midnight-liquidation/src/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index 647d1a73..6e7928a3 100644 --- a/bots/midnight-liquidation/src/quotes.ts +++ b/bots/midnight-liquidation/src/quotes.ts @@ -76,9 +76,7 @@ export function composeQuoting(deps: { ...rest, executor, unwrappers, - // Midnight liquidates loan-as-collateral slots with no route at all, and `ALLOW_BAD_DEBT_ONLY` - // is a supported posture, so a venue-less deployment must still resolve the unwrap chain to find - // those. Blue leaves this off: it has no such mode, and staying unarmed is the point there. + // Loan-as-collateral slots need no route, and `ALLOW_BAD_DEBT_ONLY` is supported here. swapFreeWithoutVenues: true, // The composer owns the probe refresh now: it runs after unwrap resolution so probes price the // tradable underlying, not an exotic collateral the venues can't quote. diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index 70a138ac..aebf821c 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -384,9 +384,8 @@ const prepareRoutes = async (deps: { sized: readonly SizedCandidate[] routing: TickRouting /** - * Whether phase B will skip this candidate outright. Suppression is position-keyed and constant - * within a tick, so consulting it here is safe and keeps backoff's contract — a suppressed position - * costs no RPC and no venue traffic — which resolving routes ahead of the gate would have broken. + * Whether phase B will skip this candidate outright, so a suppressed position costs no RPC and no + * venue traffic. */ suppressed: (candidate: SizedCandidate) => boolean logger: Logger @@ -394,11 +393,11 @@ const prepareRoutes = async (deps: { const { sized, routing, suppressed, logger } = deps const states = new Map() const pairs = new Map() + let resolvedCount = 0 for (const candidate of sized) { - // `unknown` rather than `no_route`: this route's cost is genuinely unmeasured, and claiming it - // needs none would price it at zero and let it outrank a costed sibling. Every candidate of a - // suppressed position is suppressed, so the fail-open ordering this triggers is moot for it. + // `unknown`, not `no_route`: claiming it needs none would price it at zero and let it outrank a + // costed sibling. The fail-open ordering that triggers is moot — this position never executes. if (suppressed(candidate)) { states.set(candidate, { kind: 'unknown' }) continue @@ -412,6 +411,7 @@ const prepareRoutes = async (deps: { states.set(candidate, { kind: 'no_route' }) continue } + resolvedCount += 1 const resolved = await tryCatch( routing.resolveRoute(candidate.plan, candidate.out, candidate.label) ) @@ -443,7 +443,9 @@ const prepareRoutes = async (deps: { } }) } - if (pairs.size > 0) logger.info('probe.warmed', { pairs: pairs.size, candidates: sized.length }) + // `candidates` counts what actually reached route resolution, not `sized.length` — under a backlog + // most of the batch is suppressed and resolves nothing, so the ratio would otherwise drift. + if (pairs.size > 0) logger.info('probe.warmed', { pairs: pairs.size, candidates: resolvedCount }) return states } @@ -714,6 +716,15 @@ export async function runTick(deps: { } } + // The two suppression gates, defined once because phase A.5 and phase B must agree: A.5 skips the + // route RPC for whatever B will skip outright, which is what keeps backoff's promise to bound API + // and RPC usage under a backlog. Both stores are position-keyed and unwritten until the tick's + // `finally`, so their verdict is constant across a position's candidates within one tick. + const isCooled = (label: string) => cooldown.shouldSkip(label) + // A write-off sells nothing, so backoff — which suppresses quote/simulate churn — never applies. + const isBackedOff = (label: string, plan: LiquidationPlan) => + !isBadDebtRealization(plan) && backoff.shouldSkip(label, chainHead) + // 4. Bound the probe fan-out BEFORE resolving anything, on the gross ordering — this is the one cap // that must be applied blind, because it is what bounds learning the cost. It is looser than the // final cap so the net ordering below still has a superset to reorder within. @@ -729,11 +740,8 @@ export async function runTick(deps: { const states = await prepareRoutes({ sized: probeable, routing, - // The same two gates phase B applies at its cooldown/backoff checks, read here so a suppressed - // position spends nothing. `needsSwap` mirrors phase B exactly: a write-off is never backed off. suppressed: candidate => - cooldown.shouldSkip(candidate.label) || - (!isBadDebtRealization(candidate.plan) && backoff.shouldSkip(candidate.label, chainHead)), + isCooled(candidate.label) || isBackedOff(candidate.label, candidate.plan), logger }) @@ -841,7 +849,7 @@ export async function runTick(deps: { // submittable tx is skipped until its wall-clock window elapses — bad-debt realizations included, // so a repeatedly-reverting one also backs off. No-op when disabled // (POSITION_LIQUIDATION_COOLDOWN_MS=0). - if (cooldown.shouldSkip(label)) { + if (isCooled(label)) { counters.cooledDown += 1 logger.info('cooldown.skip', { id: label, @@ -856,9 +864,7 @@ export async function runTick(deps: { // The swap funds repay/seize liquidations. Pure bad-debt realization transfers no assets, so it // deliberately skips quoting and executes as a no-callback `liquidate`. const needsSwap = !isBadDebtRealization(liquidationPlan) - // Suppress positions that keep failing to quote/simulate — bounds API + RPC usage under a - // backlog, since executable quotes are spent only on positions not currently backed off. - if (needsSwap && backoff.shouldSkip(label, chainHead)) { + if (isBackedOff(label, liquidationPlan)) { counters.backoffSkipped += 1 continue } @@ -1029,9 +1035,8 @@ export async function runTick(deps: { } else { counters.sendRejected += 1 // The send machinery failed (nonce, funds, RPC): a fact about this position that says nothing - // about the chain's view of the plan, so it arms backoff and ends the streak. FORCED, because - // a sibling's execution revert is a verdict on that sibling's plan and refutes nothing about - // broken send machinery — without the override the position would re-send every block. + // about the chain's view of the plan, so it arms backoff and ends the streak — forced over any + // sibling's exemption, see `backoffForced`. pendingBackoff.add(label) backoffForced.add(label) revertStreaks.reset(label) diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index e7e6b983..3cb99976 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -186,6 +186,25 @@ describe('composeQuoting (Midnight lens-projection adapter)', () => { expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 }) }) + it('still clears a loan-as-collateral slot with no venues enabled', async () => { + // Midnight's `swapFreeWithoutVenues` opt-in, which is what keeps these liquidatable under + // ALLOW_BAD_DEBT_ONLY: the seize is already the loan token, so it needs no route and the + // package's default refusal must not apply. Blue deliberately leaves the flag off. + const selfMarket: Market = { + ...MARKET, + collateralParams: [{ ...MARKET.collateralParams[0]!, token: LOAN }] + } + const { selector } = fakeSelector([]) + const { quoteFor } = compose(selector, { venues: [] }) + const outcome = await quoteFor( + { ...PLAN, swapFree: true }, + { ...OUT, market: selfMarket }, + LABEL + ) + expect(outcome.kind).toBe('swap') + if (outcome.kind === 'swap') expect(outcome.plan.steps).toHaveLength(0) + }) + it('threads the position label into quote log events as the correlation id', async () => { const events: { event: string; fields?: Record }[] = [] const capturing: Logger = { diff --git a/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md b/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md index 7868e571..41ef8176 100644 --- a/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md +++ b/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md @@ -89,8 +89,11 @@ computed in `planInputFromLens` (the only layer holding both token addresses and rides onto `LiquidationPlan`. It has two consequences: - `@repo/swaps` short-circuits to `swapFreePlan` whenever the sell path already ends in the loan - token, producing a **zero-step `SwapPlan`** — and that short-circuit is evaluated _before_ the - no-venues gate, so a keyless / `ALLOW_BAD_DEBT_ONLY` deployment can still clear these positions. + token, producing a **zero-step `SwapPlan`**. That short-circuit is evaluated _before_ the no-venues + gate for callers that pass `swapFreeWithoutVenues`, so a keyless / `ALLOW_BAD_DEBT_ONLY` deployment + can still clear these positions. It is **opt-in, defaulting to off**: the ordering was briefly + unconditional, which let a venue-less `blue-liquidation` — whose `ALLOW_DETECTION_ONLY` posture + promises it never acts — return a broadcastable plan. Midnight sets it; Blue deliberately does not. - `gateOnHeadroom` does not apply. The floor bounds a route cost this path does not pay; charging it would forfeit the contested early seconds of every loan-as-collateral maturity, which is where an ascending-price auction is won (see `rankByUsdSurplus`). Break-even still binds via diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index f15e207a..fc0db6d0 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -580,11 +580,8 @@ export type QuoteLogger = { * cost is reported as {@link QuoteOutcome.firmCalls}. Quotes are made ONLY for liquidatable positions, * so API + probe usage is bounded by the (small) liquidatable set, never the full candidate universe. * - * No enabled venues → `no_config`, preserving the caller's no-swap posture. Whether that refusal is - * immediate depends on {@link composeMultiVenueQuoting}'s `swapFreeWithoutVenues`: a caller with a - * swap-free liquidation path resolves the unwrap chain first, because that chain is what decides - * whether a venue is needed at all; a caller without one refuses before spending any RPC, since - * nothing downstream could succeed. + * No enabled venues → `no_config`, preserving the caller's no-swap posture; `swapFreeWithoutVenues` + * decides whether that refusal precedes or follows the unwrap chain. */ export function composeMultiVenueQuoting(deps: { httpClient: RateLimitedClient @@ -598,13 +595,12 @@ export function composeMultiVenueQuoting(deps: { /** Pre-swap converters, tried in order each hop. Pass `[]` for venue-only quoting. */ unwrappers: readonly Unwrapper[] /** - * Whether a venue-less deployment may still act on a plan that needs no venue (the sell path - * already ends in the loan token). Only for callers whose protocol has such a mode — Midnight's - * loan-as-collateral slots, which must stay liquidatable in `ALLOW_BAD_DEBT_ONLY` mode. + * Whether a venue-less deployment may still act on a plan needing no venue — the unwrap chain is + * then resolved first, since it is what decides whether a venue is needed at all. Only for callers + * whose protocol has such a mode (Midnight's loan-as-collateral slots under `ALLOW_BAD_DEBT_ONLY`). * - * Defaults to `false`, which is what keeps a deliberately unarmed deployment unarmed: with no - * venues the quote refuses immediately, before any unwrap RPC, so the bot can neither broadcast - * nor arm per-position backoff off a transient read failure. + * Defaults to `false`, which is what keeps a deliberately unarmed deployment unarmed: it refuses + * before any unwrap RPC, so it can neither broadcast nor arm backoff off a transient read failure. */ swapFreeWithoutVenues?: boolean /** @@ -706,10 +702,6 @@ export function composeMultiVenueQuoting(deps: { const { collateralToken, loanToken, referenceAmountOut } = request const correlation = correlationOf(request) - // A caller with no swap-free path cannot act on ANY outcome once the venues are gone, so the - // refusal comes before the unwrap chain rather than after it. Resolving first would both spend - // reads that provably cannot lead to a broadcast and downgrade a transient read failure from - // `no_config` (skip) to `failed` (arms backoff) — see `swapFreeWithoutVenues`. if (venues.length === 0 && !swapFreeWithoutVenues) return { kind: 'no_config', firmCalls: 0 } const unwrapped = await tryResolveUnwraps(unwrappers, request, executor, logger) diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 2bcd5797..54751554 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -67,7 +67,7 @@ function fakeUnwrapper(args: { from: Address; to: Address; out: bigint }): Unwra // An unwrapper whose detection read fails, standing in for a transient RPC failure inside erc4626 / // Pendle resolution. Records whether it was reached at all. -function throwingUnwrapper(): Unwrapper & { probed: Address[] } { +const throwingUnwrapper = (): Unwrapper & { probed: Address[] } => { const probed: Address[] = [] return { kind: 'throwing', @@ -451,6 +451,8 @@ describe('composeMultiVenueQuoting', () => { const unwrapper = throwingUnwrapper() const { quoteFor } = composeMulti([], [], multiHttp({}), { unwrappers: [unwrapper] }) expect(await quoteFor(REQUEST)).toEqual({ kind: 'no_config', firmCalls: 0 }) + // The throw never runs: refusing first is what makes it unreachable, which is the fix. + expect(unwrapper.probed).toHaveLength(0) }) it('still resolves unwraps with no venues when the caller has a swap-free path', async () => { From 5283c74ba84298e5196cf758061cfd528191e890 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Tue, 1 Sep 2026 01:12:55 -0500 Subject: [PATCH 3/3] address the automated review of the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged all 20 Codex/Devin findings across #184, #187, #188, #189 and #192. Fixed: - the venue-less swap-free exception admitted unwrap-only plans, so an ALLOW_BAD_DEBT_ONLY deployment could broadcast an asset-moving liquidation - candidate ranking trusted stale and incomplete curves that quoting refuses; both now share one exported `curveIsTrusted` - a Pendle PT resolved its unwrap chain twice per tick, both hosted API calls; new `previewTokenOut` seam answers phase A.5 from cache - phase A.5 resolved routes serially, in discovery order - a sibling's `no_route` suppressed a position whose other candidate was `floor_unmet`, which is meant to retry every block as the LIF ramps - the wall-clock cooldown verdict could flip mid-tick, leaving a candidate quoted but unpriced - revert-streak state never expired, so a reused label reported false crossings - `tx.submit_failed` carried no candidate discriminator - docs: the swap-free "iff" guarantee, `selectorConstant`, two README rows Declined, with reasoning for the threads: narrowing the backoff exemption to post-maturity plans (a regression — the sets are per-position while the mode is per-candidate), `stopAfterWinner`, and the sub-1e-18 rate truncation. Co-Authored-By: Claude Opus 5 (1M context) --- bots/midnight-liquidation/README.md | 5 +- bots/midnight-liquidation/src/index.ts | 10 +- bots/midnight-liquidation/src/quotes.ts | 37 +++-- .../src/runner/revert-streak.ts | 37 ++++- bots/midnight-liquidation/src/runner/tick.ts | 79 ++++++++--- .../midnight-liquidation/test/fork/harness.ts | 4 +- bots/midnight-liquidation/test/quotes.test.ts | 63 ++++++++- .../test/runner/revert-streak.test.ts | 70 +++++++++- .../test/runner/tick.test.ts | 131 ++++++++++++++++-- .../test/sizing/plan.test.ts | 26 ++-- ...-2026-08-28-midnight-loan-as-collateral.md | 5 + packages/bot-kit/src/queue/pending-queue.ts | 9 ++ .../bot-kit/test/queue/pending-queue.test.ts | 23 +++ packages/swaps/src/quoting.ts | 46 +++--- packages/swaps/src/unwrappers/erc4626.ts | 4 + packages/swaps/src/unwrappers/pendle-pt.ts | 7 + packages/swaps/src/unwrappers/resolve.ts | 42 ++++++ packages/swaps/src/venue-selector.ts | 32 +++++ packages/swaps/test/quoting.test.ts | 22 +++ .../swaps/test/unwrappers/resolve.test.ts | 66 ++++++++- packages/swaps/test/venue-selector.test.ts | 50 ++++++- 21 files changed, 660 insertions(+), 108 deletions(-) diff --git a/bots/midnight-liquidation/README.md b/bots/midnight-liquidation/README.md index 00a5467d..4587e7a0 100644 --- a/bots/midnight-liquidation/README.md +++ b/bots/midnight-liquidation/README.md @@ -79,7 +79,7 @@ Environment variables: | `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). | +| `ALLOW_BAD_DEBT_ONLY` | no | `false` | When no venue is enabled, the bot refuses to start unless this is `true` (it then discovers positions, realizes bad debt, and liquidates loan-as-collateral slots, which need no route; it never swap-liquidates, and an unwrap chain counts as a swap here). | | `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. | @@ -440,7 +440,8 @@ valid && gateAllows && hasDebt && !locked && (block.timestamp > maturity || !hea - Post-maturity healthy positions use post-maturity mode, where LIF ramps from `1e18` to `maxLif` over 60 minutes and the RCF cap is disabled. - Post-maturity **unhealthy** positions open both on-chain gates, so the bot builds both candidate - plans and keeps the one with the higher expected surplus. Normal mode pays the full `maxLif` + plans and **retains both**, ranked best-first and attempted in order — a mode that fails falls + through to the other in the same tick rather than forfeiting the position. Normal mode pays the full `maxLif` immediately while the post-maturity LIF is still ramping, so `plan.built { postMaturityMode: false }` on a matured position shortly after maturity is expected behavior, not a mode-selection bug; once the ramp completes, ties resolve to post-maturity (its gate cannot close if the price recovers). diff --git a/bots/midnight-liquidation/src/index.ts b/bots/midnight-liquidation/src/index.ts index 8795b135..70bfda89 100644 --- a/bots/midnight-liquidation/src/index.ts +++ b/bots/midnight-liquidation/src/index.ts @@ -369,6 +369,13 @@ async function main() { data: encodeExec(market, borrower, plan, swapPlan) }, label, + // One position submits several `(slot, mode)` alternatives under one label, so without this + // their `tx.submit_failed` rows are indistinguishable — the same discriminator every other + // stage already emits. + correlation: { + collateralIndex: plan.collateralIndex, + postMaturityMode: plan.postMaturityMode + }, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas, blockNumber @@ -385,7 +392,8 @@ async function main() { routing: { resolveRoute, warmRoute: venueSelector.refresh, - routeCost: venueSelector.select + routeCost: venueSelector.select, + venues }, logger }) diff --git a/bots/midnight-liquidation/src/quotes.ts b/bots/midnight-liquidation/src/quotes.ts index 6e7928a3..2d48f6ab 100644 --- a/bots/midnight-liquidation/src/quotes.ts +++ b/bots/midnight-liquidation/src/quotes.ts @@ -9,7 +9,7 @@ import type { } from '@repo/swaps' import type { Address } from 'viem' -import { composeMultiVenueQuoting, resolveUnwraps } from '@repo/swaps' +import { composeMultiVenueQuoting, previewUnwrapChain, resolveUnwraps } from '@repo/swaps' import { isAddressEqual } from 'viem' import type { LiquidationPlan } from './sizing/plan' @@ -37,9 +37,11 @@ const candidateOf = (plan: LiquidationPlan) => ({ * * `resolveRoute` exposes just the pair half of that pipeline, for the tick's phase A.5, so the expensive * half is not duplicated: the probe refresh the composer then drives for the same pair is absorbed by - * the selector's staleness gate, phase A.5 having already warmed it. Resolving twice is free for plain - * collateral (the unwrappers memoize their negatives per token) but does cost one extra amount-dependent - * read per candidate for a genuinely exotic one — an `eth_call`, never a venue call. + * the selector's staleness gate, phase A.5 having already warmed it. It walks + * {@link previewUnwrapChain} rather than resolving, so an exotic collateral costs NO amount-dependent + * work here — a PT's pair comes off the TTL-cached markets list and a vault share's off the memoized + * `asset()`, where resolving would have spent a hosted Pendle request whose calldata is then discarded + * and re-fetched at quote time. Falls back to a full resolve only if some unwrapper lacks the seam. */ export function composeQuoting(deps: { httpClient: RateLimitedClient @@ -89,18 +91,25 @@ export function composeQuoting(deps: { async resolveRoute(plan, out, label) { const collateral = out.market.collateralParams[plan.collateralIndex] if (!collateral || excluded(collateral.token)) return null - const resolution = await resolveUnwraps(unwrappers, { + const previewed = await previewUnwrapChain(unwrappers, { token: collateral.token, - amountIn: plan.seizedAssets, - executor, - stopToken: out.market.loanToken, - correlation: { id: label, ...candidateOf(plan) } + stopToken: out.market.loanToken }) - if (isAddressEqual(resolution.token, out.market.loanToken)) return null - return { - pair: { collateral: resolution.token, loan: out.market.loanToken }, - amountIn: resolution.amountIn - } + // `amountIn` is a probe-interpolation input, so the seize is close enough: it feeds a cost + // estimate, never an encoded min-out. The full resolve is the fallback, and only it threads the + // chain's worst-case output. + const { token, amountIn } = + previewed !== null + ? { token: previewed, amountIn: plan.seizedAssets } + : await resolveUnwraps(unwrappers, { + token: collateral.token, + amountIn: plan.seizedAssets, + executor, + stopToken: out.market.loanToken, + correlation: { id: label, ...candidateOf(plan) } + }) + if (isAddressEqual(token, out.market.loanToken)) return null + return { pair: { collateral: token, loan: out.market.loanToken }, amountIn } }, async quoteFor(plan, out, label) { diff --git a/bots/midnight-liquidation/src/runner/revert-streak.ts b/bots/midnight-liquidation/src/runner/revert-streak.ts index 8e1ceb52..a444288e 100644 --- a/bots/midnight-liquidation/src/runner/revert-streak.ts +++ b/bots/midnight-liquidation/src/runner/revert-streak.ts @@ -12,6 +12,17 @@ import type { Hex } from 'viem' */ export const REVERT_STREAK_ESCALATE_MS = 15 * 60_000 +/** + * How long a label may go with no recorded revert before the next one starts a FRESH streak. + * + * Independent of {@link REVERT_STREAK_ESCALATE_MS}, which measures how long one streak has run — a + * stuck position is re-attempted every sweep, so silence means it stopped being attempted at all, not + * that it is stuck harder. Sized against the sweep period rather than the incentive ramp: ~30 Base + * blocks, loose enough to ride out a tick that skipped the position, far tighter than the minutes or + * hours between two separate borrow episodes under one `market:borrower` label. + */ +export const REVERT_STREAK_EPISODE_GAP_MS = 60_000 + /** What {@link RevertStreakStore.record} learned about the streak the just-recorded revert extends. */ export type RevertStreak = { /** Consecutive execution-reverted sends, this one included. */ @@ -21,9 +32,11 @@ export type RevertStreak = { /** The 4-byte selector this send reverted with, absent when the payload carried none. */ selector: Hex | undefined /** - * True while every send in the streak reported the same selector — much stronger evidence of a - * structural fault (a closed gate, malformed calldata, an estimator discrepancy) than a mixed - * streak, which reads as ordinary min-out shortfalls against whichever pool the route hit. + * True while every send in the streak reported the same 4-byte selector. Evidence of a structural + * fault (a closed gate, malformed calldata, an estimator discrepancy) rather than ordinary min-out + * shortfalls, but WEAK evidence in one direction: every `require` string shares `0x08c379a0` and + * every arithmetic fault `0x4e487b71`, so unrelated failures can hold it true. Read it with the + * decoded `reason` on the corresponding `tx.submit_failed`, which does distinguish them. */ selectorConstant: boolean /** @@ -46,6 +59,10 @@ export type RevertStreak = { * provider-side estimator behaviour, and pool state that moved in between. It only reports; it never * suppresses. * + * A streak spans one EPISODE: {@link REVERT_STREAK_EPISODE_GAP_MS} of silence starts a fresh one, so + * a label reused by a later borrow neither inherits an escalation nor reports a crossing it did not + * earn. + * * In-memory only, like the shared `Backoff` and `CooldownStore` — chain truth wins on restart. Entries * for a position that recovers to non-liquidatable are never re-checked and linger until process exit: * the same accepted, bounded leak `createBackoff` documents at its canonical home. @@ -60,6 +77,7 @@ export type RevertStreakStore = { type Entry = { count: number startedAt: number + lastAt: number selector: Hex | undefined constant: boolean escalated: boolean @@ -70,22 +88,29 @@ type Entry = { * incentive shape — a wall-clock LIF ramp — and no second consumer exists yet. */ export const createRevertStreakStore = ( - opts: { escalateAfterMs?: number; now?: () => number } = {} + opts: { escalateAfterMs?: number; episodeGapMs?: number; now?: () => number } = {} ): RevertStreakStore => { const escalateAfterMs = opts.escalateAfterMs ?? REVERT_STREAK_ESCALATE_MS + const episodeGapMs = opts.episodeGapMs ?? REVERT_STREAK_EPISODE_GAP_MS const now = opts.now ?? (() => Date.now()) const streaks = new Map() return { record: (label, selector) => { const at = now() - const previous = streaks.get(label) + const prior = streaks.get(label) + // A gap past {@link REVERT_STREAK_EPISODE_GAP_MS} is a NEW episode, not one long streak: nothing + // was being declined in between (a competitor cleared the position, or it stopped being + // liquidatable). Labels are `market:borrower` and so are reused across borrow episodes, and + // without this a stale `startedAt` makes the first revert of the next one report a false + // crossing — or, if the old entry had escalated, yield `ongoing` forever and never warn at all. + const previous = prior && at - prior.lastAt <= episodeGapMs ? prior : undefined const count = (previous?.count ?? 0) + 1 const startedAt = previous?.startedAt ?? at const constant = previous ? previous.constant && previous.selector === selector : true const durationMs = at - startedAt const past = durationMs > escalateAfterMs - streaks.set(label, { count, startedAt, selector, constant, escalated: past }) + streaks.set(label, { count, startedAt, lastAt: at, selector, constant, escalated: past }) return { count, durationMs, diff --git a/bots/midnight-liquidation/src/runner/tick.ts b/bots/midnight-liquidation/src/runner/tick.ts index aebf821c..f1e46dbe 100644 --- a/bots/midnight-liquidation/src/runner/tick.ts +++ b/bots/midnight-liquidation/src/runner/tick.ts @@ -1,8 +1,8 @@ import type { Backoff, CooldownStore, Logger, SimulateResult, SubmitOutcome } from '@repo/bot-kit' -import type { QuoteOutcome, SwapPlan, VenueCostEstimate, VenuePair } from '@repo/swaps' +import type { QuoteOutcome, SwapPlan, Venue, VenueCostEstimate, VenuePair } from '@repo/swaps' import type { Address } from 'viem' -import { venuePairKey } from '@repo/swaps' +import { curveIsTrusted, venuePairKey } from '@repo/swaps' import { assertNever, lensKey, tryCatch } from '@repo/utils' import { formatUnits } from 'viem' @@ -350,6 +350,11 @@ type TickRouting = { amountIn: bigint, referenceAmountOut: bigint ) => readonly VenueCostEstimate[] + /** + * The enabled venues, so {@link curveIsTrusted} can tell a complete curve from one that ranked a + * subset. Ordering is irrelevant here — the quoting layer owns the fall-through order. + */ + venues: readonly Venue[] } /** @@ -395,6 +400,8 @@ const prepareRoutes = async (deps: { const pairs = new Map() let resolvedCount = 0 + // Classify synchronously first, so the I/O below is the only thing that waits. + const resolvable: SizedCandidate[] = [] for (const candidate of sized) { // `unknown`, not `no_route`: claiming it needs none would price it at zero and let it outrank a // costed sibling. The fail-open ordering that triggers is moot — this position never executes. @@ -411,11 +418,23 @@ const prepareRoutes = async (deps: { states.set(candidate, { kind: 'no_route' }) continue } - resolvedCount += 1 - const resolved = await tryCatch( - routing.resolveRoute(candidate.plan, candidate.out, candidate.label) + resolvable.push(candidate) + } + resolvedCount = resolvable.length + + // Concurrently: these are independent, and awaiting them in discovery order put every candidate + // behind the slowest unrelated one — on the critical path of the maturity burst this phase exists + // to speed up. Bounded by the probe cap upstream, and the underlying clients are rate-limited, so + // the fan-out is throttled rather than unbounded. `states` is keyed by candidate identity and + // `pairs` is a dedupe map, so neither depends on completion order. + const resolutions = await Promise.all( + resolvable.map(candidate => + tryCatch(routing.resolveRoute(candidate.plan, candidate.out, candidate.label)) ) - if (resolved.error) { + ) + for (const [index, candidate] of resolvable.entries()) { + const resolved = resolutions[index] + if (resolved?.error) { logger.warn('route.unresolved', { id: candidate.label, marketId: candidate.pair.id, @@ -425,7 +444,7 @@ const prepareRoutes = async (deps: { detail: resolved.error.message }) } - const route = resolved.data ?? null + const route = resolved?.data ?? null states.set(candidate, route ? { kind: 'route', route } : { kind: 'unknown' }) if (route) pairs.set(venuePairKey(route.pair), route.pair) } @@ -461,13 +480,16 @@ type CostedCandidate = SizedCandidate & { * subtractable. A candidate that sells nothing costs zero by construction — a `swapFree` slot, or a * bad-debt write-off, which trades no assets at all. * - * Everything else fails open to `null` (see {@link RouteState}) — a cold pair (`[]`), a `clamped` - * estimate taken from a ladder end rather than between two rungs, no oracle reference, or an unpriced - * loan token. Cost is floored at zero: a venue quoting above the oracle is a stale oracle, never a - * bonus to score (see {@link VenueCostEstimate.costBps}). + * Everything else fails open to `null` (see {@link RouteState}) — a cold pair (`[]`), a curve + * {@link curveIsTrusted} refuses, no oracle reference, or an unpriced loan token. Cost is floored at + * zero: a venue quoting above the oracle is a stale oracle, never a bonus to score (see + * {@link VenueCostEstimate.costBps}). * - * Reads only the LEADING venue's estimate, because that is the venue the quoting layer will try first. - * Pure: {@link TickRouting.routeCost} is a cache lookup, cheap enough for the protocol's ceiling of 16 + * That predicate is shared with the quoting layer ON PURPOSE, and it is the reason this reads an + * absolute LEVEL at all: `estimatedOut` is the one term cache age decays, and a cutoff scored on a + * level the quoting layer would not trust can preselect away the only executable liquidation. Reads + * only the LEADING venue's estimate, because that is the venue quoting will try first. Pure: + * {@link TickRouting.routeCost} is a cache lookup, cheap enough for the protocol's ceiling of 16 * collateral slots in two modes. */ const costRoutes = (deps: { @@ -483,8 +505,10 @@ const costRoutes = (deps: { if (state.kind === 'unknown') return uncosted const reference = expectedLoanOut(candidate.plan) - const best = deps.routing.routeCost(state.route.pair, state.route.amountIn, reference)[0] - if (!best || best.clamped || best.costBps === null) return uncosted + const estimates = deps.routing.routeCost(state.route.pair, state.route.amountIn, reference) + if (!curveIsTrusted(estimates, deps.routing.venues)) return uncosted + const best = estimates[0] + if (!best || best.costBps === null) return uncosted const shortfall = reference > best.estimatedOut ? reference - best.estimatedOut : 0n return { ...candidate, @@ -719,8 +743,13 @@ export async function runTick(deps: { // The two suppression gates, defined once because phase A.5 and phase B must agree: A.5 skips the // route RPC for whatever B will skip outright, which is what keeps backoff's promise to bound API // and RPC usage under a backlog. Both stores are position-keyed and unwritten until the tick's - // `finally`, so their verdict is constant across a position's candidates within one tick. - const isCooled = (label: string) => cooldown.shouldSkip(label) + // `finally` — but that only makes BACKOFF's verdict constant across the tick, because it is keyed + // on the tick-constant `chainHead`. `CooldownStore.shouldSkip` is wall-clock, so a window expiring + // between the two phases would leave a candidate quoted with its route never warmed; since + // `scoreNetOfRouteCost` fails open per POSITION, that one candidate drops its whole alternative set + // back to gross ordering. Snapshot it so the two phases cannot disagree. + const cooled = new Set(sized.filter(entry => cooldown.shouldSkip(entry.label)).map(e => e.label)) + const isCooled = (label: string) => cooled.has(label) // A write-off sells nothing, so backoff — which suppresses quote/simulate churn — never applies. const isBackedOff = (label: string, plan: LiquidationPlan) => !isBadDebtRealization(plan) && backoff.shouldSkip(label, chainHead) @@ -784,6 +813,12 @@ export async function runTick(deps: { // by position, so without this a sibling's execution revert would cancel the backoff a broken // nonce/funds/RPC send earned — see the `sendRejected` arm. const backoffForced = new Set() + // Positions at least one candidate of which ended in an outcome the next block is expected to fix on + // its own — a floor the ramping LIF has not reached yet. Every set here is keyed by POSITION while + // candidates are per `(slot, mode)`, so without this a sibling's `no_route` or sim revert suppresses + // the very position the economic arms below refuse to suppress. `backoffForced` still wins: a broken + // send is a fact about the position, not about one candidate's economics. + const retryWorthy = new Set() const pendingCooldown = new Set() let complete = false // Firm venue calls this tick, `null` while no quote reported any: an absent `firmCalls` is unknown, @@ -897,6 +932,7 @@ export async function runTick(deps: { // quoting layer already logged `quote.floor_unmet` per venue with the numbers. if (quote.reason === 'floor_unmet') { counters.quoteUnprofitable += 1 + retryWorthy.add(label) continue } counters.quoteFailed += 1 @@ -918,6 +954,7 @@ export async function runTick(deps: { // by the pre-quote headroom gate in sizing, not by suppressing a position that may be one // block of LIF ramp away from being fundable. counters.quoteUnprofitable += 1 + retryWorthy.add(label) // `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. @@ -1048,9 +1085,13 @@ export async function runTick(deps: { } finally { // Suppression applied once per position, after every candidate has had its turn (see the phase B // preamble). In the `finally` so an aborting `submit` still records what the tick learned. - for (const label of pendingCooldown) cooldown.mark(label) + for (const label of pendingCooldown) { + if (!retryWorthy.has(label)) cooldown.mark(label) + } for (const label of pendingBackoff) { - if (backoffForced.has(label) || !backoffExempt.has(label)) backoff.record(label, chainHead) + if (backoffForced.has(label) || (!backoffExempt.has(label) && !retryWorthy.has(label))) { + backoff.record(label, chainHead) + } } logger.info('tick.end', { ...counters, diff --git a/bots/midnight-liquidation/test/fork/harness.ts b/bots/midnight-liquidation/test/fork/harness.ts index 12c183ca..f3d9a6a7 100644 --- a/bots/midnight-liquidation/test/fork/harness.ts +++ b/bots/midnight-liquidation/test/fork/harness.ts @@ -180,7 +180,6 @@ export function testClient(rpcUrl: string) { ) } -/** Funds `address` with 100 ETH for gas. */ // Runtime that returns 1e36 for any calldata: PUSH32 1e36, MSTORE at 0, RETURN 32 bytes from 0. const IDENTITY_ORACLE_RUNTIME = '0x7f0000000000000000000000000000000000c097ce7bc90715b34b9f100000000060005260206000f3' as const @@ -194,11 +193,12 @@ const IDENTITY_ORACLE_RUNTIME = * lens and the seeder use is `price() returns (uint256)`, and returning that constant is the identity * oracle's entire behavior. */ -export async function deployIdentityOracle(test: TestClient): Promise
{ +export const deployIdentityOracle = async (test: TestClient): Promise
=> { await test.setCode({ address: IDENTITY_ORACLE, bytecode: IDENTITY_ORACLE_RUNTIME }) return IDENTITY_ORACLE } +/** Funds `address` with 100 ETH for gas. */ export async function fundEth(test: TestClient, address: Address): Promise { await test.setBalance({ address, value: parseEther('100') }) } diff --git a/bots/midnight-liquidation/test/quotes.test.ts b/bots/midnight-liquidation/test/quotes.test.ts index 3cb99976..e5d1a6fe 100644 --- a/bots/midnight-liquidation/test/quotes.test.ts +++ b/bots/midnight-liquidation/test/quotes.test.ts @@ -1,7 +1,7 @@ import type { Logger } from '@repo/bot-kit' -import type { RateLimitedClient, Venue, VenuePair, VenueSelector } from '@repo/swaps' +import type { RateLimitedClient, Unwrapper, Venue, VenuePair, VenueSelector } from '@repo/swaps' -import { getAddress } from 'viem' +import { getAddress, isAddressEqual } from 'viem' import { describe, expect, it } from 'vitest' import type { Market } from '../src/execution/encode-call' @@ -112,6 +112,33 @@ function fakeSelector( return { selector, refreshed } } +// A collateral that unwraps straight to the loan token (a vault share or PT in a USDC market), with +// the pair-only seam answered separately from the calldata-building half. +const unwrapsToLoan = (): Unwrapper & { resolved: () => number } => { + let resolved = 0 + return { + kind: 'fake-erc4626', + resolved: () => resolved, + previewTokenOut: async token => (isAddressEqual(token, COLLATERAL) ? LOAN : null), + async resolve({ token }) { + resolved += 1 + if (!isAddressEqual(token, COLLATERAL)) return null + return { + step: { + tokenIn: COLLATERAL, + tokenOut: LOAN, + target: TARGET, + value: 0n, + callData: '0x12345678', + amountIn: { source: 'balance', offset: 4n } + }, + expectedAmountOut: 1000n, + amountOutMinimum: 1000n + } + } + } +} + function compose( selector: VenueSelector, overrides: { @@ -119,6 +146,7 @@ function compose( excludeCollaterals?: `0x${string}`[] logger?: Logger httpClient?: RateLimitedClient + unwrappers?: readonly Unwrapper[] } = {} ) { return composeQuoting({ @@ -129,7 +157,7 @@ function compose( venues: overrides.venues ?? ['0x'], baseUrls: {}, maxRouteImpactBps: 500, - unwrappers: [], + unwrappers: overrides.unwrappers ?? [], excludeCollaterals: overrides.excludeCollaterals ?? [], logger: overrides.logger ?? NOOP_LOGGER }) @@ -205,6 +233,35 @@ describe('composeQuoting (Midnight lens-projection adapter)', () => { if (outcome.kind === 'swap') expect(outcome.plan.steps).toHaveLength(0) }) + it('refuses an unwrap-only collateral with no venues, despite the swap-free opt-in', async () => { + // `swapFreeWithoutVenues` arms the zero-step shape only. A chain that merely LANDS on the loan + // token still moves assets, which is broader than `ALLOW_BAD_DEBT_ONLY` promises. + const unwrapper = unwrapsToLoan() + const { selector } = fakeSelector([]) + const { quoteFor } = compose(selector, { venues: [], unwrappers: [unwrapper] }) + expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 }) + }) + + it('still takes that unwrap-only collateral when a venue is enabled', async () => { + const unwrapper = unwrapsToLoan() + const { selector } = fakeSelector(['0x']) + const { quoteFor } = compose(selector, { unwrappers: [unwrapper] }) + const outcome = await quoteFor(PLAN, OUT, LABEL) + expect(outcome.kind).toBe('swap') + if (outcome.kind === 'swap') expect(outcome.plan.steps).toHaveLength(1) + }) + + it('resolveRoute previews the pair without building calldata', async () => { + // The seam's whole point: for a Pendle PT, `resolve` is a rate-limited hosted request whose + // calldata phase A.5 discards and the firm quote then re-fetches. + const unwrapper = unwrapsToLoan() + const { selector } = fakeSelector(['0x']) + const { resolveRoute } = compose(selector, { unwrappers: [unwrapper] }) + // Ends on the loan token, so there is no pair left to probe. + expect(await resolveRoute(PLAN, OUT, LABEL)).toBeNull() + expect(unwrapper.resolved()).toBe(0) + }) + it('threads the position label into quote log events as the correlation id', async () => { const events: { event: string; fields?: Record }[] = [] const capturing: Logger = { diff --git a/bots/midnight-liquidation/test/runner/revert-streak.test.ts b/bots/midnight-liquidation/test/runner/revert-streak.test.ts index 625ef02f..de8abb9b 100644 --- a/bots/midnight-liquidation/test/runner/revert-streak.test.ts +++ b/bots/midnight-liquidation/test/runner/revert-streak.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { createRevertStreakStore, REVERT_STREAK_ESCALATE_MS } from '../../src/runner/revert-streak' +import { + createRevertStreakStore, + REVERT_STREAK_EPISODE_GAP_MS, + REVERT_STREAK_ESCALATE_MS +} from '../../src/runner/revert-streak' const LABEL = 'market:borrower' const OTHER = 'market:other' @@ -70,12 +74,72 @@ describe('createRevertStreakStore', () => { const time = clock() const store = createRevertStreakStore({ now: time.now }) store.record(LABEL) - time.advance(REVERT_STREAK_ESCALATE_MS) - expect(store.record(LABEL).escalate).toBe('below') + // Swept throughout, which is what a genuinely stuck position looks like — the bot re-attempts it + // every block. Covering the threshold in ONE jump would instead be silence, i.e. a new episode. + for ( + let elapsed = 0; + elapsed < REVERT_STREAK_ESCALATE_MS; + elapsed += REVERT_STREAK_EPISODE_GAP_MS + ) { + time.advance(REVERT_STREAK_EPISODE_GAP_MS) + expect(store.record(LABEL).escalate).toBe('below') + } time.advance(1) expect(store.record(LABEL).escalate).toBe('crossed') }) + it('starts a fresh streak after a gap with no recorded revert', () => { + // `market:borrower` labels are reused across borrow episodes, and a position can stop being + // attempted without this bot broadcasting — a competitor clears it. Carrying `startedAt` across + // that silence makes the FIRST revert of the next episode report a crossing it did not earn. + const time = clock() + const store = createRevertStreakStore({ + escalateAfterMs: 10_000, + episodeGapMs: 1000, + now: time.now + }) + expect(store.record(LABEL).escalate).toBe('below') + time.advance(60_000) + const fresh = store.record(LABEL) + expect(fresh).toMatchObject({ count: 1, durationMs: 0, escalate: 'below' }) + }) + + it('does not resurrect an escalation across that gap, which would silence the next episode', () => { + // The other direction, and the worse one: an entry left `escalated` yields only `ongoing`, and a + // reporter that fires on `crossed` alone would then never warn about the new stuck episode. + const time = clock() + const store = createRevertStreakStore({ + escalateAfterMs: 1000, + episodeGapMs: 5000, + now: time.now + }) + store.record(LABEL) + time.advance(1001) + expect(store.record(LABEL).escalate).toBe('crossed') + time.advance(60_000) + expect(store.record(LABEL).escalate).toBe('below') + time.advance(1001) + expect(store.record(LABEL).escalate).toBe('crossed') + }) + + it('keeps one streak alive while the position is swept inside the episode gap', () => { + // The gap is about SILENCE, not about how long the streak has run — a stuck position re-attempted + // every block must still accumulate toward its escalation. + const time = clock() + const store = createRevertStreakStore({ + escalateAfterMs: 10_000, + episodeGapMs: 5000, + now: time.now + }) + store.record(LABEL) + let last = store.record(LABEL) + for (let i = 0; i < 3; i++) { + time.advance(4000) + last = store.record(LABEL) + } + expect(last).toMatchObject({ count: 5, durationMs: 12_000, escalate: 'crossed' }) + }) + it('starts a fresh streak after a reset', () => { const time = clock() const store = createRevertStreakStore({ escalateAfterMs: 1000, now: time.now }) diff --git a/bots/midnight-liquidation/test/runner/tick.test.ts b/bots/midnight-liquidation/test/runner/tick.test.ts index 7c3c261d..b2a63c14 100644 --- a/bots/midnight-liquidation/test/runner/tick.test.ts +++ b/bots/midnight-liquidation/test/runner/tick.test.ts @@ -1,9 +1,10 @@ import type { Logger, SimulateResult, SubmitOutcome } from '@repo/bot-kit' import type { CooldownStore } from '@repo/bot-kit' -import type { QuoteOutcome, SwapPlan, VenuePair } from '@repo/swaps' +import type { QuoteOutcome, SwapPlan, Venue, VenuePair } from '@repo/swaps' import type { Address, Hex } from 'viem' import { createBackoff, createCooldownStore, createPendingQueue, TxSendError } from '@repo/bot-kit' +import { MAX_COST_LEVEL_AGE_MS } from '@repo/swaps' import { lensKey } from '@repo/utils' import { getAddress } from 'viem' import { describe, expect, it } from 'vitest' @@ -102,16 +103,14 @@ const SWAP_PLAN: SwapPlan = { // A liquidatable reading: valid, gate open, has debt, unlocked, unhealthy, pre-maturity. // One activated collateral slot. The default addresses `collateralParams[1]` (COLLATERAL), so it needs // a swap; `index: 0` addresses the market's loan token and is therefore swap-free. -function slot(overrides: Partial = {}): LensCollateral { - return { - index: 1, - amt: 5000n, - price: 10n ** 36n, - maxLif: 1100000000000000000n, - lltv: 860000000000000000n, - ...overrides - } -} +const slot = (overrides: Partial = {}): LensCollateral => ({ + index: 1, + amt: 5000n, + price: 10n ** 36n, + maxLif: 1100000000000000000n, + lltv: 860000000000000000n, + ...overrides +}) function lensOut(overrides: Partial = {}): LensOut { return { @@ -225,6 +224,10 @@ function runWith(opts: { routeCostBps?: Map /** Models an estimate taken from a ladder end: present in the curve, but not trustworthy. */ clampedRoutes?: boolean + /** Ages every estimate, to exercise the level bound `curveIsTrusted` applies. */ + routeCostAgeMs?: number + /** Enabled venues; the stub curve only ever ranks `0x`, so a longer list models an INCOMPLETE one. */ + venues?: readonly Venue[] /** Shared spy, so a caller can observe the tick's and the queue's events in ONE stream. */ spy?: ReturnType /** Replaces the stub `submit` — used to broadcast through a real pending queue. */ @@ -292,10 +295,11 @@ function runWith(opts: { costBps: bps, costBpsRaw: bps, clamped: opts.clampedRoutes ?? false, - ageMs: 0 + ageMs: opts.routeCostAgeMs ?? 0 } ] - } + }, + venues: opts.venues ?? (['0x'] as const) } const result = runTick({ discover: async () => { @@ -532,6 +536,24 @@ describe('runTick', () => { expect(events.some(e => e.event === 'cooldown.skip')).toBe(true) }) + it('holds one verdict for the whole tick, even as the wall-clock window expires', async () => { + // `CooldownStore.shouldSkip` is wall clock, unlike block-keyed backoff, so a window expiring + // mid-tick would have phase A.5 skip the route warm and phase B quote anyway — leaving the + // candidate ranked on gross surplus with a route it never priced. The verdict is snapshotted. + let asked = 0 + const expiring: CooldownStore = { + shouldSkip: () => { + asked += 1 + return asked === 1 // cooled when A.5 looks, expired by the time B would + }, + mark: () => {} + } + const { counters, quoteCalls, simulateCalls } = await runWith({ cooldown: expiring }) + expect(counters).toMatchObject({ liquidatable: 1, cooledDown: 1, submitted: 0 }) + expect(quoteCalls()).toBe(0) + expect(simulateCalls()).toBe(0) + }) + it('marks the position on a failed quote so the next tick cools it down', async () => { const cooldown = createCooldownStore({ cooldownMs: 60_000 }) const { counters } = await runWith({ @@ -764,7 +786,8 @@ describe('runTick', () => { routing: { resolveRoute: async () => null, warmRoute: async () => {}, - routeCost: () => [] + routeCost: () => [], + venues: ['0x'] }, logger }) @@ -809,6 +832,54 @@ describe('runTick', () => { expectCounterIdentities(counters) }) + it('a no-route quote then a floor_unmet sibling leaves the position unsuppressed', async () => { + // Every suppression set is keyed by POSITION while candidates are per `(slot, mode)`, so + // without the retry-worthy latch the first candidate's `no_route` backs off the position and + // the second — an economic refusal the ramping LIF is expected to clear next block — never + // gets its contested block. + const { counters, backoff, cooldown } = await runWith({ + out: twoSlots(), + cooldown: createCooldownStore({ cooldownMs: 60_000 }), + quoteOutcomes: [ + { kind: 'failed', reason: 'no_route' }, + { kind: 'failed', reason: 'floor_unmet' } + ] + }) + expect(counters).toMatchObject({ candidates: 2, quoteFailed: 1, quoteUnprofitable: 1 }) + expect(backoff.shouldSkip(LABEL, 101n)).toBe(false) + expect(cooldown.shouldSkip(LABEL)).toBe(false) + expectCounterIdentities(counters) + }) + + it('still suppresses when NO sibling is retry-worthy', async () => { + // The other half: the latch must not become a blanket exemption. + const { counters, backoff, cooldown } = await runWith({ + out: twoSlots(), + cooldown: createCooldownStore({ cooldownMs: 60_000 }), + quoteOutcome: { kind: 'failed', reason: 'no_route' } + }) + expect(counters).toMatchObject({ candidates: 2, quoteFailed: 2 }) + expect(backoff.shouldSkip(LABEL, 101n)).toBe(true) + expect(cooldown.shouldSkip(LABEL)).toBe(true) + expectCounterIdentities(counters) + }) + + it('a broken send still arms backoff over a retry-worthy sibling', async () => { + // `backoffForced` outranks the latch: a nonce/funds/RPC rejection is a fact about the + // position, not about one candidate's economics. + const { counters, backoff } = await runWith({ + out: twoSlots(), + quoteOutcomes: [ + { kind: 'failed', reason: 'floor_unmet' }, + { kind: 'swap', plan: SWAP_PLAN } + ], + submitOutcome: { sent: false, reason: 'send_failed', executionRevert: false } + }) + expect(counters).toMatchObject({ quoteUnprofitable: 1, sendRejected: 1 }) + expect(backoff.shouldSkip(LABEL, 101n)).toBe(true) + expectCounterIdentities(counters) + }) + it('a no-route quote then an execution revert leaves the position unsuppressed', async () => { const { counters, backoff } = await runWith({ out: twoSlots(), @@ -1481,6 +1552,38 @@ describe('runTick', () => { expect(trusted.counters).toMatchObject({ preselectSkipped: 1, quoteFailed: 2 }) }) + it('falls open the same way when the curve is STALE', async () => { + // `estimatedOut` is consumed here as an absolute LEVEL against a current oracle, and the level + // is the one term cache age decays. Past the bound it must read as unknown, not as costed — + // otherwise an arbitrarily old price drives the cutoff and permanently skips a candidate. + const out = multiSlot({ surpluses: [100, 80, 60] }) + const map = costs([SWAP_TOKENS[0]!, 1000], [SWAP_TOKENS[1]!, 0], [SWAP_TOKENS[2]!, 0]) + const stale = await runWith({ + out, + routeCostBps: map, + routeCostAgeMs: MAX_COST_LEVEL_AGE_MS + 1, + quoteOutcome: { kind: 'failed', reason: 'no_route' } + }) + expect(builtIndexes(stale.events)).toEqual([1, 2, 3]) + expect(stale.counters).toMatchObject({ preselectSkipped: 0, quoteFailed: 3 }) + }) + + it('falls open the same way when the curve ranked only SOME enabled venues', async () => { + // The quoting layer keeps its full fall-through unless every enabled venue is ranked, because a + // missing venue could be the better one. A cutoff scored on that same partial curve must not be + // stricter than the quoting it is preselecting for. + const out = multiSlot({ surpluses: [100, 80, 60] }) + const map = costs([SWAP_TOKENS[0]!, 1000], [SWAP_TOKENS[1]!, 0], [SWAP_TOKENS[2]!, 0]) + const partial = await runWith({ + out, + routeCostBps: map, + venues: ['0x', '1inch'], // the stub curve only ever ranks `0x` + quoteOutcome: { kind: 'failed', reason: 'no_route' } + }) + expect(builtIndexes(partial.events)).toEqual([1, 2, 3]) + expect(partial.counters).toMatchObject({ preselectSkipped: 0, quoteFailed: 3 }) + }) + it('leaves a position whose loan token is unpriced on gross ordering', async () => { // Both terms come from the same USD conversion, so an unpriced loan token makes the cost unknown // rather than zero — the position must not be scored as if its route were free. diff --git a/bots/midnight-liquidation/test/sizing/plan.test.ts b/bots/midnight-liquidation/test/sizing/plan.test.ts index da436eb4..dd495e2a 100644 --- a/bots/midnight-liquidation/test/sizing/plan.test.ts +++ b/bots/midnight-liquidation/test/sizing/plan.test.ts @@ -16,17 +16,15 @@ const MAX_LIF = 1036269430051813471n const LLTV = 860000000000000000n // The cbBTC-shaped slot these cases were originally written against: lltv 86%, so maxLif ~1.0363. -function slot(overrides: Partial = {}): CollateralSlot { - return { - index: 3, - amt: 100n * WAD, - price: ORACLE_PRICE_SCALE, - maxLif: MAX_LIF, - lltv: LLTV, - swapFree: false, - ...overrides - } -} +const slot = (overrides: Partial = {}): CollateralSlot => ({ + index: 3, + amt: 100n * WAD, + price: ORACLE_PRICE_SCALE, + maxLif: MAX_LIF, + lltv: LLTV, + swapFree: false, + ...overrides +}) // Pre-maturity (now < maturity), unhealthy borrower with one activated slot — the normal-mode base. function baseInput(overrides: Partial = {}): PlanInput { @@ -47,12 +45,10 @@ function baseInput(overrides: Partial = {}): PlanInput { // `baseInput` with a single slot carrying `slotOverrides` — the shape most cases want, since they // vary one slot field and leave the position alone. -function inputWithSlot( +const inputWithSlot = ( slotOverrides: Partial, overrides: Partial = {} -): PlanInput { - return baseInput({ collaterals: [slot(slotOverrides)], ...overrides }) -} +): PlanInput => baseInput({ collaterals: [slot(slotOverrides)], ...overrides }) describe('plan', () => { it('skips a position with no debt', () => { diff --git a/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md b/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md index 41ef8176..5f940864 100644 --- a/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md +++ b/docs/decisions/TIB-2026-08-28-midnight-loan-as-collateral.md @@ -94,6 +94,11 @@ rides onto `LiquidationPlan`. It has two consequences: can still clear these positions. It is **opt-in, defaulting to off**: the ordering was briefly unconditional, which let a venue-less `blue-liquidation` — whose `ALLOW_DETECTION_ONLY` posture promises it never acts — return a broadcastable plan. Midnight sets it; Blue deliberately does not. + The exception is also narrowed to the **zero-step** shape. `swapFreePlan` serves two (see + `swapFreePath`), and the other — an unwrap chain that merely _lands_ on the loan token, PT-USDC in a + USDC market — still moves assets and carries per-hop execution risk. Letting it through venue-less + was the same class of mistake as the ordering, one layer down: the flag names loan-as-collateral + slots, not every plan that happens to need no venue. Venue-enabled callers keep both shapes. - `gateOnHeadroom` does not apply. The floor bounds a route cost this path does not pay; charging it would forfeit the contested early seconds of every loan-as-collateral maturity, which is where an ascending-price auction is won (see `rankByUsdSurplus`). Break-even still binds via diff --git a/packages/bot-kit/src/queue/pending-queue.ts b/packages/bot-kit/src/queue/pending-queue.ts index 99cbf0cf..8fc67c26 100644 --- a/packages/bot-kit/src/queue/pending-queue.ts +++ b/packages/bot-kit/src/queue/pending-queue.ts @@ -61,6 +61,14 @@ export type SubmitArgs = { * and let a second nonce-consuming send go out for a position already in flight. */ label: string + /** + * What separates two sends sharing one {@link SubmitArgs.label} — Midnight's `(collateralIndex, + * postMaturityMode)` alternatives, say — spread onto this send's log events beside `id`. + * + * Correlation only: never parsed, never behavioral, and NOT part of the dedup key (that is `label`, + * for the reason its own doc gives). Omit it when a label already identifies a send uniquely. + */ + correlation?: Readonly> maxFeePerGas: bigint maxPriorityFeePerGas: bigint blockNumber: bigint @@ -293,6 +301,7 @@ export function createPendingQueue({ const selector = revertSelector(sent.error) logger.warn('tx.submit_failed', { id: args.label, + ...args.correlation, reason: revertReason(sent.error), executionRevert, ...(selector ? { selector } : {}), diff --git a/packages/bot-kit/test/queue/pending-queue.test.ts b/packages/bot-kit/test/queue/pending-queue.test.ts index 4bc44475..76433fd2 100644 --- a/packages/bot-kit/test/queue/pending-queue.test.ts +++ b/packages/bot-kit/test/queue/pending-queue.test.ts @@ -173,6 +173,29 @@ describe('createPendingQueue', () => { expect(events.find(e => e.event === 'tx.submit_failed')?.level).toBe('warn') }) + it('spreads the caller correlation onto tx.submit_failed, beside the shared id', async () => { + // A liquidator can submit several alternatives of ONE position under one label, so the label + // alone cannot tell their failures apart in a structured query. + const { logger, events } = captureLogger() + const send: SendTx = async () => { + throw new Error('rpc down') + } + const { queue } = setup({ send, logger }) + await queue.submit({ + request: REQUEST, + label: 'market:borrower', + correlation: { collateralIndex: 2, postMaturityMode: true }, + maxFeePerGas: 1000n, + maxPriorityFeePerGas: 1000n, + blockNumber: 0n + }) + expect(events.find(e => e.event === 'tx.submit_failed')?.fields).toMatchObject({ + id: 'market:borrower', + collateralIndex: 2, + postMaturityMode: true + }) + }) + it('rethrows a first-send failure after a nonce was claimed but no hash was returned', async () => { const { logger, events } = captureLogger() const send: SendTx = async () => { diff --git a/packages/swaps/src/quoting.ts b/packages/swaps/src/quoting.ts index fc0db6d0..10c0888b 100644 --- a/packages/swaps/src/quoting.ts +++ b/packages/swaps/src/quoting.ts @@ -23,6 +23,7 @@ import { BPS } from './constants' import { routeCostBps } from './cost-bps.utils' import { QuoteError } from './types' import { resolveUnwraps } from './unwrappers/resolve' +import { curveIsTrusted, MAX_COST_LEVEL_AGE_MS } from './venue-selector' import { priceLifi, quoteLifi } from './venues/lifi' import { priceLiquidSwap, quoteLiquidSwap } from './venues/liquidswap' import { priceOneInch, quoteOneInch } from './venues/oneinch' @@ -232,23 +233,10 @@ const CURVE_PREDICTION_MARGIN_BPS = 10n */ const MAX_FLOOR_OVERSHOOT_BPS = 2n * CURVE_PREDICTION_MARGIN_BPS -/** - * How stale a curve may be and still be trusted to predict a venue's absolute output. - * - * Venue ORDERING is drift-immune at any cache age, but a min-out denominator consumes the absolute - * LEVEL, which decays as the pair's price leaves the cached rate behind (see `createVenueSelector`). - * A prediction older than this falls back to the oracle reference and the ordinary second pass, which - * costs one HTTP call — so this can be set against the cost level's own half-life without regard to - * the probe cadence. It is deliberately NOT a bot-tunable: a bot whose `PROBE_STALE_MS` is long - * because it only consumes ordering (blue's is 10 minutes) must not thereby inherit a 10-minute-old - * denominator. - */ -const MAX_PREDICTION_AGE_MS = 60_000 - /** * A curve estimate turned into a first-pass min-out denominator, or `undefined` when the curve has * nothing trustworthy to say (an unranked venue, an estimate clamped off the probed ladder, or one - * older than {@link MAX_PREDICTION_AGE_MS}). + * older than {@link MAX_COST_LEVEL_AGE_MS}). * * **This block's subject is the DIRECTION of the bias relative to the venue's real quote**, which is * what makes the prediction safe to encode against; how far down it is taken is @@ -266,7 +254,7 @@ const predictedVenueOut = ( referenceAmountOut: bigint ): bigint | undefined => { if (!estimate || estimate.clamped || estimate.estimatedOut <= 0n) return undefined - if (estimate.ageMs > MAX_PREDICTION_AGE_MS) return undefined + if (estimate.ageMs > MAX_COST_LEVEL_AGE_MS) return undefined const capped = estimate.estimatedOut < referenceAmountOut ? estimate.estimatedOut : referenceAmountOut return (capped * (BPS - CURVE_PREDICTION_MARGIN_BPS)) / BPS @@ -496,8 +484,10 @@ const swapFreePath = (resolution: UnwrapResolution): 'no-swap' | 'unwrap-only' = * Still oracle-sanity-checked and floor-checked: `resolution.amountIn` is the chain's threaded * worst-case output — an on-chain bound, not an estimate — and stands in for a venue's quoted output. * With zero steps it is exactly `request.amountIn`, so both checks reduce to statements about the - * oracle: route quality passes iff the price is within `maxRouteImpactBps` of 1:1, and the floor - * passes iff the seize covers its own break-even repay (it does, by construction, for `lif >= WAD`). + * oracle: route quality passes unless the oracle prices the collateral more than `maxRouteImpactBps` + * ABOVE 1:1 — {@link passesRouteQuality} is a floor, not a band, so an underpricing oracle passes at + * any margin — and the floor passes iff the seize covers its own break-even repay (it does, by + * construction, for `lif >= WAD`). */ const swapFreePlan = (args: { resolution: UnwrapResolution @@ -581,7 +571,7 @@ export type QuoteLogger = { * so API + probe usage is bounded by the (small) liquidatable set, never the full candidate universe. * * No enabled venues → `no_config`, preserving the caller's no-swap posture; `swapFreeWithoutVenues` - * decides whether that refusal precedes or follows the unwrap chain. + * carves out the one exception, a resolution needing neither a venue nor a call. */ export function composeMultiVenueQuoting(deps: { httpClient: RateLimitedClient @@ -595,9 +585,10 @@ export function composeMultiVenueQuoting(deps: { /** Pre-swap converters, tried in order each hop. Pass `[]` for venue-only quoting. */ unwrappers: readonly Unwrapper[] /** - * Whether a venue-less deployment may still act on a plan needing no venue — the unwrap chain is - * then resolved first, since it is what decides whether a venue is needed at all. Only for callers - * whose protocol has such a mode (Midnight's loan-as-collateral slots under `ALLOW_BAD_DEBT_ONLY`). + * Whether a venue-less deployment may still act on a plan needing no venue AND NO CALLS — the + * unwrap chain is resolved first, since it is what decides whether a venue is needed at all, but + * only a zero-step resolution is then acted on. Only for callers whose protocol has such a mode + * (Midnight's loan-as-collateral slots under `ALLOW_BAD_DEBT_ONLY`). * * Defaults to `false`, which is what keeps a deliberately unarmed deployment unarmed: it refuses * before any unwrap RPC, so it can neither broadcast nor arm backoff off a transient read failure. @@ -690,11 +681,7 @@ export function composeMultiVenueQuoting(deps: { order }) } - const trusted = - ranked.length > 0 && - venues.every(venue => estimates.has(venue)) && - ranked.every(estimate => !estimate.clamped) - return { order, estimates, trusted } + return { order, estimates, trusted: curveIsTrusted(ranked, venues) } } return { @@ -713,6 +700,13 @@ export function composeMultiVenueQuoting(deps: { // a caller that opted into `swapFreeWithoutVenues` must still be able to liquidate it; gating // on `venues` first would refuse the one case that provably does not need one. if (isAddressEqual(resolution.token, loanToken)) { + // ...but only the `'no-swap'` shape (see {@link swapFreePath}). An unwrap chain that merely + // LANDS on the loan token still moves assets and carries per-hop execution risk, which is + // more than a venue-less posture promises — `ALLOW_BAD_DEBT_ONLY` names loan-as-collateral + // slots, not PT-USDC. Venue-enabled callers keep both shapes, exactly as before. + if (venues.length === 0 && resolution.steps.length > 0) { + return { kind: 'no_config', firmCalls: 0 } + } return { ...swapFreePlan({ resolution, request, maxRouteImpactBps, logger }), firmCalls: 0 } } diff --git a/packages/swaps/src/unwrappers/erc4626.ts b/packages/swaps/src/unwrappers/erc4626.ts index b470f919..9f504abd 100644 --- a/packages/swaps/src/unwrappers/erc4626.ts +++ b/packages/swaps/src/unwrappers/erc4626.ts @@ -80,6 +80,10 @@ export function createErc4626Unwrapper(deps: { client: Client; logger: QuoteLogg return { kind: 'erc4626', + // `underlyingFor` alone: the `previewRedeem` gate `resolve` also applies is amount-dependent, and + // a token that passes `asset()` but fails it routes to a venue as itself — which is the pair a + // probe would want anyway. + previewTokenOut: underlyingFor, async resolve({ token, amountIn, executor, correlation }) { const underlying = await underlyingFor(token) if (underlying === null) return null diff --git a/packages/swaps/src/unwrappers/pendle-pt.ts b/packages/swaps/src/unwrappers/pendle-pt.ts index 5073b579..40e3ecf4 100644 --- a/packages/swaps/src/unwrappers/pendle-pt.ts +++ b/packages/swaps/src/unwrappers/pendle-pt.ts @@ -139,6 +139,13 @@ export function createPendlePtUnwrapper(deps: { return { kind: 'pendle-pt', + // The markets list already carries every PT's underlying, and it is TTL-cached, so previewing the + // pair costs no request at all — where `resolve` spends a rate-limited `/redeem` or `/swap` call + // to build amount-bound calldata that a pair-only caller would discard. + async previewTokenOut(token) { + const markets = await marketsFor() + return markets.find(entry => isAddressEqual(entry.pt, token))?.underlying ?? null + }, async resolve({ token, amountIn, executor }) { const markets = await marketsFor() const market = markets.find(entry => isAddressEqual(entry.pt, token)) diff --git a/packages/swaps/src/unwrappers/resolve.ts b/packages/swaps/src/unwrappers/resolve.ts index 8cab2db1..795ad5aa 100644 --- a/packages/swaps/src/unwrappers/resolve.ts +++ b/packages/swaps/src/unwrappers/resolve.ts @@ -24,6 +24,15 @@ export type Unwrapper = { */ correlation?: Readonly> }) => Promise<{ step: SwapStep; expectedAmountOut: bigint; amountOutMinimum: bigint } | null> + /** + * The token this unwrapper would convert `token` into, WITHOUT building calldata — `null` when it + * does not apply, matching {@link Unwrapper.resolve}'s negative. + * + * Must not perform amount-dependent I/O. It exists so a caller needing only the post-unwrap PAIR + * (to warm a venue probe) does not pay `resolve`'s cost, which for a hosted-API unwrapper is a + * rate-limited request whose calldata would then be discarded and re-fetched at quote time. + */ + previewTokenOut?: (token: Address) => Promise
} /** @@ -87,3 +96,36 @@ export async function resolveUnwraps( return { steps, token, amountIn } } + +/** + * Where {@link resolveUnwraps} would leave off, resolved through {@link Unwrapper.previewTokenOut} + * alone — so it costs no calldata construction and no amount-dependent call. + * + * `null` when ANY unwrapper in the list lacks the seam: a partial walk would silently report the + * wrong terminal token, and the wrong token is a probe of the wrong pair. Callers fall back to + * `resolveUnwraps` on `null`, which is what makes the seam safe to adopt one unwrapper at a time. + */ +export const previewUnwrapChain = async ( + unwrappers: readonly Unwrapper[], + args: { token: Address; stopToken: Address } +): Promise
=> { + if (unwrappers.some(unwrapper => !unwrapper.previewTokenOut)) return null + + let token = args.token + for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) { + if (isAddressEqual(token, args.stopToken)) break + + let advanced = false + for (const unwrapper of unwrappers) { + const next = await unwrapper.previewTokenOut?.(token) + // Same termination rule as the resolving walk: a hop that doesn't change the token can never + // terminate, so it counts as "does not apply". + if (!next || isAddressEqual(next, token)) continue + token = next + advanced = true + break + } + if (!advanced) break + } + return token +} diff --git a/packages/swaps/src/venue-selector.ts b/packages/swaps/src/venue-selector.ts index b465517d..a9564f28 100644 --- a/packages/swaps/src/venue-selector.ts +++ b/packages/swaps/src/venue-selector.ts @@ -66,6 +66,38 @@ export type VenueCostEstimate = { ageMs: number } +/** + * How old a cached curve may be before its absolute LEVEL stops being usable. + * + * Bounds every consumer of {@link VenueCostEstimate.estimatedOut} as a level — a first-pass min-out + * denominator and a cross-candidate route-cost ranking alike — because that is the single term + * staleness decays (see {@link createVenueSelector} for the asymmetry). Venue ORDERING is deliberately + * unbounded, which is why this is not a second `PROBE_STALE_MS`: a bot whose probe cadence is long + * because it only consumes ordering (blue's is 10 minutes) must not thereby inherit a 10-minute-old + * denominator. + */ +export const MAX_COST_LEVEL_AGE_MS = 60_000 + +/** + * Whether a pair's curve may be consumed as an absolute cost level: every enabled venue ranked, none + * clamped off the probed ladder, none past {@link MAX_COST_LEVEL_AGE_MS}, and every level positive. + * + * One predicate for both consumers — the firm-quote fall-through bound here, and a bot's + * cross-candidate ranking — because a curve either supports level comparisons or it does not. Two + * hand-rolled subsets of these clauses is how a candidate cutoff comes to trust a curve that quoting + * itself refuses. `false` is always the fail-open answer: score gross, walk every venue. + */ +export const curveIsTrusted = ( + estimates: readonly VenueCostEstimate[], + venues: readonly Venue[] +): boolean => + estimates.length > 0 && + venues.every(venue => estimates.some(estimate => estimate.venue === venue)) && + estimates.every( + estimate => + !estimate.clamped && estimate.estimatedOut > 0n && estimate.ageMs <= MAX_COST_LEVEL_AGE_MS + ) + /** One venue's indicative execution at one ladder rung — pure market data, no oracle. */ type RungQuote = { amountIn: bigint; expectedOut: bigint } diff --git a/packages/swaps/test/quoting.test.ts b/packages/swaps/test/quoting.test.ts index 54751554..0408c277 100644 --- a/packages/swaps/test/quoting.test.ts +++ b/packages/swaps/test/quoting.test.ts @@ -468,6 +468,28 @@ describe('composeMultiVenueQuoting', () => { expect(unwrapper.probed.length).toBeGreaterThan(0) }) + it('refuses an unwrap-only chain with no venues, even opted into a swap-free path', async () => { + // The venue-less exception covers `'no-swap'` only. A chain that merely LANDS on the loan token + // (a PT-USDC or vault-share collateral in a USDC market) still moves assets, which is more than + // an ALLOW_BAD_DEBT_ONLY posture promises — and `kind: 'swap'` goes straight to simulate+submit. + const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: LOAN, out: 1000n }) + const { quoteFor } = composeMulti([], [], throwingHttp, { + unwrappers: [unwrapper], + swapFreeWithoutVenues: true + }) + expect(await quoteFor(REQUEST)).toEqual({ kind: 'no_config', firmCalls: 0 }) + }) + + it('still takes that same unwrap-only chain when a venue IS enabled', async () => { + // The other half of the pin: only the venue-less case narrowed. An armed deployment keeps the + // unwrap-only plan it always built, without spending a venue call on a path with nothing to sell. + const unwrapper = fakeUnwrapper({ from: COLLATERAL, to: LOAN, out: 1000n }) + const { quoteFor } = composeMulti(['0x'], [], throwingHttp, { unwrappers: [unwrapper] }) + const outcome = await quoteFor(REQUEST) + expect(outcome.kind).toBe('swap') + if (outcome.kind === 'swap') expect(outcome.plan.steps).toHaveLength(1) + }) + describe('collateral token IS the loan token (loan-as-collateral)', () => { // referenceAmountOut === amountIn === 1000 in REQUEST, which is what an identity oracle at // price 1e36 produces, so these cases are the live loan-as-collateral shape. diff --git a/packages/swaps/test/unwrappers/resolve.test.ts b/packages/swaps/test/unwrappers/resolve.test.ts index 549a952a..2d7f37c4 100644 --- a/packages/swaps/test/unwrappers/resolve.test.ts +++ b/packages/swaps/test/unwrappers/resolve.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { Unwrapper } from '../../src/unwrappers/resolve' -import { MAX_UNWRAP_DEPTH, resolveUnwraps } from '../../src/unwrappers/resolve' +import { MAX_UNWRAP_DEPTH, previewUnwrapChain, resolveUnwraps } from '../../src/unwrappers/resolve' const A = getAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') const B = getAddress('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb') @@ -115,3 +115,67 @@ describe('resolveUnwraps', () => { ).rejects.toThrow('probe exploded') }) }) + +// The same conversion as `hop`, but answering the pair-only seam too — and recording whether the +// expensive half was ever reached, which is the whole point of having the seam. +const previewingHop = (from: Address, to: Address): Unwrapper & { resolved: number } => { + const state = { resolved: 0 } + return { + kind: `preview:${from.slice(0, 6)}`, + get resolved() { + return state.resolved + }, + previewTokenOut: async token => (isAddressEqual(token, from) ? to : null), + async resolve({ token, amountIn }) { + state.resolved += 1 + if (!isAddressEqual(token, from)) return null + return { + step: { + tokenIn: from, + tokenOut: to, + target: from, + value: 0n, + callData: '0x12345678', + amountIn: { source: 'fixed', value: amountIn } + }, + expectedAmountOut: amountIn, + amountOutMinimum: amountIn + } + } + } +} + +describe('previewUnwrapChain', () => { + it('walks to the terminal token without resolving a single hop', async () => { + const first = previewingHop(A, B) + const second = previewingHop(B, LOAN) + expect(await previewUnwrapChain([first, second], { token: A, stopToken: LOAN })).toBe(LOAN) + // The reason the seam exists: `resolve` is a rate-limited hosted request for a Pendle PT, and + // phase A.5 discards its calldata. + expect(first.resolved).toBe(0) + expect(second.resolved).toBe(0) + }) + + it('returns the input token when nothing applies', async () => { + expect(await previewUnwrapChain([previewingHop(B, LOAN)], { token: A, stopToken: LOAN })).toBe( + A + ) + }) + + it('stops at the stop token rather than unwrapping past it', async () => { + const beyond = previewingHop(LOAN, C) + expect(await previewUnwrapChain([beyond], { token: LOAN, stopToken: LOAN })).toBe(LOAN) + }) + + it('treats a self-loop hop as not applying, like the resolving walk', async () => { + expect(await previewUnwrapChain([previewingHop(A, A)], { token: A, stopToken: LOAN })).toBe(A) + }) + + it('reports unavailable when ANY unwrapper lacks the seam', async () => { + // A partial walk would report the wrong terminal token, and the wrong token is a probe of the + // wrong pair — so the caller must fall back to a full resolve rather than trust a subset. + expect( + await previewUnwrapChain([previewingHop(A, B), hop(B, LOAN)], { token: A, stopToken: LOAN }) + ).toBeNull() + }) +}) diff --git a/packages/swaps/test/venue-selector.test.ts b/packages/swaps/test/venue-selector.test.ts index 7f8a9bc1..bbdd3e3c 100644 --- a/packages/swaps/test/venue-selector.test.ts +++ b/packages/swaps/test/venue-selector.test.ts @@ -5,10 +5,15 @@ import { describe, expect, it } from 'vitest' import type { QuoteLogger } from '../src/quoting' import type { PriceParameters, Venue } from '../src/types' -import type { VenuePair } from '../src/venue-selector' +import type { VenueCostEstimate, VenuePair } from '../src/venue-selector' import { QuoteError } from '../src/types' -import { createVenueSelector, USD_LADDER_PRICE_DECIMALS } from '../src/venue-selector' +import { + createVenueSelector, + curveIsTrusted, + MAX_COST_LEVEL_AGE_MS, + USD_LADDER_PRICE_DECIMALS +} from '../src/venue-selector' const NOOP_LOGGER: QuoteLogger = { info: () => {}, warn: () => {} } @@ -417,3 +422,44 @@ describe('createVenueSelector', () => { }) }) }) + +describe('curveIsTrusted', () => { + // The predicate both the firm-quote fall-through bound and a bot's candidate ranking read, so that + // a cutoff can never trust a curve the quoting layer refuses. + const estimate = (overrides: Partial = {}): VenueCostEstimate => ({ + venue: '0x', + estimatedOut: 1000n, + costBps: 10, + costBpsRaw: 10, + clamped: false, + ageMs: 0, + ...overrides + }) + + it('accepts a complete, fresh, unclamped curve', () => { + expect(curveIsTrusted([estimate(), estimate({ venue: '1inch' })], ['0x', '1inch'])).toBe(true) + }) + + it('refuses a cold curve', () => { + expect(curveIsTrusted([], ['0x'])).toBe(false) + }) + + it('refuses an INCOMPLETE curve, because the missing venue could be the better one', () => { + expect(curveIsTrusted([estimate()], ['0x', '1inch'])).toBe(false) + }) + + it('refuses a clamped estimate', () => { + expect(curveIsTrusted([estimate({ clamped: true })], ['0x'])).toBe(false) + }) + + it('refuses an estimate past the level age bound, and accepts one exactly at it', () => { + // `estimatedOut` is the one term cache age decays (venue ORDERING is drift-immune at any age), + // so this bound is what separates a usable level from a usable ranking. + expect(curveIsTrusted([estimate({ ageMs: MAX_COST_LEVEL_AGE_MS + 1 })], ['0x'])).toBe(false) + expect(curveIsTrusted([estimate({ ageMs: MAX_COST_LEVEL_AGE_MS })], ['0x'])).toBe(true) + }) + + it('refuses a non-positive level, which prices the route at 100% cost', () => { + expect(curveIsTrusted([estimate({ estimatedOut: 0n })], ['0x'])).toBe(false) + }) +})