From 08331c624b496ae8cae5bed0b570089235a13674 Mon Sep 17 00:00:00 2001 From: doomsower <12031673+doomsower@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:38:05 -0500 Subject: [PATCH] feat: batch liquidation wip --- src/config/env.ts | 2 + src/config/schema.ts | 2 + src/services/Client.ts | 8 +- src/services/liquidate/AbstractLiquidator.ts | 12 +- src/services/liquidate/BatchLiquidator.ts | 143 ++++++++++++++++++ .../liquidate/SingularFullLiquidator.ts | 6 +- src/services/liquidate/SingularLiquidator.ts | 21 +-- src/services/liquidate/factory.ts | 15 +- src/services/liquidate/types.ts | 8 +- src/services/notifier/messages.ts | 82 ++++++++++ src/utils/ethers-6-temp/pathfinder/core.ts | 16 ++ .../ethers-6-temp/pathfinder/pathfinder.ts | 44 +++++- 12 files changed, 323 insertions(+), 36 deletions(-) create mode 100644 src/services/liquidate/BatchLiquidator.ts diff --git a/src/config/env.ts b/src/config/env.ts index 5219ccf..dab6de7 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -3,10 +3,12 @@ import type { ConfigSchema } from "./schema.js"; const envConfigMapping: Record = { addressProviderOverride: "ADDRESS_PROVIDER", appName: "APP_NAME", + batchLiquidatorAddress: "BATCH_LIQUIDATOR_ADDRESS", debugAccounts: "DEBUG_ACCOUNTS", debugManagers: "DEBUG_MANAGERS", castBin: "CAST_BIN", deployPartialLiquidatorContracts: "DEPLOY_PARTIAL_LIQUIDATOR", + deployBatchLiquidatorContracts: "DEPLOY_BATCG_LIQUIDATOR", ethProviderRpcs: ["JSON_RPC_PROVIDERS", "JSON_RPC_PROVIDER"], hfThreshold: "HF_TRESHOLD", restakingWorkaround: "RESTAKING_WORKAROUND", diff --git a/src/config/schema.ts b/src/config/schema.ts index fed99af..3396a34 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -50,6 +50,8 @@ export const ConfigSchema = z.object({ optimistic: booleanLike.pipe(z.boolean().optional()), deployPartialLiquidatorContracts: booleanLike.pipe(z.boolean().optional()), partialLiquidatorAddress: Address.optional(), + deployBatchLiquidatorContracts: booleanLike.pipe(z.boolean().optional()), + batchLiquidatorAddress: Address.optional(), slippage: z.coerce.number().min(0).max(10000).int().default(50), restakingWorkaround: booleanLike.pipe(z.boolean().optional()), diff --git a/src/services/Client.ts b/src/services/Client.ts index 73b09fe..d189a04 100644 --- a/src/services/Client.ts +++ b/src/services/Client.ts @@ -34,7 +34,6 @@ import { privateKeyToAccount } from "viem/accounts"; import { arbitrum, base, mainnet, optimism } from "viem/chains"; import type { Config } from "../config/index.js"; -import type { CreditAccountData } from "../data/index.js"; import { exceptionsAbis } from "../data/index.js"; import { DI } from "../di.js"; import { TransactionRevertedError } from "../errors/TransactionRevertedError.js"; @@ -155,14 +154,9 @@ export default class Client { } public async liquidate( - ca: CreditAccountData, request: SimulateContractReturnType["request"], + logger: ILogger, ): Promise { - const logger = this.logger.child({ - account: ca.addr, - borrower: ca.borrower, - manager: ca.managerName, - }); logger.debug("sending liquidation tx"); const { abi, address, args, dataSuffix, functionName, ...rest } = request; const data = encodeFunctionData({ diff --git a/src/services/liquidate/AbstractLiquidator.ts b/src/services/liquidate/AbstractLiquidator.ts index 4fa0267..efe3778 100644 --- a/src/services/liquidate/AbstractLiquidator.ts +++ b/src/services/liquidate/AbstractLiquidator.ts @@ -15,7 +15,7 @@ import { TxParserHelper } from "../../utils/ethers-6-temp/txparser/index.js"; import type { IDataCompressorContract } from "../../utils/index.js"; import type { AddressProviderService } from "../AddressProviderService.js"; import type Client from "../Client.js"; -import type { INotifier } from "../notifier/index.js"; +import { type INotifier, StartedMessage } from "../notifier/index.js"; import type OracleServiceV3 from "../OracleServiceV3.js"; import type { IOptimisticOutputWriter } from "../output/index.js"; import type { RedstoneServiceV3 } from "../RedstoneServiceV3.js"; @@ -59,6 +59,7 @@ export default abstract class AbstractLiquidator { #errorHandler?: ErrorHandler; #compressor?: IDataCompressorContract; #pathFinder?: PathFinder; + #router?: Address; #cmCache: Record = {}; public async launch(): Promise { @@ -67,6 +68,7 @@ export default abstract class AbstractLiquidator { this.addressProvider.findService("ROUTER", 300), this.addressProvider.findService("DATA_COMPRESSOR", 300), ]); + this.#router = pfAddr; this.#compressor = getContract({ abi: iDataCompressorV3Abi, address: dcAddr, @@ -77,6 +79,7 @@ export default abstract class AbstractLiquidator { this.client.pub, this.config.network, ); + this.notifier.notify(new StartedMessage()); } protected newOptimisticResult(acc: CreditAccountData): OptimisticResultV2 { @@ -226,4 +229,11 @@ export default abstract class AbstractLiquidator { } return this.#pathFinder; } + + protected get router(): Address { + if (!this.#router) { + throw new Error("liquidator not launched"); + } + return this.#router; + } } diff --git a/src/services/liquidate/BatchLiquidator.ts b/src/services/liquidate/BatchLiquidator.ts new file mode 100644 index 0000000..8556d9f --- /dev/null +++ b/src/services/liquidate/BatchLiquidator.ts @@ -0,0 +1,143 @@ +import { + batchLiquidatorAbi, + iBatchLiquidatorAbi, +} from "@gearbox-protocol/liquidator-v2-contracts/abi"; +import { BatchLiquidator_bytecode } from "@gearbox-protocol/liquidator-v2-contracts/bytecode"; +import { iCreditFacadeV3Abi } from "@gearbox-protocol/types/abi"; +import { type Address, parseEventLogs } from "viem"; + +import type { CreditAccountData } from "../../data/index.js"; +import { + BatchLiquidationErrorMessage, + BatchLiquidationFinishedMessage, +} from "../notifier/messages.js"; +import AbstractLiquidator from "./AbstractLiquidator.js"; +import type { BatchLiquidationResult, ILiquidatorService } from "./types.js"; + +export default abstract class BatchLiquidator + extends AbstractLiquidator + implements ILiquidatorService +{ + #batchLiquidator?: Address; + + public override async launch(): Promise { + await super.launch(); + await this.#deployContract(); + } + + public async liquidate(accounts: CreditAccountData[]): Promise { + if (!accounts.length) { + return; + } + this.logger.warn(`Need to liquidate ${accounts.length} accounts`); + try { + const result = await this.#liquidateBatch(accounts); + this.notifier.notify( + new BatchLiquidationFinishedMessage( + result.liquidated, + result.notLiquidated, + result.receipt, + ), + ); + } catch (e) { + const decoded = await this.errorHandler.explain(e); + this.logger.error(decoded, "cant liquidate"); + this.notifier.notify( + new BatchLiquidationErrorMessage(accounts, decoded.shortMessage), + ); + } + } + + public async liquidateOptimistic( + accounts: CreditAccountData[], + ): Promise { + const total = accounts.length; + this.logger.info(`optimistic batch-liquidation for ${total} accounts`); + const result = await this.#liquidateBatch(accounts); + this.logger.info( + `optimistic batch-liquidation finished: ${result.liquidated.length}/${total} accounts liquidated`, + ); + } + + async #liquidateBatch( + accounts: CreditAccountData[], + ): Promise { + const input = accounts.map(ca => + this.pathFinder.getEstimateBatchInput(ca, this.config.slippage), + ); + const { result } = await this.client.pub.simulateContract({ + account: this.client.account, + address: this.batchLiquidator, + abi: iBatchLiquidatorAbi, + functionName: "estimateBatch", + args: [input] as any, // TODO: types + }); + this.logger.debug(result, "estimated batch"); + const { request } = await this.client.pub.simulateContract({ + account: this.client.account, + address: this.batchLiquidator, + abi: iBatchLiquidatorAbi, + functionName: "liquidateBatch", + args: [ + result.map(i => ({ + calls: i.calls, + creditAccount: i.creditAccount, + creditFacade: accounts.find( + ca => ca.addr === i.creditAccount.toLowerCase(), + )?.creditFacade!, // TODO: checks + })), + this.client.address, + ], + }); + const receipt = await this.client.liquidate(request as any, this.logger); // TODO: types + + const logs = parseEventLogs({ + abi: iCreditFacadeV3Abi, + eventName: "LiquidateCreditAccount", + logs: receipt.logs, + }); + const liquidated = new Set( + logs.map(l => l.args.creditAccount.toLowerCase() as Address), + ); + return { + receipt, + liquidated: accounts.filter(a => liquidated.has(a.addr)), + notLiquidated: accounts.filter(a => !liquidated.has(a.addr)), + }; + } + + async #deployContract(): Promise { + this.#batchLiquidator = this.config.batchLiquidatorAddress; + if (!this.#batchLiquidator) { + this.logger.debug("deploying batch liquidator"); + + let hash = await this.client.wallet.deployContract({ + abi: batchLiquidatorAbi, + bytecode: BatchLiquidator_bytecode, + args: [this.router], + }); + this.logger.debug( + `waiting for BatchLiquidator to deploy, tx hash: ${hash}`, + ); + const { contractAddress } = + await this.client.pub.waitForTransactionReceipt({ + hash, + timeout: 120_000, + }); + if (!contractAddress) { + throw new Error(`BatchLiquidator was not deployed, tx hash: ${hash}`); + } + this.#batchLiquidator = contractAddress; + } + this.logger.debug( + `using batch liquidator contract ${this.#batchLiquidator}`, + ); + } + + private get batchLiquidator(): Address { + if (!this.#batchLiquidator) { + throw new Error("batch liquidator not deployed"); + } + return this.#batchLiquidator; + } +} diff --git a/src/services/liquidate/SingularFullLiquidator.ts b/src/services/liquidate/SingularFullLiquidator.ts index 8a674a8..f3df9ce 100644 --- a/src/services/liquidate/SingularFullLiquidator.ts +++ b/src/services/liquidate/SingularFullLiquidator.ts @@ -1,6 +1,6 @@ import { getDecimals } from "@gearbox-protocol/sdk-gov"; import { iCreditFacadeV3Abi } from "@gearbox-protocol/types/abi"; -import type { SimulateContractReturnType } from "viem"; +import type { Address, SimulateContractReturnType } from "viem"; import { type Balance, @@ -25,8 +25,8 @@ export default class SingularFullLiquidator extends SingularLiquidator { try { const cm = await this.getCreditManagerData(ca.creditManager); - const expectedBalances: Record = {}; - const leftoverBalances: Record = {}; + const expectedBalances: Record = {}; + const leftoverBalances: Record = {}; for (const { token, balance, isEnabled } of ca.allBalances) { expectedBalances[token] = { token, balance }; // filter out dust, we don't want to swap it diff --git a/src/services/liquidate/SingularLiquidator.ts b/src/services/liquidate/SingularLiquidator.ts index 03d514d..1cea08a 100644 --- a/src/services/liquidate/SingularLiquidator.ts +++ b/src/services/liquidate/SingularLiquidator.ts @@ -7,7 +7,6 @@ import { LiquidationErrorMessage, LiquidationStartMessage, LiquidationSuccessMessage, - StartedMessage, } from "../notifier/index.js"; import AbstractLiquidator from "./AbstractLiquidator.js"; import type { @@ -23,21 +22,13 @@ export default abstract class SingularLiquidator protected abstract readonly name: string; protected abstract readonly adverb: string; - /** - * Launch LiquidatorService - */ - public async launch(): Promise { - await super.launch(); - this.notifier.notify(new StartedMessage()); - } - public async liquidate(accounts: CreditAccountData[]): Promise { if (!accounts.length) { return; } this.logger.warn(`Need to liquidate ${accounts.length} accounts`); for (const ca of accounts) { - await this._liquidate(ca); + await this.#liquidateOne(ca); } } @@ -50,7 +41,7 @@ export default abstract class SingularLiquidator for (let i = 0; i < total; i++) { const acc = accounts[i]; - const result = await this._liquidateOptimistic(acc); + const result = await this.#liquidateOneOptimistic(acc); const status = result.isError ? "FAIL" : "OK"; const msg = `[${i + 1}/${total}] ${acc.addr} in ${acc.creditManager} ${status}`; if (result.isError) { @@ -65,7 +56,7 @@ export default abstract class SingularLiquidator ); } - public async _liquidate(ca: CreditAccountData): Promise { + async #liquidateOne(ca: CreditAccountData): Promise { const logger = this.logger.child({ account: ca.addr, borrower: ca.borrower, @@ -85,7 +76,7 @@ export default abstract class SingularLiquidator logger.debug({ pathHuman }, "path found"); const { request } = await this.simulate(ca, preview); - const receipt = await this.client.liquidate(ca, request); + const receipt = await this.client.liquidate(request, logger); this.notifier.alert( new LiquidationSuccessMessage(ca, this.adverb, receipt, pathHuman), @@ -109,7 +100,7 @@ export default abstract class SingularLiquidator } } - public async _liquidateOptimistic( + async #liquidateOneOptimistic( acc: CreditAccountData, ): Promise { const logger = this.logger.child({ @@ -138,7 +129,7 @@ export default abstract class SingularLiquidator snapshotId = await this.client.anvil.snapshot(); } // ------ Actual liquidation (write request start here) ----- - const receipt = await this.client.liquidate(acc, request); + const receipt = await this.client.liquidate(request, logger); logger.debug(`Liquidation tx hash: ${receipt.transactionHash}`); result.isError = receipt.status === "reverted"; logger.debug( diff --git a/src/services/liquidate/factory.ts b/src/services/liquidate/factory.ts index 3cbf79b..56a5f5a 100644 --- a/src/services/liquidate/factory.ts +++ b/src/services/liquidate/factory.ts @@ -2,6 +2,7 @@ import type { IFactory } from "di-at-home"; import type { Config } from "../../config/index.js"; import { DI } from "../../di.js"; +import BatchLiquidator from "./BatchLiquidator.js"; import SingularFullLiquidator from "./SingularFullLiquidator.js"; import SingularPartialLiquidator from "./SingularPartialLiquidator.js"; import type { ILiquidatorService } from "./types.js"; @@ -12,12 +13,18 @@ export class LiquidatorFactory implements IFactory { config!: Config; produce(): ILiquidatorService { - if ( - this.config.deployPartialLiquidatorContracts || - this.config.partialLiquidatorAddress - ) { + const { + deployBatchLiquidatorContracts, + deployPartialLiquidatorContracts, + partialLiquidatorAddress, + batchLiquidatorAddress, + } = this.config; + if (deployPartialLiquidatorContracts || partialLiquidatorAddress) { return new SingularPartialLiquidator(); } + if (deployBatchLiquidatorContracts || batchLiquidatorAddress) { + return new BatchLiquidator(); + } return new SingularFullLiquidator(); } } diff --git a/src/services/liquidate/types.ts b/src/services/liquidate/types.ts index 472f2b4..0aae6a9 100644 --- a/src/services/liquidate/types.ts +++ b/src/services/liquidate/types.ts @@ -1,5 +1,5 @@ import type { PartialLiquidationCondition } from "@gearbox-protocol/types/optimist"; -import type { Address, Hash, Hex } from "viem"; +import type { Address, Hash, Hex, TransactionReceipt } from "viem"; import type { CreditAccountData, @@ -79,3 +79,9 @@ export interface MerkleDistributorInfo { } >; } + +export interface BatchLiquidationResult { + receipt: TransactionReceipt; + liquidated: CreditAccountData[]; + notLiquidated: CreditAccountData[]; +} diff --git a/src/services/notifier/messages.ts b/src/services/notifier/messages.ts index ecf101d..3ea912b 100644 --- a/src/services/notifier/messages.ts +++ b/src/services/notifier/messages.ts @@ -196,6 +196,63 @@ ${callsMd(this.#callsHuman)}`, } } +export class BatchLiquidationFinishedMessage + extends BaseMessage + implements INotifierMessage +{ + #liquidated: CreditAccountData[]; + #notLiquidated: CreditAccountData[]; + + constructor( + liquidated: CreditAccountData[], + notLiquidated: CreditAccountData[], + receipt: TransactionReceipt, + ) { + super({ receipt }); + this.#liquidated = liquidated; + this.#notLiquidated = notLiquidated; + } + + public get plain(): string { + if (this.receipt?.status === "success") { + if (this.#notLiquidated.length === 0) { + return `✅ [${this.network}] batch-liquidated ${this.#liquidated.length} accounts: +Tx receipt: ${this.receiptPlain} +Gas used: ${this.receipt?.gasUsed?.toLocaleString("en")}`; + } else { + return `❌ [${this.network}] batch-liquidated ${this.#liquidated.length} accounts, but failed to liquidate ${this.#notLiquidated.length} more +Tx receipt: ${this.receiptPlain} +Gas used: ${this.receipt?.gasUsed?.toLocaleString("en")}`; + } + } + + return `❌ [${this.network}] batch-liquidate tx reverted +Tx: ${this.receiptPlain}`; + } + + public get markdown(): string { + if (this.receipt?.status === "success") { + if (this.#notLiquidated.length === 0) { + return md.build( + md`✅ [${this.network}] batch-liquidated ${this.#liquidated.length} accounts +Tx receipt: ${this.receiptMd} +Gas used: ${md.bold(this.receipt?.gasUsed?.toLocaleString("en"))}`, + ); + } else { + return md.build( + md`❌ [${this.network}] batch-liquidated ${this.#liquidated.length} accounts, but failed to liquidate ${this.#notLiquidated.length} more +Tx receipt: ${this.receiptMd} +Gas used: ${md.bold(this.receipt?.gasUsed?.toLocaleString("en"))}`, + ); + } + } + return md.build( + md`❌ [${this.network}] batch-liquidate tx reverted +Tx: ${this.receiptMd}`, + ); + } +} + export class LiquidationErrorMessage extends BaseMessage implements INotifierMessage @@ -239,6 +296,31 @@ ${this.#skipOnFailure}`, ); } } +export class BatchLiquidationErrorMessage + extends BaseMessage + implements INotifierMessage +{ + #error: string; + #accounts: CreditAccountData[]; + + constructor(accounts: CreditAccountData[], error: string) { + super({}); + this.#accounts = accounts; + this.#error = error.length > 128 ? error.slice(0, 128) + "..." : error; + } + + public get plain(): string { + return `❌ [${this.network}] failed to batch-liquidate ${this.#accounts.length} accounts +Error: ${this.#error}`; + } + + public get markdown(): string { + return md.build( + md`❌ [${this.network}] failed to batch-liquidate ${this.#accounts.length} accounts +Error: ${md.inlineCode(this.#error)}`, + ); + } +} function callsPlain(calls?: string[]): string { return calls ? calls.map(c => " ➤ " + c).join("\n") : "-"; diff --git a/src/utils/ethers-6-temp/pathfinder/core.ts b/src/utils/ethers-6-temp/pathfinder/core.ts index 1ddaca3..6e2fdd3 100644 --- a/src/utils/ethers-6-temp/pathfinder/core.ts +++ b/src/utils/ethers-6-temp/pathfinder/core.ts @@ -1,6 +1,8 @@ import type { Address } from "viem"; +import type { Balance } from "../../../data/Balance.js"; import type { MultiCall } from "../../../data/MultiCall.js"; +import type { PathOption } from "./pathOptions.js"; export enum SwapOperation { EXACT_INPUT, @@ -22,3 +24,17 @@ export interface PathFinderOpenStrategyResult extends PathFinderResult { export interface PathFinderCloseResult extends PathFinderResult { underlyingBalance: bigint; } + +/** + * RouterLiqParams in contract + */ +export interface EstimateBatchInput { + creditAccount: Address; + expectedBalances: Balance[]; + leftoverBalances: Balance[]; + connectors: Address[]; + slippage: bigint; + pathOptions: PathOption[]; + iterations: bigint; + force: boolean; +} diff --git a/src/utils/ethers-6-temp/pathfinder/pathfinder.ts b/src/utils/ethers-6-temp/pathfinder/pathfinder.ts index 637b6ad..74a68e2 100644 --- a/src/utils/ethers-6-temp/pathfinder/pathfinder.ts +++ b/src/utils/ethers-6-temp/pathfinder/pathfinder.ts @@ -1,5 +1,5 @@ import type { NetworkType } from "@gearbox-protocol/sdk-gov"; -import { getConnectors } from "@gearbox-protocol/sdk-gov"; +import { getConnectors, getDecimals } from "@gearbox-protocol/sdk-gov"; import { iRouterV3Abi } from "@gearbox-protocol/types/abi"; import { type Address, getContract, type PublicClient } from "viem"; @@ -8,12 +8,13 @@ import type { CreditAccountData, CreditManagerData, } from "../../../data/index.js"; -import type { PathFinderCloseResult } from "./core.js"; +import type { EstimateBatchInput, PathFinderCloseResult } from "./core.js"; import { PathOptionFactory } from "./pathOptions.js"; import type { IRouterV3Contract, RouterResult } from "./viem-types.js"; const MAX_GAS_PER_ROUTE = 200_000_000n; const GAS_PER_BLOCK = 400_000_000n; +const LOOPS_PER_TX = Number(GAS_PER_BLOCK / MAX_GAS_PER_ROUTE); interface FindBestClosePathProps { creditAccount: CreditAccountData; @@ -54,10 +55,9 @@ export class PathFinder { leftoverBalances, slippage, }: FindBestClosePathProps): Promise { - const loopsPerTx = Number(GAS_PER_BLOCK / MAX_GAS_PER_ROUTE); const pathOptions = PathOptionFactory.generatePathOptions( creditAccount.allBalances, - loopsPerTx, + LOOPS_PER_TX, this.#network, ); @@ -88,7 +88,7 @@ export class PathFinder { connectors, BigInt(slippage), po, - BigInt(loopsPerTx), + BigInt(LOOPS_PER_TX), false, ], { @@ -122,6 +122,40 @@ export class PathFinder { }; } + // TODO: readme + getEstimateBatchInput( + ca: CreditAccountData, + slippage: number, + ): EstimateBatchInput { + const expectedBalances: Record = {}; + const leftoverBalances: Record = {}; + for (const { token, balance, isEnabled } of ca.allBalances) { + expectedBalances[token] = { token, balance }; + // filter out dust, we don't want to swap it + const minBalance = 10n ** BigInt(Math.max(8, getDecimals(token)) - 8); + // also: gearbox liquidator does not need to swap disabled tokens. third-party liquidators might want to do it + if (balance < minBalance || !isEnabled) { + leftoverBalances[token] = { token, balance }; + } + } + const connectors = this.getAvailableConnectors(ca.allBalances); + const pathOptions = PathOptionFactory.generatePathOptions( + ca.allBalances, + LOOPS_PER_TX, + this.#network, + ); + return { + creditAccount: ca.addr, + expectedBalances: Object.values(expectedBalances), + leftoverBalances: Object.values(leftoverBalances), + connectors, + slippage: BigInt(slippage), + pathOptions: pathOptions[0] ?? [], // TODO: what to put here? + iterations: BigInt(LOOPS_PER_TX), + force: false, + }; + } + static compare(r1: T, r2: T): T { return r1.amount > r2.amount ? r1 : r2; }