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 }