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) } }