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