init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View 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 返回 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)
}
}