package lending import ( "context" "crypto/ecdsa" "encoding/json" "fmt" "log" "math/big" "reflect" "strings" "sync" "time" "gorm.io/gorm/clause" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" dbpkg "github.com/gothinkster/golang-gin-realworld-example-app/common" appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config" "github.com/gothinkster/golang-gin-realworld-example-app/models" ) const ( botLoopDelay = 5 * time.Second botLookbackBlocks = int64(10000) ) // minimal ABI used by the liquidation bot const lendingBotABI = `[ {"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isLiquidatable","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}, {"inputs":[{"internalType":"address","name":"absorber","type":"address"},{"internalType":"address[]","name":"accounts","type":"address[]"}],"name":"absorbMultiple","outputs":[],"stateMutability":"nonpayable","type":"function"}, {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Withdraw","type":"event"}, {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Supply","type":"event"}, {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"SupplyCollateral","type":"event"}, {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"WithdrawCollateral","type":"event"} ]` // BotStatus is returned by the status API type BotStatus struct { Running bool `json:"running"` LiquidatorAddr string `json:"liquidator_addr"` ChainID int `json:"chain_id"` ContractAddr string `json:"contract_addr"` TotalLiquidations int64 `json:"total_liquidations"` LastCheckTime time.Time `json:"last_check_time"` LastBlockChecked uint64 `json:"last_block_checked"` LoopDelayMs int `json:"loop_delay_ms"` LookbackBlocks int64 `json:"lookback_blocks"` Error string `json:"error,omitempty"` } // liquidationBot holds mutable bot state type liquidationBot struct { mu sync.Mutex running bool stopChan chan struct{} liquidatorAddr string chainID int contractAddr string totalLiquidations int64 lastCheckTime time.Time lastBlockChecked uint64 lastErr string } var globalBot = &liquidationBot{} // GetBotStatus returns a snapshot of the current bot state func GetBotStatus() BotStatus { globalBot.mu.Lock() defer globalBot.mu.Unlock() return BotStatus{ Running: globalBot.running, LiquidatorAddr: globalBot.liquidatorAddr, ChainID: globalBot.chainID, ContractAddr: globalBot.contractAddr, TotalLiquidations: globalBot.totalLiquidations, LastCheckTime: globalBot.lastCheckTime, LastBlockChecked: globalBot.lastBlockChecked, LoopDelayMs: int(botLoopDelay.Milliseconds()), LookbackBlocks: botLookbackBlocks, Error: globalBot.lastErr, } } // StartLiquidationBot starts the bot goroutine; idempotent if already running func StartLiquidationBot(cfg *appcfg.Config) { if cfg.LiquidatorPrivateKey == "" { log.Println("[LiquidationBot] LIQUIDATOR_PRIVATE_KEY not set, bot disabled") globalBot.mu.Lock() globalBot.lastErr = "LIQUIDATOR_PRIVATE_KEY not configured" globalBot.mu.Unlock() return } globalBot.mu.Lock() if globalBot.running { globalBot.mu.Unlock() return } globalBot.stopChan = make(chan struct{}) globalBot.running = true globalBot.lastErr = "" globalBot.mu.Unlock() go runBot(cfg) log.Println("[LiquidationBot] Started") } // StopLiquidationBot signals the bot to stop func StopLiquidationBot() { globalBot.mu.Lock() defer globalBot.mu.Unlock() if !globalBot.running { return } close(globalBot.stopChan) globalBot.running = false log.Println("[LiquidationBot] Stop signal sent") } func runBot(cfg *appcfg.Config) { defer func() { globalBot.mu.Lock() globalBot.running = false globalBot.mu.Unlock() log.Println("[LiquidationBot] Goroutine exited") }() // Parse private key privKeyHex := strings.TrimPrefix(cfg.LiquidatorPrivateKey, "0x") privateKey, err := crypto.HexToECDSA(privKeyHex) if err != nil { log.Printf("[LiquidationBot] Invalid private key: %v", err) globalBot.mu.Lock() globalBot.lastErr = fmt.Sprintf("invalid private key: %v", err) globalBot.running = false globalBot.mu.Unlock() return } liquidatorAddr := crypto.PubkeyToAddress(privateKey.PublicKey) // Load contract addresses from DB lendingAddr, chainID, loadErr := loadBotContracts() if loadErr != nil { log.Printf("[LiquidationBot] Load contracts: %v", loadErr) globalBot.mu.Lock() globalBot.lastErr = loadErr.Error() globalBot.running = false globalBot.mu.Unlock() return } rpcURL := getRPCURL(chainID) if rpcURL == "" { log.Printf("[LiquidationBot] No RPC URL for chain %d", chainID) globalBot.mu.Lock() globalBot.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID) globalBot.running = false globalBot.mu.Unlock() return } globalBot.mu.Lock() globalBot.liquidatorAddr = liquidatorAddr.Hex() globalBot.chainID = chainID globalBot.contractAddr = lendingAddr globalBot.mu.Unlock() log.Printf("[LiquidationBot] Liquidator: %s | Chain: %d | Lending: %s", liquidatorAddr.Hex(), chainID, lendingAddr) // Parse ABI lABI, err := abi.JSON(strings.NewReader(lendingBotABI)) if err != nil { log.Printf("[LiquidationBot] ABI parse error: %v", err) globalBot.mu.Lock() globalBot.lastErr = fmt.Sprintf("ABI parse error: %v", err) globalBot.running = false globalBot.mu.Unlock() return } // Load last processed block from DB; 0 means first run processedBlock := loadLiquidationBlock(chainID) for { select { case <-globalBot.stopChan: return default: } caughtUp := botTick(rpcURL, lABI, privateKey, liquidatorAddr, lendingAddr, chainID, &processedBlock) if caughtUp { // Normal pace select { case <-globalBot.stopChan: return case <-time.After(botLoopDelay): } } // Not caught up: loop immediately to process next chunk } } // botTick processes one chunk of blocks. Returns true when caught up to chain head. func botTick( rpcURL string, lABI abi.ABI, privateKey *ecdsa.PrivateKey, liquidatorAddr ethcommon.Address, lendingAddr string, chainID int, processedBlock *uint64, ) bool { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() client, err := ethclient.DialContext(ctx, rpcURL) if err != nil { log.Printf("[LiquidationBot] RPC dial error: %v", err) return true } defer client.Close() currentBlock, err := client.BlockNumber(ctx) if err != nil { log.Printf("[LiquidationBot] BlockNumber error: %v", err) return true } // On first run (processedBlock == 0), start from current - lookback if *processedBlock == 0 { if currentBlock >= uint64(botLookbackBlocks) { *processedBlock = currentBlock - uint64(botLookbackBlocks) } } if currentBlock <= *processedBlock { return true // already up to date } // Scan at most botLookbackBlocks blocks per tick fromBlock := *processedBlock + 1 toBlock := fromBlock + uint64(botLookbackBlocks) - 1 if toBlock > currentBlock { toBlock = currentBlock } globalBot.mu.Lock() globalBot.lastCheckTime = time.Now() globalBot.lastBlockChecked = toBlock globalBot.mu.Unlock() lendingContract := ethcommon.HexToAddress(lendingAddr) // ── 1. 扫描事件,将新地址存入 known_borrowers(永久积累)────────────── log.Printf("[LiquidationBot] Scanning %d~%d (chain head: %d)", fromBlock, toBlock, currentBlock) newAddrs := queryUniqueAddresses(ctx, client, lABI, lendingContract, fromBlock, toBlock) if len(newAddrs) > 0 { saveKnownBorrowers(chainID, newAddrs) } // ── 2. 对所有历史地址检查 isLiquidatable(含价格变动导致健康因子归零的账户) allBorrowers := loadKnownBorrowers(chainID) liquidated, txHash, gasUsed, blockNum, execErr := checkAndAbsorb( ctx, client, lABI, privateKey, liquidatorAddr, lendingContract, chainID, allBorrowers, ) // Always save progress, even if no liquidation happened saveLiquidationBlock(chainID, toBlock) *processedBlock = toBlock if execErr != nil { log.Printf("[LiquidationBot] Execution error: %v", execErr) if len(liquidated) > 0 { saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "failed", execErr.Error()) } return toBlock >= currentBlock } if len(liquidated) > 0 { saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "success", "") globalBot.mu.Lock() globalBot.totalLiquidations += int64(len(liquidated)) globalBot.mu.Unlock() } return toBlock >= currentBlock } const isLiquidatableMaxConcurrent = 10 // 并行检查上限,作为 Multicall3 失败时的兜底 const multicallBatchSize = 500 // 每批 Multicall3 最多打包的地址数 // Multicall3 已部署在所有主流 EVM 链的固定地址 var multicall3Addr = ethcommon.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") const multicall3ABIStr = `[{ "inputs": [{"components": [ {"internalType":"address","name":"target","type":"address"}, {"internalType":"bool","name":"allowFailure","type":"bool"}, {"internalType":"bytes","name":"callData","type":"bytes"} ],"name":"calls","type":"tuple[]"}], "name":"aggregate3", "outputs": [{"components": [ {"internalType":"bool","name":"success","type":"bool"}, {"internalType":"bytes","name":"returnData","type":"bytes"} ],"name":"returnData","type":"tuple[]"}], "stateMutability":"payable","type":"function" }]` // mc3Call 是 Multicall3.aggregate3 的输入结构体(字段名需与 ABI 组件名 lowercase 匹配) type mc3Call struct { Target ethcommon.Address AllowFailure bool CallData []byte } // multicallIsLiquidatable 用 Multicall3 批量检查,每批 500 个地址只发 1 个 RPC // 失败时自动降级到并发 goroutine 方案 func multicallIsLiquidatable( ctx context.Context, client *ethclient.Client, lABI abi.ABI, lendingContract ethcommon.Address, addresses []ethcommon.Address, ) []ethcommon.Address { m3ABI, err := abi.JSON(strings.NewReader(multicall3ABIStr)) if err != nil { log.Printf("[LiquidationBot] multicall3 ABI parse error: %v,降级到并发模式", err) return parallelIsLiquidatable(ctx, client, lABI, lendingContract, addresses) } var liquidatable []ethcommon.Address totalBatches := (len(addresses) + multicallBatchSize - 1) / multicallBatchSize for i := 0; i < len(addresses); i += multicallBatchSize { end := i + multicallBatchSize if end > len(addresses) { end = len(addresses) } batch := addresses[i:end] batchIdx := i/multicallBatchSize + 1 // 构造调用列表 calls := make([]mc3Call, len(batch)) for j, addr := range batch { data, packErr := lABI.Pack("isLiquidatable", addr) if packErr != nil { continue } calls[j] = mc3Call{Target: lendingContract, AllowFailure: true, CallData: data} } packed, packErr := m3ABI.Pack("aggregate3", calls) if packErr != nil { log.Printf("[LiquidationBot] multicall3 pack error batch %d/%d: %v", batchIdx, totalBatches, packErr) continue } mc3 := multicall3Addr raw, callErr := client.CallContract(ctx, ethereum.CallMsg{To: &mc3, Data: packed}, nil) if callErr != nil { log.Printf("[LiquidationBot] multicall3 rpc error batch %d/%d: %v", batchIdx, totalBatches, callErr) continue } unpacked, unpackErr := m3ABI.Unpack("aggregate3", raw) if unpackErr != nil || len(unpacked) == 0 { log.Printf("[LiquidationBot] multicall3 unpack error batch %d/%d: %v", batchIdx, totalBatches, unpackErr) continue } // go-ethereum 返回动态 struct 类型,通过反射访问字段 rv := reflect.ValueOf(unpacked[0]) for j := 0; j < rv.Len() && j < len(batch); j++ { elem := rv.Index(j) if !elem.FieldByName("Success").Bool() { continue } retData := elem.FieldByName("ReturnData").Bytes() // isLiquidatable 返回 bool,ABI 编码为 32 字节,最后一字节 1=true if len(retData) >= 32 && retData[31] == 1 { log.Printf("[LiquidationBot] Liquidatable: %s", batch[j].Hex()) liquidatable = append(liquidatable, batch[j]) } } log.Printf("[LiquidationBot] Multicall3 batch %d/%d (%d addresses)", batchIdx, totalBatches, len(batch)) } return liquidatable } // parallelIsLiquidatable 是 Multicall3 不可用时的兜底方案 func parallelIsLiquidatable( ctx context.Context, client *ethclient.Client, lABI abi.ABI, lendingContract ethcommon.Address, addresses []ethcommon.Address, ) []ethcommon.Address { type result struct { addr ethcommon.Address isLiq bool } resultCh := make(chan result, len(addresses)) sem := make(chan struct{}, isLiquidatableMaxConcurrent) var wg sync.WaitGroup for _, addr := range addresses { addr := addr wg.Add(1) go func() { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() isLiq, _ := callViewBool(ctx, client, lABI, lendingContract, "isLiquidatable", addr) resultCh <- result{addr, isLiq} }() } wg.Wait() close(resultCh) var liquidatable []ethcommon.Address for r := range resultCh { if r.isLiq { log.Printf("[LiquidationBot] Liquidatable: %s", r.addr.Hex()) liquidatable = append(liquidatable, r.addr) } } return liquidatable } // checkAndAbsorb 检查所有已知地址,对可清算账户执行 absorbMultiple func checkAndAbsorb( ctx context.Context, client *ethclient.Client, lABI abi.ABI, privateKey *ecdsa.PrivateKey, liquidatorAddr ethcommon.Address, lendingContract ethcommon.Address, chainID int, addresses []ethcommon.Address, ) (liquidated []ethcommon.Address, txHash string, gasUsed uint64, blockNum uint64, err error) { if len(addresses) == 0 { return } log.Printf("[LiquidationBot] Checking %d known borrowers via Multicall3...", len(addresses)) toAbsorb := multicallIsLiquidatable(ctx, client, lABI, lendingContract, addresses) if len(toAbsorb) == 0 { log.Println("[LiquidationBot] No liquidatable accounts") return } log.Printf("[LiquidationBot] Liquidating %d accounts...", len(toAbsorb)) chainIDBig := big.NewInt(int64(chainID)) boundContract := bind.NewBoundContract(lendingContract, lABI, client, client, client) auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig) if authErr != nil { err = fmt.Errorf("create transactor: %w", authErr) return } auth.GasLimit = uint64(300000 + 150000*uint64(len(toAbsorb))) tx, txErr := boundContract.Transact(auth, "absorbMultiple", liquidatorAddr, toAbsorb) if txErr != nil { err = fmt.Errorf("absorbMultiple: %w", txErr) return } log.Printf("[LiquidationBot] Tx sent: %s", tx.Hash().Hex()) txHash = tx.Hash().Hex() liquidated = toAbsorb receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 60*time.Second) defer receiptCancel() receipt, waitErr := bind.WaitMined(receiptCtx, client, tx) if waitErr != nil { log.Printf("[LiquidationBot] Wait receipt error: %v", waitErr) return } gasUsed = receipt.GasUsed blockNum = receipt.BlockNumber.Uint64() if receipt.Status == 1 { log.Printf("[LiquidationBot] Success! Gas: %d | Block: %d", gasUsed, blockNum) } else { err = fmt.Errorf("transaction reverted at block %d", blockNum) log.Printf("[LiquidationBot] Tx reverted: %s", txHash) } return } // queryUniqueAddresses queries 4 event types and returns deduplicated addresses func queryUniqueAddresses(ctx context.Context, client *ethclient.Client, lABI abi.ABI, contractAddr ethcommon.Address, fromBlock, toBlock uint64) []ethcommon.Address { query := ethereum.FilterQuery{ FromBlock: new(big.Int).SetUint64(fromBlock), ToBlock: new(big.Int).SetUint64(toBlock), Addresses: []ethcommon.Address{contractAddr}, Topics: [][]ethcommon.Hash{{ lABI.Events["Withdraw"].ID, lABI.Events["Supply"].ID, lABI.Events["SupplyCollateral"].ID, lABI.Events["WithdrawCollateral"].ID, }}, } logs, err := client.FilterLogs(ctx, query) if err != nil { log.Printf("[LiquidationBot] FilterLogs error: %v", err) return nil } seen := make(map[ethcommon.Address]struct{}) for _, l := range logs { // topic[1] = first indexed addr, topic[2] = second indexed addr if len(l.Topics) >= 2 { seen[ethcommon.BytesToAddress(l.Topics[1].Bytes())] = struct{}{} } if len(l.Topics) >= 3 { seen[ethcommon.BytesToAddress(l.Topics[2].Bytes())] = struct{}{} } } addrs := make([]ethcommon.Address, 0, len(seen)) for addr := range seen { addrs = append(addrs, addr) } log.Printf("[LiquidationBot] Events [%d~%d]: %d logs → %d unique addresses", fromBlock, toBlock, len(logs), len(addrs)) return addrs } // callViewBool calls a view function and returns bool result func callViewBool(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) (bool, error) { res, err := callViewRaw(ctx, client, contractABI, addr, method, args...) if err != nil { return false, err } if len(res) == 0 { return false, fmt.Errorf("empty result") } v, ok := res[0].(bool) if !ok { return false, fmt.Errorf("unexpected type %T", res[0]) } return v, nil } // callViewRaw calls a view function and returns decoded values func callViewRaw(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) ([]interface{}, error) { data, err := contractABI.Pack(method, args...) if err != nil { return nil, fmt.Errorf("pack %s: %w", method, err) } result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil) if err != nil { return nil, fmt.Errorf("call %s: %w", method, err) } decoded, err := contractABI.Unpack(method, result) if err != nil { return nil, fmt.Errorf("unpack %s: %w", method, err) } return decoded, nil } // saveKnownBorrowers 批量 upsert:新地址插入,已存在的只更新 last_seen_at // 使用单条 SQL 替代 N+1 查询,几千地址也只需 ceil(N/200) 次 DB 请求 func saveKnownBorrowers(chainID int, addrs []ethcommon.Address) { if len(addrs) == 0 { return } now := time.Now() records := make([]models.KnownBorrower, len(addrs)) for i, addr := range addrs { records[i] = models.KnownBorrower{ ChainID: chainID, Address: addr.Hex(), FirstSeenAt: now, LastSeenAt: now, } } database := dbpkg.GetDB() if err := database.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "chain_id"}, {Name: "address"}}, DoUpdates: clause.Assignments(map[string]interface{}{"last_seen_at": now}), }).CreateInBatches(records, 200).Error; err != nil { log.Printf("[LiquidationBot] saveKnownBorrowers error: %v", err) } } // loadKnownBorrowers returns all addresses ever seen for a chain func loadKnownBorrowers(chainID int) []ethcommon.Address { database := dbpkg.GetDB() var borrowers []models.KnownBorrower database.Where("chain_id = ?", chainID).Find(&borrowers) addrs := make([]ethcommon.Address, len(borrowers)) for i, b := range borrowers { addrs[i] = ethcommon.HexToAddress(b.Address) } if len(addrs) > 0 { log.Printf("[LiquidationBot] Loaded %d known borrowers from DB", len(addrs)) } return addrs } // loadLiquidationBlock reads the last processed block from scanner_state func loadLiquidationBlock(chainID int) uint64 { database := dbpkg.GetDB() var state models.ScannerState if err := database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").First(&state).Error; err != nil { return 0 } return state.LastScannedBlock } // saveLiquidationBlock persists the last processed block to scanner_state func saveLiquidationBlock(chainID int, block uint64) { database := dbpkg.GetDB() state := models.ScannerState{ ScannerType: "liquidation", ChainID: chainID, LastScannedBlock: block, } database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").Assign(state).FirstOrCreate(&state) database.Model(&state).Updates(map[string]interface{}{ "last_scanned_block": block, "updated_at": time.Now(), }) } // loadBotContracts loads lendingProxy address from system_contracts func loadBotContracts() (lendingAddr string, chainID int, err error) { database := dbpkg.GetDB() type row struct { ChainID int Address string } var r row if dbErr := database.Table("system_contracts"). Where("name = ? AND is_active = ?", "lendingProxy", true). Select("chain_id, address"). First(&r).Error; dbErr != nil { err = fmt.Errorf("lendingProxy not found in system_contracts: %w", dbErr) return } if r.Address == "" { err = fmt.Errorf("lendingProxy address is empty") return } lendingAddr = r.Address chainID = r.ChainID return } // saveLiquidationRecord persists a liquidation event to DB func saveLiquidationRecord(chainID int, txHash string, liquidatorAddr ethcommon.Address, accounts []ethcommon.Address, gasUsed, blockNum uint64, status, errMsg string) { addrs := make([]string, len(accounts)) for i, a := range accounts { addrs[i] = a.Hex() } addrsJSON, _ := json.Marshal(addrs) record := models.LiquidationRecord{ ChainID: chainID, TxHash: txHash, LiquidatorAddr: liquidatorAddr.Hex(), AccountCount: len(accounts), Accounts: string(addrsJSON), GasUsed: gasUsed, BlockNumber: blockNum, Status: status, ErrorMessage: errMsg, CreatedAt: time.Now(), } database := dbpkg.GetDB() if dbErr := database.Create(&record).Error; dbErr != nil { log.Printf("[LiquidationBot] Save record error: %v", dbErr) } }