Skip to content
Merged
12 changes: 8 additions & 4 deletions bots/blue-liquidation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Env vars (fail-loud on a missing required var, an unknown chain, or a malformed
| `ALLOW_DETECTION_ONLY` | no | `false` | Opt-in: boot with zero venues (discover + log only, skip every liquidation). Without it, zero venues is a startup error |
| `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral deny-list (skipped with `config.no_swap_path`) |
| `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | — | Optional per-venue API host overrides |
| `PROBE_STALE_MS` / `PROBE_HTTP_RPS` / `PROBE_LADDER` | no | see `config.ts` | Venue-probe cache staleness, isolated probe rate, and whole-token ladder sizes |
| `PROBE_STALE_MS` / `PROBE_HTTP_RPS` / `PROBE_LADDER` | no | see `config.ts` | Venue-probe cache staleness, isolated probe rate, and ladder sizes (whole collateral tokens — this bot wires no USD price source) |
| `MAX_FEE_GWEI` | no | `300` | Hard ceiling for fee bumps |
| `MAX_ROUTE_IMPACT_BPS` | no | `500` | Reject aggregator routes this far below the oracle ref |
| `PENDLE_SLIPPAGE_BPS` | no | `50` | Slippage for the Pendle PT → underlying unwrap hop (before the downstream venue sells) |
Expand All @@ -68,9 +68,13 @@ container the runtime env remains unsuffixed because each service runs exactly o
There is no per-collateral routing file. Venues are **enabled by key presence** (`ZEROX_API_KEY`,
`ONEINCH_API_KEY`, `LIFI_API_KEY` — or `ENABLE_LIFI=true` for keyless LiFi), and for each
liquidatable position a background probe cache ranks the enabled venues best-first for the
`(collateral, loan)` pair (log-scaled indicative quotes on an isolated rate budget — see
`PROBE_LADDER`/`PROBE_STALE_MS`/`PROBE_HTTP_RPS`). The firm quote goes to the top venue and falls
through to the next on failure, so a transient venue outage costs coverage, never correctness.
`(collateral, loan)` pair (log-scaled indicative quotes on an isolated rate budget, interpolated at
the seize size — see `PROBE_LADDER`/`PROBE_STALE_MS`/`PROBE_HTTP_RPS`; ladder sizes are whole
collateral tokens here, since this bot wires no USD price source). The firm quote goes to the top
venue and falls through to the next on failure, so a transient venue outage costs coverage, never
correctness. The curve also predicts that venue's own output to set the quote's min-out denominator,
but only while it is fresher than the package's prediction-age ceiling — well under this bot's
ten-minute `PROBE_STALE_MS`, so an older curve simply pays the two-pass derivation's extra call.
Collateral that wraps an ERC-4626 vault or a Pendle PT is auto-unwrapped before the venue swap.
`EXCLUDE_COLLATERALS` is the operator's deny-list for collaterals the bot must never seize/hold.

Expand Down
24 changes: 18 additions & 6 deletions bots/blue-liquidation/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,20 @@ const DEFAULT_BACKOFF_MAX_BLOCKS = 64n
const DEFAULT_POSITION_LIQUIDATION_COOLDOWN_MS = 0

// Venue-probing defaults. The probe uses an ISOLATED rps budget (see index.ts) so its bursts never
// queue ahead of a time-sensitive firm quote; log-scaled ladder sizes are whole collateral tokens
// (converted per-collateral to base units). `PROBE_STALE_MS` caps probe cadence per pair; a pair is
// queue ahead of a time-sensitive firm quote. `PROBE_STALE_MS` caps probe cadence per pair; a pair is
// re-probed only when a liquidatable position touches it after the cache goes stale.
//
// This bot wires no USD price source, so the ladder keeps its whole-collateral-token reading, and the
// TTL stays long — ten minutes against midnight's 45 seconds — because what this bot consumes from the
// curve is venue ORDERING, which is drift-immune at any cache age: every venue at a rung shares one
// refresh, so their rates drift together (see `createVenueSelector`).
//
// The absolute cost LEVEL does decay with age, and the quoting layer does read it — it sets the
// first-pass min-out denominator. That consumer carries its OWN age bound (`@repo/swaps`'
// prediction-age ceiling), so a curve older than it falls back to the two-pass derivation for one extra
// HTTP call rather than encoding a ten-minute-old denominator. Bounding the consumer rather than
// shortening this TTL is deliberate: it protects both bots against a stale cache, including midnight's,
// and it does not multiply blue's probe traffic across a much larger discovered pair set.
const DEFAULT_PROBE_STALE_MS = 600_000
const DEFAULT_PROBE_HTTP_RPS = 1
const DEFAULT_PROBE_LADDER = ['0.01', '0.1', '1', '10', '100']
Expand Down Expand Up @@ -137,13 +148,14 @@ export type VenueConfig = {

/**
* Venue-probing knobs. The probe fetches indicative quotes across enabled venues at each log-scaled
* `ladderWholeTokens` size, caches the best-first ranking per pair for `staleMs`, and runs on its own
* `httpRps` budget (isolated from firm quotes). Sizes stay as raw strings until converted per-collateral.
* `ladderSizes` rung, caches the per-venue rate curve for the pair for `staleMs`, and runs on its own
* `httpRps` budget (isolated from firm quotes). Sizes stay as raw strings until converted
* per-collateral; with no USD price source wired they are read as whole collateral tokens.
*/
export type ProbeConfig = {
staleMs: number
httpRps: number
ladderWholeTokens: string[]
ladderSizes: string[]
}

export type Config = {
Expand Down Expand Up @@ -373,7 +385,7 @@ export function loadConfig(
const probe: ProbeConfig = {
staleMs: intEnv(env, 'PROBE_STALE_MS', DEFAULT_PROBE_STALE_MS, { min: 1 }),
httpRps: intEnv(env, 'PROBE_HTTP_RPS', DEFAULT_PROBE_HTTP_RPS, { min: 1 }),
ladderWholeTokens: ladderEnv(env, 'PROBE_LADDER', DEFAULT_PROBE_LADDER)
ladderSizes: ladderEnv(env, 'PROBE_LADDER', DEFAULT_PROBE_LADDER)
}

const quoting: QuotingConfig = {
Expand Down
7 changes: 4 additions & 3 deletions bots/blue-liquidation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,14 @@ async function main() {
timeoutMs: config.quoting.quoteTimeoutMs
})

// Venue selector: caches a best-first venue ranking per pair from log-scaled indicative probes.
// Venue selector: caches each venue's rate curve per pair from log-scaled indicative probes.
// Decimals are read once per collateral (memoized in the selector); the collateral set is bounded
// by the discovered markets, so these are a handful of one-off reads over the process lifetime.
// by the discovered markets, so these are a handful of one-off reads over the process lifetime. No
// `usdPriceOf` is wired here, so the ladder stays denominated in whole collateral tokens.
const venueSelector = createVenueSelector({
venues,
chainId: config.chainId,
ladderWholeTokens: config.probe.ladderWholeTokens,
ladderSizes: config.probe.ladderSizes,
getDecimals: token =>
readContract(client, { address: token, abi: erc20Abi, functionName: 'decimals' }),
indicativeQuote: (venue, params) => priceByVenue(probeClient, { venue, baseUrls, params }),
Expand Down
4 changes: 3 additions & 1 deletion bots/blue-liquidation/src/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export function composeQuoting(deps: {
// anymore, so this is its escape hatch from the auto-unwrap path too.
if (excludeCollaterals.some(token => isAddressEqual(token, out.params.collateralToken))) {
logger.info('quote.excluded_collateral', { collateral: out.params.collateralToken })
return { kind: 'no_config' }
// `firmCalls: 0` explicitly: an absent count reads as UNKNOWN, and this path provably spent
// nothing (see {@link QuoteOutcome.firmCalls}).
return { kind: 'no_config', firmCalls: 0 }
}

return quoteRequest({
Expand Down
4 changes: 2 additions & 2 deletions bots/blue-liquidation/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ describe('loadConfig', () => {
const config = loadConfig(baseEnv())
expect(config.probe.staleMs).toBe(600_000)
expect(config.probe.httpRps).toBe(1)
expect(config.probe.ladderWholeTokens).toEqual(['0.01', '0.1', '1', '10', '100'])
expect(loadConfig(baseEnv({ PROBE_LADDER: '0.5, 5, 50' })).probe.ladderWholeTokens).toEqual([
expect(config.probe.ladderSizes).toEqual(['0.01', '0.1', '1', '10', '100'])
expect(loadConfig(baseEnv({ PROBE_LADDER: '0.5, 5, 50' })).probe.ladderSizes).toEqual([
'0.5',
'5',
'50'
Expand Down
44 changes: 31 additions & 13 deletions bots/blue-liquidation/test/quotes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Logger } from '@repo/bot-kit'
import type { RateLimitedClient, VenuePair, VenueQuoteEstimate, VenueSelector } from '@repo/swaps'
import type { RateLimitedClient, Venue, VenuePair, VenueSelector } from '@repo/swaps'

import { getAddress } from 'viem'
import { describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -62,15 +62,28 @@ const httpStub: RateLimitedClient = { getJson: async <T>() => OK_ZEROX_BODY as T
// The position label the tick threads as the QuoteRequest correlation id (`${id}:${borrower}`).
const LABEL = '0xabc:0x9999999999999999999999999999999999999999'

// A selector stub: records which pairs were refreshed and returns a fixed best-first order.
function fakeSelector(order: VenueQuoteEstimate[], onRefresh?: () => Promise<void>) {
// A selector stub: records which pairs were refreshed and returns a fixed best-first order. `clamped`
// makes the curve untrustworthy, which is how a case asks for the oracle-reference min-out derivation
// instead of the curve-predicted one.
function fakeSelector(
order: Venue[],
options: { onRefresh?: () => Promise<void>; clamped?: boolean } = {}
) {
const refreshed: VenuePair[] = []
const selector: VenueSelector = {
refresh: async pair => {
refreshed.push(pair)
if (onRefresh) await onRefresh()
if (options.onRefresh) await options.onRefresh()
},
select: () => order,
select: () =>
order.map(venue => ({
venue,
estimatedOut: 1000n,
costBps: null,
costBpsRaw: null,
clamped: options.clamped ?? false,
ageMs: 0
})),
snapshot: () => []
}
return { selector, refreshed }
Expand Down Expand Up @@ -101,14 +114,15 @@ function compose(

describe('composeQuoting (Blue lens-projection adapter)', () => {
it('returns no_config (and never probes) for an excluded collateral', async () => {
const { selector, refreshed } = fakeSelector([{ venue: '0x', expectedOut: 1000n }])
const { selector, refreshed } = fakeSelector(['0x'])
const { quoteFor } = compose(selector, { excludeCollaterals: [COLLATERAL] })
expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config' })
// `firmCalls: 0`, not absent: an absent count reads as unknown, and this path provably spent none.
expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 })
expect(refreshed).toHaveLength(0)
})

it('refreshes the pair probe, then projects out.params into an executable swap', async () => {
const { selector, refreshed } = fakeSelector([{ venue: '0x', expectedOut: 1000n }])
const { selector, refreshed } = fakeSelector(['0x'])
const { quoteFor } = compose(selector)
const outcome = await quoteFor(PLAN, OUT, LABEL)

Expand All @@ -130,8 +144,10 @@ describe('composeQuoting (Blue lens-projection adapter)', () => {
it('still quotes (cold-default) when the probe refresh throws', async () => {
// Cold cache (select → []) + a refresh that rejects → the firm-quote step falls back to the
// deterministic enabled-venue order rather than failing the position.
const { selector } = fakeSelector([], async () => {
throw new Error('probe boom')
const { selector } = fakeSelector([], {
onRefresh: async () => {
throw new Error('probe boom')
}
})
const { quoteFor } = compose(selector)
expect((await quoteFor(PLAN, OUT, LABEL)).kind).toBe('swap')
Expand All @@ -140,7 +156,7 @@ describe('composeQuoting (Blue lens-projection adapter)', () => {
it('returns no_config when no venues are enabled (detection-only posture)', async () => {
const { selector } = fakeSelector([])
const { quoteFor } = compose(selector, { venues: [] })
expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config' })
expect(await quoteFor(PLAN, OUT, LABEL)).toEqual({ kind: 'no_config', firmCalls: 0 })
})

it('threads the position label into quote log events as the correlation id', async () => {
Expand All @@ -151,7 +167,7 @@ describe('composeQuoting (Blue lens-projection adapter)', () => {
warn: () => {},
error: () => {}
}
const { selector } = fakeSelector([{ venue: '0x', expectedOut: 1000n }])
const { selector } = fakeSelector(['0x'])
const { quoteFor } = compose(selector, { logger: capturing })
await quoteFor(PLAN, OUT, LABEL)
const selectOk = events.find(e => e.event === 'select.ok')
Expand All @@ -168,7 +184,9 @@ describe('composeQuoting (Blue lens-projection adapter)', () => {
return OK_ZEROX_BODY as T
}
}
const { selector } = fakeSelector([{ venue: '0x', expectedOut: 1000n }])
// Clamped, so the percentage is derived against the oracle reference: the adapter's projection is
// what is pinned here, not the curve's prediction of the venue's own output.
const { selector } = fakeSelector(['0x'], { clamped: true })
return compose(selector, { httpClient: capturing })
.quoteFor(PLAN, OUT, LABEL)
.then(() => {
Expand Down
Loading
Loading