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
9 changes: 4 additions & 5 deletions bots/blue-liquidation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ async function main() {
})
void heartbeatMonitor.start()

// An HTTP block-poll watcher drives one tick per new block (coalescing backlog), passing the polled
// height as the queue's submittedAtBlock. Each liquidatable position resolves its swap, simulates
// An HTTP block-poll watcher drives one tick per new block (coalescing backlog). Each
// liquidatable position resolves its swap, simulates
// the real `exec_606BaXt`, and — on a sim-ok result — broadcasts that same exec via the Executor
// singleton. Pending-queue upkeep runs in `maintain`.
const tick = (chainHead: bigint) =>
Expand All @@ -276,7 +276,7 @@ async function main() {
eoa,
data: encodeExec(market, borrower, plan, swapPlan)
}),
submit: async ({ market, borrower, plan, swapPlan, blockNumber, label }) => {
submit: async ({ market, borrower, plan, swapPlan, label }) => {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const fees = initialFees(await signer.getBaseFee(), config.maxFeeWei)
return queue.submit({
request: {
Expand All @@ -285,8 +285,7 @@ async function main() {
},
label,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
blockNumber
maxPriorityFeePerGas: fees.maxPriorityFeePerGas
})
},
backoff,
Expand Down
4 changes: 1 addition & 3 deletions bots/blue-liquidation/src/runner/tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const LEVEL_BY_REASON: Record<PlanSkipReason, LogLevel> = {
*/
export async function runTick(deps: {
discover: () => Promise<BorrowerCandidate[]>
/** Chain head the runner just polled — the queue's `submittedAtBlock`. */
/** Chain head the runner just polled — the tick-constant height backoff windows are measured in. */
chainHead: bigint
readLens: (pairs: LensInput[]) => Promise<Map<string, LensOut>>
/**
Expand All @@ -119,7 +119,6 @@ export async function runTick(deps: {
borrower: Address
plan: LiquidationPlan
swapPlan: SwapPlan
blockNumber: bigint
label: string
}) => Promise<SubmitOutcome>
/** Per-position exponential backoff suppressing repeated quote/simulate failures (rate-limit defense). */
Expand Down Expand Up @@ -269,7 +268,6 @@ export async function runTick(deps: {
borrower: pair.borrower,
plan: liquidationPlan,
swapPlan,
blockNumber: chainHead,
label
})
if (outcome.sent) {
Expand Down
7 changes: 3 additions & 4 deletions bots/blue-liquidation/test/runner/tick.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function runWith(opts: {
/** 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>
submitWith?: (args: { label: string }) => Promise<SubmitOutcome>
}) {
const { logger, events } = opts.spy ?? spyLogger()
let simulateCalls = 0
Expand Down Expand Up @@ -468,13 +468,12 @@ describe('runTick', () => {
})
await runWith({
spy,
submitWith: ({ label, blockNumber }) =>
submitWith: ({ label }) =>
queue.submit({
request: { to: ROUTER, data: '0x' },
label,
maxFeePerGas: 1000n,
maxPriorityFeePerGas: 1000n,
blockNumber
maxPriorityFeePerGas: 1000n
})
})
const planBuilt = spy.events.find(e => e.event === 'plan.built')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface OrderBookService {

export interface ResolverService {
simulate(matches: readonly CrossedMatch[]): Promise<SimulationResult>
submit(prepared: PreparedResolution, blockNumber: bigint): Promise<void>
submit(prepared: PreparedResolution): Promise<void>
}

interface BotLogger {
Expand Down Expand Up @@ -53,11 +53,17 @@ export class CrossedBooksBotService {

/**
* Computes the first profitable crossed resolution for one block.
* @param blockNumber - Block label used only when queueing a write-mode transaction.
* @returns Submission status and the number of listed markets inspected.
* @remarks Always simulates first. Readonly mode logs `match.computed` and performs no submission.
* @returns Whether a profitable resolution was found and handed to the resolver for attempted
* queue submission, and the number of listed markets inspected.
* @throws Whatever market discovery, book reads, simulation, or submission raise — none are caught
* here, so the caller's tick is what isolates them.
* @remarks Always simulates first, and submits at most one resolution per call. Logs
* `match.not_profitable` / `match.computed` / `match.submitted`. Readonly mode performs no
* submission; write mode hands the prepared resolution to the resolver, which submits it through
* the pending queue. `match.submitted` records acceptance by the resolver, not an on-chain
* broadcast guarantee — the pending queue may still decline the transaction.
*/
async run({ blockNumber }: { blockNumber: bigint }) {
async run() {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const markets = await this.markets.listListedActiveMarkets()
const inflight = this.inflightMarketIds()
let computed = false
Expand Down Expand Up @@ -95,7 +101,7 @@ export class CrossedBooksBotService {
continue
}

await this.resolver.submit(simulation.prepared, blockNumber)
await this.resolver.submit(simulation.prepared)
this.logger.info('match.submitted', fields)

return { submitted: true, markets: markets.length }
Expand Down
4 changes: 2 additions & 2 deletions bots/midnight-crossed-books/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ export async function createApplication(
let nextScanAt = 0
const runner = createRunner({
getBlockNumber: () => getBlockNumber(chainClient),
tick: async blockNumber => {
tick: async () => {
if (Date.now() < nextScanAt || (queue?.size ?? 0) > 0) return
nextScanAt = Date.now() + config.scanIntervalMs
await bot.run({ blockNumber })
await bot.run()
},
maintain: async blockNumber => {
await queue?.onBlock(blockNumber)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ export class ResolverExecutionService implements ResolverService {
}
}

submit(prepared: PreparedResolution, blockNumber: bigint) {
return this.transport.submit(prepared, blockNumber)
/**
* Hands the simulated resolution to the pending queue for broadcast.
* @param prepared - Resolver target calldata and market label, exactly as simulation produced it.
* @returns A promise resolving once the queue has accepted (or declined) the request.
* @throws `ReadonlyMutationError` when the transport was composed without submission dependencies,
* and propagates any signer or queue failure the transport raises.
*/
submit(prepared: PreparedResolution) {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return this.transport.submit(prepared)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ResolverSimulation =

export interface ResolverTransport {
simulate(data: Hex): Promise<ResolverSimulation>
submit(prepared: PreparedResolution, blockNumber: bigint): Promise<void>
submit(prepared: PreparedResolution): Promise<void>
}

export class ViemResolverTransport implements ResolverTransport {
Expand Down Expand Up @@ -62,19 +62,17 @@ export class ViemResolverTransport implements ResolverTransport {
/**
* Queues the immutable request prepared by simulation.
* @param prepared - Resolver target calldata and market label.
* @param blockNumber - Block used to seed queue fee and replacement policy.
* @returns A promise that resolves once the request is accepted by the queue.
* @throws `ReadonlyMutationError` when submission dependencies were intentionally omitted.
*/
async submit(prepared: PreparedResolution, blockNumber: bigint) {
async submit(prepared: PreparedResolution) {
if (!this.submission) throw new ReadonlyMutationError()
const fees = initialFees(await this.submission.signer.getBaseFee(), this.submission.maxFeeWei)

await this.submission.queue.submit({
request: { to: this.resolver, data: prepared.data },
label: prepared.marketId,
...fees,
blockNumber
...fees
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe('CrossedBooksBotService', () => {
test('loads listed active markets once per run', async () => {
const { service, listListedActiveMarkets } = setup()

await service.run({ blockNumber: 10n })
await service.run()

expect(listListedActiveMarkets).toHaveBeenCalledTimes(1)
})
Expand All @@ -99,7 +99,7 @@ describe('CrossedBooksBotService', () => {
inflight: new Set([MARKET_ID])
})

const result = await service.run({ blockNumber: 10n })
const result = await service.run()

expect(result).toEqual({ submitted: false, markets: 1 })
expect(getTakeableBook).not.toHaveBeenCalled()
Expand All @@ -109,7 +109,7 @@ describe('CrossedBooksBotService', () => {
test('does not simulate or submit when books do not cross', async () => {
const { service, simulate, submit } = setup({ matches: [] })

await service.run({ blockNumber: 10n })
await service.run()

expect(simulate).not.toHaveBeenCalled()
expect(submit).not.toHaveBeenCalled()
Expand All @@ -120,15 +120,15 @@ describe('CrossedBooksBotService', () => {
simulation: { status: 'revert', reason: 'InsufficientProfit' }
})

await service.run({ blockNumber: 10n })
await service.run()

expect(submit).not.toHaveBeenCalled()
})

test('simulates every crossed offer in one resolution', async () => {
const { service, match, simulate } = setup()

await service.run({ blockNumber: 10n })
await service.run()

expect(match).toHaveBeenCalledWith({
asks: [MATCH.ask],
Expand All @@ -141,7 +141,7 @@ describe('CrossedBooksBotService', () => {
test('uses the configured match cap', async () => {
const { service, match } = setup({ maxMatches: 3 })

await service.run({ blockNumber: 10n })
await service.run()

expect(match).toHaveBeenCalledWith({
asks: [MATCH.ask],
Expand All @@ -158,9 +158,9 @@ describe('CrossedBooksBotService', () => {
}
const { service, submit } = setup({ simulation: { status: 'ok', prepared } })

const result = await service.run({ blockNumber: 10n })
const result = await service.run()

expect(submit).toHaveBeenCalledWith(prepared, 10n)
expect(submit).toHaveBeenCalledWith(prepared)
expect(result).toEqual({ submitted: true, markets: 1 })
})

Expand All @@ -175,7 +175,7 @@ describe('CrossedBooksBotService', () => {
simulation: { status: 'ok', prepared }
})

const result = await service.run({ blockNumber: 10n })
const result = await service.run()

expect(submit).not.toHaveBeenCalled()
expect(logger.info).toHaveBeenCalledWith('match.computed', {
Expand All @@ -192,7 +192,7 @@ describe('CrossedBooksBotService', () => {
markets: [MARKET, OTHER_MARKET]
})

const result = await service.run({ blockNumber: 10n })
const result = await service.run()

expect(getTakeableBook).toHaveBeenCalledTimes(2)
expect(simulate).toHaveBeenCalledTimes(2)
Expand Down Expand Up @@ -221,7 +221,7 @@ describe('CrossedBooksBotService', () => {
}
})

const result = await service.run({ blockNumber: 10n })
const result = await service.run()

expect(calls).toBe(2)
expect(submit).toHaveBeenCalledTimes(1)
Expand All @@ -231,7 +231,7 @@ describe('CrossedBooksBotService', () => {
test('stops after the first successful submission', async () => {
const { service, getTakeableBook, submit } = setup({ markets: [MARKET, OTHER_MARKET] })

await service.run({ blockNumber: 10n })
await service.run()

expect(getTakeableBook).toHaveBeenCalledTimes(1)
expect(submit).toHaveBeenCalledTimes(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ describe('ResolverExecutionService', () => {
profit: 42n
}

await service.submit(prepared, 99n)
await service.submit(prepared)

expect(submit).toHaveBeenCalledWith(prepared, 99n)
expect(submit).toHaveBeenCalledWith(prepared)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('ViemResolverTransport', () => {
const transport = new ViemResolverTransport(client, ADDRESS, ADDRESS)

await expect(
transport.submit({ marketId: MARKET_ID, data: '0x1234', profit: 42n }, 10n)
transport.submit({ marketId: MARKET_ID, data: '0x1234', profit: 42n })
).rejects.toBeInstanceOf(ReadonlyMutationError)
})
})
6 changes: 6 additions & 0 deletions bots/midnight-liquidation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ same behaviour keeps the same timing on a ~2s and a ~12s chain.
| `reconcileEveryBlocks` / `balanceEveryBlocks` | `3` / `30n` | `1` / `5n` | Same, for nonce reconciliation and the gas metric. |
| `maxBumpAttempts` | `3` | `6` | Mainnet basefee can climb 12.5% per block. |

`stuckBlocks` is counted from the block that first _sights_ a broadcast, not from the block the tick
started on, so the wait before a bump is one block longer than the number itself — on mainnet's `1n`
that is the difference between one block and two. The baseline is deliberately sourced this way: a
tick's head is captured before quoting and simulation, and ageing against it once fee-bumped fresh
transactions within a second of sending them.

**Env-overridable** — the chain's row supplies the default, the env var wins:

| Default | Base (8453) | Mainnet (1) | Why it differs |
Expand Down
13 changes: 6 additions & 7 deletions bots/midnight-liquidation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,10 @@ async function main() {
})
void heartbeatMonitor.start()

// Phase-4 runner: an HTTP block-poll watcher drives one tick per new block (coalescing backlog),
// passing the polled height as the queue's submittedAtBlock. Each liquidatable position resolves its
// swap step, simulates the real `exec_606BaXt`, and — on a sim-ok result — broadcasts that same exec
// via the Executor singleton. Pending-queue upkeep runs in `maintain`.
// Phase-4 runner: an HTTP block-poll watcher drives one tick per new block (coalescing backlog).
// Each liquidatable position resolves its swap step, simulates the real `exec_606BaXt`, and — on a
// sim-ok result — broadcasts that same exec via the Executor singleton. Pending-queue upkeep runs
// in `maintain`.
const tick = (chainHead: bigint) =>
runTick({
discover,
Expand All @@ -365,7 +365,7 @@ async function main() {
eoa,
data: encodeExec(market, borrower, plan, swapPlan)
}),
submit: async ({ market, borrower, plan, swapPlan, blockNumber, label }) => {
submit: async ({ market, borrower, plan, swapPlan, label }) => {
const fees = initialFees(await signer.getBaseFee(), config.maxFeeWei, config.priorityFeeWei)
return queue.submit({
request: {
Expand All @@ -381,8 +381,7 @@ async function main() {
postMaturityMode: plan.postMaturityMode
},
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
blockNumber
maxPriorityFeePerGas: fees.maxPriorityFeePerGas
})
},
backoff,
Expand Down
4 changes: 1 addition & 3 deletions bots/midnight-liquidation/src/runner/tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ const capPerPosition = <T extends { label: string; plan: LiquidationPlan }>(
*/
export async function runTick(deps: {
discover: () => Promise<BorrowerCandidate[]>
/** Chain head the runner just polled — the queue's `submittedAtBlock`. */
/** Chain head the runner just polled — the tick-constant height backoff windows are measured in. */
chainHead: bigint
/** The Executor singleton — the `liquidate` msg.sender whose gate the lens checks. */
caller: Address
Expand Down Expand Up @@ -616,7 +616,6 @@ export async function runTick(deps: {
borrower: Address
plan: LiquidationPlan
swapPlan: SwapPlan | null
blockNumber: bigint
label: string
}) => Promise<SubmitOutcome>
/** Per-position exponential backoff suppressing repeated quote/simulate failures (rate-limit defense). */
Expand Down Expand Up @@ -1017,7 +1016,6 @@ export async function runTick(deps: {
borrower: pair.borrower,
plan: liquidationPlan,
swapPlan,
blockNumber: chainHead,
label
})
if (outcome.sent) {
Expand Down
10 changes: 5 additions & 5 deletions bots/midnight-liquidation/test/fork/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ describe('fork: pending-queue bump + replacement against a real node', () => {
request: { to: LIQUIDATOR, data: '0x' },
label: 'queue-fork',
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
blockNumber: 0n
maxPriorityFeePerGas: fees.maxPriorityFeePerGas
})
expect(queue.size).toBe(1)
const original = queue.snapshot()[0]
if (!original) throw new Error('expected a pending entry')
expect(original.attempt).toBe(0)

// 2. Advance past STUCK_BLOCKS (4) without mining → real getReceipt returns null, so onBlock detects
// the stuck tx and replaces it at the same nonce with ≥12.5% higher fees (a fresh hash).
for (let block = 1n; block <= 5n; block++) await queue.onBlock(block)
// 2. Advance without mining → real getReceipt returns null, so onBlock detects the stuck tx and
// replaces it at the same nonce with ≥12.5% higher fees (a fresh hash). Block 1 only sights
// the entry, so the bump lands at sighting + STUCK_BLOCKS (4) + 1 = block 6.
for (let block = 1n; block <= 6n; block++) await queue.onBlock(block)
const bumped = queue.snapshot()[0]
if (!bumped) throw new Error('expected the bumped entry to remain pending')
expect(queue.size).toBe(1)
Expand Down
Loading
Loading