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,174 @@
# Integration with Existing Code
## 发现holders 包已有类似实现
`holders/db_config.go` 中已经实现了从数据库加载合约地址的完整逻辑:
### holders/db_config.go 的实现
```go
// LoadConfigFromDB 从数据库加载配置(已存在)
func LoadConfigFromDB(chainID int64) (Config, error) {
// 1. 从 assets 表加载 YT-A, YT-B, YT-C
var assets []Asset
db.Table("assets").
Where("asset_code IN (?, ?, ?)", "YT-A", "YT-B", "YT-C").
Find(&assets)
// 2. 根据 chainID 选择合约地址
for _, asset := range assets {
address := asset.ContractAddressArb
if isBSC {
address = asset.ContractAddressBsc
}
ytVaults = append(ytVaults, VaultConfig{
Name: asset.AssetCode,
Address: address,
})
}
// 3. 从 lending_markets 表加载 Lending 合约
var lendingMarkets []LendingMarket
db.Table("lending_markets").
Where("is_active = ?", 1).
Find(&lendingMarkets)
lendingAddress := market.ContractAddressArb
if isBSC {
lendingAddress = market.ContractAddressBsc
}
return Config{
YTVaults: ytVaults,
LendingAddress: lendingAddress,
}
}
```
### lending/helpers.go 的实现(新建)
```go
// GetYTTokenInfo 从 assets 表获取 YT token 信息(新实现)
func GetYTTokenInfo(assetCode string) (*TokenInfo, error) {
var asset models.Asset
db.Where("asset_code = ? AND is_active = ?", assetCode, true).
First(&asset)
return &TokenInfo{
Symbol: asset.Name,
ContractAddressArb: asset.ContractAddressArb,
ContractAddressBsc: asset.ContractAddressBsc,
}
}
```
## 代码复用建议
### 选项 1统一使用 holders 包的结构体(推荐)
```go
// lending/helpers.go
import "github.com/gothinkster/golang-gin-realworld-example-app/holders"
func GetYTTokensFromHolders(chainID int64) ([]TokenInfo, error) {
// 复用 holders.LoadConfigFromDB
config, err := holders.LoadConfigFromDB(chainID)
if err != nil {
return nil, err
}
tokens := make([]TokenInfo, len(config.YTVaults))
for i, vault := range config.YTVaults {
tokens[i] = TokenInfo{
Symbol: vault.Name,
ContractAddressArb: vault.Address, // 已根据 chainID 选择
AssetCode: vault.Name,
}
}
return tokens, nil
}
```
### 选项 2共享数据模型
```go
// common/models.go - 创建共享的 Asset 模型
type Asset struct {
ID int64
AssetCode string
Name string
ContractAddressArb string
ContractAddressBsc string
}
// holders 和 lending 都使用这个共享模型
```
### 选项 3保持当前实现已完成
- ✅ lending 包独立实现
- ✅ 功能完整,逻辑清晰
- ⚠️ 与 holders 包有重复代码
## 数据库表结构(一致)
两个包都使用相同的表:
### assets 表
```sql
CREATE TABLE assets (
id BIGINT PRIMARY KEY,
asset_code VARCHAR(20), -- YT-A, YT-B, YT-C
name VARCHAR(255),
contract_address_arb VARCHAR(42),
contract_address_bsc VARCHAR(42),
is_active BOOLEAN
)
```
### lending_markets 表
```sql
CREATE TABLE lending_markets (
id BIGINT PRIMARY KEY,
market_name VARCHAR(100),
contract_address_arb VARCHAR(42),
contract_address_bsc VARCHAR(42),
is_active BOOLEAN
)
```
## Chain ID 处理
### holders 包
- 421614 = Arbitrum Sepolia
- 97 = BSC Testnet
### lending 包(需要添加)
```go
// helpers.go 添加 chain ID 支持
func GetContractAddress(tokenInfo TokenInfo, chainID int) string {
switch chainID {
case 421614: // Arbitrum Sepolia
return tokenInfo.ContractAddressArb
case 97: // BSC Testnet
return tokenInfo.ContractAddressBsc
case 42161: // Arbitrum One (mainnet)
return tokenInfo.ContractAddressArb
case 56: // BSC Mainnet
return tokenInfo.ContractAddressBsc
default:
return tokenInfo.ContractAddressArb
}
}
```
## 总结
### 已实现功能
- ✅ lending 包可以独立从 assets 表读取 YT tokens
- ✅ lending 包可以从 lending_markets 表读取市场配置
- ✅ USDC 地址已硬编码
- ✅ 与 holders 包的实现逻辑一致
### 建议优化(可选)
1. 考虑复用 holders.Asset 结构体
2. 添加对 holders.LoadConfigFromDB 的引用
3. 统一 chain ID 处理逻辑
### 当前状态
**可以直接使用**,无需修改。与 holders 包的实现独立但兼容。

View File

@@ -0,0 +1,285 @@
# Lending API Implementation
## Overview
This directory contains the backend implementation for the AssetX Lending Market system.
## API Endpoints
### Query Endpoints (Public)
#### Get User Position
```
GET /api/lending/position/:address
```
Returns user's lending position including:
- Supplied USDC balance
- Borrowed USDC balance
- Collateral balances (YT-A, YT-B, YT-C)
- Health Factor
- LTV (Loan-to-Value) ratio
- Supply/Borrow APY
**Response:**
```json
{
"success": true,
"data": {
"user_address": "0x...",
"wallet_address": "0x...",
"supplied_balance": "10000.00",
"supplied_balance_usd": 10000.00,
"borrowed_balance": "1000.00",
"borrowed_balance_usd": 1000.00,
"collateral_balances": {
"YT-A": {
"token_symbol": "YT-A",
"balance": "1000.00",
"balance_usd": 2000000.00,
"collateral_value": 1400000.00
}
},
"health_factor": 14.0,
"ltv": 10.0,
"supply_apy": 6.1,
"borrow_apy": 9.1
}
}
```
#### Get Lending Stats
```
GET /api/lending/stats
```
Returns lending market statistics:
- Total supplied/borrowed USD
- Total collateral USD
- Utilization rate
- Average APYs
- User counts
- Total TVL
**Response:**
```json
{
"success": true,
"data": {
"total_supplied_usd": 50000000.00,
"total_borrowed_usd": 30000000.00,
"total_collateral_usd": 80000000.00,
"utilization_rate": 60.0,
"avg_supply_apy": 6.1,
"avg_borrow_apy": 9.1,
"total_users": 1250,
"active_borrowers": 450,
"total_tvl": 130000000.00
}
}
```
#### Get Lending Markets
```
GET /api/lending/markets
```
Returns lending market configuration from `lending_markets` table.
#### Get All Tokens Info
```
GET /api/lending/tokens
```
Returns information about all supported tokens (USDC + YT tokens from `assets` table).
**Response:**
```json
{
"success": true,
"data": {
"stablecoins": [
{
"symbol": "USDC",
"name": "USD Coin",
"decimals": 6,
"contract_address_arb": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"contract_address_bsc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"
}
],
"yt_tokens": [
{
"symbol": "YT-A",
"name": "YT-A",
"decimals": 18,
"contract_address_arb": "0x...",
"contract_address_bsc": "0x...",
"asset_code": "YT-A"
}
]
}
}
```
#### Get Token Info
```
GET /api/lending/tokens/:assetCode
```
Returns information about a specific token (USDC or YT-A/YT-B/YT-C).
### Transaction Endpoints
#### Supply USDC
```
POST /api/lending/supply
Content-Type: application/json
{
"amount": "1000.00",
"tx_hash": "0x..."
}
```
#### Withdraw USDC
```
POST /api/lending/withdraw
Content-Type: application/json
{
"amount": "500.00",
"tx_hash": "0x..."
}
```
#### Supply Collateral
```
POST /api/lending/supply-collateral
Content-Type: application/json
{
"asset": "YT-A",
"amount": "100.00",
"tx_hash": "0x..."
}
```
#### Withdraw Collateral
```
POST /api/lending/withdraw-collateral
Content-Type: application/json
{
"asset": "YT-A",
"amount": "50.00",
"tx_hash": "0x..."
}
```
#### Borrow USDC
```
POST /api/lending/borrow
Content-Type: application/json
{
"amount": "1000.00",
"tx_hash": "0x..."
}
```
#### Repay USDC
```
POST /api/lending/repay
Content-Type: application/json
{
"amount": "500.00",
"tx_hash": "0x..."
}
```
## Files
- `models.go` - Data models and request/response structures
- `handlers.go` - HTTP request handlers
- `helpers.go` - Helper functions (token info, calculations, validation)
- `tokens.go` - Token information endpoints
- `routers.go` - Route documentation (routes registered in main.go)
- `README.md` - This file
## Current Implementation Status
### ✅ Completed
- API endpoint structure
- Request/response models
- Basic validation
- Mock data responses
- Database integration for token information
- USDC configuration (hardcoded as per frontend)
- YT token information from `assets` table
- Token validation against database
- Health factor and LTV calculation formulas
- Token info query endpoints
### ⏳ TODO
1. **Blockchain Integration**
- Connect to smart contracts
- Verify transactions on-chain
- Query real-time balances
- Calculate health factor from chain data
2. **Database Integration**
- Store transaction records
- Update lending_markets table
- Track user positions
- Generate statistics
3. **Authentication**
- Add wallet signature verification
- Implement user session management
4. **Business Logic**
- Interest accrual calculations
- Health factor monitoring
- Liquidation logic
- APY calculations
## Database Schema
The lending system uses the following tables from `database-schema-v1.1-final.sql`:
- `lending_markets` - Lending market configuration
- `transactions` - Transaction records
- `protocol_stats` - Protocol-level statistics
Note: User position data is primarily read from blockchain, with caching in database.
## Integration with Frontend
Frontend components that use these APIs:
- `/lending/supply/SupplyPanel.tsx` - Supply USDC
- `/lending/supply/WithdrawPanel.tsx` - Withdraw USDC
- `/lending/repay/RepayBorrowDebt.tsx` - Borrow/Repay USDC
- `/lending/repay/RepaySupplyCollateral.tsx` - Supply/Withdraw Collateral
- `/lending/BorrowMarket.tsx` - Display lending positions
## Testing
```bash
# Start the server
go run main.go
# Test endpoints
curl http://localhost:8080/api/lending/stats
curl http://localhost:8080/api/lending/position/0x1234...
# Test transactions (requires auth)
curl -X POST http://localhost:8080/api/lending/supply \
-H "Content-Type: application/json" \
-d '{"amount": "1000", "tx_hash": "0x..."}'
```
## Next Steps
1. Implement blockchain RPC integration
2. Add real transaction verification
3. Implement health factor calculations
4. Add liquidation monitoring
5. Integrate with frontend
6. Add comprehensive tests
7. Deploy to testnet

View File

@@ -0,0 +1,631 @@
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)
}
}

View File

@@ -0,0 +1,342 @@
package lending
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// GetUserPosition returns user's lending position
// GET /api/lending/position/:address
func GetUserPosition(c *gin.Context) {
address := c.Param("address")
if address == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "wallet address is required",
})
return
}
config := GetLendingMarketConfig()
position := &UserPosition{
UserAddress: address,
WalletAddress: address,
SuppliedBalance: "0",
SuppliedBalanceUSD: 0,
BorrowedBalance: "0",
BorrowedBalanceUSD: 0,
CollateralBalances: map[string]CollateralInfo{},
HealthFactor: 0,
LTV: 0,
SupplyAPY: config["base_supply_apy"].(float64),
BorrowAPY: config["base_borrow_apy"].(float64),
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": position,
})
}
// GetLendingStats returns lending market statistics
// GET /api/lending/stats
func GetLendingStats(c *gin.Context) {
config := GetLendingMarketConfig()
stats := &LendingStats{
TotalSuppliedUSD: 0,
TotalBorrowedUSD: 0,
TotalCollateralUSD: 0,
UtilizationRate: 0,
AvgSupplyAPY: config["base_supply_apy"].(float64),
AvgBorrowAPY: config["base_borrow_apy"].(float64),
TotalUsers: 0,
ActiveBorrowers: 0,
TotalTVL: 0,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetLendingMarkets returns lending market configuration.
// Contract addresses come from system_contracts; static config from GetLendingMarketConfig.
// GET /api/lending/markets
func GetLendingMarkets(c *gin.Context) {
db := common.GetDB()
var lc struct {
Address string `gorm:"column:address"`
ChainID int `gorm:"column:chain_id"`
}
db.Table("system_contracts").
Where("name = ? AND is_active = ?", "lendingProxy", true).
Select("address, chain_id").
First(&lc)
config := GetLendingMarketConfig()
config["contract_address"] = lc.Address
config["chain_id"] = lc.ChainID
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []interface{}{config},
})
}
// SupplyUSDC handles USDC supply (deposit) transaction
// POST /api/lending/supply
func SupplyUSDC(c *gin.Context) {
var req SupplyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user position
// TODO: Record transaction in database
c.JSON(http.StatusOK, SupplyResponse{
Success: true,
Message: "USDC supplied successfully",
})
}
// WithdrawUSDC handles USDC withdrawal transaction
// POST /api/lending/withdraw
func WithdrawUSDC(c *gin.Context) {
var req WithdrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user position
// TODO: Record transaction in database
c.JSON(http.StatusOK, WithdrawResponse{
Success: true,
Message: "USDC withdrawn successfully",
})
}
// SupplyCollateral handles collateral supply transaction
// POST /api/lending/supply-collateral
func SupplyCollateral(c *gin.Context) {
var req SupplyCollateralRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Validate asset type - check against assets table
if !ValidateYTToken(req.Asset) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
})
return
}
// Get token info from database
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch token information",
})
return
}
_ = tokenInfo // Will be used for blockchain validation
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
// TODO: Update user collateral position
// TODO: Record transaction in database
c.JSON(http.StatusOK, SupplyCollateralResponse{
Success: true,
Message: "Collateral supplied successfully",
})
}
// WithdrawCollateral handles collateral withdrawal transaction
// POST /api/lending/withdraw-collateral
func WithdrawCollateral(c *gin.Context) {
var req WithdrawCollateralRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Validate asset type - check against assets table
if !ValidateYTToken(req.Asset) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
})
return
}
// Get token info from database
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch token information",
})
return
}
_ = tokenInfo // Will be used for blockchain validation
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
// TODO: Check if withdrawal is safe (health factor)
// TODO: Update user collateral position
// TODO: Record transaction in database
c.JSON(http.StatusOK, WithdrawCollateralResponse{
Success: true,
Message: "Collateral withdrawn successfully",
})
}
// BorrowUSDC handles USDC borrow transaction
// POST /api/lending/borrow
func BorrowUSDC(c *gin.Context) {
var req BorrowRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Check collateral sufficiency
// TODO: Update user borrow position
// TODO: Record transaction in database
c.JSON(http.StatusOK, BorrowResponse{
Success: true,
Message: "USDC borrowed successfully",
})
}
// RepayUSDC handles USDC repayment transaction
// POST /api/lending/repay
func RepayUSDC(c *gin.Context) {
var req RepayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user borrow position
// TODO: Record transaction in database
c.JSON(http.StatusOK, RepayResponse{
Success: true,
Message: "USDC repaid successfully",
})
}
// GetLendingAPYHistory returns historical supply/borrow APY snapshots
// GET /api/lending/apy-history?period=1W&chain_id=97
func GetLendingAPYHistory(c *gin.Context) {
period := c.DefaultQuery("period", "1W")
chainId := parseChainID(c)
database := common.GetDB()
var usdcAsset models.Asset
if err := database.Where("asset_code = ?", "USDC").First(&usdcAsset).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "USDC asset not configured"})
return
}
now := time.Now()
var since time.Time
switch period {
case "1M":
since = now.AddDate(0, -1, 0)
case "1Y":
since = now.AddDate(-1, 0, 0)
default: // 1W
since = now.AddDate(0, 0, -7)
}
query := database.Where("asset_id = ? AND snapshot_time >= ?", usdcAsset.ID, since).
Order("snapshot_time ASC")
if chainId != 0 {
query = query.Where("chain_id = ?", chainId)
}
var snapshots []models.APYSnapshot
if err := query.Find(&snapshots).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch APY history"})
return
}
type DataPoint struct {
Time string `json:"time"`
SupplyAPY float64 `json:"supply_apy"`
BorrowAPY float64 `json:"borrow_apy"`
}
points := make([]DataPoint, 0, len(snapshots))
for _, s := range snapshots {
points = append(points, DataPoint{
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
SupplyAPY: s.SupplyAPY,
BorrowAPY: s.BorrowAPY,
})
}
var currentSupplyAPY, currentBorrowAPY float64
if len(snapshots) > 0 {
last := snapshots[len(snapshots)-1]
currentSupplyAPY = last.SupplyAPY
currentBorrowAPY = last.BorrowAPY
}
// APY change vs first point in period
var apyChange float64
if len(snapshots) > 1 {
apyChange = snapshots[len(snapshots)-1].SupplyAPY - snapshots[0].SupplyAPY
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"history": points,
"current_supply_apy": currentSupplyAPY,
"current_borrow_apy": currentBorrowAPY,
"apy_change": apyChange,
"period": period,
},
})
}

View File

@@ -0,0 +1,245 @@
package lending
import (
"context"
"encoding/hex"
"fmt"
"log"
"math/big"
"time"
"github.com/ethereum/go-ethereum"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// TokenInfo represents token contract information
type TokenInfo struct {
Symbol string
Name string
Decimals int
ContractAddress string
ChainID int
AssetCode string
}
// getRPCURL returns the RPC URL for a given chain ID from config
func getRPCURL(chainId int) string {
cfg := appcfg.AppConfig
switch chainId {
case 97: // BSC Testnet
if cfg != nil && cfg.BSCTestnetRPC != "" {
return cfg.BSCTestnetRPC
}
case 421614: // Arbitrum Sepolia
if cfg != nil && cfg.ArbSepoliaRPC != "" {
return cfg.ArbSepoliaRPC
}
}
return ""
}
// getContractAddressByChain returns the contract address from TokenInfo (chain-agnostic now)
func getContractAddressByChain(info TokenInfo, chainId int) string {
return info.ContractAddress
}
// fetchDecimalsOnChain calls decimals() on an ERC20 contract via RPC
// Returns error if chain call fails so caller can fall back to DB
func fetchDecimalsOnChain(contractAddress, rpcURL string) (int, error) {
if rpcURL == "" || contractAddress == "" {
return 0, fmt.Errorf("missing rpcURL or contractAddress")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return 0, fmt.Errorf("dial rpc: %w", err)
}
defer client.Close()
// decimals() selector = keccak256("decimals()")[0:4] = 313ce567
data, _ := hex.DecodeString("313ce567")
addr := ethcommon.HexToAddress(contractAddress)
result, err := client.CallContract(ctx, ethereum.CallMsg{
To: &addr,
Data: data,
}, nil)
if err != nil {
return 0, fmt.Errorf("call contract: %w", err)
}
if len(result) < 1 {
return 0, fmt.Errorf("empty result")
}
decimals := new(big.Int).SetBytes(result[len(result)-1:])
return int(decimals.Int64()), nil
}
// resolveDecimals tries chain first (if chainId != 0), falls back to dbDecimals
func resolveDecimals(contractAddress string, chainId, dbDecimals int) int {
if chainId == 0 {
return dbDecimals
}
rpcURL := getRPCURL(chainId)
if rpcURL == "" {
return dbDecimals
}
decimals, err := fetchDecimalsOnChain(contractAddress, rpcURL)
if err != nil {
log.Printf("⚠️ [Lending] 链上获取精度失败 %s (chain %d): %v使用数据库配置 %d", contractAddress, chainId, err, dbDecimals)
return dbDecimals
}
return decimals
}
// GetTokenInfoFromDB queries any token from assets table by asset_code
func GetTokenInfoFromDB(assetCode string) (*TokenInfo, error) {
db := common.GetDB()
var asset models.Asset
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
return nil, err
}
return &TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}, nil
}
// GetUSDCInfo returns USDC token information.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
// chainId == 0: DB only.
func GetUSDCInfo(chainId int) TokenInfo {
info, err := GetTokenInfoFromDB("USDC")
if err != nil {
log.Printf("⚠️ [Lending] USDC 不在 assets 表: %v使用默认值", err)
return TokenInfo{AssetCode: "USDC", Symbol: "USDC", Name: "USD Coin", Decimals: 18}
}
contractAddr := getContractAddressByChain(*info, chainId)
info.Decimals = resolveDecimals(contractAddr, chainId, info.Decimals)
return *info
}
// GetYTTokenInfo returns YT token information.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
func GetYTTokenInfo(assetCode string, chainId int) (*TokenInfo, error) {
db := common.GetDB()
var asset models.Asset
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
return nil, err
}
info := &TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
return info, nil
}
// GetAllStablecoins returns all active stablecoin tokens (token_role = 'stablecoin').
func GetAllStablecoins(chainId int) ([]TokenInfo, error) {
db := common.GetDB()
var assets []models.Asset
if err := db.Where("token_role = ? AND is_active = ?", "stablecoin", true).Find(&assets).Error; err != nil {
return nil, err
}
tokens := make([]TokenInfo, 0, len(assets))
for _, asset := range assets {
info := TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
tokens = append(tokens, info)
}
return tokens, nil
}
// GetAllYTTokens returns all active YT tokens.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
func GetAllYTTokens(chainId int) ([]TokenInfo, error) {
db := common.GetDB()
var assets []models.Asset
if err := db.Where("token_role = ? AND is_active = ?", "yt_token", true).Find(&assets).Error; err != nil {
return nil, err
}
tokens := make([]TokenInfo, 0, len(assets))
for _, asset := range assets {
info := TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
tokens = append(tokens, info)
}
return tokens, nil
}
// ValidateYTToken checks if the given asset code is a valid YT token
func ValidateYTToken(assetCode string) bool {
db := common.GetDB()
var count int64
db.Model(&models.Asset{}).
Where("asset_code = ? AND token_role = ? AND is_active = ?", assetCode, "yt_token", true).
Count(&count)
return count > 0
}
// GetLendingMarketConfig returns lending market configuration
func GetLendingMarketConfig() map[string]interface{} {
return map[string]interface{}{
"market_name": "AssetX Lending Market",
"base_supply_apy": 6.1,
"base_borrow_apy": 9.1,
"borrow_collateral_factor": 0.7,
"liquidate_collateral_factor": 0.75,
"liquidation_penalty": 0.1,
"kink_rate": 0.8,
}
}
// CalculateHealthFactor calculates health factor
func CalculateHealthFactor(collateralValueUSD, borrowedValueUSD float64) float64 {
if borrowedValueUSD == 0 {
return 999999.0
}
config := GetLendingMarketConfig()
liquidationThreshold := config["liquidate_collateral_factor"].(float64)
return (collateralValueUSD * liquidationThreshold) / borrowedValueUSD
}
// CalculateLTV calculates Loan-to-Value ratio
func CalculateLTV(collateralValueUSD, borrowedValueUSD float64) float64 {
if collateralValueUSD == 0 {
return 0
}
return (borrowedValueUSD / collateralValueUSD) * 100
}
// GetContractAddress returns contract address for the given chain
func GetContractAddress(token TokenInfo, chainID int) string {
return getContractAddressByChain(token, chainID)
}

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

View File

@@ -0,0 +1,113 @@
package lending
// UserPosition represents user's lending position (from chain)
type UserPosition struct {
UserAddress string `json:"user_address"`
WalletAddress string `json:"wallet_address"`
SuppliedBalance string `json:"supplied_balance"` // USDC supplied
SuppliedBalanceUSD float64 `json:"supplied_balance_usd"` // USD value
BorrowedBalance string `json:"borrowed_balance"` // USDC borrowed
BorrowedBalanceUSD float64 `json:"borrowed_balance_usd"` // USD value
CollateralBalances map[string]CollateralInfo `json:"collateral_balances"` // YT-A, YT-B, YT-C
HealthFactor float64 `json:"health_factor"`
LTV float64 `json:"ltv"` // Loan-to-Value ratio
SupplyAPY float64 `json:"supply_apy"`
BorrowAPY float64 `json:"borrow_apy"`
}
// CollateralInfo represents collateral asset information
type CollateralInfo struct {
TokenSymbol string `json:"token_symbol"` // YT-A, YT-B, YT-C
Balance string `json:"balance"` // Token balance
BalanceUSD float64 `json:"balance_usd"` // USD value
CollateralValue float64 `json:"collateral_value"` // Collateral value (with factor)
}
// LendingStats represents lending market statistics
type LendingStats struct {
TotalSuppliedUSD float64 `json:"total_supplied_usd"`
TotalBorrowedUSD float64 `json:"total_borrowed_usd"`
TotalCollateralUSD float64 `json:"total_collateral_usd"`
UtilizationRate float64 `json:"utilization_rate"`
AvgSupplyAPY float64 `json:"avg_supply_apy"`
AvgBorrowAPY float64 `json:"avg_borrow_apy"`
TotalUsers int `json:"total_users"`
ActiveBorrowers int `json:"active_borrowers"`
TotalTVL float64 `json:"total_tvl"`
}
// Supply request/response
type SupplyRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type SupplyResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Withdraw request/response
type WithdrawRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type WithdrawResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// SupplyCollateral request/response
type SupplyCollateralRequest struct {
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type SupplyCollateralResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// WithdrawCollateral request/response
type WithdrawCollateralRequest struct {
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type WithdrawCollateralResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Borrow request/response
type BorrowRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type BorrowResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Repay request/response
type RepayRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type RepayResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}

View File

@@ -0,0 +1,14 @@
package lending
// Routing is now handled in main.go
// All lending routes are registered directly in main.go:
//
// GET /api/lending/position/:address - Get user lending position
// GET /api/lending/stats - Get lending market statistics
// GET /api/lending/markets - Get lending markets configuration
// POST /api/lending/supply - Supply USDC
// POST /api/lending/withdraw - Withdraw USDC
// POST /api/lending/supply-collateral - Supply collateral (YT tokens)
// POST /api/lending/withdraw-collateral - Withdraw collateral
// POST /api/lending/borrow - Borrow USDC
// POST /api/lending/repay - Repay USDC

View File

@@ -0,0 +1,208 @@
package lending
import (
"context"
"fmt"
"log"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
"gorm.io/gorm"
)
const lendingSnapshotInterval = 15 * time.Minute
// Only the 4 view functions needed for APY snapshot
const lendingRateABIJSON = `[
{"inputs":[],"name":"getSupplyRate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"getBorrowRate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"getTotalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"getTotalBorrow","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}
]`
// StartLendingAPYSnapshot starts the hourly lending APY snapshot background service.
// Call as: go lending.StartLendingAPYSnapshot(cfg)
func StartLendingAPYSnapshot(cfg *appcfg.Config) {
log.Println("=== Lending APY Snapshot Service Started (interval: 15min) ===")
runLendingSnapshot(cfg)
ticker := time.NewTicker(lendingSnapshotInterval)
defer ticker.Stop()
for range ticker.C {
runLendingSnapshot(cfg)
}
}
func runLendingSnapshot(cfg *appcfg.Config) {
start := time.Now()
log.Printf("[LendingSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
database := db.GetDB()
// Load lendingProxy contracts from system_contracts
var lendingContracts []struct {
Name string `gorm:"column:name"`
ChainID int `gorm:"column:chain_id"`
Address string `gorm:"column:address"`
}
if err := database.Table("system_contracts").
Where("name = ? AND is_active = ?", "lendingProxy", true).
Select("name, chain_id, address").
Find(&lendingContracts).Error; err != nil {
log.Printf("[LendingSnapshot] Load lendingProxy error: %v", err)
return
}
if len(lendingContracts) == 0 {
log.Println("[LendingSnapshot] No lendingProxy in system_contracts, skipping")
return
}
var usdcAsset models.Asset
if err := database.Where("asset_code = ?", "USDC").First(&usdcAsset).Error; err != nil {
log.Printf("[LendingSnapshot] USDC asset not found in assets table: %v", err)
return
}
parsedABI, err := abi.JSON(strings.NewReader(lendingRateABIJSON))
if err != nil {
log.Printf("[LendingSnapshot] Parse ABI error: %v", err)
return
}
for _, lc := range lendingContracts {
if lc.Address == "" {
log.Printf("[LendingSnapshot] lendingProxy chain_id=%d has no address, skipping", lc.ChainID)
continue
}
rpcURL := getRPCURL(lc.ChainID)
if rpcURL == "" {
log.Printf("[LendingSnapshot] lendingProxy unsupported chain_id=%d, skipping", lc.ChainID)
continue
}
if err := snapshotLendingMarket(database, parsedABI, rpcURL, lc.Address, lc.ChainID, usdcAsset); err != nil {
log.Printf("[LendingSnapshot] lendingProxy chain_id=%d error: %v", lc.ChainID, err)
}
}
log.Printf("[LendingSnapshot] Done in %v", time.Since(start))
}
func snapshotLendingMarket(
database *gorm.DB,
parsedABI abi.ABI,
rpcURL, contractAddr string,
chainID int,
usdcAsset models.Asset,
) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return fmt.Errorf("dial rpc: %w", err)
}
defer client.Close()
addr := ethcommon.HexToAddress(contractAddr)
// Generic call helper: packs, calls, unpacks, returns *big.Int
call := func(name string) (*big.Int, error) {
data, err := parsedABI.Pack(name)
if err != nil {
return nil, fmt.Errorf("pack %s: %w", name, err)
}
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
if err != nil {
return nil, fmt.Errorf("call %s: %w", name, err)
}
decoded, err := parsedABI.Unpack(name, result)
if err != nil {
return nil, fmt.Errorf("unpack %s: %w", name, err)
}
if len(decoded) == 0 {
return nil, fmt.Errorf("%s: empty result", name)
}
switch v := decoded[0].(type) {
case uint64:
return new(big.Int).SetUint64(v), nil
case *big.Int:
return v, nil
default:
return nil, fmt.Errorf("%s: unexpected type %T", name, decoded[0])
}
}
supplyRateRaw, err := call("getSupplyRate")
if err != nil {
return err
}
borrowRateRaw, err := call("getBorrowRate")
if err != nil {
return err
}
totalSupplyRaw, err := call("getTotalSupply")
if err != nil {
return err
}
totalBorrowRaw, err := call("getTotalBorrow")
if err != nil {
return err
}
// APR% = rate / 1e18 * 100
// This contract's getSupplyRate/getBorrowRate already return annualized rate (APR × 1e18),
// NOT per-second rate. Do NOT multiply by secondsPerYear.
toAPRPct := func(raw *big.Int) float64 {
f, _ := new(big.Float).SetPrec(256).Quo(
new(big.Float).SetPrec(256).SetInt(raw),
new(big.Float).SetPrec(256).SetFloat64(1e18),
).Float64()
return f * 100
}
usdcDec := int64(usdcAsset.Decimals)
totalSupply := lendingBigIntToFloat(totalSupplyRaw, usdcDec)
totalBorrow := lendingBigIntToFloat(totalBorrowRaw, usdcDec)
supplyAPR := toAPRPct(supplyRateRaw)
borrowAPR := toAPRPct(borrowRateRaw)
log.Printf("[LendingSnapshot] supply=%.4f USDC, borrow=%.4f USDC, utilization=%.2f%%, supplyAPR=%.4f%%, borrowAPR=%.4f%%",
totalSupply, totalBorrow,
func() float64 {
if totalSupply == 0 {
return 0
}
return totalBorrow / totalSupply * 100
}(),
supplyAPR, borrowAPR,
)
snap := models.APYSnapshot{
AssetID: usdcAsset.ID,
ChainID: chainID,
ContractAddress: contractAddr,
SupplyAPY: supplyAPR,
BorrowAPY: borrowAPR,
TotalAssets: totalSupply, // total USDC deposited
TotalSupply: totalBorrow, // total USDC borrowed
SnapshotTime: time.Now(),
}
return database.Create(&snap).Error
}
// lendingBigIntToFloat converts *big.Int to float64 by dividing by 10^decimals
func lendingBigIntToFloat(n *big.Int, decimals int64) float64 {
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(decimals), nil)
f, _ := new(big.Float).SetPrec(256).Quo(
new(big.Float).SetPrec(256).SetInt(n),
new(big.Float).SetPrec(256).SetInt(divisor),
).Float64()
return f
}

View File

@@ -0,0 +1,80 @@
package lending
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetTokensInfo returns information about all supported tokens
// GET /api/lending/tokens?chain_id=97
func GetTokensInfo(c *gin.Context) {
chainId := parseChainID(c)
stablecoins, err := GetAllStablecoins(chainId)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch stablecoins",
})
return
}
ytTokens, err := GetAllYTTokens(chainId)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch YT tokens",
})
return
}
allTokens := make([]TokenInfo, 0, len(stablecoins)+len(ytTokens))
allTokens = append(allTokens, stablecoins...)
allTokens = append(allTokens, ytTokens...)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"stablecoins": stablecoins,
"yt_tokens": ytTokens,
"all_tokens": allTokens,
},
})
}
// GetTokenInfo returns information about a specific token
// GET /api/lending/tokens/:assetCode?chain_id=97
func GetTokenInfo(c *gin.Context) {
assetCode := c.Param("assetCode")
chainId := parseChainID(c)
info, err := GetTokenInfoFromDB(assetCode)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "token not found",
})
return
}
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": info,
})
}
// parseChainID reads chain_id from query string, returns 0 if not provided
func parseChainID(c *gin.Context) int {
s := c.Query("chain_id")
if s == "" {
return 0
}
id, err := strconv.Atoi(s)
if err != nil {
return 0
}
return id
}