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