Files
assetx/webapp-back/lending/snapshot.go
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

209 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}