init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
174
webapp-back/lending/INTEGRATION_NOTE.md
Normal file
174
webapp-back/lending/INTEGRATION_NOTE.md
Normal 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 包的实现独立但兼容。
|
||||
285
webapp-back/lending/README.md
Normal file
285
webapp-back/lending/README.md
Normal 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
|
||||
631
webapp-back/lending/collateral_buyer_bot.go
Normal file
631
webapp-back/lending/collateral_buyer_bot.go
Normal 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=%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)
|
||||
}
|
||||
}
|
||||
342
webapp-back/lending/handlers.go
Normal file
342
webapp-back/lending/handlers.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
245
webapp-back/lending/helpers.go
Normal file
245
webapp-back/lending/helpers.go
Normal 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)
|
||||
}
|
||||
692
webapp-back/lending/liquidation_bot.go
Normal file
692
webapp-back/lending/liquidation_bot.go
Normal 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 返回 bool,ABI 编码为 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)
|
||||
}
|
||||
}
|
||||
113
webapp-back/lending/models.go
Normal file
113
webapp-back/lending/models.go
Normal 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"`
|
||||
}
|
||||
14
webapp-back/lending/routers.go
Normal file
14
webapp-back/lending/routers.go
Normal 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
|
||||
208
webapp-back/lending/snapshot.go
Normal file
208
webapp-back/lending/snapshot.go
Normal 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
|
||||
}
|
||||
80
webapp-back/lending/tokens.go
Normal file
80
webapp-back/lending/tokens.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user