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
25 changes: 25 additions & 0 deletions bots/blue-liquidation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,31 @@ distinct nonces, EIP-1559 ≥12.5% fee bump on stuck nonces, and a hard `MAX_FEE
drops rather than chases a gas spike. State is not persisted — chain truth wins; a restart re-derives
the nonce from `getTransactionCount('pending')`.

### Log correlation

Every **position-scoped** event carries the position in one field, **`id`**, whose value is
`lensKey(marketId, borrower)`: the two halves joined by `:` with both lowercased. So a window's events
group into one row per position with **no normalization in the query** (`GROUP BY id`). `tx.*` used to
name the same string `label`; it does not any more. The full set:

`plan.built`, `cooldown.skip`, `config.no_swap_path`, `quote.excluded_collateral`, `unwrap.failed`,
`unwrap.resolved`, `unwrap.bad_route`, `unwrap.preview_reverted`, `unwrap.preview_zero`,
`quote.floor_unmet`, `quote.ok`, `quote.failed`, `quote.route_quality_failed`, `probe.error`,
`select.cold_default`, `select.ok`, `simulate.ok`, `simulate.revert`, `tx.send_aborted`,
`tx.submit_failed`, `tx.sent`, `tx.bumped`, `tx.confirmed`, `tx.reverted`, `tx.dropped`,
`tx.replace_failed`, `tx.onblock_error`, `nonce.sync_failed`, `queue.nonce_hole`.

`plan.built` also keeps `marketId` and `borrower` as human-readable extras — for reading a single
line, not for grouping. A Blue market has exactly one collateral, so one position is one candidate:
unlike `bots/midnight-liquidation`, `id` alone identifies a row and no candidate discriminator is
emitted.

Everything else is scoped to something other than a position and carries **no** `id`, by design —
per tick (`discover.*`, `lens.read`, `tick.end`, `tick.error`, `block.new`), per venue pair
(`probe.venue_error`, `probe.refreshed`), queue-wide (`queue.nonce_hole_cleared`,
`queue.maintenance_failed`, `reconcile.failed`), or process/config (`startup`, `shutdown`,
`quoting.*`, `discovery.*`, `signer.*`, `heartbeat.*`, `runner.*`, `watcher.error`, `pendle.*`).

## Testing

- `pnpm test` — unit tests for the math, LIF, seize-exact planner (incl. the underflow-safety sweep),
Expand Down
6 changes: 4 additions & 2 deletions bots/blue-liquidation/src/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export function composeQuoting(deps: {
// The operator opt-out applies to the RAW collateral — blue has no per-collateral config file
// 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 })
logger.info('quote.excluded_collateral', {
id: label,
collateral: out.params.collateralToken
})
// `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 }
Expand All @@ -61,7 +64,6 @@ export function composeQuoting(deps: {
// 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
7 changes: 4 additions & 3 deletions bots/blue-liquidation/src/runner/tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export async function runTick(deps: {
if (!liquidationPlan) continue
counters.planned += 1
logger.info('plan.built', {
id: label,
marketId: id,
borrower: pair.borrower,
seizedAssets: liquidationPlan.seizedAssets
Expand All @@ -156,7 +157,7 @@ export async function runTick(deps: {
// disabled (POSITION_LIQUIDATION_COOLDOWN_MS=0).
if (cooldown.shouldSkip(label)) {
counters.cooledDown += 1
logger.info('cooldown.skip', { marketId: id, borrower: pair.borrower })
logger.info('cooldown.skip', { id: label, marketId: id, borrower: pair.borrower })
continue
}
// Suppress positions that keep failing to quote/simulate — bounds API + RPC usage under a
Expand All @@ -169,7 +170,7 @@ export async function runTick(deps: {
if (outcome.kind === 'no_config') {
counters.noSwapPath += 1
cooldown.mark(label)
logger.info('config.no_swap_path', { marketId: id, borrower: pair.borrower })
logger.info('config.no_swap_path', { id: label, marketId: id, borrower: pair.borrower })
continue
}
if (outcome.kind === 'failed') {
Expand All @@ -186,7 +187,7 @@ export async function runTick(deps: {
plan: liquidationPlan,
swapPlan
})
const fields = { marketId: id, borrower: pair.borrower }
const fields = { id: label, marketId: id, borrower: pair.borrower }
switch (result.status) {
case 'ok':
counters.ok += 1
Expand Down
39 changes: 36 additions & 3 deletions bots/blue-liquidation/test/runner/tick.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { CooldownStore } from '@repo/bot-kit'
import type { QuoteOutcome, SwapPlan } from '@repo/swaps'
import type { Address } from 'viem'

import { createBackoff, createCooldownStore } from '@repo/bot-kit'
import { createBackoff, createCooldownStore, createPendingQueue } from '@repo/bot-kit'
import { lensKey } from '@repo/utils'
import { getAddress } from 'viem'
import { describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -103,8 +103,12 @@ function runWith(opts: {
cooldown?: CooldownStore
/** Models the queue's outcome; the two no-broadcast reasons are NOT interchangeable. */
submitOutcome?: SubmitOutcome
/** Shared spy, so a caller can observe the tick's and the queue's events in ONE stream. */
spy?: ReturnType<typeof spyLogger>
/** Replaces the stub `submit` — used to broadcast through a real pending queue. */
submitWith?: (args: { label: string; blockNumber: bigint }) => Promise<SubmitOutcome>
}) {
const { logger, events } = spyLogger()
const { logger, events } = opts.spy ?? spyLogger()
let simulateCalls = 0
let submitCalls = 0
let quoteCalls = 0
Expand All @@ -131,8 +135,9 @@ function runWith(opts: {
simulateCalls += 1
return opts.simulateResult ?? { status: 'ok' }
},
submit: async () => {
submit: async args => {
submitCalls += 1
if (opts.submitWith) return opts.submitWith(args)
return opts.submitOutcome ?? { sent: true }
},
backoff,
Expand Down Expand Up @@ -375,4 +380,32 @@ describe('runTick', () => {
expect(backoff.shouldSkip(LABEL, 1n)).toBe(false)
})
})
it('emits one id that joins plan.built to the queue tx.sent', async () => {
// BOTS-90's acceptance criterion as a test: grouping a window's events by `id` must not split one
// position. Broadcast through the REAL queue, since the split was between the tick's field name
// and the queue's — a stubbed submit cannot see it.
const spy = spyLogger()
const queue = createPendingQueue({
send: async () => ({ nonce: 7, txHash: `0x${'1'.repeat(64)}` }),
getReceipt: async () => null,
getBaseFee: async () => 1n,
maxFeeWei: 10n ** 18n,
logger: spy.logger
})
await runWith({
spy,
submitWith: ({ label, blockNumber }) =>
queue.submit({
request: { to: ROUTER, data: '0x' },
label,
maxFeePerGas: 1000n,
maxPriorityFeePerGas: 1000n,
blockNumber
})
})
const planBuilt = spy.events.find(e => e.event === 'plan.built')
const txSent = spy.events.find(e => e.event === 'tx.sent')
expect(planBuilt?.fields?.id).toBe(LABEL)
expect(txSent?.fields?.id).toBe(planBuilt?.fields?.id)
})
})
6 changes: 6 additions & 0 deletions bots/midnight-crossed-books/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ label or a manual production workflow dispatch and the `crossed-books-prod` GitH
Each GitHub Environment defines `RAILWAY_PROJECT_ID` as a variable and `RAILWAY_TOKEN` as a secret;
bot runtime secrets remain on Railway.

## Observability

The shared queue identifies a tracked transaction by the key this bot hands it, under the field `id`
(it was `label`). This bot's key is the **market id**, so `tx.*.id` is a market rather than a
position — it does not join to a liquidator's `id`, which is `marketId:borrower`.

## Test

```sh
Expand Down
42 changes: 39 additions & 3 deletions bots/midnight-liquidation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,10 +540,10 @@ does not broadcast.

On simulation success, `@repo/bot-kit`'s shared pending queue
([packages/bot-kit/src/queue/pending-queue.ts](../../packages/bot-kit/src/queue/pending-queue.ts))
sends the transaction through the signer client and tracks it by nonce and `(marketId, borrower)`
label.
sends the transaction through the signer client and tracks it by nonce and by the position's
`(marketId, borrower)` key.

While a label is pending, later ticks skip that position. On each block the queue checks receipts,
While that position is in flight, later ticks skip it. On each block the queue checks receipts,
logs confirmed or reverted transactions, and fee-bumps stuck transactions until either they confirm,
hit the fee ceiling, or exhaust bump attempts.

Expand Down Expand Up @@ -588,6 +588,42 @@ signer nonce cursor starts from the pending chain nonce. If the initial raw broa
nonce is claimed but before a hash is returned, the signer rolls the cursor back and the queue aborts
that tick instead of counting a hashless transaction as submitted.

### Log Correlation

Every **position-scoped** event carries the position in one field, **`id`**, whose value is
`lensKey(marketId, borrower)`: the two halves joined by `:` with both lowercased. So a maturity's
events group into one row per position with **no normalization in the query** (`GROUP BY id`). `tx.*`
used to name the same string `label`; it does not any more. The full set:

`plan.skipped`, `plan.built`, `preselect.skipped`, `route.unresolved`, `cooldown.skip`,
`config.no_swap_path`, `quote.excluded_collateral`, `quote.unprofitable`, `unwrap.failed`,
`unwrap.resolved`, `unwrap.bad_route`, `unwrap.preview_reverted`, `unwrap.preview_zero`,
`quote.floor_unmet`, `quote.ok`, `quote.failed`, `quote.route_quality_failed`, `probe.error`,
`select.cold_default`, `select.ok`, `simulate.ok`, `simulate.revert`, `send.revert_streak`,
`tx.send_aborted`, `tx.submit_failed`, `tx.sent`, `tx.bumped`, `tx.confirmed`, `tx.reverted`,
`tx.dropped`, `tx.replace_failed`, `tx.onblock_error`, `nonce.sync_failed`, `queue.nonce_hole`.

`plan.built` also keeps `marketId` and `borrower` as human-readable extras. They are for an operator
reading a single line — grouping keys on `id`.

`id` identifies a **position**, and one position yields several candidates (one per activated
collateral slot, and a matured-and-unhealthy slot in both open modes). The per-**candidate** key is
therefore `(id, collateralIndex, postMaturityMode)`, and both discriminators ride on every
per-candidate event, the `@repo/swaps` quote events included. `send.revert_streak` deliberately
carries none: the streak is keyed by position and spans whichever siblings reverted, so attributing it
to one `(slot, mode)` would misreport it.

Everything else is scoped to something other than a position and carries **no** `id`, by design —
don't group it by one:

- **per tick** — `discover.*`, `lens.read`, `probe.warmed`, `tick.end`, `tick.error`, `block.new`
- **per venue pair** — `probe.warm_failed`, `probe.venue_error`, `probe.refreshed` (several positions
in one market share a probe)
- **queue-wide** — `queue.nonce_hole_cleared`, `queue.maintenance_failed`, `reconcile.failed` (a
condition that would have refused any position)
- **process / config** — `startup`, `shutdown`, `quoting.*`, `markets.*`, `prices.*`, `discovery.*`,
`signer.*`, `heartbeat.*`, `runner.*`, `watcher.error`, `pendle.*`

## Important Operational Notes

- The liquidator gate checks the Executor address, not the EOA, because `liquidate` is called by the
Expand Down
27 changes: 21 additions & 6 deletions bots/midnight-liquidation/src/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ import type { LensOut } from './state/lens.sol'

import { expectedLoanOut } from './execution/swap-step'

/**
* What separates one position's `(slot, mode)` alternatives in a log row, for `@repo/swaps`'
* `QuoteRequest.candidate`. Correlation only; the quoting layer never reads it.
*/
const candidateOf = (plan: LiquidationPlan) => ({
collateralIndex: plan.collateralIndex,
postMaturityMode: plan.postMaturityMode
})

/**
* The Midnight-shaped adapter over `@repo/swaps`' {@link composeMultiVenueQuoting}: keeps the
* `(plan, out)` signature the tick consumes and projects the lens output into the package's plain
Expand Down Expand Up @@ -56,7 +65,8 @@ export function composeQuoting(deps: {
*/
resolveRoute: (
plan: LiquidationPlan,
out: LensOut
out: LensOut,
label: string
) => Promise<{ pair: VenuePair; amountIn: bigint } | null>
} {
const { selector, excludeCollaterals, logger, executor, unwrappers, ...rest } = deps
Expand All @@ -74,14 +84,15 @@ export function composeQuoting(deps: {
})

return {
async resolveRoute(plan, out) {
async resolveRoute(plan, out, label) {
const collateral = out.market.collateralParams[plan.collateralIndex]
if (!collateral || excluded(collateral.token)) return null
const resolution = await resolveUnwraps(unwrappers, {
token: collateral.token,
amountIn: plan.seizedAssets,
executor,
stopToken: out.market.loanToken
stopToken: out.market.loanToken,
correlation: { id: label, ...candidateOf(plan) }
})
if (isAddressEqual(resolution.token, out.market.loanToken)) return null
return {
Expand All @@ -98,7 +109,11 @@ export function composeQuoting(deps: {
// The operator opt-out applies to the RAW collateral — midnight has no per-collateral config
// file, so this is its escape hatch from the auto-unwrap path too.
if (excluded(collateral.token)) {
logger.info('quote.excluded_collateral', { collateral: collateral.token })
logger.info('quote.excluded_collateral', {
id: label,
...candidateOf(plan),
collateral: collateral.token
})
return { kind: 'no_config', firmCalls: 0 }
}

Expand All @@ -111,8 +126,8 @@ export function composeQuoting(deps: {
// the plan was sized at. Read rather than recomputed — the matured-and-unhealthy branch picks a
// mode by surplus, so the LIF is not recoverable from `postMaturityMode` or from chain time.
minAcceptableAmountOut: plan.impliedRepaidUnits,
// The tick's position label (`${id}:${borrower}`) — the correlation id join across quote logs.
id: label
id: label,
candidate: candidateOf(plan)
})
}
}
Expand Down
Loading
Loading