包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
246 lines
7.6 KiB
Go
246 lines
7.6 KiB
Go
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)
|
||
}
|