632 lines
22 KiB
Go
632 lines
22 KiB
Go
|
|
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=%s,receivedAmount 记录为 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)
|
|||
|
|
}
|
|||
|
|
}
|