ytLending清算文档 重要说明 本清算机器人采用无状态轮询设计,通过监听链上事件发现活跃借款人,并自动执行清算操作以保护协议免受坏账风险。 核心特性: - 无状态设计:无需数据库,所有数据从链上实时查询 - 轮询模式:每 5 秒检查一次新区块 - 事件驱动:监听 4 种事件发现活跃借款人 - 批量清算:一次交易清算多个账户,节省 Gas 目录 1.[系统启动流程](#1-系统启动流程) 2.[主循环轮询流程](#2-主循环轮询流程) 3.[活跃地址获取流程](#3-活跃地址获取流程) 4.[清算检查流程](#4-清算检查流程) 5.[批量清算执行流程](#5-批量清算执行流程) 6.[重要参数说明](#6-重要参数说明) 7.[完整脚本](#7-完整脚本) 8.[运行和部署](#8-运行和部署) 1. 系统启动流程 启动命令: Bash npx hardhat run scripts/liquidation_bot/index.ts --network arbSepolia 启动步骤: 步骤 1 — 初始化 Hardhat 环境,读取网络配置 Plain Text network: arbSepolia chainId: 421614 rpcUrl: https://arbitrum-sepolia.gateway.tenderly.co 步骤 2 — 读取部署信息(`deployments-lending.json`),加载合约地址 Plain Text Lending Proxy: 0xCb4E7B1069F6C26A1c27523ce4c8dfD884552d1D Base Token (USDC): 0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d Collateral Assets: YT-A: 0x97204190B35D9895a7a47aa7BaC61ac08De3cF05 YT-B: 0x181ef4011c35C4a2Fda08eBC5Cf509Ef58E553fF YT-C: 0xE9A5b9f3a2Eda4358f81d4E2eF4f3280A664e5B0 注:Price Feed 合约不再需要初始化 步骤 3 — 初始化签名者(从 `PRIVATE_KEY` 环境变量读取) Plain Text Liquidator Address: 0x... ETH Balance: 0.5 ETH 要求: - 需要 ETH 支付 Gas 费用 - 建议保持至少 0.1 ETH 余额 步骤 4 — 初始化合约实例 TypeScript lendingContract = await ethers.getContractAt( 'Lending', deployment.lendingProxy, signer ) 步骤 5 — 进入主循环 2. 主循环轮询流程 主循环参数:LOOP_DELAY = 5000ms(5 秒) 循环步骤: 1.调用 provider.getBlockNumber() 获取当前区块号 2.判断是否为新区块: - 新区块 → 执行清算逻辑(见下方步骤 3) - 已检查过 → 等待 5 秒后继续 3.执行 liquidateUnderwaterBorrowers(lendingContract, signer): ○① 获取活跃地址(查询事件) ○② 检查每个地址是否可清算 ○③ 批量执行清算 4.更新 lastBlockNumber = currentBlockNumber,循环回步骤 1 容错机制: TypeScript try { // 执行清算逻辑 } catch (error) { console.error('Error in main loop:', error) // 继续运行,不中断 } // 无论成功或失败,都等待 5 秒后继续 await sleep(5000) 关键设计决策: 参数 值 说明 LOOP_DELAY 5000ms 轮询间隔 去重检查 lastBlockNumber 每个区块只处理一次,避免重复 容错机制 try-catch 异常不中断,继续运行 3. 活跃地址获取流程 函数:getUniqueAddresses(lendingContract),LOOKBACK_BLOCKS = 10000 步骤 1 — 计算查询区块范围 Plain Text currentBlock = 12345678 fromBlock = 12345678 - 10000 = 12335678 toBlock = 12345678 时间窗口(Arbitrum Sepolia,出块 ~1 秒): 10000 区块 ≈ 2.7 小时 步骤 2 — 查询 4 种事件,提取活跃地址 事件 说明 提取字段 Withdraw 用户借款/提取 USDC event.args.src、event.args.dst Supply 用户存入 USDC event.args.from、event.args.dst SupplyCollateral 用户存入抵押品 event.args.from、event.args.dst WithdrawCollateral 用户提取抵押品 event.args.src、event.args.to 步骤 3 — 合并去重 使用 Set 自动去重,示例: Plain Text 总事件数: 150 + 80 + 120 + 90 = 440 个 唯一地址数: 85 个(去重后) 覆盖率对比: Compound V3(仅 Withdraw): ~95% 本机器人(4 种事件): ~98% 步骤 4 — 返回地址列表,进入清算检查 4. 清算检查流程 遍历所有活跃地址,对每个地址执行: 1.调用 lendingContract.isLiquidatable(address)(链上计算) 2.结果判断: - false → 跳过(账户健康) - true → 加入清算列表 `liquidatableAccounts[]` 3.继续检查下一个地址,全部检查完毕后进入批量清算 5. 批量清算执行流程 清算列表示例: `[0xAlice, 0xBob, 0xCharlie]`,共 3 个可清算账户 步骤 1 — 检查列表是否为空 •空列表 → 无清算,等待下一轮 •非空 → 继续执行 **步骤 2 — 调用 `absorbMultiple()`** Solidity function absorbMultiple( address absorber, // 清算者地址(机器人) address[] accounts // [0xAlice, 0xBob, 0xCharlie] ) external override nonReentrant // 修饰符检查: // ✓ nonReentrant - 防重入保护 // ✓ 未暂停检查 步骤 3 — 累积利息(`accrueInternal()`) •更新 supplyIndex(存款利息) •更新 borrowIndex(借款利息) •确保清算时使用最新的债务余额 **步骤 4 — 对每个账户执行 `_absorbInternal()`** 以 Alice 为例: 1.再次验证可清算性:if (!isLiquidatable(Alice)) revert NotLiquidatable()(防止抢跑) 2.获取用户债务:principal = userBasic[Alice].principal(负数 = 借款,如 -1000e6) 3.遍历所有抵押资产 [YT-A, YT-B, YT-C],获取抵押数量 4.计算抵押品价值:collateralValue = collateralAmount × assetPrice × liquidationFactor(0.95) 5.转移抵押品:userCollateral[Alice][asset] = 0,userCollateral[absorber][asset] += collateralAmount 6.清零债务:userBasic[Alice].principal = 0,totalBorrowBase -= 1000e6 结果: Alice 债务清零,抵押品转移给清算者,清算者获得 5% 折扣 步骤 5 — 继续清算 Bob、Charlie(相同流程) 步骤 6 — 发送交易并等待确认 TypeScript const tx = await lendingContract.absorbMultiple( await signer.getAddress(), // 清算者地址 liquidatableAccounts // [Alice, Bob, Charlie] ) const receipt = await tx.wait() 清算结果示例: Plain Text ✅ Liquidation successful! Transaction: 0xabc123... Gas used: 450,000 Block: 12345679 清算了 3 个账户: 0xAlice: $1,000 债务 0xBob: $500 债务 0xCharlie: $2,000 债务 清算者获得的抵押品: YT-A: 500 个(估值 $1,750) YT-B: 800 个(估值 $1,600) 总价值: $3,350(覆盖 $3,500 债务,5% 折扣) 6. 重要参数说明 6.1 轮询参数 参数名 值 单位 说明 调优建议 LOOP_DELAY 5000 毫秒 轮询间隔 测试网 5000ms / 主网高活跃 2000ms / 激进模式 1000ms(注意 RPC 限流) LOOKBACK_BLOCKS 10000 区块 事件查询范围 Arbitrum 测试网 50000(~14小时)/ Arbitrum 主网 10000-20000(~3-6小时)/ 以太坊主网 10000(~33小时) 时间窗口参考: Plain Text Arbitrum(出块 ~1 秒): 10000 区块 ≈ 2.7 小时 50000 区块 ≈ 14 小时 100000 区块 ≈ 28 小时 Ethereum 主网(出块 ~12 秒): 10000 区块 ≈ 33 小时 6.2 合约参数(只读) 参数名 典型值 精度 说明 位置 borrowCollateralFactor 0.80 18 decimals 借款抵押率(80% LTV) assetConfig liquidateCollateralFactor 0.85 18 decimals 清算触发阈值(85%) assetConfig liquidationFactor 0.95 18 decimals 清算激励因子(5% 折扣) 全局配置 storeFrontPriceFactor 0.50 18 decimals buyCollateral 折扣系数 全局配置 三个抵押率的关系: Plain Text 正常借款 → 接近清算 → 触发清算 → 清算完成 80% 83% 85% 清零 (借款) (预警) (清算) (债务) 健康因子计算: Plain Text healthFactor = (collateralValue × 0.85) / debtValue healthFactor > 1.0:安全 healthFactor < 1.0:可清算 6.3 监听事件类型 事件名 触发场景 提取字段 说明 Withdraw 用户借款/提取 USDC src, dst 最主要的借款信号 Supply 用户存入 USDC from, dst 可能有借款人还款 SupplyCollateral 用户存入抵押品 from, dst 借款前的准备动作 WithdrawCollateral 用户提取抵押品 src, to 可能触发清算条件 6.4 日志示例 Plain Text [2025-01-06T10:30:15.000Z] Block: 12345678 📊 Querying events from block 12335678 to 12345678... - Withdraw events: 150 - Supply events: 80 - SupplyCollateral events: 120 - WithdrawCollateral events: 90 ✅ Found 85 unique addresses from all events 🔍 Checking 85 addresses for liquidation... 💰 Liquidatable: 0xAlice...1234 💰 Liquidatable: 0xBob...5678 💰 Liquidatable: 0xEve...9012 🎯 Found 3 liquidatable accounts 📤 Sending liquidation transaction... 🔗 Transaction sent: 0xabc123... ✅ Liquidation successful! Gas used: 450000 Block: 12345679 7. 完整脚本 index.ts TypeScript import hre from 'hardhat'; import { liquidateUnderwaterBorrowers } from './liquidateUnderwaterBorrowers'; import * as fs from 'fs'; import * as path from 'path'; const LOOP_DELAY = 5000; // 5 秒轮询间隔 async function main() { const network = hre.network.name; const chainId = hre.network.config.chainId; console.log('\n=========================================='); console.log('🤖 YT Lending Liquidation Bot Started'); console.log('=========================================='); console.log('Network:', network); console.log('Chain ID:', chainId); console.log('Loop Delay:', LOOP_DELAY, 'ms\n'); const deploymentsPath = path.join(__dirname, '../../deployments-lending.json'); if (!fs.existsSync(deploymentsPath)) { throw new Error('deployments-lending.json not found'); } const deployments = JSON.parse(fs.readFileSync(deploymentsPath, 'utf-8')); const deployment = deployments[chainId?.toString() || '421614']; if (!deployment) { throw new Error(`No deployment found for chainId: ${chainId}`); } console.log('📋 Contract Addresses:'); console.log(' Lending Proxy:', deployment.lendingProxy); console.log(' Base Token (USDC):', deployment.usdcAddress); console.log(''); const [signer] = await hre.ethers.getSigners(); console.log('👤 Liquidator Address:', await signer.getAddress()); console.log('💰 Liquidator Balance:', hre.ethers.formatEther(await hre.ethers.provider.getBalance(signer)), 'ETH\n'); const lendingContract = await hre.ethers.getContractAt( 'Lending', deployment.lendingProxy, signer ); console.log('✅ Contracts initialized\n'); console.log('=========================================='); console.log('🔄 Starting main loop...\n'); let lastBlockNumber: number | undefined; while (true) { try { const currentBlockNumber = await hre.ethers.provider.getBlockNumber(); console.log(`[${new Date().toISOString()}] Block: ${currentBlockNumber}`); if (currentBlockNumber !== lastBlockNumber) { lastBlockNumber = currentBlockNumber; await liquidateUnderwaterBorrowers(lendingContract, signer); console.log(''); } else { console.log(`Block already checked; waiting ${LOOP_DELAY}ms...\n`); } await new Promise(resolve => setTimeout(resolve, LOOP_DELAY)); } catch (error) { console.error('❌ Error in main loop:', error); console.log(`Retrying in ${LOOP_DELAY}ms...\n`); await new Promise(resolve => setTimeout(resolve, LOOP_DELAY)); } } } main() .then(() => process.exit(0)) .catch((error) => { console.error('❌ Fatal error:', error); process.exit(1); }); liquidateUnderwaterBorrowers.ts TypeScript import hre from 'hardhat'; import { Signer } from 'ethers'; const LOOKBACK_BLOCKS = 10000; export async function getUniqueAddresses(lendingContract: any): Promise { const currentBlock = await hre.ethers.provider.getBlockNumber(); const fromBlock = Math.max(currentBlock - LOOKBACK_BLOCKS, 0); console.log(`📊 Querying events from block ${fromBlock} to ${currentBlock}...`); const uniqueAddresses = new Set(); try { const withdrawEvents = await lendingContract.queryFilter( lendingContract.filters.Withdraw(), fromBlock, currentBlock ); for (const event of withdrawEvents) { if (event.args?.src) uniqueAddresses.add(event.args.src); if (event.args?.dst) uniqueAddresses.add(event.args.dst); } console.log(` - Withdraw events: ${withdrawEvents.length}`); } catch (error) { console.error(' ⚠️ Failed to query Withdraw events:', error); } try { const supplyEvents = await lendingContract.queryFilter( lendingContract.filters.Supply(), fromBlock, currentBlock ); for (const event of supplyEvents) { if (event.args?.from) uniqueAddresses.add(event.args.from); if (event.args?.dst) uniqueAddresses.add(event.args.dst); } console.log(` - Supply events: ${supplyEvents.length}`); } catch (error) { console.error(' ⚠️ Failed to query Supply events:', error); } try { const supplyCollateralEvents = await lendingContract.queryFilter( lendingContract.filters.SupplyCollateral(), fromBlock, currentBlock ); for (const event of supplyCollateralEvents) { if (event.args?.from) uniqueAddresses.add(event.args.from); if (event.args?.dst) uniqueAddresses.add(event.args.dst); } console.log(` - SupplyCollateral events: ${supplyCollateralEvents.length}`); } catch (error) { console.error(' ⚠️ Failed to query SupplyCollateral events:', error); } try { const withdrawCollateralEvents = await lendingContract.queryFilter( lendingContract.filters.WithdrawCollateral(), fromBlock, currentBlock ); for (const event of withdrawCollateralEvents) { if (event.args?.src) uniqueAddresses.add(event.args.src); if (event.args?.to) uniqueAddresses.add(event.args.to); } console.log(` - WithdrawCollateral events: ${withdrawCollateralEvents.length}`); } catch (error) { console.error(' ⚠️ Failed to query WithdrawCollateral events:', error); } console.log(`✅ Found ${uniqueAddresses.size} unique addresses from all events`); return Array.from(uniqueAddresses); } export async function liquidateUnderwaterBorrowers( lendingContract: any, signer: Signer ): Promise { const uniqueAddresses = await getUniqueAddresses(lendingContract); if (uniqueAddresses.length === 0) { console.log('ℹ️ No active addresses found'); return false; } console.log(`🔍 Checking ${uniqueAddresses.length} addresses for liquidation...`); const liquidatableAccounts: string[] = []; for (const address of uniqueAddresses) { try { const isLiquidatable = await lendingContract.isLiquidatable(address); if (isLiquidatable) { console.log(`💰 Liquidatable: ${address}`); liquidatableAccounts.push(address); } } catch (error) { console.error(`Error checking ${address}:`, error); } } if (liquidatableAccounts.length > 0) { console.log(`\n🎯 Found ${liquidatableAccounts.length} liquidatable accounts`); console.log('📤 Sending liquidation transaction...'); try { const liquidatorAddress = await signer.getAddress(); const tx = await lendingContract.connect(signer).absorbMultiple( liquidatorAddress, liquidatableAccounts ); console.log(`🔗 Transaction sent: ${tx.hash}`); const receipt = await tx.wait(); console.log(`✅ Liquidation successful!`); console.log(` Gas used: ${receipt.gasUsed.toString()}`); console.log(` Block: ${receipt.blockNumber}`); return true; } catch (error) { console.error('❌ Liquidation transaction failed:', error); return false; } } else { console.log('✅ No liquidatable accounts found'); return false; } } 8. 运行和部署 8.1 开发模式(前台运行) Bash npx hardhat run scripts/liquidation_bot/index.ts --network arbSepolia 8.2 生产部署(PM2 后台运行) Bash # 1. 安装 PM2 npm install -g pm2 # 2. 启动清算机器人 pm2 start scripts/liquidation_bot/index.ts \ --name ytlp-liquidation-bot \ --interpreter npx \ --interpreter-args "hardhat run --network arbSepolia" # 3. 查看日志 pm2 logs ytlp-liquidation-bot --lines 100 # 4. 保存配置(开机自启) pm2 save pm2 startup # 5. 监控管理 pm2 list # 列出所有进程 pm2 restart ytlp-liquidation-bot # 重启 pm2 stop ytlp-liquidation-bot # 停止 pm2 delete ytlp-liquidation-bot # 删除 8.3 参数调优指南 场景 1:测试网(低活跃度) TypeScript const LOOP_DELAY = 5000; // 5 秒(稳定) const LOOKBACK_BLOCKS = 50000; // ~14 小时(覆盖更多历史) 场景 2:主网(高活跃度) TypeScript const LOOP_DELAY = 2000; // 2 秒(快速响应) const LOOKBACK_BLOCKS = 10000; // ~2.7 小时(用户活跃,足够) 总结 本清算机器人采用无状态轮询设计,通过监听多种事件提高覆盖率,对所有 isLiquidatable 为 true 的账户无金额限制地执行清算,确保及时清算水下账户,保护协议免受坏账风险。