693 lines
23 KiB
Go
693 lines
23 KiB
Go
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|