init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
134
webapp-back/alp/routers.go
Normal file
134
webapp-back/alp/routers.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package alp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// GetALPHistory returns historical APR and price data from alp_snapshots.
|
||||
// Each point is computed from consecutive snapshot pairs.
|
||||
// Query param: days=30 (default 30, max 90)
|
||||
func GetALPHistory(c *gin.Context) {
|
||||
database := db.GetDB()
|
||||
|
||||
days := 30
|
||||
if d := c.Query("days"); d != "" {
|
||||
if v := parseInt(d, 30); v > 0 && v <= 90 {
|
||||
days = v
|
||||
}
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
|
||||
var snaps []models.ALPSnapshot
|
||||
if err := database.
|
||||
Where("snapshot_time >= ?", since).
|
||||
Order("snapshot_time ASC").
|
||||
Find(&snaps).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Time string `json:"time"`
|
||||
TS int64 `json:"ts"`
|
||||
PoolAPR float64 `json:"poolAPR"`
|
||||
ALPPrice float64 `json:"alpPrice"`
|
||||
FeeSurplus float64 `json:"feeSurplus"`
|
||||
PoolValue float64 `json:"poolValue"`
|
||||
}
|
||||
|
||||
// Deduplicate: keep the latest snapshot per calendar day (UTC)
|
||||
dayMap := make(map[string]models.ALPSnapshot)
|
||||
dayOrder := make([]string, 0)
|
||||
for _, snap := range snaps {
|
||||
key := snap.SnapshotTime.UTC().Format("2006-01-02")
|
||||
if _, exists := dayMap[key]; !exists {
|
||||
dayOrder = append(dayOrder, key)
|
||||
}
|
||||
dayMap[key] = snap // overwrite keeps the latest of the day
|
||||
}
|
||||
daily := make([]models.ALPSnapshot, 0, len(dayOrder))
|
||||
for _, key := range dayOrder {
|
||||
daily = append(daily, dayMap[key])
|
||||
}
|
||||
|
||||
points := make([]Point, 0, len(daily))
|
||||
for i, snap := range daily {
|
||||
apr := 0.0
|
||||
if i > 0 {
|
||||
prev := daily[i-1]
|
||||
d := snap.SnapshotTime.Sub(prev.SnapshotTime).Hours() / 24
|
||||
if d > 0 && snap.PoolValue > 0 && snap.FeeSurplus > prev.FeeSurplus {
|
||||
apr = (snap.FeeSurplus-prev.FeeSurplus) / snap.PoolValue / d * 365 * 100
|
||||
}
|
||||
}
|
||||
points = append(points, Point{
|
||||
Time: snap.SnapshotTime.Format("01/02"),
|
||||
TS: snap.SnapshotTime.Unix(),
|
||||
PoolAPR: apr,
|
||||
ALPPrice: snap.ALPPrice,
|
||||
FeeSurplus: snap.FeeSurplus,
|
||||
PoolValue: snap.PoolValue,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": points})
|
||||
}
|
||||
|
||||
func parseInt(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return def
|
||||
}
|
||||
v = v*10 + int(c-'0')
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetALPStats returns current ALP pool APR calculated from snapshots.
|
||||
// TVL and ALP price are read directly from chain on the frontend;
|
||||
// this endpoint only provides the APR which requires historical data.
|
||||
func GetALPStats(c *gin.Context) {
|
||||
database := db.GetDB()
|
||||
|
||||
// Latest snapshot
|
||||
var latest models.ALPSnapshot
|
||||
if err := database.Order("snapshot_time DESC").First(&latest).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"poolAPR": 0.0, "rewardAPR": 0.0},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the oldest available snapshot as reference (maximises data coverage)
|
||||
var past models.ALPSnapshot
|
||||
database.Order("snapshot_time ASC").First(&past)
|
||||
found := past.ID != 0 && past.ID != latest.ID
|
||||
|
||||
poolAPR := 0.0
|
||||
if found && latest.PoolValue > 0 && past.FeeSurplus < latest.FeeSurplus {
|
||||
days := latest.SnapshotTime.Sub(past.SnapshotTime).Hours() / 24
|
||||
if days > 0 {
|
||||
surplusDelta := latest.FeeSurplus - past.FeeSurplus
|
||||
poolAPR = surplusDelta / latest.PoolValue / days * 365 * 100 // annualized %
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"poolAPR": poolAPR,
|
||||
"rewardAPR": 0.0, // placeholder until reward contract is connected
|
||||
},
|
||||
})
|
||||
}
|
||||
171
webapp-back/alp/snapshot.go
Normal file
171
webapp-back/alp/snapshot.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package alp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ytPoolManagerAddress = "0xb11824eAA659F8A4648711709dA60720d5Cdabd2"
|
||||
usdyAddress = "0x29774970556407fAE16BC07e87704fE0E9559BC4"
|
||||
alpSnapshotInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
const poolManagerABIJSON = `[
|
||||
{
|
||||
"inputs":[{"internalType":"bool","name":"_maximise","type":"bool"}],
|
||||
"name":"getAumInUsdy",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
},
|
||||
{
|
||||
"inputs":[{"internalType":"bool","name":"_maximise","type":"bool"}],
|
||||
"name":"getPrice",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
}
|
||||
]`
|
||||
|
||||
const erc20TotalSupplyABIJSON = `[{
|
||||
"inputs":[],
|
||||
"name":"totalSupply",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
}]`
|
||||
|
||||
// StartALPSnapshot starts the ALP pool snapshot service (interval: 1h).
|
||||
func StartALPSnapshot(cfg *config.Config) {
|
||||
log.Println("=== ALP Snapshot Service Started (interval: 1h) ===")
|
||||
runALPSnapshot(cfg)
|
||||
ticker := time.NewTicker(alpSnapshotInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runALPSnapshot(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func runALPSnapshot(cfg *config.Config) {
|
||||
start := time.Now()
|
||||
log.Printf("[ALPSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
|
||||
|
||||
client, err := ethclient.Dial(cfg.BSCTestnetRPC)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] RPC connect error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
poolValue, alpPrice, err := fetchPoolStats(client)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] fetchPoolStats error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
usdySupply, err := fetchUSDYSupply(client)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] fetchUSDYSupply error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
feeSurplus := poolValue - usdySupply
|
||||
|
||||
snap := models.ALPSnapshot{
|
||||
PoolValue: poolValue,
|
||||
UsdySupply: usdySupply,
|
||||
FeeSurplus: feeSurplus,
|
||||
ALPPrice: alpPrice,
|
||||
SnapshotTime: time.Now().UTC(),
|
||||
}
|
||||
if err := db.GetDB().Create(&snap).Error; err != nil {
|
||||
log.Printf("[ALPSnapshot] DB save error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ALPSnapshot] poolValue=%.6f USDY, usdySupply=%.6f, feeSurplus=%.6f, alpPrice=%.8f | done in %v",
|
||||
poolValue, usdySupply, feeSurplus, alpPrice, time.Since(start))
|
||||
}
|
||||
|
||||
func fetchPoolStats(client *ethclient.Client) (poolValue float64, alpPrice float64, err error) {
|
||||
pmABI, err := abi.JSON(strings.NewReader(poolManagerABIJSON))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse poolManager ABI: %w", err)
|
||||
}
|
||||
|
||||
pmAddr := common.HexToAddress(ytPoolManagerAddress)
|
||||
ctx := context.Background()
|
||||
|
||||
// getAumInUsdy(true)
|
||||
callData, err := pmABI.Pack("getAumInUsdy", true)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("pack getAumInUsdy: %w", err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &pmAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("call getAumInUsdy: %w", err)
|
||||
}
|
||||
decoded, err := pmABI.Unpack("getAumInUsdy", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, 0, fmt.Errorf("unpack getAumInUsdy: %w", err)
|
||||
}
|
||||
poolValue = bigToFloat(decoded[0].(*big.Int), 18)
|
||||
|
||||
// getPrice(false)
|
||||
callData, err = pmABI.Pack("getPrice", false)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("pack getPrice: %w", err)
|
||||
}
|
||||
result, err = client.CallContract(ctx, ethereum.CallMsg{To: &pmAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("call getPrice: %w", err)
|
||||
}
|
||||
decoded, err = pmABI.Unpack("getPrice", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, 0, fmt.Errorf("unpack getPrice: %w", err)
|
||||
}
|
||||
alpPrice = bigToFloat(decoded[0].(*big.Int), 18)
|
||||
|
||||
return poolValue, alpPrice, nil
|
||||
}
|
||||
|
||||
func fetchUSDYSupply(client *ethclient.Client) (float64, error) {
|
||||
supplyABI, err := abi.JSON(strings.NewReader(erc20TotalSupplyABIJSON))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse totalSupply ABI: %w", err)
|
||||
}
|
||||
|
||||
usdyAddr := common.HexToAddress(usdyAddress)
|
||||
callData, err := supplyABI.Pack("totalSupply")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("pack totalSupply: %w", err)
|
||||
}
|
||||
result, err := client.CallContract(context.Background(), ethereum.CallMsg{To: &usdyAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("call totalSupply: %w", err)
|
||||
}
|
||||
decoded, err := supplyABI.Unpack("totalSupply", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, fmt.Errorf("unpack totalSupply: %w", err)
|
||||
}
|
||||
return bigToFloat(decoded[0].(*big.Int), 18), nil
|
||||
}
|
||||
|
||||
func bigToFloat(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
|
||||
}
|
||||
Reference in New Issue
Block a user