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