init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
692
webapp-back/lending/liquidation_bot.go
Normal file
692
webapp-back/lending/liquidation_bot.go
Normal file
@@ -0,0 +1,692 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user