Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion bots/blue-liquidation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ Env vars (fail-loud on a missing required var, an unknown chain, or a malformed
| `ZEROX_API_KEY` / `ONEINCH_API_KEY` / `LIFI_API_KEY` | no | — | Each key **enables** its venue; LiFi also via `ENABLE_LIFI` (keyless) |
| `ENABLE_LIFI` | no | `false` | Enable LiFi without a key |
| `ALLOW_DETECTION_ONLY` | no | `false` | Opt-in: boot with zero venues (discover + log only, skip every liquidation). Without it, zero venues is a startup error |
| `SLIPPAGE_BPS` | no | `100` | Global venue slippage (routing is no longer per-collateral) |
| `EXCLUDE_COLLATERALS` | no | — | Comma-separated collateral deny-list (skipped with `config.no_swap_path`) |
| `ZEROX_BASE_URL` / `ONEINCH_BASE_URL` / `LIFI_BASE_URL` | no | — | Optional per-venue API host overrides |
| `PROBE_STALE_MS` / `PROBE_HTTP_RPS` / `PROBE_LADDER` | no | see `config.ts` | Venue-probe cache staleness, isolated probe rate, and whole-token ladder sizes |
Expand Down
10 changes: 4 additions & 6 deletions bots/blue-liquidation/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ const DEFAULT_POSITION_LIQUIDATION_COOLDOWN_MS = 0
// queue ahead of a time-sensitive firm quote; log-scaled ladder sizes are whole collateral tokens
// (converted per-collateral to base units). `PROBE_STALE_MS` caps probe cadence per pair; a pair is
// re-probed only when a liquidatable position touches it after the cache goes stale.
const DEFAULT_SLIPPAGE_BPS = 100
const DEFAULT_PROBE_STALE_MS = 600_000
const DEFAULT_PROBE_HTTP_RPS = 1
const DEFAULT_PROBE_LADDER = ['0.01', '0.1', '1', '10', '100']
Expand Down Expand Up @@ -122,13 +121,13 @@ export type DiscoveryConfig = {
}

/**
* Enabled swap venues + global routing knobs. Venues are enabled by the PRESENCE of their API key in
* env (secrets themselves are read at the point of use in index.ts, never stored here). `slippageBps`
* is global now that routing is not per-collateral; `baseUrl` overrides are optional per-venue hosts.
* Enabled swap venues + their optional host overrides. Venues are enabled by the PRESENCE of their API
* key in env (secrets themselves are read at the point of use in index.ts, never stored here). There is
* no slippage knob: the quoting layer derives each venue's allowance from the liquidation's break-even
* output, so the min-out floor is economic rather than operator-chosen.
*/
export type VenueConfig = {
enabled: Venue[]
slippageBps: number
zeroxBaseUrl: string | undefined
oneinchBaseUrl: string | undefined
lifiBaseUrl: string | undefined
Expand Down Expand Up @@ -365,7 +364,6 @@ export function loadConfig(
}
const venues: VenueConfig = {
enabled: enabledVenues,
slippageBps: intEnv(env, 'SLIPPAGE_BPS', DEFAULT_SLIPPAGE_BPS, { min: 0, max: 10_000 }),
zeroxBaseUrl,
oneinchBaseUrl,
lifiBaseUrl,
Expand Down
2 changes: 1 addition & 1 deletion bots/blue-liquidation/src/execution/swap-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ import { mulDivDown } from '../sizing/math'
* ORACLE_PRICE_SCALE`), with token decimals already baked in. This reference feeds Uniswap min-out
* construction and aggregator route-quality checks.
*/
export function expectedLoanOut(plan: LiquidationPlan, out: LensOut): bigint {
export function expectedLoanOut(plan: Pick<LiquidationPlan, 'seizedAssets'>, out: LensOut): bigint {
return mulDivDown(plan.seizedAssets, out.collateralPrice, ORACLE_PRICE_SCALE)
}
1 change: 0 additions & 1 deletion bots/blue-liquidation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ async function main() {
chainId: config.chainId,
executor: config.executooorAddress,
venues,
slippageBps: config.venues.slippageBps,
baseUrls,
maxRouteImpactBps: config.quoting.maxRouteImpactBps,
unwrappers,
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 @@ -26,7 +26,6 @@ export function composeQuoting(deps: {
chainId: number
executor: Address
venues: readonly Venue[]
slippageBps: number
baseUrls: Partial<Record<Venue, string>>
maxRouteImpactBps: number
unwrappers: readonly Unwrapper[]
Expand Down Expand Up @@ -57,6 +56,9 @@ export function composeQuoting(deps: {
loanToken: out.params.loanToken,
amountIn: plan.seizedAssets,
referenceAmountOut: expectedLoanOut(plan, out),
// Break-even, straight off the plan: the loan assets `liquidate` will pull for this seize,
// including the shares round-trip Blue settles through.
minAcceptableAmountOut: plan.impliedRepaidAssets,
// The tick's position label (`${id}:${borrower}`) — the correlation id join across quote logs.
id: label
})
Expand Down
65 changes: 62 additions & 3 deletions bots/blue-liquidation/src/sizing/plan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
import { ORACLE_PRICE_SCALE } from '../constants'
import { lifFromLltv } from './lif'
import { min, mulDivDown, toAssetsDown, wMulDown } from './math'
import {
min,
mulDivDown,
mulDivUp,
toAssetsDown,
toAssetsUp,
toSharesUp,
wDivUp,
wMulDown
} from './math'

/**
* The loan assets `liquidate` pulls from the liquidator for a given seize — the swap's break-even
* output. Mirrors Morpho Blue's own chain, every step of which rounds UP against the liquidator:
*
* ```text
* quoted = seizedAssets.mulDivUp(collateralPrice, ORACLE_PRICE_SCALE)
* shares = quoted.wDivUp(lif).toSharesUp(totalBorrowAssets, totalBorrowShares)
* assets = shares.toAssetsUp(totalBorrowAssets, totalBorrowShares)
* ```
*
* The shares round-trip is not decorative: Blue settles the repay in shares, so an assets-only
* estimate is short by the two rounding steps and understates what the callback must produce.
*
* Module-private: every sized plan carries its own value as
* {@link LiquidationPlan.impliedRepaidAssets}, which is the surface callers want.
*/
const impliedRepaidAssets = (args: {
seizedAssets: bigint
collateralPrice: bigint
lltv: bigint
accruedTotalBorrowAssets: bigint
totalBorrowShares: bigint
}): bigint => {
const quoted = mulDivUp(args.seizedAssets, args.collateralPrice, ORACLE_PRICE_SCALE)
const shares = toSharesUp(
wDivUp(quoted, lifFromLltv(args.lltv)),
args.accruedTotalBorrowAssets,
args.totalBorrowShares
)
return toAssetsUp(shares, args.accruedTotalBorrowAssets, args.totalBorrowShares)
}

/**
* The fresh, lens-derived inputs the sizing decision depends on. All fields come from one `eth_call`
Expand All @@ -22,7 +63,16 @@ export type PlanInput = {
}

/** A seize-exact plan: pin `seizedAssets` and pass `repaidShares = 0`, letting Blue ceil-derive it. */
export type LiquidationPlan = { seizedAssets: bigint }
export type LiquidationPlan = {
seizedAssets: bigint
/**
* The loan assets Blue will pull for `seizedAssets` — the swap's break-even output. Mirrors
* `liquidate`'s own derivation, including the shares round-trip: Blue converts the quoted seize to
* shares and back, and BOTH conversions round up, so an assets-only estimate understates what is
* actually transferred.
*/
impliedRepaidAssets: bigint
}

/**
* Turns a fresh lens reading into a seize-exact liquidation plan, or `null` when the position is not
Expand Down Expand Up @@ -67,5 +117,14 @@ export function plan(input: PlanInput): LiquidationPlan | null {
const seizedAssets = min(input.collateral, seizeForFullDebt)
// Rounds to nothing (dust position, or price ≫ debt): can't pass 0 to `liquidate`, so skip it.
if (seizedAssets === 0n) return null
return { seizedAssets }
return {
seizedAssets,
impliedRepaidAssets: impliedRepaidAssets({
seizedAssets,
collateralPrice: input.collateralPrice,
lltv: input.lltv,
accruedTotalBorrowAssets: input.accruedTotalBorrowAssets,
totalBorrowShares: input.totalBorrowShares
})
}
}
7 changes: 4 additions & 3 deletions bots/blue-liquidation/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ describe('loadConfig', () => {
// Executor address is derived from the deterministic CREATE2 factory when not overridden.
expect(config.executooorAddress).toMatch(/^0x[0-9a-fA-F]{40}$/)
expect(config.venues.enabled).toEqual(['0x'])
expect(config.venues.slippageBps).toBe(100)
expect(config.venues.excludeCollaterals).toEqual([])
expect(config.venues.zeroxBaseUrl).toBeUndefined()
})
Expand Down Expand Up @@ -113,8 +112,10 @@ describe('loadConfig', () => {
)
})

it('parses SLIPPAGE_BPS and EXCLUDE_COLLATERALS, failing loud on a bad address', () => {
expect(loadConfig(baseEnv({ SLIPPAGE_BPS: '250' })).venues.slippageBps).toBe(250)
it('parses EXCLUDE_COLLATERALS, failing loud on a bad address', () => {
// A stale SLIPPAGE_BPS must not fail startup: the knob was removed when the min-out floor became
// break-even-derived, and an unknown env var is not a misconfiguration.
expect(() => loadConfig(baseEnv({ SLIPPAGE_BPS: '250' }))).not.toThrow()
const config = loadConfig(baseEnv({ EXCLUDE_COLLATERALS: ` ${COLLATERAL} , ${MORPHO}` }))
expect(config.venues.excludeCollaterals).toEqual([getAddress(COLLATERAL), MORPHO])
expect(() => loadConfig(baseEnv({ EXCLUDE_COLLATERALS: '0x123' }))).toThrow(
Expand Down
3 changes: 3 additions & 0 deletions bots/blue-liquidation/test/fork/liquidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ describe.skipIf(!FORK_URL || !FIXTURE)(
tokenOut: out.params.loanToken,
amountIn: liquidationPlan.seizedAssets,
slippageBps: SLIPPAGE_BPS,
// The fork suite drives the venue directly, so it sets its own floor rather than deriving
// one; 0 means "no economic floor", which is what a raw exec-path test wants.
minAcceptableAmountOut: 0n,
executor: executooor,
referenceAmountOut: expectedLoanOut(liquidationPlan, out)
}
Expand Down
27 changes: 23 additions & 4 deletions bots/blue-liquidation/test/quotes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ const PARAMS: MarketParams = {
lltv: (WAD * 86n) / 100n
}

// price = 1e36 → expectedLoanOut = seizedAssets = 1000 (the route-quality reference).
const PLAN: LiquidationPlan = { seizedAssets: 1000n }
// Break-even at 800, under the 0x stub's reported min-out of 995, so these cases exercise the lens
// projection rather than the economic floor (which is covered in @repo/swaps).
const PLAN: LiquidationPlan = { seizedAssets: 1000n, impliedRepaidAssets: 800n }

const OUT: LensOut = {
params: PARAMS,
Expand Down Expand Up @@ -81,15 +82,15 @@ function compose(
venues?: ('0x' | '1inch')[]
excludeCollaterals?: `0x${string}`[]
logger?: Logger
httpClient?: RateLimitedClient
} = {}
) {
return composeQuoting({
httpClient: httpStub,
httpClient: overrides.httpClient ?? httpStub,
selector,
chainId: 8453,
executor: EXECUTOR,
venues: overrides.venues ?? ['0x'],
slippageBps: 100,
baseUrls: {},
maxRouteImpactBps: 500,
unwrappers: [],
Expand Down Expand Up @@ -156,4 +157,22 @@ describe('composeQuoting (Blue lens-projection adapter)', () => {
const selectOk = events.find(e => e.event === 'select.ok')
expect(selectOk?.fields?.id).toBe(LABEL)
})

it('projects the plan break-even into the venue slippage it asks for', () => {
// seizedAssets 1000 at price 1e36 -> reference 1000; break-even 800 -> the route may give up
// 200/1000 = 2000bps. If the adapter stopped threading `impliedRepaidAssets` this would read 0.
const calls: (Record<string, string> | undefined)[] = []
const capturing: RateLimitedClient = {
getJson: async <T>(args: { searchParams?: Record<string, string> }) => {
calls.push(args.searchParams)
return OK_ZEROX_BODY as T
}
}
const { selector } = fakeSelector([{ venue: '0x', expectedOut: 1000n }])
return compose(selector, { httpClient: capturing })
.quoteFor(PLAN, OUT, LABEL)
.then(() => {
expect(calls[0]?.slippageBps).toBe('2000')
})
})
})
52 changes: 51 additions & 1 deletion bots/blue-liquidation/test/sizing/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
toSharesUp,
wDivUp,
wMulDown,
mulDivUp
mulDivUp,
toAssetsUp
} from '../../src/sizing/math'
import { plan } from '../../src/sizing/plan'

Expand Down Expand Up @@ -141,3 +142,52 @@ describe('plan — repaidShares ≤ borrowShares (no on-chain underflow)', () =>
expect(checked).toBeGreaterThan(100)
})
})

describe('impliedRepaidAssets', () => {
// Checked against `contractRepaidShares` above — an independent reimplementation of Blue's round-up
// chain, written for the underflow sweep before this field existed, so this is not circular.
it('matches what liquidate pulls, across share prices and sizes', () => {
const cases: Partial<PlanInput>[] = [
{},
{ accruedTotalBorrowAssets: 1_000_000n * WAD, totalBorrowShares: 999_999n * WAD },
{ accruedTotalBorrowAssets: 7_777_777n, totalBorrowShares: 3_333_331n },
{ collateralPrice: (ORACLE_PRICE_SCALE * 3n) / 7n },
{ collateralPrice: ORACLE_PRICE_SCALE * 41n, collateral: 5n * WAD },
{ borrowShares: 1n, collateral: 1n }
]
let checked = 0
for (const overrides of cases) {
const input = baseInput(overrides)
const built = plan(input)
if (!built) continue
checked += 1
const expected = toAssetsUp(
contractRepaidShares(input, built.seizedAssets),
input.accruedTotalBorrowAssets,
input.totalBorrowShares
)
expect(built.impliedRepaidAssets).toBe(expected)
}
expect(checked).toBeGreaterThan(3)
})

it('is not the assets-only estimate, which understates the repay', () => {
// The bug this field replaced: flooring the quoted value and skipping the shares round-trip. Both
// errors round the wrong way, so the estimate came in UNDER what Blue actually pulls — a min-out
// floor derived from it does not protect the repay.
const input = baseInput({
collateralPrice: (ORACLE_PRICE_SCALE * 3n) / 7n,
accruedTotalBorrowAssets: 7_777_777n,
totalBorrowShares: 3_333_331n
})
const built = plan(input)
expect(built).not.toBeNull()
if (!built) return
const assetsOnly = mulDivUp(
mulDivDown(built.seizedAssets, input.collateralPrice, ORACLE_PRICE_SCALE),
WAD,
lifFromLltv(input.lltv)
)
expect(built.impliedRepaidAssets).toBeGreaterThan(assetsOnly)
})
})
Loading
Loading