Files
assetx/webapp-back/lending/liquidation_bot.go
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

693 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 返回 boolABI 编码为 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)
}
}