Liquidation Bots
Build bots that monitor credit accounts and execute profitable liquidations.
Overview
Liquidation bots need to:
- Find accounts with low health factors
- Filter for liquidatable accounts
- Compute optimal liquidation paths
- Execute liquidations profitably
This guide covers each step with verified SDK patterns.
Understanding Liquidation
WHY: Know what you're building before writing code.
When Accounts Become Liquidatable
An account becomes liquidatable when its health factor drops below 1.0:
Health Factor = Total Weighted Collateral Value / Total Debt
Where:
- Weighted Value = Sum of (Token Balance * Price * Liquidation Threshold)
- Total Debt = Principal + Accrued Interest + Quota Fees
Health factor is scaled by 10000, so healthFactor < 10000 means liquidatable.
The Liquidation Process
- Liquidator calls
creditFacade.liquidateCreditAccount() - Protocol converts collateral to underlying token
- Debt is repaid from converted collateral
- Liquidator receives premium (configured per Credit Manager)
- Remaining funds go to account owner (if any)
The liquidator provides the multicall that handles collateral conversion. This is where profit comes from - efficient routing means better conversion rates.
Finding Liquidatable Accounts
WHY: Efficiently scan all accounts to find opportunities.
Using CreditAccountCompressor
The CreditAccountCompressor has built-in health factor filtering:
import { creditAccountCompressorAbi, AP_CREDIT_ACCOUNT_COMPRESSOR, VERSION_RANGE_310, } from '@gearbox-protocol/sdk'; const [accountCompressor] = sdk.addressProvider.mustGetLatest( AP_CREDIT_ACCOUNT_COMPRESSOR, VERSION_RANGE_310 ); // Find accounts with HF < 1.0 (10000 in basis points) const [accounts, nextOffset] = await client.readContract({ address: accountCompressor, abi: creditAccountCompressorAbi, functionName: 'getCreditAccounts', args: [ creditManagerAddress, { owner: '0x0000000000000000000000000000000000000000', // Any owner minHealthFactor: 0n, maxHealthFactor: 10000n, // HF < 1.0 includeZeroDebt: false, reverting: false, }, 0n, // offset ], }); console.log(`Found ${accounts.length} accounts with HF < 1.0`);
Inspecting the results
Every account returned by the query above already has HF below 1.0, which is the liquidation condition. Skip entries with success: false (their state could not be computed):
const liquidatable = accounts.filter(a => a.success); for (const account of liquidatable) { console.log(`Account: ${account.creditAccount}`); console.log(` Health Factor: ${Number(account.healthFactor) / 10000}`); console.log(` Debt: ${account.debt}`); console.log(` Collaterals:`); for (const token of account.tokens) { if (token.balance > 0n) { console.log(` ${token.token}: ${token.balance}`); } } }
Pagination for Large Result Sets
The compressor returns paginated results. Iterate through all pages:
async function getAllLiquidatableAccounts( creditManager: `0x${string}` ): Promise<CreditAccountData[]> { const filter = { owner: '0x0000000000000000000000000000000000000000' as const, includeZeroDebt: false, minHealthFactor: 0n, maxHealthFactor: 10000n, reverting: false, }; let offset = 0n; let allAccounts: CreditAccountData[] = []; while (true) { const [accounts, nextOffset] = await client.readContract({ address: accountCompressor, abi: creditAccountCompressorAbi, functionName: 'getCreditAccounts', args: [creditManager, filter, offset], }); allAccounts.push(...accounts.filter(a => a.success)); // Non-zero nextOffset means the call stopped near the gas limit if (nextOffset === 0n) break; offset = nextOffset; } return allAccounts; }
Account Analysis
WHY: Understand an account's composition before liquidating.
Collateral Breakdown
TokenInfo carries raw balances and quotas. Token metadata (symbol, decimals) and liquidation thresholds come from the market data (MarketCompressor) or the SDK.
function analyzeCollateral(account: CreditAccountData) { return account.tokens .filter(t => t.balance > 0n) .sort((a, b) => Number(b.quota - a.quota)); } const positions = analyzeCollateral(account); console.log('Collateral (by quota):'); for (const pos of positions) { console.log(` ${pos.token}: balance ${pos.balance}, quota ${pos.quota}`); }
Estimating Profit
interface LiquidationEstimate { totalCollateralValue: bigint; debt: bigint; liquidationPremium: bigint; estimatedProfit: bigint; } function estimateLiquidation( account: CreditAccountData, premiumBps: number // e.g., 400 = 4% ): LiquidationEstimate { // totalValueUSD / totalDebtUSD are oracle-priced (8 decimals) const premium = account.totalValueUSD * BigInt(premiumBps) / 10000n; // Simplified: assumes perfect conversion const estimatedProfit = account.totalValueUSD - account.totalDebtUSD; return { totalCollateralValue: account.totalValueUSD, debt: account.totalDebtUSD, liquidationPremium: premium, estimatedProfit: estimatedProfit > 0n ? estimatedProfit : 0n, }; }
Building the Liquidation Multicall
WHY: The multicall handles collateral conversion and determines profit.
Basic Structure
A liquidation multicall typically:
- Updates stale price feeds (if needed)
- Swaps collateral tokens to underlying
- Repays debt (handled by protocol)
import { encodeFunctionData } from 'viem'; import { iCreditFacadeV300MulticallAbi } from '@gearbox-protocol/sdk'; // Build liquidation multicall const calls: Array<{ target: `0x${string}`; callData: `0x${string}` }> = []; // 1. Update any stale price feeds first for (const feed of stalePriceFeeds) { calls.push({ target: creditFacadeAddress, callData: encodeFunctionData({ abi: iCreditFacadeV300MulticallAbi, functionName: 'onDemandPriceUpdate', args: [feed.token, feed.reserve, feed.data], }), }); } // 2. Swap collateral to underlying via adapters for (const collateral of collateralToSwap) { const adapter = await creditManager.read.contractToAdapter([ collateral.protocol, ]); calls.push({ target: adapter, callData: encodeFunctionData({ abi: adapterAbi, functionName: 'swap', args: [collateral.swapParams], }), }); }
Using Slippage Protection
Always protect against sandwich attacks:
// Store expected minimum output calls.push({ target: creditFacadeAddress, callData: encodeFunctionData({ abi: iCreditFacadeV300MulticallAbi, functionName: 'storeExpectedBalances', args: [[{ token: underlyingToken, amount: minExpectedOutput }]], }), }); // Perform swap calls.push({ target: adapter, callData: encodeFunctionData({ abi: adapterAbi, functionName: 'swap', args: [swapParams], }), }); // Verify slippage calls.push({ target: creditFacadeAddress, callData: encodeFunctionData({ abi: iCreditFacadeV300MulticallAbi, functionName: 'compareBalances', args: [], }), });
See Controlling Slippage for details.
Executing Liquidation
WHY: Actually perform the liquidation and capture profit.
The liquidateCreditAccount Call
// Get credit facade for the account's credit manager const market = sdk.marketRegister.findByCreditManager(account.creditManager); // Execute liquidation const hash = await walletClient.writeContract({ address: market.creditFacade.address, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [ account.creditAccount, // Credit account to liquidate receiverAddress, // Where to send remaining funds calls, // Liquidation multicall ], }); console.log(`Liquidation submitted: ${hash}`); // Wait for confirmation const receipt = await client.waitForTransactionReceipt({ hash }); console.log(`Liquidation ${receipt.status === 'success' ? 'succeeded' : 'failed'}`);
Handling Partial Liquidation
In some configurations, partial liquidation is possible. Check the Credit Manager configuration:
// Full liquidation only if account is deeply underwater // Partial liquidation may be allowed above certain HF threshold
Bot Architecture
WHY: Production bots need proper design for reliability and competitiveness.
Monitoring Loop
async function monitoringLoop() { const POLL_INTERVAL = 3000; // 3 seconds while (true) { try { // Scan all credit managers for (const cm of creditManagers) { const accounts = await getAllLiquidatableAccounts(cm); for (const account of accounts) { // Analyze opportunity const estimate = estimateLiquidation(account, liquidationPremiumBps); if (estimate.estimatedProfit > minProfitThreshold) { await attemptLiquidation(account); } } } } catch (error) { console.error('Monitoring error:', error); } await sleep(POLL_INTERVAL); } }
Simulation Before Execution
Always simulate before sending transactions:
async function attemptLiquidation(account: CreditAccountData) { const calls = buildLiquidationMulticall(account); // Simulate first try { await client.simulateContract({ address: creditFacadeAddress, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [account.creditAccount, receiverAddress, calls], account: liquidatorAddress, }); } catch (error) { console.log(`Simulation failed for ${account.creditAccount}:`, error); return; } // Simulation passed, execute try { const hash = await walletClient.writeContract({ address: creditFacadeAddress, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [account.creditAccount, receiverAddress, calls], }); console.log(`Liquidation tx: ${hash}`); } catch (error) { console.error(`Execution failed:`, error); } }
Competition Considerations
Liquidation is competitive. Other bots are scanning the same accounts.
Strategies:
- Speed: Use faster RPC endpoints, optimize code paths
- Gas: Pay higher gas for priority (use
maxPriorityFeePerGas) - Efficiency: Better swap routing means higher profit, can afford more gas
- Flashbots: Use MEV-protected submission to avoid frontrunning
// Higher priority fee for competitive liquidations const hash = await walletClient.writeContract({ address: creditFacadeAddress, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [account.creditAccount, receiverAddress, calls], maxPriorityFeePerGas: parseGwei('3'), // Higher tip });
Complete Example: Simple Liquidation Bot
import { createPublicClient, createWalletClient, http, parseGwei } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { mainnet } from 'viem/chains'; import { GearboxSDK, creditAccountCompressorAbi, AP_CREDIT_ACCOUNT_COMPRESSOR, VERSION_RANGE_310, } from '@gearbox-protocol/sdk'; const MIN_PROFIT_USD = 100n * 10n ** 8n; // \$100 minimum profit (oracle USD, 8 decimals) async function runLiquidationBot(creditManagerAddress: `0x${string}`) { const client = createPublicClient({ chain: mainnet, transport: http(), }); const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const walletClient = createWalletClient({ account, chain: mainnet, transport: http(), }); const sdk = await GearboxSDK.attach({ client, marketConfigurators: [], }); const [accountCompressor] = sdk.addressProvider.mustGetLatest( AP_CREDIT_ACCOUNT_COMPRESSOR, VERSION_RANGE_310 ); const market = sdk.marketRegister.findByCreditManager(creditManagerAddress); console.log(`Monitoring ${market.creditManagers[0].address}`); console.log(`Liquidator: ${account.address}`); while (true) { try { // Find liquidatable accounts const [accounts] = await client.readContract({ address: accountCompressor, abi: creditAccountCompressorAbi, functionName: 'getCreditAccounts', args: [ creditManagerAddress, { owner: '0x0000000000000000000000000000000000000000', includeZeroDebt: false, minHealthFactor: 0n, maxHealthFactor: 10000n, reverting: false, }, 0n, ], }); const liquidatable = accounts.filter(a => a.success); if (liquidatable.length > 0) { console.log(`Found ${liquidatable.length} liquidatable accounts`); for (const target of liquidatable) { // Oracle-priced USD values (8 decimals) const estimatedProfit = target.totalValueUSD - target.totalDebtUSD; if (estimatedProfit > MIN_PROFIT_USD) { console.log(`Profitable opportunity: ${target.creditAccount}`); console.log(` Debt (USD): ${target.totalDebtUSD}`); console.log(` Value (USD): ${target.totalValueUSD}`); console.log(` Est. Profit (USD): ${estimatedProfit}`); // Build and execute liquidation const calls = buildLiquidationCalls(target, market); try { // Simulate await client.simulateContract({ address: market.creditFacade.address, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [target.creditAccount, account.address, calls], account: account.address, }); // Execute const hash = await walletClient.writeContract({ address: market.creditFacade.address, abi: creditFacadeAbi, functionName: 'liquidateCreditAccount', args: [target.creditAccount, account.address, calls], maxPriorityFeePerGas: parseGwei('2'), }); console.log(`Liquidation submitted: ${hash}`); } catch (error) { console.log(`Failed to liquidate ${target.creditAccount}:`, error); } } } } } catch (error) { console.error('Loop error:', error); } // Poll every 3 seconds await new Promise(resolve => setTimeout(resolve, 3000)); } } function buildLiquidationCalls( account: CreditAccountData, market: MarketData ): Array<{ target: `0x${string}`; callData: `0x${string}` }> { const calls: Array<{ target: `0x${string}`; callData: `0x${string}` }> = []; const creditFacade = market.creditFacade.address; const underlying = market.pool.underlying.address; // Swap each non-underlying collateral token to underlying via adapter for (const token of account.tokens) { if (token.balance <= 1n) continue; // skip dust if (token.token === underlying) continue; // skip underlying itself // Use Uniswap V3 adapter for swaps (simplified: hardcoded router) const uniswapAdapter = market.adapters?.['UNISWAP_V3_ROUTER']; if (!uniswapAdapter) continue; // exactAllInputSingle swaps entire balance minus 1 wei calls.push({ target: uniswapAdapter, callData: encodeFunctionData({ abi: uniswapV3AdapterAbi, functionName: 'exactAllInputSingle', args: [{ tokenIn: token.token, tokenOut: underlying, fee: 3000, // 0.3% pool (use 500 for stablecoin pairs) deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), rateMinRAY: 0n, // No slippage protection (simplified) sqrtPriceLimitX96: 0n, }], }), }); } return calls; }
Gotchas
Price Updates Must Come First
If any price feeds are stale, update them at the start of your multicall:
// WRONG: Swap first, then update prices (will fail) // CORRECT: Update prices first, then swap const calls = [ ...priceUpdateCalls, ...swapCalls, ];
See Updating Price Feeds.
Account State Can Change
Between scanning and executing, another bot may liquidate the account:
try { await walletClient.writeContract({ ... }); } catch (error) { if (error.message.includes('account not liquidatable')) { console.log('Account already liquidated by another bot'); } }
Gas Estimation
Liquidation gas costs vary based on:
- Number of collateral tokens
- Complexity of swaps
- Price feed updates needed
Always estimate gas before calculating profitability.
Next Steps
- Making External Calls - Swap patterns via adapters
- Controlling Slippage - Protect against MEV
- Updating Price Feeds - Required for stale oracles
- Compressors Reference - Complete filter options