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

632 lines
22 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"
"fmt"
"log"
"math/big"
"strings"
"sync"
"time"
"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 (
buyerLoopDelay = 10 * time.Second
buyerLookbackBlocks = int64(10000)
)
const collateralBuyerABIStr = `[
{"anonymous":false,"inputs":[
{"indexed":true,"name":"buyer","type":"address"},
{"indexed":true,"name":"asset","type":"address"},
{"indexed":false,"name":"baseAmount","type":"uint256"},
{"indexed":false,"name":"collateralAmount","type":"uint256"}
],"name":"BuyCollateral","type":"event"},
{"inputs":[],"name":"getReserves","outputs":[{"name":"","type":"int256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"targetReserves","outputs":[{"name":"","type":"uint104"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"}],"name":"getCollateralReserves","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"},{"name":"baseAmount","type":"uint256"}],"name":"quoteCollateral","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"},{"name":"minAmount","type":"uint256"},{"name":"baseAmount","type":"uint256"},{"name":"recipient","type":"address"}],"name":"buyCollateral","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[],"name":"baseToken","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"","type":"uint256"}],"name":"assetList","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"}
]`
const erc20BuyerABIStr = `[
{"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"stateMutability":"view","type":"function"}
]`
// BuyerBotStatus is returned by the status API
type BuyerBotStatus struct {
Running bool `json:"running"`
BuyerAddr string `json:"buyer_addr"`
ChainID int `json:"chain_id"`
ContractAddr string `json:"contract_addr"`
TotalBuys int64 `json:"total_buys"`
LastCheckTime time.Time `json:"last_check_time"`
LastBlockChecked uint64 `json:"last_block_checked"`
LoopDelayMs int `json:"loop_delay_ms"`
LookbackBlocks int64 `json:"lookback_blocks"`
SlippagePct int `json:"slippage_pct"`
Error string `json:"error,omitempty"`
}
type collateralBuyerBot struct {
mu sync.Mutex
running bool
stopChan chan struct{}
buyerAddr string
chainID int
contractAddr string
totalBuys int64
lastCheckTime time.Time
lastBlockChecked uint64
lastErr string
slippagePct int
}
var globalBuyer = &collateralBuyerBot{}
func GetBuyerBotStatus() BuyerBotStatus {
globalBuyer.mu.Lock()
defer globalBuyer.mu.Unlock()
return BuyerBotStatus{
Running: globalBuyer.running,
BuyerAddr: globalBuyer.buyerAddr,
ChainID: globalBuyer.chainID,
ContractAddr: globalBuyer.contractAddr,
TotalBuys: globalBuyer.totalBuys,
LastCheckTime: globalBuyer.lastCheckTime,
LastBlockChecked: globalBuyer.lastBlockChecked,
LoopDelayMs: int(buyerLoopDelay.Milliseconds()),
LookbackBlocks: buyerLookbackBlocks,
SlippagePct: globalBuyer.slippagePct,
Error: globalBuyer.lastErr,
}
}
func StartCollateralBuyerBot(cfg *appcfg.Config) {
// 优先用独立私钥,未配置则 fallback 到清算机器人私钥(不修改 cfg 原始值)
privKey := cfg.CollateralBuyerPrivateKey
if privKey == "" && cfg.LiquidatorPrivateKey != "" {
privKey = cfg.LiquidatorPrivateKey
log.Println("[BuyerBot] COLLATERAL_BUYER_PRIVATE_KEY 未配置,使用 LIQUIDATOR_PRIVATE_KEY")
}
if privKey == "" {
log.Println("[BuyerBot] 未配置私钥Bot 未启动")
globalBuyer.mu.Lock()
globalBuyer.lastErr = "未配置私钥Bot 未启动"
globalBuyer.mu.Unlock()
return
}
globalBuyer.mu.Lock()
if globalBuyer.running {
globalBuyer.mu.Unlock()
return
}
globalBuyer.stopChan = make(chan struct{})
globalBuyer.running = true
globalBuyer.lastErr = ""
globalBuyer.mu.Unlock()
go runBuyerBot(cfg, privKey)
log.Println("[BuyerBot] Started")
}
func StopCollateralBuyerBot() {
globalBuyer.mu.Lock()
defer globalBuyer.mu.Unlock()
if !globalBuyer.running {
return
}
close(globalBuyer.stopChan)
globalBuyer.running = false
log.Println("[BuyerBot] Stop signal sent")
}
func runBuyerBot(cfg *appcfg.Config, privKeyStr string) {
defer func() {
globalBuyer.mu.Lock()
globalBuyer.running = false
globalBuyer.mu.Unlock()
log.Println("[BuyerBot] Goroutine exited")
}()
privKeyHex := strings.TrimPrefix(privKeyStr, "0x")
privateKey, err := crypto.HexToECDSA(privKeyHex)
if err != nil {
log.Printf("[BuyerBot] Invalid private key: %v", err)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("invalid private key: %v", err)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
buyerAddr := crypto.PubkeyToAddress(privateKey.PublicKey)
lendingAddr, chainID, loadErr := loadBotContracts()
if loadErr != nil {
log.Printf("[BuyerBot] Load contracts: %v", loadErr)
globalBuyer.mu.Lock()
globalBuyer.lastErr = loadErr.Error()
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
rpcURL := getRPCURL(chainID)
if rpcURL == "" {
log.Printf("[BuyerBot] No RPC URL for chain %d", chainID)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
slippagePct := cfg.CollateralBuyerSlippage
if slippagePct <= 0 || slippagePct > 10 {
slippagePct = 1
}
globalBuyer.mu.Lock()
globalBuyer.buyerAddr = buyerAddr.Hex()
globalBuyer.chainID = chainID
globalBuyer.contractAddr = lendingAddr
globalBuyer.slippagePct = slippagePct
globalBuyer.mu.Unlock()
log.Printf("[BuyerBot] Buyer: %s | Chain: %d | Lending: %s | Slippage: %d%%",
buyerAddr.Hex(), chainID, lendingAddr, slippagePct)
lABI, err := abi.JSON(strings.NewReader(collateralBuyerABIStr))
if err != nil {
log.Printf("[BuyerBot] ABI parse error: %v", err)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("ABI parse error: %v", err)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
processedBlock := loadBuyerBlock(chainID)
for {
select {
case <-globalBuyer.stopChan:
return
default:
}
caughtUp := buyerTick(rpcURL, lABI, privateKey, buyerAddr,
lendingAddr, chainID, slippagePct, &processedBlock)
if caughtUp {
select {
case <-globalBuyer.stopChan:
return
case <-time.After(buyerLoopDelay):
}
}
}
}
func buyerTick(
rpcURL string,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
buyerAddr ethcommon.Address,
lendingAddr string,
chainID int,
slippagePct int,
processedBlock *uint64,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
log.Printf("[BuyerBot] RPC dial error: %v", err)
return true
}
defer client.Close()
currentBlock, err := client.BlockNumber(ctx)
if err != nil {
log.Printf("[BuyerBot] BlockNumber error: %v", err)
return true
}
if *processedBlock == 0 {
if currentBlock >= uint64(buyerLookbackBlocks) {
*processedBlock = currentBlock - uint64(buyerLookbackBlocks)
}
}
fromBlock := *processedBlock + 1
toBlock := fromBlock + uint64(buyerLookbackBlocks) - 1
if toBlock > currentBlock {
toBlock = currentBlock
}
globalBuyer.mu.Lock()
globalBuyer.lastCheckTime = time.Now()
globalBuyer.lastBlockChecked = toBlock
globalBuyer.mu.Unlock()
lendingContract := ethcommon.HexToAddress(lendingAddr)
// ── 1. 检查国库条件(轮询核心) ──────────────────────────────────────
reserves, targReserves, condErr := checkBuyCondition(ctx, client, lABI, lendingContract)
if condErr != nil {
log.Printf("[BuyerBot] 条件检查失败: %v", condErr)
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
return toBlock >= currentBlock
}
if reserves.Cmp(targReserves) >= 0 {
// 国库充足,静默推进
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
return toBlock >= currentBlock
}
log.Printf("[BuyerBot] 国库不足: reserves=%s < targetReserves=%s开始购买",
reserves.String(), targReserves.String())
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
// ── 2. 获取 baseToken 及精度 ─────────────────────────────────────────
eABI, eABIErr := abi.JSON(strings.NewReader(erc20BuyerABIStr))
if eABIErr != nil {
log.Printf("[BuyerBot] ERC20 ABI parse error: %v", eABIErr)
return toBlock >= currentBlock
}
baseTokenAddr, btErr := callViewAddress(ctx, client, lABI, lendingContract, "baseToken")
if btErr != nil {
log.Printf("[BuyerBot] baseToken error: %v", btErr)
return toBlock >= currentBlock
}
// ── 3. 扫描所有已配置资产,过滤出储备 > 0 的 ────────────────────────
type assetInfo struct {
addr ethcommon.Address
reserve *big.Int
symbol string
}
var assetsWithReserves []assetInfo
allAssets := getAllAssets(ctx, client, lABI, lendingContract)
for addr := range allAssets {
collRes, err := callViewRaw(ctx, client, lABI, lendingContract, "getCollateralReserves", addr)
if err != nil {
continue
}
reserve, ok := collRes[0].(*big.Int)
if !ok || reserve.Sign() == 0 {
continue
}
sym := getAssetSymbol(ctx, client, eABI, addr)
assetsWithReserves = append(assetsWithReserves, assetInfo{addr: addr, reserve: new(big.Int).Set(reserve), symbol: sym})
log.Printf("[BuyerBot] 可购买资产: %s 储备=%s", sym, reserve.String())
}
if len(assetsWithReserves) == 0 {
log.Println("[BuyerBot] 无可购买资产(储备均为零)")
return toBlock >= currentBlock
}
// ── 4. 一次性授权检查allowance < MaxUint256/2 才 approve──────────
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
halfMax := new(big.Int).Rsh(maxUint256, 1)
allowance, alwErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "allowance", buyerAddr, lendingContract)
if alwErr != nil {
log.Printf("[BuyerBot] allowance error: %v", alwErr)
return toBlock >= currentBlock
}
if allowance.Cmp(halfMax) < 0 {
log.Printf("[BuyerBot] 授权不足,执行 approve(MaxUint256)...")
chainIDBig := big.NewInt(int64(chainID))
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
if authErr != nil {
log.Printf("[BuyerBot] create transactor: %v", authErr)
return toBlock >= currentBlock
}
auth.GasLimit = 100000
erc20Bound := bind.NewBoundContract(baseTokenAddr, eABI, client, client, client)
approveTx, appErr := erc20Bound.Transact(auth, "approve", lendingContract, maxUint256)
if appErr != nil {
log.Printf("[BuyerBot] approve error: %v", appErr)
return toBlock >= currentBlock
}
approveCtx, approveCancel := context.WithTimeout(context.Background(), 60*time.Second)
_, waitErr := bind.WaitMined(approveCtx, client, approveTx)
approveCancel()
if waitErr != nil {
log.Printf("[BuyerBot] approve wait: %v", waitErr)
return toBlock >= currentBlock
}
log.Printf("[BuyerBot] 授权成功: %s", approveTx.Hash().Hex())
} else {
log.Println("[BuyerBot] 授权充足,跳过 approve")
}
// ── 5. 逐资产购买 ───────────────────────────────────────────────────
chainIDBig := big.NewInt(int64(chainID))
successCount := 0
for _, asset := range assetsWithReserves {
// 每次购买前重新读取余额
freshBalance, balErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "balanceOf", buyerAddr)
if balErr != nil {
log.Printf("[BuyerBot] balanceOf error: %v", balErr)
break
}
if freshBalance.Sign() == 0 {
log.Println("[BuyerBot] 买家余额耗尽,停止购买")
break
}
// minAmount = reserve * (100 - slippagePct) / 100
minAmount := new(big.Int).Mul(asset.reserve, big.NewInt(int64(100-slippagePct)))
minAmount.Div(minAmount, big.NewInt(100))
log.Printf("[BuyerBot] 购买 %s: balance=%s reserve=%s minAmount=%s",
asset.symbol, freshBalance.String(), asset.reserve.String(), minAmount.String())
actualPaid, actualReceived, txHash, gasUsed, blockNum, buyErr := executeBuy(
ctx, client, lABI, privateKey, buyerAddr,
lendingContract, asset.addr, minAmount, freshBalance,
chainIDBig,
)
if buyErr != nil {
log.Printf("[BuyerBot] 购买 %s 失败(跳过): %v", asset.symbol, buyErr)
// 失败时用时间戳生成唯一 key避免 uniqueIndex 冲突
failKey := fmt.Sprintf("FAILED_%s_%d", asset.addr.Hex()[:10], time.Now().UnixNano())
saveBuyRecord(chainID, failKey, buyerAddr, asset.addr, asset.symbol,
freshBalance, big.NewInt(0), 0, 0, "failed", buyErr.Error())
continue // 单资产失败不影响其他资产
}
log.Printf("[BuyerBot] %s 购买成功: 支付=%s 获得=%s tx=%s",
asset.symbol, actualPaid.String(), actualReceived.String(), txHash)
saveBuyRecord(chainID, txHash, buyerAddr, asset.addr, asset.symbol,
actualPaid, actualReceived, gasUsed, blockNum, "success", "")
globalBuyer.mu.Lock()
globalBuyer.totalBuys++
globalBuyer.mu.Unlock()
successCount++
}
log.Printf("[BuyerBot] 本轮完成: 成功 %d / %d 个资产", successCount, len(assetsWithReserves))
return toBlock >= currentBlock
}
// executeBuy 执行一次 buyCollateral 并从事件解析实际支付/获得量
func executeBuy(
ctx context.Context,
client *ethclient.Client,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
buyerAddr ethcommon.Address,
lendingContract ethcommon.Address,
asset ethcommon.Address,
minAmount *big.Int,
baseAmount *big.Int, // 买家全部余额作为上限
chainIDBig *big.Int,
) (actualPaid *big.Int, actualReceived *big.Int, txHash string, gasUsed, blockNum uint64, err error) {
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
if authErr != nil {
return nil, nil, "", 0, 0, fmt.Errorf("create transactor: %w", authErr)
}
auth.GasLimit = 300000
lendingBound := bind.NewBoundContract(lendingContract, lABI, client, client, client)
tx, txErr := lendingBound.Transact(auth, "buyCollateral", asset, minAmount, baseAmount, buyerAddr)
if txErr != nil {
return nil, nil, "", 0, 0, fmt.Errorf("buyCollateral: %w", txErr)
}
txHash = tx.Hash().Hex()
log.Printf("[BuyerBot] 交易已提交: %s", txHash)
receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 90*time.Second)
defer receiptCancel()
receipt, waitErr := bind.WaitMined(receiptCtx, client, tx)
if waitErr != nil {
return nil, nil, txHash, 0, 0, fmt.Errorf("wait mined: %w", waitErr)
}
gasUsed = receipt.GasUsed
blockNum = receipt.BlockNumber.Uint64()
if receipt.Status != 1 {
return nil, nil, txHash, gasUsed, blockNum,
fmt.Errorf("交易回滚 block %d, tx %s", blockNum, txHash)
}
log.Printf("[BuyerBot] 交易确认 Gas=%d Block=%d", gasUsed, blockNum)
// 从 BuyCollateral 事件解析实际数值
eventID := lABI.Events["BuyCollateral"].ID
for _, l := range receipt.Logs {
if len(l.Topics) < 1 || l.Topics[0] != eventID {
continue
}
decoded, decErr := lABI.Unpack("BuyCollateral", l.Data)
if decErr != nil || len(decoded) < 2 {
continue
}
paid, ok1 := decoded[0].(*big.Int)
received, ok2 := decoded[1].(*big.Int)
if ok1 && ok2 {
return paid, received, txHash, gasUsed, blockNum, nil
}
}
// BuyCollateral 事件未找到,用余额差估算(实际数量需链上核实)
log.Printf("[BuyerBot] 警告: BuyCollateral 事件解析失败 tx=%sreceivedAmount 记录为 0", txHash)
return baseAmount, big.NewInt(0), txHash, gasUsed, blockNum, nil
}
// ── 合约枚举 / 条件检查 helpers ──────────────────────────────────────────────
// getAllAssets 遍历 assetList(0,1,...) 直到 revert
func getAllAssets(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
contract ethcommon.Address) map[ethcommon.Address]struct{} {
result := make(map[ethcommon.Address]struct{})
zero := ethcommon.Address{}
for i := 0; i < 50; i++ {
res, err := callViewRaw(ctx, client, lABI, contract, "assetList", big.NewInt(int64(i)))
if err != nil {
break
}
addr, ok := res[0].(ethcommon.Address)
if !ok || addr == zero {
break
}
result[addr] = struct{}{}
}
log.Printf("[BuyerBot] assetList: %d 个资产", len(result))
return result
}
// checkBuyCondition 返回 (reserves int256, targetReserves, error)
func checkBuyCondition(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
addr ethcommon.Address) (*big.Int, *big.Int, error) {
res, err := callViewRaw(ctx, client, lABI, addr, "getReserves")
if err != nil {
return nil, nil, fmt.Errorf("getReserves: %w", err)
}
reserves, ok := res[0].(*big.Int)
if !ok {
return nil, nil, fmt.Errorf("getReserves type %T", res[0])
}
res2, err := callViewRaw(ctx, client, lABI, addr, "targetReserves")
if err != nil {
return nil, nil, fmt.Errorf("targetReserves: %w", err)
}
targReserves, ok2 := res2[0].(*big.Int)
if !ok2 {
return nil, nil, fmt.Errorf("targetReserves type %T", res2[0])
}
return reserves, targReserves, nil
}
// ── ABI call helpers ──────────────────────────────────────────────────────────
func callViewAddress(ctx context.Context, client *ethclient.Client, contractABI abi.ABI,
addr ethcommon.Address, method string, args ...interface{}) (ethcommon.Address, error) {
res, err := callViewRaw(ctx, client, contractABI, addr, method, args...)
if err != nil {
return ethcommon.Address{}, err
}
v, ok := res[0].(ethcommon.Address)
if !ok {
return ethcommon.Address{}, fmt.Errorf("%s type %T", method, res[0])
}
return v, nil
}
func callERC20BigInt(ctx context.Context, client *ethclient.Client, eABI abi.ABI,
addr ethcommon.Address, method string, args ...interface{}) (*big.Int, error) {
data, err := eABI.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 := eABI.Unpack(method, result)
if err != nil {
return nil, fmt.Errorf("unpack %s: %w", method, err)
}
v, ok := decoded[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("%s type %T", method, decoded[0])
}
return v, nil
}
func getAssetSymbol(ctx context.Context, client *ethclient.Client, eABI abi.ABI, asset ethcommon.Address) string {
data, _ := eABI.Pack("symbol")
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &asset, Data: data}, nil)
if err != nil {
return asset.Hex()[:10]
}
decoded, err := eABI.Unpack("symbol", result)
if err != nil || len(decoded) == 0 {
return asset.Hex()[:10]
}
s, ok := decoded[0].(string)
if !ok {
return asset.Hex()[:10]
}
return s
}
// ── DB helpers ────────────────────────────────────────────────────────────────
func loadBuyerBlock(chainID int) uint64 {
db := dbpkg.GetDB()
var state models.ScannerState
if err := db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").First(&state).Error; err != nil {
return 0
}
return state.LastScannedBlock
}
func saveBuyerBlock(chainID int, block uint64) {
db := dbpkg.GetDB()
state := models.ScannerState{
ScannerType: "collateral_buyer",
ChainID: chainID,
LastScannedBlock: block,
}
db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").Assign(state).FirstOrCreate(&state)
db.Model(&state).Updates(map[string]interface{}{
"last_scanned_block": block,
"updated_at": time.Now(),
})
}
func saveBuyRecord(chainID int, txHash string, buyerAddr, assetAddr ethcommon.Address,
assetSymbol string, paidAmount, receivedAmount *big.Int, gasUsed, blockNum uint64, status, errMsg string) {
record := models.CollateralBuyRecord{
ChainID: chainID,
TxHash: txHash,
BuyerAddr: buyerAddr.Hex(),
AssetAddr: assetAddr.Hex(),
AssetSymbol: assetSymbol,
PaidAmount: paidAmount.String(),
ReceivedAmount: receivedAmount.String(),
GasUsed: gasUsed,
BlockNumber: blockNum,
Status: status,
ErrorMessage: errMsg,
CreatedAt: time.Now(),
}
db := dbpkg.GetDB()
if dbErr := db.Create(&record).Error; dbErr != nil {
log.Printf("[BuyerBot] Save buy record error: %v", dbErr)
}
}