Files
assetx/webapp-back/lending/snapshot.go

209 lines
6.5 KiB
Go
Raw Normal View History

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
}