init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
bin/
logs/
data/
*.log
.git
assetx-api
webapp-back
golang-gin-realworld-example-app
logo.png

9
webapp-back/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Server Configuration
PORT=8080
GIN_MODE=debug
# Database Configuration (SQLite)
DB_PATH=./data/gorm.db
# Test Database Path (optional, for running tests)
# TEST_DB_PATH=./data/gorm_test.db

41
webapp-back/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
/bower_components
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
#System Files
.DS_Store
Thumbs.db
vendor/*
!vendor/vendor.json
tmp/*
# Database files
*.db
data/*.db
gorm.db
gorm_test.db
# Test output
coverage.txt
coverage.out
profile.out
results.sarif
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
bak.*
tmp/

19
webapp-back/.golangci.yml Normal file
View File

@@ -0,0 +1,19 @@
linters:
disable-all: true
enable:
- staticcheck
- unused
- misspell
linters-settings:
misspell:
locale: US
issues:
exclude-use-default: true
max-issues-per-linter: 50
max-same-issues: 10
run:
timeout: 5m
go: '1.22'

114
webapp-back/AGENTS.md Normal file
View File

@@ -0,0 +1,114 @@
# Copilot Coding Agent Instructions
This document provides instructions for AI coding agents working on this repository.
## Project Overview
This is a **Golang/Gin** implementation of the [RealWorld](https://github.com/gothinkster/realworld) example application. It demonstrates a fully fledged fullstack application including CRUD operations, authentication, routing, pagination, and more.
## Technology Stack
- **Go**: 1.21+ required
- **Web Framework**: [Gin](https://github.com/gin-gonic/gin) v1.10+
- **ORM**: [GORM v2](https://gorm.io/) (gorm.io/gorm)
- **Database**: SQLite (for development/testing)
- **Authentication**: JWT using [golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt)
- **Validation**: [go-playground/validator/v10](https://github.com/go-playground/validator)
## Directory Structure
```
.
├── hello.go # Main entry point
├── common/ # Shared utilities
│ ├── database.go # Database connection manager
│ ├── utils.go # Helper functions (JWT, validation, etc.)
│ └── unit_test.go # Common package tests
├── users/ # User module
│ ├── models.go # User data models & DB operations
│ ├── serializers.go # Response formatting
│ ├── routers.go # Route handlers
│ ├── middlewares.go # Auth middleware
│ ├── validators.go # Input validation
│ └── unit_test.go # User package tests
├── articles/ # Articles module
│ ├── models.go # Article data models & DB operations
│ ├── serializers.go # Response formatting
│ ├── routers.go # Route handlers
│ ├── validators.go # Input validation
│ └── unit_test.go # Article package tests
└── scripts/ # Build/test scripts
```
## Development Commands
```bash
# Install dependencies
go mod download
# Build
go build ./...
# Run tests
go test ./...
# Run tests with coverage
go test -coverprofile=coverage.out ./...
# Format code
go fmt ./...
# Run linter
golangci-lint run
# Start the server
go run hello.go
```
## Code Style Guidelines
1. **Error Handling**: Always handle errors explicitly. Do not ignore errors with `_`.
2. **GORM v2 Patterns**:
- Use `Preload()` instead of `Related()` for eager loading
- Use `Association().Find()` for many-to-many relationships
- Use `Updates()` instead of `Update()` for struct/map updates
- Use pointers with `Delete()`: `Delete(&Model{})`
- Count returns `int64`, handle overflow when converting to `uint`
3. **Validation Tags**: Use `required` instead of deprecated `exists` tag
4. **JWT**: Use `jwt.NewWithClaims()` for token creation
## Testing
- Tests are in `*_test.go` files alongside source code
- Use `common.TestDBInit()` and `common.TestDBFree()` for test database setup/teardown
- Run `go test ./...` before committing changes
## API Endpoints
The API follows the [RealWorld API Spec](https://realworld-docs.netlify.app/docs/specs/backend-specs/endpoints):
- `POST /api/users` - Register
- `POST /api/users/login` - Login
- `GET /api/user` - Get current user
- `PUT /api/user` - Update user
- `GET /api/profiles/:username` - Get profile
- `POST /api/profiles/:username/follow` - Follow user
- `DELETE /api/profiles/:username/follow` - Unfollow user
- `GET /api/articles` - List articles
- `GET /api/articles/feed` - Feed articles
- `GET /api/articles/:slug` - Get article
- `POST /api/articles` - Create article
- `PUT /api/articles/:slug` - Update article
- `DELETE /api/articles/:slug` - Delete article
- `POST /api/articles/:slug/comments` - Add comment
- `GET /api/articles/:slug/comments` - Get comments
- `DELETE /api/articles/:slug/comments/:id` - Delete comment
- `POST /api/articles/:slug/favorite` - Favorite article
- `DELETE /api/articles/:slug/favorite` - Unfavorite article
- `GET /api/tags` - Get tags
## Security Considerations
- JWT secret is defined in `common/utils.go` - do not expose in production
- Always validate user input using validator tags
- Use parameterized queries (GORM handles this automatically)

16
webapp-back/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ---- Build stage ----
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git gcc musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
# ---- Run stage ----
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

21
webapp-back/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 wangzitian0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,13 @@
> *Note: Delete this file before publishing your app!*
# [Mobile Icons (iOS/Android)](https://github.com/gothinkster/realworld/tree/master/spec/mobile_icons)
### Using the hosted API
Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go!
### Styles/Templates
Unfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps.
Instead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a "north star" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :)

154
webapp-back/SETUP.md Normal file
View File

@@ -0,0 +1,154 @@
# AssetX 后端设置指南
## 📋 前置要求
- Go 1.23+
- MySQL 8.0+
- Git
## 🚀 快速开始
### 1. 安装依赖
```bash
go mod download
```
### 2. 配置环境变量
已配置的 `.env` 文件:
```bash
# MySQL 配置
DB_TYPE=mysql
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=assetx
# 服务器配置
PORT=8080
GIN_MODE=debug
```
### 3. 初始化数据库
```bash
# 启动 MySQL
sudo service mysql start
# 运行初始化脚本
./init_db.sh
```
### 4. 启动服务器
```bash
./start.sh
```
或者直接运行:
```bash
go run main.go
```
## 🔌 API 端点
### Public Routes
- `GET /api/ping` - 健康检查
- `GET /api/holders/stats` - 获取所有代币统计
- `GET /api/holders/:tokenType` - 获取指定代币的持有者列表
支持的 tokenType:
- `YT-A`
- `YT-B`
- `YT-C`
- `ytLP`
- `Lending`
### Protected Routes (需要管理员权限)
- `POST /api/holders/update` - 触发区块链数据更新
## 📊 数据库表
### holder_snapshots
持有者快照表,存储从区块链读取的代币持有者数据。
```sql
CREATE TABLE holder_snapshots (
id INT PRIMARY KEY AUTO_INCREMENT,
holder_address VARCHAR(42) NOT NULL,
token_type VARCHAR(50) NOT NULL,
token_address VARCHAR(42) NOT NULL,
balance VARCHAR(78) NOT NULL,
chain_id INT NOT NULL,
first_seen BIGINT NOT NULL,
last_updated BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
## 🧪 测试
```bash
# 测试 API
curl http://localhost:8080/api/ping
# 获取统计数据
curl http://localhost:8080/api/holders/stats
# 获取 YT-A 持有者
curl http://localhost:8080/api/holders/YT-A
```
## 🐛 常见问题
### MySQL 连接失败
```bash
# 检查 MySQL 是否运行
sudo service mysql status
# 启动 MySQL
sudo service mysql start
# 测试连接
mysql -u root -p123456 -e "SELECT 1"
```
### 数据库不存在
```bash
# 手动创建数据库
mysql -u root -p123456 -e "CREATE DATABASE assetx"
```
### GORM 自动迁移
服务器启动时会自动创建表(如果不存在)。
## 📝 开发说明
### 添加新的 API 端点
1.`holders/` 目录创建处理函数
2.`main.go` 注册路由
3.`models/` 添加数据模型(如需要)
### 数据库迁移
GORM 会自动处理表结构迁移。如需手动迁移:
```go
db.AutoMigrate(&models.YourModel{})
```
## 🔗 相关项目
- 前端项目: `/home/coder/myprojects/assetx/antdesign`
- 数据库 Schema: `/home/coder/myprojects/assetx/database-schema-v1.1-final.sql`

View File

@@ -0,0 +1,111 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListAssetAuditReports(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.AssetAuditReport{})
if v := c.Query("asset_id"); v != "" {
query = query.Where("asset_id = ?", v)
}
if v := c.Query("report_type"); v != "" {
query = query.Where("report_type = ?", v)
}
if v := c.Query("auditor_name"); v != "" {
query = query.Where("auditor_name LIKE ?", "%"+v+"%")
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.AssetAuditReport
if err := query.Order("asset_id ASC, display_order ASC, id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch audit reports")
return
}
OKList(c, items, total)
}
func GetAssetAuditReport(c *gin.Context) {
db := common.GetDB()
var item models.AssetAuditReport
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Audit report not found")
return
}
OK(c, item)
}
func CreateAssetAuditReport(c *gin.Context) {
db := common.GetDB()
var item models.AssetAuditReport
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "asset_audit_report", "create_audit_report", &item.ID, item)
OK(c, item)
}
func UpdateAssetAuditReport(c *gin.Context) {
db := common.GetDB()
var existing models.AssetAuditReport
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Audit report not found")
return
}
var item models.AssetAuditReport
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "asset_audit_report", "update_audit_report", &item.ID, item)
OK(c, item)
}
func DeleteAssetAuditReport(c *gin.Context) {
db := common.GetDB()
var item models.AssetAuditReport
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Audit report not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "asset_audit_report", "delete_audit_report", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,105 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListAssetCustody(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.AssetCustody{})
if v := c.Query("asset_id"); v != "" {
query = query.Where("asset_id = ?", v)
}
if v := c.Query("custodian_name"); v != "" {
query = query.Where("custodian_name LIKE ?", "%"+v+"%")
}
var total int64
query.Count(&total)
var items []models.AssetCustody
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch asset custody records")
return
}
OKList(c, items, total)
}
func GetAssetCustody(c *gin.Context) {
db := common.GetDB()
var item models.AssetCustody
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset custody record not found")
return
}
OK(c, item)
}
func CreateAssetCustody(c *gin.Context) {
db := common.GetDB()
var item models.AssetCustody
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "asset_custody", "create_asset_custody", &item.ID, item)
OK(c, item)
}
func UpdateAssetCustody(c *gin.Context) {
db := common.GetDB()
var existing models.AssetCustody
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset custody record not found")
return
}
var item models.AssetCustody
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "asset_custody", "update_asset_custody", &item.ID, item)
OK(c, item)
}
func DeleteAssetCustody(c *gin.Context) {
db := common.GetDB()
var item models.AssetCustody
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset custody record not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "asset_custody", "delete_asset_custody", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

127
webapp-back/admin/assets.go Normal file
View File

@@ -0,0 +1,127 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListAssets(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.Asset{})
if v := c.Query("name"); v != "" {
query = query.Where("name LIKE ?", "%"+v+"%")
}
if v := c.Query("asset_code"); v != "" {
query = query.Where("asset_code LIKE ?", "%"+v+"%")
}
if v := c.Query("category"); v != "" {
query = query.Where("category = ?", v)
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.Asset
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch assets")
return
}
OKList(c, items, total)
}
func GetAsset(c *gin.Context) {
db := common.GetDB()
var item models.Asset
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset not found")
return
}
OK(c, item)
}
func CreateAsset(c *gin.Context) {
db := common.GetDB()
var item models.Asset
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
// 合约地址唯一性校验(非空才校验)
if item.ContractAddress != "" {
var conflict models.Asset
if err := db.Where("contract_address = ?", item.ContractAddress).First(&conflict).Error; err == nil {
Fail(c, http.StatusConflict, "合约地址已被资产「"+conflict.Name+"」使用,请检查后重试")
return
}
}
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "asset", "create_asset", &item.ID, item)
OK(c, item)
}
func UpdateAsset(c *gin.Context) {
db := common.GetDB()
var existing models.Asset
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset not found")
return
}
var item models.Asset
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
// 合约地址唯一性校验(非空且与原值不同时校验)
if item.ContractAddress != "" && item.ContractAddress != existing.ContractAddress {
var conflict models.Asset
if err := db.Where("contract_address = ? AND id != ?", item.ContractAddress, existing.ID).First(&conflict).Error; err == nil {
Fail(c, http.StatusConflict, "合约地址已被资产「"+conflict.Name+"」使用,请检查后重试")
return
}
}
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "asset", "update_asset", &item.ID, item)
OK(c, item)
}
func DeleteAsset(c *gin.Context) {
db := common.GetDB()
var item models.Asset
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Asset not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "asset", "delete_asset", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

131
webapp-back/admin/common.go Normal file
View File

@@ -0,0 +1,131 @@
package admin
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// PaginationParams holds parsed ProTable pagination query params
type PaginationParams struct {
Current int
PageSize int
}
// ParsePagination reads ?current=N&pageSize=M from the request
func ParsePagination(c *gin.Context) PaginationParams {
current, _ := strconv.Atoi(c.DefaultQuery("current", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if current < 1 {
current = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return PaginationParams{Current: current, PageSize: pageSize}
}
// Offset returns the DB offset for GORM queries
func (p PaginationParams) Offset() int {
return (p.Current - 1) * p.PageSize
}
// OK responds with {success: true, data: ...}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// OKList responds with {success: true, data: [...], total: N} — used by ProTable
func OKList(c *gin.Context, data interface{}, total int64) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data, "total": total})
}
// Fail responds with {success: false, message: ...}
func Fail(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{"success": false, "message": msg})
}
// flexTimeLayouts is the set of time formats accepted from the frontend.
var flexTimeLayouts = []string{
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
"2006-01-02",
}
// BindJSONFlexTime works like c.ShouldBindJSON but also accepts non-RFC3339
// time strings such as "2026-02-28 00:00:00" that Ant Design's DatePicker sends.
// It reads the raw body, converts any date-like strings to RFC3339, then unmarshals.
func BindJSONFlexTime(c *gin.Context, obj interface{}) error {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return json.Unmarshal(body, obj) // fallback: let normal errors surface
}
for key, val := range raw {
var s string
if err := json.Unmarshal(val, &s); err != nil {
continue // not a string, skip
}
for _, layout := range flexTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
converted, _ := json.Marshal(t) // produces RFC3339
raw[key] = converted
break
}
}
}
fixed, err := json.Marshal(raw)
if err != nil {
return err
}
return json.Unmarshal(fixed, obj)
}
// LogOp writes a record to operation_logs table
func LogOp(c *gin.Context, opType, targetType, action string, targetID *uint, payload interface{}) {
db := common.GetDB()
user, _ := middleware.GetCurrentUser(c)
var userID *uint
if user != nil {
uid := uint(user.UserID)
userID = &uid
}
var changesStr string
if payload != nil {
b, _ := json.Marshal(payload)
changesStr = string(b)
}
db.Create(&models.OperationLog{
UserID: userID,
OperationType: opType,
TargetType: targetType,
Action: action,
TargetID: targetID,
Changes: changesStr,
IPAddress: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
Status: "success",
CreatedAt: time.Now(),
})
}

View File

@@ -0,0 +1,85 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListInviteCodes(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.InviteCode{})
if v := c.Query("wallet_address"); v != "" {
query = query.Where("wallet_address LIKE ?", "%"+v+"%")
}
if v := c.Query("code"); v != "" {
query = query.Where("code LIKE ?", "%"+v+"%")
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.InviteCode
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch invite codes")
return
}
OKList(c, items, total)
}
func ToggleInviteCode(c *gin.Context) {
db := common.GetDB()
var item models.InviteCode
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Invite code not found")
return
}
item.IsActive = !item.IsActive
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "invite_code", "toggle_invite_code", &item.ID, gin.H{"is_active": item.IsActive})
OK(c, item)
}
func UpdateInviteCode(c *gin.Context) {
db := common.GetDB()
var existing models.InviteCode
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Invite code not found")
return
}
var body struct {
ExpiresAt models.NullTime `json:"expires_at"`
}
if err := BindJSONFlexTime(c, &body); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
existing.ExpiresAt = body.ExpiresAt
if existing.CreatedAt.IsZero() {
existing.CreatedAt = time.Now()
}
if err := db.Save(&existing).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "invite_code", "update_invite_code", &existing.ID, gin.H{"expires_at": body.ExpiresAt})
OK(c, existing)
}

View File

@@ -0,0 +1,121 @@
package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/lending"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// ── Collateral Buyer Bot ───────────────────────────────────────────────────
func GetBuyerBotStatus(c *gin.Context) {
OK(c, lending.GetBuyerBotStatus())
}
func StartBuyerBot(c *gin.Context) {
cfg := appcfg.AppConfig
if cfg == nil {
Fail(c, http.StatusInternalServerError, "Configuration not loaded")
return
}
if cfg.CollateralBuyerPrivateKey == "" {
Fail(c, http.StatusBadRequest, "COLLATERAL_BUYER_PRIVATE_KEY not configured on server")
return
}
lending.StartCollateralBuyerBot(cfg)
OK(c, gin.H{"message": "Collateral buyer bot start signal sent"})
}
func StopBuyerBot(c *gin.Context) {
lending.StopCollateralBuyerBot()
OK(c, gin.H{"message": "Collateral buyer bot stop signal sent"})
}
func ListBuyRecords(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.CollateralBuyRecord{})
if v := c.Query("chain_id"); v != "" {
if id, err := strconv.Atoi(v); err == nil {
query = query.Where("chain_id = ?", id)
}
}
if v := c.Query("status"); v != "" {
query = query.Where("status = ?", v)
}
if v := c.Query("asset_addr"); v != "" {
query = query.Where("asset_addr = ?", v)
}
var total int64
query.Count(&total)
var records []models.CollateralBuyRecord
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&records).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch buy records")
return
}
OKList(c, records, total)
}
// GetLiquidationStatus returns the current liquidation bot status
func GetLiquidationStatus(c *gin.Context) {
status := lending.GetBotStatus()
OK(c, status)
}
// StartLiquidationBot starts the liquidation bot
func StartLiquidationBot(c *gin.Context) {
cfg := appcfg.AppConfig
if cfg == nil {
Fail(c, http.StatusInternalServerError, "Configuration not loaded")
return
}
if cfg.LiquidatorPrivateKey == "" {
Fail(c, http.StatusBadRequest, "LIQUIDATOR_PRIVATE_KEY not configured on server")
return
}
lending.StartLiquidationBot(cfg)
OK(c, gin.H{"message": "Liquidation bot start signal sent"})
}
// StopLiquidationBot stops the liquidation bot
func StopLiquidationBot(c *gin.Context) {
lending.StopLiquidationBot()
OK(c, gin.H{"message": "Liquidation bot stop signal sent"})
}
// ListLiquidationRecords returns paginated liquidation history
func ListLiquidationRecords(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.LiquidationRecord{})
if v := c.Query("chain_id"); v != "" {
if id, err := strconv.Atoi(v); err == nil {
query = query.Where("chain_id = ?", id)
}
}
if v := c.Query("status"); v != "" {
query = query.Where("status = ?", v)
}
if v := c.Query("liquidator_addr"); v != "" {
query = query.Where("liquidator_addr = ?", v)
}
var total int64
query.Count(&total)
var records []models.LiquidationRecord
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&records).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch liquidation records")
return
}
OKList(c, records, total)
}

View File

@@ -0,0 +1,108 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListPointsRules(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.PointsRule{})
if v := c.Query("rule_name"); v != "" {
query = query.Where("rule_name LIKE ?", "%"+v+"%")
}
if v := c.Query("rule_type"); v != "" {
query = query.Where("rule_type = ?", v)
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.PointsRule
if err := query.Order("priority DESC, id ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch points rules")
return
}
OKList(c, items, total)
}
func GetPointsRule(c *gin.Context) {
db := common.GetDB()
var item models.PointsRule
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Points rule not found")
return
}
OK(c, item)
}
func CreatePointsRule(c *gin.Context) {
db := common.GetDB()
var item models.PointsRule
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "points_rule", "create_points_rule", &item.ID, item)
OK(c, item)
}
func UpdatePointsRule(c *gin.Context) {
db := common.GetDB()
var existing models.PointsRule
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Points rule not found")
return
}
var item models.PointsRule
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "points_rule", "update_points_rule", &item.ID, item)
OK(c, item)
}
func DeletePointsRule(c *gin.Context) {
db := common.GetDB()
var item models.PointsRule
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Points rule not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "points_rule", "delete_points_rule", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,108 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListProductLinks(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.ProductLink{})
if v := c.Query("asset_id"); v != "" {
query = query.Where("asset_id = ?", v)
}
if v := c.Query("link_text"); v != "" {
query = query.Where("link_text LIKE ?", "%"+v+"%")
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.ProductLink
if err := query.Order("asset_id ASC, display_order ASC, id ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch product links")
return
}
OKList(c, items, total)
}
func GetProductLink(c *gin.Context) {
db := common.GetDB()
var item models.ProductLink
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Product link not found")
return
}
OK(c, item)
}
func CreateProductLink(c *gin.Context) {
db := common.GetDB()
var item models.ProductLink
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "product_link", "create_product_link", &item.ID, item)
OK(c, item)
}
func UpdateProductLink(c *gin.Context) {
db := common.GetDB()
var existing models.ProductLink
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Product link not found")
return
}
var item models.ProductLink
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "product_link", "update_product_link", &item.ID, item)
OK(c, item)
}
func DeleteProductLink(c *gin.Context) {
db := common.GetDB()
var item models.ProductLink
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Product link not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "product_link", "delete_product_link", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,96 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
)
// RegisterRoutes mounts all /api/admin/* routes onto the provided parent group.
// Call this BEFORE v1.Use(AuthMiddleware) in main.go so auth is applied here only.
func RegisterRoutes(v1 *gin.RouterGroup) {
g := v1.Group("/admin")
g.Use(middleware.AuthMiddleware(true), middleware.RequireAdmin())
{
// P0 — Assets
g.GET("/assets", ListAssets)
g.GET("/assets/:id", GetAsset)
g.POST("/assets", CreateAsset)
g.PUT("/assets/:id", UpdateAsset)
g.DELETE("/assets/:id", DeleteAsset)
// P0 — Asset Custody
g.GET("/asset-custody", ListAssetCustody)
g.GET("/asset-custody/:id", GetAssetCustody)
g.POST("/asset-custody", CreateAssetCustody)
g.PUT("/asset-custody/:id", UpdateAssetCustody)
g.DELETE("/asset-custody/:id", DeleteAssetCustody)
// P0 — Asset Audit Reports
g.GET("/asset-audit-reports", ListAssetAuditReports)
g.GET("/asset-audit-reports/:id", GetAssetAuditReport)
g.POST("/asset-audit-reports", CreateAssetAuditReport)
g.PUT("/asset-audit-reports/:id", UpdateAssetAuditReport)
g.DELETE("/asset-audit-reports/:id", DeleteAssetAuditReport)
// P1 — Points Rules
g.GET("/points-rules", ListPointsRules)
g.GET("/points-rules/:id", GetPointsRule)
g.POST("/points-rules", CreatePointsRule)
g.PUT("/points-rules/:id", UpdatePointsRule)
g.DELETE("/points-rules/:id", DeletePointsRule)
// P1 — Seasons
g.GET("/seasons", ListSeasons)
g.GET("/seasons/:id", GetSeason)
g.POST("/seasons", CreateSeason)
g.PUT("/seasons/:id", UpdateSeason)
g.DELETE("/seasons/:id", DeleteSeason)
// P1 — VIP Tiers
g.GET("/vip-tiers", ListVIPTiers)
g.GET("/vip-tiers/:id", GetVIPTier)
g.POST("/vip-tiers", CreateVIPTier)
g.PUT("/vip-tiers/:id", UpdateVIPTier)
g.DELETE("/vip-tiers/:id", DeleteVIPTier)
// P2 — Users (list + edit only)
g.GET("/users", ListUsers)
g.GET("/users/:wallet", GetUser)
g.PUT("/users/:wallet", UpdateUser)
// P2 — Invite Codes
g.GET("/invite-codes", ListInviteCodes)
g.PATCH("/invite-codes/:id", UpdateInviteCode)
g.PUT("/invite-codes/:id/toggle", ToggleInviteCode)
// P3 — Product Links (per-asset links on product detail page)
g.GET("/product-links", ListProductLinks)
g.GET("/product-links/:id", GetProductLink)
g.POST("/product-links", CreateProductLink)
g.PUT("/product-links/:id", UpdateProductLink)
g.DELETE("/product-links/:id", DeleteProductLink)
// System Contracts (central infrastructure contract registry)
g.GET("/system-contracts", ListSystemContracts)
g.GET("/system-contracts/:id", GetSystemContract)
g.POST("/system-contracts", CreateSystemContract)
g.PUT("/system-contracts/:id", UpdateSystemContract)
g.DELETE("/system-contracts/:id", DeleteSystemContract)
// File Upload
g.POST("/upload", UploadFile)
g.DELETE("/upload", DeleteUploadedFile)
// Liquidation Bot
g.GET("/liquidation/status", GetLiquidationStatus)
g.POST("/liquidation/start", StartLiquidationBot)
g.POST("/liquidation/stop", StopLiquidationBot)
g.GET("/liquidation/records", ListLiquidationRecords)
// Collateral Buyer Bot
g.GET("/buyer/status", GetBuyerBotStatus)
g.POST("/buyer/start", StartBuyerBot)
g.POST("/buyer/stop", StopBuyerBot)
g.GET("/buyer/records", ListBuyRecords)
}
}

View File

@@ -0,0 +1,108 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListSeasons(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.Season{})
if v := c.Query("status"); v != "" {
query = query.Where("status = ?", v)
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
if v := c.Query("season_name"); v != "" {
query = query.Where("season_name LIKE ?", "%"+v+"%")
}
var total int64
query.Count(&total)
var items []models.Season
if err := query.Order("season_number DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch seasons")
return
}
OKList(c, items, total)
}
func GetSeason(c *gin.Context) {
db := common.GetDB()
var item models.Season
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Season not found")
return
}
OK(c, item)
}
func CreateSeason(c *gin.Context) {
db := common.GetDB()
var item models.Season
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "season", "create_season", &item.ID, item)
OK(c, item)
}
func UpdateSeason(c *gin.Context) {
db := common.GetDB()
var existing models.Season
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Season not found")
return
}
var item models.Season
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "season", "update_season", &item.ID, item)
OK(c, item)
}
func DeleteSeason(c *gin.Context) {
db := common.GetDB()
var item models.Season
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "Season not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "season", "delete_season", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,138 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// ContractEntry is the contract address entry returned to the frontend.
type ContractEntry struct {
Name string `json:"name"`
ChainID int `json:"chain_id"`
Address string `json:"address"`
}
// GetContracts is a public endpoint that returns all active contract addresses.
// Merges infrastructure contracts (system_contracts) and token contracts (assets).
func GetContracts(c *gin.Context) {
db := common.GetDB()
// Infrastructure contracts from system_contracts
var sysContracts []models.SystemContract
db.Where("is_active = ?", true).Find(&sysContracts)
// Token contracts from assets
var assets []models.Asset
db.Where("is_active = ? AND contract_address != ''", true).Find(&assets)
entries := make([]ContractEntry, 0, len(sysContracts)+len(assets))
for _, sc := range sysContracts {
if sc.Address != "" {
entries = append(entries, ContractEntry{Name: sc.Name, ChainID: sc.ChainID, Address: sc.Address})
}
}
for _, a := range assets {
entries = append(entries, ContractEntry{Name: a.AssetCode, ChainID: a.ChainID, Address: a.ContractAddress})
}
c.JSON(http.StatusOK, gin.H{"contracts": entries})
}
// --- Admin CRUD for system_contracts ---
func ListSystemContracts(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.SystemContract{})
if v := c.Query("name"); v != "" {
query = query.Where("name LIKE ?", "%"+v+"%")
}
if v := c.Query("chain_id"); v != "" {
query = query.Where("chain_id = ?", v)
}
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
var total int64
query.Count(&total)
var items []models.SystemContract
if err := query.Order("chain_id ASC, name ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch system contracts")
return
}
OKList(c, items, total)
}
func GetSystemContract(c *gin.Context) {
db := common.GetDB()
var item models.SystemContract
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "System contract not found")
return
}
OK(c, item)
}
func CreateSystemContract(c *gin.Context) {
db := common.GetDB()
var item models.SystemContract
if err := c.ShouldBindJSON(&item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
item.CreatedAt = now
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "system_contract", "create_system_contract", &item.ID, item)
OK(c, item)
}
func UpdateSystemContract(c *gin.Context) {
db := common.GetDB()
var existing models.SystemContract
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "System contract not found")
return
}
var item models.SystemContract
if err := c.ShouldBindJSON(&item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
item.UpdatedAt = time.Now()
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "system_contract", "update_system_contract", &item.ID, item)
OK(c, item)
}
func DeleteSystemContract(c *gin.Context) {
db := common.GetDB()
var item models.SystemContract
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "System contract not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "system_contract", "delete_system_contract", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,99 @@
package admin
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var allowedCategories = map[string]bool{
"reports": true,
"custody": true,
"links": true,
}
var allowedExts = map[string]bool{
".pdf": true,
".png": true,
".jpg": true,
".jpeg": true,
".doc": true,
".docx": true,
".txt": true,
".xls": true,
".xlsx": true,
}
// UploadFile handles multipart file uploads, saves to ./uploads/{category}/, returns accessible URL.
// Query param: category (reports|custody), defaults to "reports".
func UploadFile(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
Fail(c, http.StatusBadRequest, "No file provided")
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedExts[ext] {
Fail(c, http.StatusBadRequest, "File type not allowed")
return
}
category := c.DefaultQuery("category", "reports")
if !allowedCategories[category] {
Fail(c, http.StatusBadRequest, "Invalid category")
return
}
dir := "uploads/" + category
if err := os.MkdirAll(dir, 0755); err != nil {
Fail(c, http.StatusInternalServerError, "Failed to create upload directory")
return
}
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(file.Filename))
dst := filepath.Join(dir, filename)
if err := c.SaveUploadedFile(file, dst); err != nil {
Fail(c, http.StatusInternalServerError, "Failed to save file")
return
}
url := "/uploads/" + category + "/" + filename
OK(c, gin.H{"url": url})
}
// DeleteUploadedFile deletes a previously uploaded file by its URL path.
func DeleteUploadedFile(c *gin.Context) {
urlPath := c.Query("path") // e.g. /uploads/reports/xxx.pdf or /uploads/custody/xxx.pdf
if !strings.HasPrefix(urlPath, "/uploads/") {
Fail(c, http.StatusBadRequest, "Invalid file path")
return
}
if strings.Contains(urlPath, "..") {
Fail(c, http.StatusBadRequest, "Invalid file path")
return
}
// Verify the second segment is a known category
parts := strings.SplitN(strings.TrimPrefix(urlPath, "/uploads/"), "/", 2)
if len(parts) != 2 || !allowedCategories[parts[0]] {
Fail(c, http.StatusBadRequest, "Invalid file path")
return
}
localPath := strings.TrimPrefix(urlPath, "/")
if err := os.Remove(localPath); err != nil {
if os.IsNotExist(err) {
OK(c, gin.H{"message": "file not found, skipped"})
return
}
Fail(c, http.StatusInternalServerError, "Failed to delete file")
return
}
OK(c, gin.H{"message": "deleted"})
}

View File

@@ -0,0 +1,98 @@
package admin
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListUsers(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.User{})
if v := c.Query("wallet_address"); v != "" {
query = query.Where("wallet_address LIKE ?", "%"+strings.ToLower(v)+"%")
}
if v := c.Query("member_tier"); v != "" {
query = query.Where("member_tier = ?", v)
}
if v := c.Query("nickname"); v != "" {
query = query.Where("nickname LIKE ?", "%"+v+"%")
}
var total int64
query.Count(&total)
var items []models.User
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch users")
return
}
OKList(c, items, total)
}
func GetUser(c *gin.Context) {
db := common.GetDB()
wallet := strings.ToLower(c.Param("wallet"))
var item models.User
if err := db.Where("wallet_address = ?", wallet).First(&item).Error; err != nil {
Fail(c, http.StatusNotFound, "User not found")
return
}
OK(c, item)
}
// UpdateUser allows updating limited fields: nickname, member_tier, vip_level, total_points, global_rank
func UpdateUser(c *gin.Context) {
db := common.GetDB()
wallet := strings.ToLower(c.Param("wallet"))
var existing models.User
if err := db.Where("wallet_address = ?", wallet).First(&existing).Error; err != nil {
Fail(c, http.StatusNotFound, "User not found")
return
}
var payload struct {
Nickname string `json:"nickname"`
MemberTier string `json:"member_tier"`
VIPLevel int `json:"vip_level"`
TotalPoints int64 `json:"total_points"`
GlobalRank *int `json:"global_rank"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
updates := map[string]interface{}{}
if payload.Nickname != "" {
updates["nickname"] = payload.Nickname
}
if payload.MemberTier != "" {
updates["member_tier"] = payload.MemberTier
}
if payload.VIPLevel > 0 {
updates["vip_level"] = payload.VIPLevel
}
if payload.TotalPoints >= 0 {
updates["total_points"] = payload.TotalPoints
}
if payload.GlobalRank != nil {
updates["global_rank"] = payload.GlobalRank
}
if err := db.Model(&existing).Updates(updates).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
// Reload updated user
db.Where("wallet_address = ?", wallet).First(&existing)
LogOp(c, "update", "user", "update_user", nil, updates)
OK(c, existing)
}

View File

@@ -0,0 +1,105 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
func ListVIPTiers(c *gin.Context) {
db := common.GetDB()
p := ParsePagination(c)
query := db.Model(&models.VIPTier{})
if v := c.Query("is_active"); v != "" {
query = query.Where("is_active = ?", v == "true")
}
if v := c.Query("tier_name"); v != "" {
query = query.Where("tier_name LIKE ?", "%"+v+"%")
}
var total int64
query.Count(&total)
var items []models.VIPTier
if err := query.Order("tier_level ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
Fail(c, http.StatusInternalServerError, "Failed to fetch VIP tiers")
return
}
OKList(c, items, total)
}
func GetVIPTier(c *gin.Context) {
db := common.GetDB()
var item models.VIPTier
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "VIP tier not found")
return
}
OK(c, item)
}
func CreateVIPTier(c *gin.Context) {
db := common.GetDB()
var item models.VIPTier
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = 0
now := time.Now()
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
item.UpdatedAt = now
if err := db.Create(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "create", "vip_tier", "create_vip_tier", &item.ID, item)
OK(c, item)
}
func UpdateVIPTier(c *gin.Context) {
db := common.GetDB()
var existing models.VIPTier
if err := db.First(&existing, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "VIP tier not found")
return
}
var item models.VIPTier
if err := BindJSONFlexTime(c, &item); err != nil {
Fail(c, http.StatusBadRequest, err.Error())
return
}
item.ID = existing.ID
if existing.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
} else {
item.CreatedAt = existing.CreatedAt
}
if err := db.Save(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "update", "vip_tier", "update_vip_tier", &item.ID, item)
OK(c, item)
}
func DeleteVIPTier(c *gin.Context) {
db := common.GetDB()
var item models.VIPTier
if err := db.First(&item, c.Param("id")).Error; err != nil {
Fail(c, http.StatusNotFound, "VIP tier not found")
return
}
if err := db.Delete(&item).Error; err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
LogOp(c, "delete", "vip_tier", "delete_vip_tier", &item.ID, nil)
OK(c, gin.H{"message": "deleted"})
}

134
webapp-back/alp/routers.go Normal file
View 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
View 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
APIURL=${APIURL:-https://api.realworld.io/api}
USERNAME=${USERNAME:-u`date +%s`}
EMAIL=${EMAIL:-$USERNAME@mail.com}
PASSWORD=${PASSWORD:-password}
DELAY_REQUEST=${DELAY_REQUEST:-"500"}
npx newman run $SCRIPTDIR/Conduit.postman_collection.json \
--delay-request "$DELAY_REQUEST" \
--global-var "APIURL=$APIURL" \
--global-var "USERNAME=$USERNAME" \
--global-var "EMAIL=$EMAIL" \
--global-var "PASSWORD=$PASSWORD" \
"$@"

View File

@@ -0,0 +1,12 @@
/*
The article module containing the article CRUD operation and relationship CRUD.
model.go: definition of orm based data model
routers.go: router binding and core logic
serializers.go: definition the schema of return data
validators.go: definition the validator of form data
*/
package articles

View File

@@ -0,0 +1,368 @@
package articles
import (
"strconv"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/users"
"gorm.io/gorm"
)
type ArticleModel struct {
gorm.Model
Slug string `gorm:"uniqueIndex"`
Title string
Description string `gorm:"size:2048"`
Body string `gorm:"size:2048"`
Author ArticleUserModel
AuthorID uint
Tags []TagModel `gorm:"many2many:article_tags;"`
Comments []CommentModel `gorm:"ForeignKey:ArticleID"`
}
type ArticleUserModel struct {
gorm.Model
UserModel users.UserModel
UserModelID uint
ArticleModels []ArticleModel `gorm:"ForeignKey:AuthorID"`
FavoriteModels []FavoriteModel `gorm:"ForeignKey:FavoriteByID"`
}
type FavoriteModel struct {
gorm.Model
Favorite ArticleModel
FavoriteID uint
FavoriteBy ArticleUserModel
FavoriteByID uint
}
type TagModel struct {
gorm.Model
Tag string `gorm:"uniqueIndex"`
ArticleModels []ArticleModel `gorm:"many2many:article_tags;"`
}
type CommentModel struct {
gorm.Model
Article ArticleModel
ArticleID uint
Author ArticleUserModel
AuthorID uint
Body string `gorm:"size:2048"`
}
func GetArticleUserModel(userModel users.UserModel) ArticleUserModel {
var articleUserModel ArticleUserModel
if userModel.ID == 0 {
return articleUserModel
}
db := common.GetDB()
db.Where(&ArticleUserModel{
UserModelID: userModel.ID,
}).FirstOrCreate(&articleUserModel)
articleUserModel.UserModel = userModel
return articleUserModel
}
func (article ArticleModel) favoritesCount() uint {
db := common.GetDB()
var count int64
db.Model(&FavoriteModel{}).Where(FavoriteModel{
FavoriteID: article.ID,
}).Count(&count)
return uint(count)
}
func (article ArticleModel) isFavoriteBy(user ArticleUserModel) bool {
db := common.GetDB()
var favorite FavoriteModel
db.Where(FavoriteModel{
FavoriteID: article.ID,
FavoriteByID: user.ID,
}).First(&favorite)
return favorite.ID != 0
}
// BatchGetFavoriteCounts returns a map of article ID to favorite count
func BatchGetFavoriteCounts(articleIDs []uint) map[uint]uint {
if len(articleIDs) == 0 {
return make(map[uint]uint)
}
db := common.GetDB()
type result struct {
FavoriteID uint
Count uint
}
var results []result
db.Model(&FavoriteModel{}).
Select("favorite_id, COUNT(*) as count").
Where("favorite_id IN ?", articleIDs).
Group("favorite_id").
Find(&results)
countMap := make(map[uint]uint)
for _, r := range results {
countMap[r.FavoriteID] = r.Count
}
return countMap
}
// BatchGetFavoriteStatus returns a map of article ID to whether the user favorited it
func BatchGetFavoriteStatus(articleIDs []uint, userID uint) map[uint]bool {
if len(articleIDs) == 0 || userID == 0 {
return make(map[uint]bool)
}
db := common.GetDB()
var favorites []FavoriteModel
db.Where("favorite_id IN ? AND favorite_by_id = ?", articleIDs, userID).Find(&favorites)
statusMap := make(map[uint]bool)
for _, f := range favorites {
statusMap[f.FavoriteID] = true
}
return statusMap
}
func (article ArticleModel) favoriteBy(user ArticleUserModel) error {
db := common.GetDB()
var favorite FavoriteModel
err := db.FirstOrCreate(&favorite, &FavoriteModel{
FavoriteID: article.ID,
FavoriteByID: user.ID,
}).Error
return err
}
func (article ArticleModel) unFavoriteBy(user ArticleUserModel) error {
db := common.GetDB()
err := db.Where("favorite_id = ? AND favorite_by_id = ?", article.ID, user.ID).Delete(&FavoriteModel{}).Error
return err
}
func SaveOne(data interface{}) error {
db := common.GetDB()
err := db.Save(data).Error
return err
}
func FindOneArticle(condition interface{}) (ArticleModel, error) {
db := common.GetDB()
var model ArticleModel
err := db.Preload("Author.UserModel").Preload("Tags").Where(condition).First(&model).Error
return model, err
}
func FindOneComment(condition *CommentModel) (CommentModel, error) {
db := common.GetDB()
var model CommentModel
err := db.Preload("Author.UserModel").Preload("Article").Where(condition).First(&model).Error
return model, err
}
func (self *ArticleModel) getComments() error {
db := common.GetDB()
err := db.Preload("Author.UserModel").Model(self).Association("Comments").Find(&self.Comments)
return err
}
func getAllTags() ([]TagModel, error) {
db := common.GetDB()
var models []TagModel
err := db.Find(&models).Error
return models, err
}
func FindManyArticle(tag, author, limit, offset, favorited string) ([]ArticleModel, int, error) {
db := common.GetDB()
var models []ArticleModel
var count int
offset_int, errOffset := strconv.Atoi(offset)
if errOffset != nil {
offset_int = 0
}
limit_int, errLimit := strconv.Atoi(limit)
if errLimit != nil {
limit_int = 20
}
tx := db.Begin()
if tag != "" {
var tagModel TagModel
tx.Where(TagModel{Tag: tag}).First(&tagModel)
if tagModel.ID != 0 {
// Get article IDs via association
var tempModels []ArticleModel
if err := tx.Model(&tagModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
tx.Rollback()
return models, count, err
}
count = int(tx.Model(&tagModel).Association("ArticleModels").Count())
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
if len(tempModels) > 0 {
var ids []uint
for _, m := range tempModels {
ids = append(ids, m.ID)
}
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
}
}
} else if author != "" {
var userModel users.UserModel
tx.Where(users.UserModel{Username: author}).First(&userModel)
articleUserModel := GetArticleUserModel(userModel)
if articleUserModel.ID != 0 {
count = int(tx.Model(&articleUserModel).Association("ArticleModels").Count())
// Get article IDs via association
var tempModels []ArticleModel
if err := tx.Model(&articleUserModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
tx.Rollback()
return models, count, err
}
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
if len(tempModels) > 0 {
var ids []uint
for _, m := range tempModels {
ids = append(ids, m.ID)
}
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
}
}
} else if favorited != "" {
var userModel users.UserModel
tx.Where(users.UserModel{Username: favorited}).First(&userModel)
articleUserModel := GetArticleUserModel(userModel)
if articleUserModel.ID != 0 {
var favoriteModels []FavoriteModel
tx.Where(FavoriteModel{
FavoriteByID: articleUserModel.ID,
}).Offset(offset_int).Limit(limit_int).Find(&favoriteModels)
count = int(tx.Model(&articleUserModel).Association("FavoriteModels").Count())
// Batch fetch articles to avoid N+1 query
if len(favoriteModels) > 0 {
var ids []uint
for _, favorite := range favoriteModels {
ids = append(ids, favorite.FavoriteID)
}
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
}
}
} else {
var count64 int64
tx.Model(&ArticleModel{}).Count(&count64)
count = int(count64)
tx.Offset(offset_int).Limit(limit_int).Preload("Author.UserModel").Preload("Tags").Find(&models)
}
err := tx.Commit().Error
return models, count, err
}
func (self *ArticleUserModel) GetArticleFeed(limit, offset string) ([]ArticleModel, int, error) {
db := common.GetDB()
models := make([]ArticleModel, 0)
var count int
offset_int, errOffset := strconv.Atoi(offset)
if errOffset != nil {
offset_int = 0
}
limit_int, errLimit := strconv.Atoi(limit)
if errLimit != nil {
limit_int = 20
}
tx := db.Begin()
followings := self.UserModel.GetFollowings()
// Batch get ArticleUserModel IDs to avoid N+1 query
if len(followings) > 0 {
var followingUserIDs []uint
for _, following := range followings {
followingUserIDs = append(followingUserIDs, following.ID)
}
var articleUserModels []ArticleUserModel
tx.Where("user_model_id IN ?", followingUserIDs).Find(&articleUserModels)
var authorIDs []uint
for _, aum := range articleUserModels {
authorIDs = append(authorIDs, aum.ID)
}
if len(authorIDs) > 0 {
var count64 int64
tx.Model(&ArticleModel{}).Where("author_id IN ?", authorIDs).Count(&count64)
count = int(count64)
tx.Preload("Author.UserModel").Preload("Tags").Where("author_id IN ?", authorIDs).Order("updated_at desc").Offset(offset_int).Limit(limit_int).Find(&models)
}
}
err := tx.Commit().Error
return models, count, err
}
func (model *ArticleModel) setTags(tags []string) error {
if len(tags) == 0 {
model.Tags = []TagModel{}
return nil
}
db := common.GetDB()
// Batch fetch existing tags
var existingTags []TagModel
db.Where("tag IN ?", tags).Find(&existingTags)
// Create a map for quick lookup
existingTagMap := make(map[string]TagModel)
for _, t := range existingTags {
existingTagMap[t.Tag] = t
}
// Create missing tags and build final list
var tagList []TagModel
for _, tag := range tags {
if existing, ok := existingTagMap[tag]; ok {
tagList = append(tagList, existing)
} else {
// Create new tag with race condition handling
newTag := TagModel{Tag: tag}
if err := db.Create(&newTag).Error; err != nil {
// If creation failed (e.g., concurrent insert), try to fetch existing
var existing TagModel
if err2 := db.Where("tag = ?", tag).First(&existing).Error; err2 == nil {
tagList = append(tagList, existing)
continue
}
return err
}
tagList = append(tagList, newTag)
}
}
model.Tags = tagList
return nil
}
func (model *ArticleModel) Update(data interface{}) error {
db := common.GetDB()
err := db.Model(model).Updates(data).Error
return err
}
func DeleteArticleModel(condition interface{}) error {
db := common.GetDB()
err := db.Where(condition).Delete(&ArticleModel{}).Error
return err
}
func DeleteCommentModel(condition interface{}) error {
db := common.GetDB()
err := db.Where(condition).Delete(&CommentModel{}).Error
return err
}

View File

@@ -0,0 +1,251 @@
package articles
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/users"
"gorm.io/gorm"
"net/http"
"strconv"
)
func ArticlesRegister(router *gin.RouterGroup) {
router.GET("/feed", ArticleFeed)
router.POST("", ArticleCreate)
router.POST("/", ArticleCreate)
router.PUT("/:slug", ArticleUpdate)
router.PUT("/:slug/", ArticleUpdate)
router.DELETE("/:slug", ArticleDelete)
router.POST("/:slug/favorite", ArticleFavorite)
router.DELETE("/:slug/favorite", ArticleUnfavorite)
router.POST("/:slug/comments", ArticleCommentCreate)
router.DELETE("/:slug/comments/:id", ArticleCommentDelete)
}
func ArticlesAnonymousRegister(router *gin.RouterGroup) {
router.GET("", ArticleList)
router.GET("/", ArticleList)
router.GET("/:slug", ArticleRetrieve)
router.GET("/:slug/comments", ArticleCommentList)
}
func TagsAnonymousRegister(router *gin.RouterGroup) {
router.GET("", TagList)
router.GET("/", TagList)
}
func ArticleCreate(c *gin.Context) {
articleModelValidator := NewArticleModelValidator()
if err := articleModelValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
//fmt.Println(articleModelValidator.articleModel.Author.UserModel)
if err := SaveOne(&articleModelValidator.articleModel); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ArticleSerializer{c, articleModelValidator.articleModel}
c.JSON(http.StatusCreated, gin.H{"article": serializer.Response()})
}
func ArticleList(c *gin.Context) {
//condition := ArticleModel{}
tag := c.Query("tag")
author := c.Query("author")
favorited := c.Query("favorited")
limit := c.Query("limit")
offset := c.Query("offset")
articleModels, modelCount, err := FindManyArticle(tag, author, limit, offset, favorited)
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
return
}
serializer := ArticlesSerializer{c, articleModels}
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
}
func ArticleFeed(c *gin.Context) {
limit := c.Query("limit")
offset := c.Query("offset")
myUserModel := c.MustGet("my_user_model").(users.UserModel)
if myUserModel.ID == 0 {
c.AbortWithError(http.StatusUnauthorized, errors.New("{error : \"Require auth!\"}"))
return
}
articleUserModel := GetArticleUserModel(myUserModel)
articleModels, modelCount, err := articleUserModel.GetArticleFeed(limit, offset)
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
return
}
serializer := ArticlesSerializer{c, articleModels}
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
}
func ArticleRetrieve(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
return
}
serializer := ArticleSerializer{c, articleModel}
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
}
func ArticleUpdate(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
return
}
// Check if current user is the author
myUserModel := c.MustGet("my_user_model").(users.UserModel)
articleUserModel := GetArticleUserModel(myUserModel)
if articleModel.AuthorID != articleUserModel.ID {
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
return
}
articleModelValidator := NewArticleModelValidatorFillWith(articleModel)
if err := articleModelValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
articleModelValidator.articleModel.ID = articleModel.ID
if err := articleModel.Update(articleModelValidator.articleModel); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ArticleSerializer{c, articleModel}
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
}
func ArticleDelete(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err == nil {
// Article exists, check authorization
myUserModel := c.MustGet("my_user_model").(users.UserModel)
articleUserModel := GetArticleUserModel(myUserModel)
if articleModel.AuthorID != articleUserModel.ID {
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
return
}
}
// Delete regardless of existence (idempotent)
if err := DeleteArticleModel(&ArticleModel{Slug: slug}); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
c.JSON(http.StatusOK, gin.H{"article": "delete success"})
}
func ArticleFavorite(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
return
}
myUserModel := c.MustGet("my_user_model").(users.UserModel)
if err = articleModel.favoriteBy(GetArticleUserModel(myUserModel)); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ArticleSerializer{c, articleModel}
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
}
func ArticleUnfavorite(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
return
}
myUserModel := c.MustGet("my_user_model").(users.UserModel)
if err = articleModel.unFavoriteBy(GetArticleUserModel(myUserModel)); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ArticleSerializer{c, articleModel}
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
}
func ArticleCommentCreate(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid slug")))
return
}
commentModelValidator := NewCommentModelValidator()
if err := commentModelValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
commentModelValidator.commentModel.Article = articleModel
if err := SaveOne(&commentModelValidator.commentModel); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := CommentSerializer{c, commentModelValidator.commentModel}
c.JSON(http.StatusCreated, gin.H{"comment": serializer.Response()})
}
func ArticleCommentDelete(c *gin.Context) {
id64, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid id")))
return
}
id := uint(id64)
commentModel, err := FindOneComment(&CommentModel{Model: gorm.Model{ID: id}})
if err == nil {
// Comment exists, check authorization
myUserModel := c.MustGet("my_user_model").(users.UserModel)
articleUserModel := GetArticleUserModel(myUserModel)
if commentModel.AuthorID != articleUserModel.ID {
c.JSON(http.StatusForbidden, common.NewError("comment", errors.New("you are not the author")))
return
}
}
// Delete regardless of existence (idempotent)
if err := DeleteCommentModel([]uint{id}); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
c.JSON(http.StatusOK, gin.H{"comment": "delete success"})
}
func ArticleCommentList(c *gin.Context) {
slug := c.Param("slug")
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Invalid slug")))
return
}
err = articleModel.getComments()
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Database error")))
return
}
serializer := CommentsSerializer{c, articleModel.Comments}
c.JSON(http.StatusOK, gin.H{"comments": serializer.Response()})
}
func TagList(c *gin.Context) {
tagModels, err := getAllTags()
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
return
}
serializer := TagsSerializer{c, tagModels}
c.JSON(http.StatusOK, gin.H{"tags": serializer.Response()})
}

View File

@@ -0,0 +1,180 @@
package articles
import (
"sort"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/users"
)
type TagSerializer struct {
C *gin.Context
TagModel
}
type TagsSerializer struct {
C *gin.Context
Tags []TagModel
}
func (s *TagSerializer) Response() string {
return s.TagModel.Tag
}
func (s *TagsSerializer) Response() []string {
response := []string{}
for _, tag := range s.Tags {
serializer := TagSerializer{C: s.C, TagModel: tag}
response = append(response, serializer.Response())
}
return response
}
type ArticleUserSerializer struct {
C *gin.Context
ArticleUserModel
}
func (s *ArticleUserSerializer) Response() users.ProfileResponse {
response := users.ProfileSerializer{C: s.C, UserModel: s.ArticleUserModel.UserModel}
return response.Response()
}
type ArticleSerializer struct {
C *gin.Context
ArticleModel
}
type ArticleResponse struct {
ID uint `json:"-"`
Title string `json:"title"`
Slug string `json:"slug"`
Description string `json:"description"`
Body string `json:"body"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Author users.ProfileResponse `json:"author"`
Tags []string `json:"tagList"`
Favorite bool `json:"favorited"`
FavoritesCount uint `json:"favoritesCount"`
}
type ArticlesSerializer struct {
C *gin.Context
Articles []ArticleModel
}
func (s *ArticleSerializer) Response() ArticleResponse {
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
response := ArticleResponse{
ID: s.ID,
Slug: s.Slug,
Title: s.Title,
Description: s.Description,
Body: s.Body,
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
//UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339Nano),
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
Author: authorSerializer.Response(),
Favorite: s.isFavoriteBy(GetArticleUserModel(myUserModel)),
FavoritesCount: s.favoritesCount(),
}
response.Tags = make([]string, 0)
for _, tag := range s.Tags {
serializer := TagSerializer{C: s.C, TagModel: tag}
response.Tags = append(response.Tags, serializer.Response())
}
sort.Strings(response.Tags)
return response
}
// ResponseWithPreloaded creates response using preloaded favorite data to avoid N+1 queries
func (s *ArticleSerializer) ResponseWithPreloaded(favorited bool, favoritesCount uint) ArticleResponse {
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
response := ArticleResponse{
ID: s.ID,
Slug: s.Slug,
Title: s.Title,
Description: s.Description,
Body: s.Body,
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
Author: authorSerializer.Response(),
Favorite: favorited,
FavoritesCount: favoritesCount,
}
response.Tags = make([]string, 0)
for _, tag := range s.Tags {
serializer := TagSerializer{C: s.C, TagModel: tag}
response.Tags = append(response.Tags, serializer.Response())
}
sort.Strings(response.Tags)
return response
}
func (s *ArticlesSerializer) Response() []ArticleResponse {
response := []ArticleResponse{}
if len(s.Articles) == 0 {
return response
}
// Batch fetch favorite counts and status
var articleIDs []uint
for _, article := range s.Articles {
articleIDs = append(articleIDs, article.ID)
}
favoriteCounts := BatchGetFavoriteCounts(articleIDs)
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
articleUserModel := GetArticleUserModel(myUserModel)
favoriteStatus := BatchGetFavoriteStatus(articleIDs, articleUserModel.ID)
for _, article := range s.Articles {
serializer := ArticleSerializer{C: s.C, ArticleModel: article}
favorited := favoriteStatus[article.ID]
count := favoriteCounts[article.ID]
response = append(response, serializer.ResponseWithPreloaded(favorited, count))
}
return response
}
type CommentSerializer struct {
C *gin.Context
CommentModel
}
type CommentsSerializer struct {
C *gin.Context
Comments []CommentModel
}
type CommentResponse struct {
ID uint `json:"id"`
Body string `json:"body"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Author users.ProfileResponse `json:"author"`
}
func (s *CommentSerializer) Response() CommentResponse {
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
response := CommentResponse{
ID: s.ID,
Body: s.Body,
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
Author: authorSerializer.Response(),
}
return response
}
func (s *CommentsSerializer) Response() []CommentResponse {
response := []CommentResponse{}
for _, comment := range s.Comments {
serializer := CommentSerializer{C: s.C, CommentModel: comment}
response = append(response, serializer.Response())
}
return response
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
package articles
import (
"github.com/gin-gonic/gin"
"github.com/gosimple/slug"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/users"
)
type ArticleModelValidator struct {
Article struct {
Title string `form:"title" json:"title" binding:"required,min=4"`
Description string `form:"description" json:"description" binding:"required,max=2048"`
Body string `form:"body" json:"body" binding:"required,max=2048"`
Tags []string `form:"tagList" json:"tagList"`
} `json:"article"`
articleModel ArticleModel `json:"-"`
}
func NewArticleModelValidator() ArticleModelValidator {
return ArticleModelValidator{}
}
func NewArticleModelValidatorFillWith(articleModel ArticleModel) ArticleModelValidator {
articleModelValidator := NewArticleModelValidator()
articleModelValidator.Article.Title = articleModel.Title
articleModelValidator.Article.Description = articleModel.Description
articleModelValidator.Article.Body = articleModel.Body
for _, tagModel := range articleModel.Tags {
articleModelValidator.Article.Tags = append(articleModelValidator.Article.Tags, tagModel.Tag)
}
return articleModelValidator
}
func (s *ArticleModelValidator) Bind(c *gin.Context) error {
myUserModel := c.MustGet("my_user_model").(users.UserModel)
err := common.Bind(c, s)
if err != nil {
return err
}
s.articleModel.Slug = slug.Make(s.Article.Title)
s.articleModel.Title = s.Article.Title
s.articleModel.Description = s.Article.Description
s.articleModel.Body = s.Article.Body
s.articleModel.Author = GetArticleUserModel(myUserModel)
s.articleModel.setTags(s.Article.Tags)
return nil
}
type CommentModelValidator struct {
Comment struct {
Body string `form:"body" json:"body" binding:"required,max=2048"`
} `json:"comment"`
commentModel CommentModel `json:"-"`
}
func NewCommentModelValidator() CommentModelValidator {
return CommentModelValidator{}
}
func (s *CommentModelValidator) Bind(c *gin.Context) error {
myUserModel := c.MustGet("my_user_model").(users.UserModel)
err := common.Bind(c, s)
if err != nil {
return err
}
s.commentModel.Body = s.Comment.Body
s.commentModel.Author = GetArticleUserModel(myUserModel)
return nil
}

BIN
webapp-back/bin/api-server Normal file

Binary file not shown.

View File

@@ -0,0 +1,61 @@
package main
import (
"fmt"
"log"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/config"
)
type Asset struct {
ID int64 `gorm:"column:id"`
AssetCode string `gorm:"column:asset_code"`
Name string `gorm:"column:name"`
ContractAddress string `gorm:"column:contract_address"`
}
func main() {
// Load configuration
cfg := config.Load()
// Initialize database
if cfg.DBType == "mysql" {
common.InitMySQL()
} else {
common.Init()
}
db := common.GetDB()
// Query YT assets
var assets []Asset
err := db.Table("assets").
Where("asset_code IN (?, ?, ?)", "YT-A", "YT-B", "YT-C").
Find(&assets).Error
if err != nil {
log.Fatalf("Failed to query assets: %v", err)
}
fmt.Println("=== 数据库中的 YT 合约地址 ===")
for _, asset := range assets {
fmt.Printf("%s: %s\n", asset.AssetCode, asset.ContractAddress)
}
// Query YTLPToken from system_contracts
var ytlp struct {
Name string `gorm:"column:name"`
Address string `gorm:"column:address"`
}
err = db.Table("system_contracts").
Where("name = ? AND is_active = ?", "YTLPToken", 1).
Select("name, address").
First(&ytlp).Error
if err != nil {
log.Printf("Failed to query YTLPToken: %v", err)
} else {
fmt.Println("\n=== ytLP 地址 ===")
fmt.Printf("%s: %s\n", ytlp.Name, ytlp.Address)
}
}

Binary file not shown.

View File

@@ -0,0 +1,113 @@
package common
import (
"fmt"
"os"
"path/filepath"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Database struct {
*gorm.DB
}
var DB *gorm.DB
// GetDBPath returns the database path from environment or default.
// Exported for use in tests.
func GetDBPath() string {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./data/gorm.db"
}
return dbPath
}
// GetTestDBPath returns the test database path from environment or default.
// Exported for use in tests.
func GetTestDBPath() string {
testDBPath := os.Getenv("TEST_DB_PATH")
if testDBPath == "" {
testDBPath = "./data/gorm_test.db"
}
return testDBPath
}
// ensureDir creates the directory for the database file if it doesn't exist
func ensureDir(filePath string) error {
dir := filepath.Dir(filePath)
if dir != "" && dir != "." {
return os.MkdirAll(dir, 0750)
}
return nil
}
// Opening a database and save the reference to `Database` struct.
func Init() *gorm.DB {
dbPath := GetDBPath()
// Ensure the directory exists
if err := ensureDir(dbPath); err != nil {
fmt.Println("db err: (Init - create dir) ", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
fmt.Println("db err: (Init) ", err)
}
sqlDB, err := db.DB()
if err != nil {
fmt.Println("db err: (Init - get sql.DB) ", err)
} else {
sqlDB.SetMaxIdleConns(10)
}
DB = db
return DB
}
// This function will create a temporarily database for running testing cases
func TestDBInit() *gorm.DB {
testDBPath := GetTestDBPath()
// Ensure the directory exists
if err := ensureDir(testDBPath); err != nil {
fmt.Println("db err: (TestDBInit - create dir) ", err)
}
test_db, err := gorm.Open(sqlite.Open(testDBPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
fmt.Println("db err: (TestDBInit) ", err)
}
sqlDB, err := test_db.DB()
if err != nil {
fmt.Println("db err: (TestDBInit - get sql.DB) ", err)
} else {
sqlDB.SetMaxIdleConns(3)
}
DB = test_db
return DB
}
// Delete the database after running testing cases.
func TestDBFree(test_db *gorm.DB) error {
sqlDB, err := test_db.DB()
if err != nil {
return err
}
if err := sqlDB.Close(); err != nil {
return err
}
testDBPath := GetTestDBPath()
err = os.Remove(testDBPath)
return err
}
// Using this function to get a connection, you can create your connection pool here.
func GetDB() *gorm.DB {
return DB
}

View File

@@ -0,0 +1,64 @@
package common
import (
"fmt"
"log"
"time"
"github.com/gothinkster/golang-gin-realworld-example-app/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// InitMySQL initializes MySQL database connection
func InitMySQL() *gorm.DB {
cfg := config.AppConfig
if cfg == nil {
log.Fatal("Config not loaded. Please call config.Load() first")
}
// Build DSN
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.DBUser,
cfg.DBPassword,
cfg.DBHost,
cfg.DBPort,
cfg.DBName,
)
// Open database connection
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("Failed to connect to MySQL:", err)
}
// Get underlying sql.DB
sqlDB, err := db.DB()
if err != nil {
log.Fatal("Failed to get sql.DB:", err)
}
// Set connection pool parameters
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
// Test connection
if err := sqlDB.Ping(); err != nil {
log.Fatal("Failed to ping MySQL:", err)
}
log.Println("✓ MySQL connected successfully")
DB = db
return DB
}
// InitRedis initializes Redis client (you'll need to add redis package)
func InitRedis() {
// TODO: Add Redis initialization
// You'll need to add github.com/redis/go-redis/v9
log.Println("✓ Redis initialization skipped (add go-redis package)")
}

View File

@@ -0,0 +1,35 @@
package common
import (
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v5"
)
// HeaderTokenMock adds authorization token to request header for testing
func HeaderTokenMock(req *http.Request, u uint) {
req.Header.Set("Authorization", fmt.Sprintf("Token %v", GenToken(u)))
}
// ExtractTokenFromHeader extracts JWT token from Authorization header
// Used for testing token extraction logic
func ExtractTokenFromHeader(authHeader string) string {
if len(authHeader) > 6 && authHeader[:6] == "Token " {
return authHeader[6:]
}
return ""
}
// VerifyTokenClaims verifies a JWT token and returns claims for testing
func VerifyTokenClaims(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(JWTSecret), nil
})
if err != nil {
return nil, err
}
return token.Claims.(jwt.MapClaims), nil
}

View File

@@ -0,0 +1,368 @@
package common
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestConnectingDatabase(t *testing.T) {
asserts := assert.New(t)
db := Init()
dbPath := GetDBPath()
// Test create & close DB
_, err := os.Stat(dbPath)
asserts.NoError(err, "Db should exist")
sqlDB, err := db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
// Test get a connecting from connection pools
connection := GetDB()
sqlDB, err = connection.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
sqlDB.Close()
// Test DB exceptions
os.Chmod(dbPath, 0000)
db = Init()
sqlDB, err = db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.Error(sqlDB.Ping(), "Db should not be able to ping")
sqlDB.Close()
os.Chmod(dbPath, 0644)
}
func TestConnectingTestDatabase(t *testing.T) {
asserts := assert.New(t)
// Test create & close DB
db := TestDBInit()
testDBPath := GetTestDBPath()
_, err := os.Stat(testDBPath)
asserts.NoError(err, "Db should exist")
sqlDB, err := db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
TestDBFree(db)
// Test close delete DB
db = TestDBInit()
TestDBFree(db)
_, err = os.Stat(testDBPath)
asserts.Error(err, "Db should not exist")
}
func TestDBDirCreation(t *testing.T) {
asserts := assert.New(t)
// Set a nested path
os.Setenv("TEST_DB_PATH", "tmp/nested/test.db")
defer os.Unsetenv("TEST_DB_PATH")
db := TestDBInit()
testDBPath := GetTestDBPath()
_, err := os.Stat(testDBPath)
asserts.NoError(err, "Db should exist in nested directory")
TestDBFree(db)
// Cleanup directory
os.RemoveAll("tmp/nested")
}
func TestDBPathOverride(t *testing.T) {
asserts := assert.New(t)
customPath := "./custom_test.db"
os.Setenv("TEST_DB_PATH", customPath)
defer os.Unsetenv("TEST_DB_PATH")
asserts.Equal(customPath, GetTestDBPath(), "Should use env var")
}
func TestRandString(t *testing.T) {
asserts := assert.New(t)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
str := RandString(0)
asserts.Equal(str, "", "length should be ''")
str = RandString(10)
asserts.Equal(len(str), 10, "length should be 10")
for _, ch := range str {
asserts.Contains(letters, ch, "char should be a-z|A-Z|0-9")
}
}
func TestRandInt(t *testing.T) {
asserts := assert.New(t)
// Test that RandInt returns a value in valid range
val := RandInt()
asserts.GreaterOrEqual(val, 0, "RandInt should be >= 0")
asserts.Less(val, 1000000, "RandInt should be < 1000000")
// Test multiple calls return different values (statistically)
vals := make(map[int]bool)
for i := 0; i < 10; i++ {
vals[RandInt()] = true
}
asserts.Greater(len(vals), 1, "RandInt should return varied values")
}
func TestGenToken(t *testing.T) {
asserts := assert.New(t)
token := GenToken(2)
asserts.IsType(token, string("token"), "token type should be string")
asserts.Len(token, 115, "JWT's length should be 115")
}
func TestGenTokenMultipleUsers(t *testing.T) {
asserts := assert.New(t)
token1 := GenToken(1)
token2 := GenToken(2)
token100 := GenToken(100)
asserts.NotEqual(token1, token2, "Different user IDs should generate different tokens")
asserts.NotEqual(token2, token100, "Different user IDs should generate different tokens")
// Token length can vary by 1 character due to timestamp changes
asserts.GreaterOrEqual(len(token1), 114, "JWT's length should be >= 114 for user 1")
asserts.LessOrEqual(len(token1), 120, "JWT's length should be <= 120 for user 1")
asserts.GreaterOrEqual(len(token100), 114, "JWT's length should be >= 114 for user 100")
asserts.LessOrEqual(len(token100), 120, "JWT's length should be <= 120 for user 100")
}
func TestHeaderTokenMock(t *testing.T) {
asserts := assert.New(t)
req, _ := http.NewRequest("GET", "/test", nil)
token := GenToken(5)
HeaderTokenMock(req, 5)
authHeader := req.Header.Get("Authorization")
asserts.Equal(fmt.Sprintf("Token %s", token), authHeader, "Authorization header should be set correctly")
}
func TestExtractTokenFromHeader(t *testing.T) {
asserts := assert.New(t)
token := "valid.jwt.token"
header := fmt.Sprintf("Token %s", token)
extracted := ExtractTokenFromHeader(header)
asserts.Equal(token, extracted, "Should extract token from header")
invalidHeader := "Bearer " + token
extracted = ExtractTokenFromHeader(invalidHeader)
asserts.Empty(extracted, "Should return empty for non-Token header")
shortHeader := "Token"
extracted = ExtractTokenFromHeader(shortHeader)
asserts.Empty(extracted, "Should return empty for short header")
}
func TestVerifyTokenClaims(t *testing.T) {
asserts := assert.New(t)
// Test valid token
userID := uint(123)
token := GenToken(userID)
claims, err := VerifyTokenClaims(token)
asserts.NoError(err, "VerifyTokenClaims should not error for valid token")
asserts.Equal(float64(userID), claims["id"], "Claims should contain correct user ID")
// Test invalid token
_, err = VerifyTokenClaims("invalid.token.string")
asserts.Error(err, "VerifyTokenClaims should error for invalid token")
}
func TestNewValidatorError(t *testing.T) {
asserts := assert.New(t)
type Login struct {
Username string `form:"username" json:"username" binding:"required,alphanum,min=4,max=255"`
Password string `form:"password" json:"password" binding:"required,min=8,max=255"`
}
var requestTests = []struct {
bodyData string
expectedCode int
responseRegexg string
msg string
}{
{
`{"username": "wangzitian0","password": "0123456789"}`,
http.StatusOK,
`{"status":"you are logged in"}`,
"valid data and should return StatusCreated",
},
{
`{"username": "wangzitian0","password": "01234567866"}`,
http.StatusUnauthorized,
`{"errors":{"user":"wrong username or password"}}`,
"wrong login status should return StatusUnauthorized",
},
{
`{"username": "wangzitian0","password": "0122"}`,
http.StatusUnprocessableEntity,
`{"errors":{"Password":"{min: 8}"}}`,
"invalid password of too short and should return StatusUnprocessableEntity",
},
{
`{"username": "_wangzitian0","password": "0123456789"}`,
http.StatusUnprocessableEntity,
`{"errors":{"Username":"{key: alphanum}"}}`,
"invalid username of non alphanum and should return StatusUnprocessableEntity",
},
}
r := gin.Default()
r.POST("/login", func(c *gin.Context) {
var json Login
if err := Bind(c, &json); err == nil {
if json.Username == "wangzitian0" && json.Password == "0123456789" {
c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
} else {
c.JSON(http.StatusUnauthorized, NewError("user", errors.New("wrong username or password")))
}
} else {
c.JSON(http.StatusUnprocessableEntity, NewValidatorError(err))
}
})
for _, testData := range requestTests {
bodyData := testData.bodyData
req, err := http.NewRequest("POST", "/login", bytes.NewBufferString(bodyData))
req.Header.Set("Content-Type", "application/json")
asserts.NoError(err)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
asserts.Equal(testData.expectedCode, w.Code, "Response Status - "+testData.msg)
asserts.Regexp(testData.responseRegexg, w.Body.String(), "Response Content - "+testData.msg)
}
}
func TestNewError(t *testing.T) {
assert := assert.New(t)
db := TestDBInit()
defer TestDBFree(db)
type NonExistentTable struct {
Field string
}
// db.AutoMigrate(NonExistentTable{}) // Intentionally skipped to cause error
err := db.Find(&NonExistentTable{Field: "value"}).Error
if err == nil {
err = errors.New("no such table: non_existent_tables")
}
commonError := NewError("database", err)
assert.IsType(commonError, commonError, "commonError should have right type")
// The exact error message might vary by driver, checking key presence is safer, but keeping original assertion style
assert.Contains(commonError.Errors, "database", "commonError should contain database key")
}
func TestDatabaseDirCreation(t *testing.T) {
asserts := assert.New(t)
// Test directory creation in Init
origDBPath := os.Getenv("DB_PATH")
defer os.Setenv("DB_PATH", origDBPath)
// Create a temp dir path
tempDir := "./tmp/test_nested/db"
os.Setenv("DB_PATH", tempDir+"/test.db")
// Clean up before test
os.RemoveAll("./tmp/test_nested")
// Init should create the directory
db := Init()
sqlDB, err := db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "DB should be created in nested directory")
// Clean up after test
sqlDB.Close()
os.RemoveAll("./tmp/test_nested")
}
func TestDBInitDirCreation(t *testing.T) {
asserts := assert.New(t)
// Test directory creation in TestDBInit
origTestDBPath := os.Getenv("TEST_DB_PATH")
defer os.Setenv("TEST_DB_PATH", origTestDBPath)
// Create a temp dir path
tempDir := "./tmp/test_nested_testdb"
os.Setenv("TEST_DB_PATH", tempDir+"/test.db")
// Clean up before test
os.RemoveAll(tempDir)
// TestDBInit should create the directory
db := TestDBInit()
sqlDB, err := db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "Test DB should be created in nested directory")
// Clean up after test
TestDBFree(db)
os.RemoveAll(tempDir)
}
func TestDatabaseWithCurrentDirectory(t *testing.T) {
asserts := assert.New(t)
// Test with simple filename (no directory)
origDBPath := os.Getenv("DB_PATH")
defer os.Setenv("DB_PATH", origDBPath)
os.Setenv("DB_PATH", "test_simple.db")
// Init should work without directory creation
db := Init()
sqlDB, err := db.DB()
asserts.NoError(err, "Should get sql.DB")
asserts.NoError(sqlDB.Ping(), "DB should be created in current directory")
// Clean up
sqlDB.Close()
os.Remove("test_simple.db")
}

View File

@@ -0,0 +1,99 @@
// Common tools and helper functions
package common
import (
"crypto/rand"
"fmt"
"math/big"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/golang-jwt/jwt/v5"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// A helper function to generate random string
func RandString(n int) string {
b := make([]rune, n)
for i := range b {
randIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
panic(err)
}
b[i] = letters[randIdx.Int64()]
}
return string(b)
}
// A helper function to generate random int
func RandInt() int {
randNum, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
panic(err)
}
return int(randNum.Int64())
}
// Keep this two config private, it should not expose to open source
const JWTSecret = "A String Very Very Very Strong!!@##$!@#$" // #nosec G101
const RandomPassword = "A String Very Very Very Random!!@##$!@#4" // #nosec G101
// A Util function to generate jwt_token which can be used in the request header
func GenToken(id uint) string {
jwt_token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"id": id,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
// Sign and get the complete encoded token as a string
token, err := jwt_token.SignedString([]byte(JWTSecret))
if err != nil {
fmt.Printf("failed to sign JWT token for id %d: %v\n", id, err)
return ""
}
return token
}
// My own Error type that will help return my customized Error info
//
// {"database": {"hello":"no such table", error: "not_exists"}}
type CommonError struct {
Errors map[string]interface{} `json:"errors"`
}
// To handle the error returned by c.Bind in gin framework
// https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go
func NewValidatorError(err error) CommonError {
res := CommonError{}
res.Errors = make(map[string]interface{})
errs := err.(validator.ValidationErrors)
for _, v := range errs {
// can translate each error one at a time.
//fmt.Println("gg",v.NameNamespace)
if v.Param() != "" {
res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param())
} else {
res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag())
}
}
return res
}
// Wrap the error info in an object
func NewError(key string, err error) CommonError {
res := CommonError{}
res.Errors = make(map[string]interface{})
res.Errors[key] = err.Error()
return res
}
// Changed the c.MustBindWith() -> c.ShouldBindWith().
// I don't want to auto return 400 when error happened.
// origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go
func Bind(c *gin.Context, obj interface{}) error {
b := binding.Default(c.Request.Method, c.ContentType())
return c.ShouldBindWith(obj, b)
}

View File

@@ -0,0 +1,108 @@
package config
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
// Server
Port string
GinMode string
// Database
DBType string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
// Redis
RedisHost string
RedisPort string
RedisPassword string
RedisDB string
// Auth
AuthCenterURL string
AuthTokenCacheTTL string
AllowedOrigins string
// Web3
ArbSepoliaRPC string
BSCTestnetRPC string
// Liquidation Bot
LiquidatorPrivateKey string
// Collateral Buyer Bot
CollateralBuyerPrivateKey string
CollateralBuyerMaxAmount string
CollateralBuyerSlippage int
}
var AppConfig *Config
// Load configuration from environment variables
func Load() *Config {
// Load .env file
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
AppConfig = &Config{
Port: getEnv("PORT", "8080"),
GinMode: getEnv("GIN_MODE", "debug"),
DBType: getEnv("DB_TYPE", "mysql"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "assetx"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: getEnv("REDIS_PORT", "6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnv("REDIS_DB", "0"),
AuthCenterURL: getEnv("AUTH_CENTER_URL", "https://auth.upay01.com"),
AuthTokenCacheTTL: getEnv("AUTH_TOKEN_CACHE_TTL", "300"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:8000"),
ArbSepoliaRPC: getEnv("ARB_SEPOLIA_RPC", "https://sepolia-rollup.arbitrum.io/rpc"),
BSCTestnetRPC: getEnv("BSC_TESTNET_RPC", "https://api.zan.top/node/v1/bsc/testnet/baf84c429d284bb5b676cb8c9ca21c07"),
LiquidatorPrivateKey: getEnv("LIQUIDATOR_PRIVATE_KEY", ""),
CollateralBuyerPrivateKey: getEnv("COLLATERAL_BUYER_PRIVATE_KEY", ""),
CollateralBuyerMaxAmount: getEnv("COLLATERAL_BUYER_MAX_AMOUNT", "100"),
CollateralBuyerSlippage: getEnvInt("COLLATERAL_BUYER_SLIPPAGE", 2),
}
return AppConfig
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func getEnvInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
var i int
if _, err := fmt.Sscanf(value, "%d", &i); err != nil {
return defaultValue
}
return i
}

View File

@@ -0,0 +1 @@
# Database files are stored here and ignored by git

8
webapp-back/doc.go Normal file
View File

@@ -0,0 +1,8 @@
/*
Golang Gonic/Gin startup project fork form RealWorld https://realworld.io
# You can find all the spec and front end demo in the Realworld project
This project will include objects and relationships' CRUD, you will know how to write a golang/gin app though small perfectly formed.
*/
package main

View File

@@ -0,0 +1,93 @@
package fundmarket
import (
"math/big"
"net/http"
"strings"
"github.com/gin-gonic/gin"
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
)
// GetNetDeposited computes the net USDC deposited by a wallet address across all YT vaults.
//
// GET /api/fundmarket/net-deposited?address=0x...&chain_id=97
//
// Net deposited = Σ(amountIn WHERE tokenIn=USDC) - Σ(amountOut WHERE tokenOut=USDC)
// (buy YT: USDC → YT, tokenIn=USDC; sell YT: YT → USDC, tokenOut=USDC)
//
// Returns netDepositedUSD (float64) for the frontend to compute:
// Your Total Earning = currentValue (on-chain) - netDepositedUSD
func GetNetDeposited(c *gin.Context) {
address := strings.ToLower(c.Query("address"))
if address == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "address required"})
return
}
chainIDStr := c.DefaultQuery("chain_id", "97")
database := db.GetDB()
// Look up USDC address for this chain (token_role = 'stablecoin')
var usdcAddr struct {
ContractAddress string `gorm:"column:contract_address"`
}
if err := database.Table("assets").
Where("token_role = 'stablecoin' AND chain_id = ?", chainIDStr).
Select("contract_address").
First(&usdcAddr).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "USDC address not found"})
return
}
usdcAddress := strings.ToLower(usdcAddr.ContractAddress)
// Sum amountIn where tokenIn = USDC (user bought YT, paid USDC)
type sumResult struct {
Total string
}
var buySum sumResult
database.Raw(`
SELECT COALESCE(SUM(CAST(amount_in AS DECIMAL(65,0))), 0) AS total
FROM yt_swap_records
WHERE account = ? AND chain_id = ? AND LOWER(token_in) = ?
`, address, chainIDStr, usdcAddress).Scan(&buySum)
// Sum amountOut where tokenOut = USDC (user sold YT, received USDC)
var sellSum sumResult
database.Raw(`
SELECT COALESCE(SUM(CAST(amount_out AS DECIMAL(65,0))), 0) AS total
FROM yt_swap_records
WHERE account = ? AND chain_id = ? AND LOWER(token_out) = ?
`, address, chainIDStr, usdcAddress).Scan(&sellSum)
buyWei, _ := new(big.Int).SetString(buySum.Total, 10)
sellWei, _ := new(big.Int).SetString(sellSum.Total, 10)
if buyWei == nil {
buyWei = big.NewInt(0)
}
if sellWei == nil {
sellWei = big.NewInt(0)
}
netWei := new(big.Int).Sub(buyWei, sellWei)
if netWei.Sign() < 0 {
netWei = big.NewInt(0)
}
// Convert from 18-decimal wei to float64 USD
divisor := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
netUSD, _ := new(big.Float).Quo(new(big.Float).SetInt(netWei), divisor).Float64()
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"address": address,
"chainId": chainIDStr,
"usdcAddress": usdcAddress,
"buyWei": buyWei.String(),
"sellWei": sellWei.String(),
"netDepositedWei": netWei.String(),
"netDepositedUSD": netUSD,
},
})
}

View File

@@ -0,0 +1,434 @@
package fundmarket
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// GetProducts returns all fund market products
func GetProducts(c *gin.Context) {
db := common.GetDB()
// Query assets with their performance data
// token_list=1: return all active assets (for dropdowns)
// default: exclude stablecoins (Fund Market product listing)
var assets []models.Asset
query := db.Where("is_active = ?", true)
if c.Query("token_list") != "1" {
query = query.Where("token_role != ?", "stablecoin")
}
err := query.Find(&assets).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to fetch products",
})
return
}
// Get performance data for each asset
products := make([]models.ProductResponse, 0)
for _, asset := range assets {
var perf models.AssetPerformance
db.Where("asset_id = ?", asset.ID).
Order("snapshot_date DESC").
First(&perf)
riskLabel := asset.RiskLabel
if riskLabel == "" {
riskLabel = "--"
}
circulatingSupplyStr := "--"
if perf.CirculatingSupply > 0 {
circulatingSupplyStr = formatUSD(perf.CirculatingSupply)
}
poolCapStr := "--"
if asset.PoolCapUSD > 0 {
poolCapStr = formatUSD(asset.PoolCapUSD)
}
product := models.ProductResponse{
ID: int(asset.ID),
Name: asset.Name,
TokenSymbol: asset.TokenSymbol,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
TokenRole: asset.TokenRole,
Category: asset.Category,
CategoryColor: asset.CategoryColor,
IconURL: asset.IconURL,
YieldAPY: fmt.Sprintf("%.1f%%", asset.TargetAPY),
PoolCap: poolCapStr,
Risk: riskLabel,
RiskLevel: asset.RiskLevel,
CirculatingSupply: circulatingSupplyStr,
PoolCapacityPercent: perf.PoolCapacityPercent,
}
products = append(products, product)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
})
}
// GetStats returns fund market statistics
func GetStats(c *gin.Context) {
db := common.GetDB()
// Total Value Locked = sum of latest tvlusd across all active assets
var totalTVL float64
db.Raw(`
SELECT COALESCE(SUM(ap.tvlusd), 0)
FROM asset_performance ap
INNER JOIN (
SELECT asset_id, MAX(snapshot_date) AS latest
FROM asset_performance
GROUP BY asset_id
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
INNER JOIN assets a ON a.id = ap.asset_id AND a.is_active = true
`).Scan(&totalTVL)
// Cumulative Yield = sum of cumulative_yield_usd from latest snapshots
var totalYield float64
db.Raw(`
SELECT COALESCE(SUM(ap.cumulative_yield_usd), 0)
FROM asset_performance ap
INNER JOIN (
SELECT asset_id, MAX(snapshot_date) AS latest
FROM asset_performance
GROUP BY asset_id
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
`).Scan(&totalYield)
stats := []models.StatsResponse{
{
Label: "Total Value Locked",
Value: formatUSD(totalTVL),
Change: "",
IsPositive: true,
},
{
Label: "Cumulative Yield",
Value: formatUSD(totalYield),
Change: "",
IsPositive: true,
},
{
Label: "Your Total Balance",
Value: "--",
Change: "",
IsPositive: true,
},
{
Label: "Your Total Earning",
Value: "--",
Change: "",
IsPositive: true,
},
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetProductByID returns detailed product information by ID
func GetProductByID(c *gin.Context) {
db := common.GetDB()
id := c.Param("id")
var asset models.Asset
err := db.Where("id = ? AND is_active = ?", id, true).First(&asset).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Product not found",
})
return
}
// Get performance data
var perf models.AssetPerformance
db.Where("asset_id = ?", asset.ID).
Order("snapshot_date DESC").
First(&perf)
// Calculate volume change vs 7-day average (excluding today)
var avg7dVolume float64
db.Raw(`
SELECT AVG(volume_24h_usd) FROM (
SELECT volume_24h_usd FROM asset_performance
WHERE asset_id = ? AND snapshot_date < CURDATE()
ORDER BY snapshot_date DESC
LIMIT 7
) t
`, asset.ID).Scan(&avg7dVolume)
volumeChangeVsAvg := 0.0
if avg7dVolume > 0 {
volumeChangeVsAvg = (perf.Volume24hUSD - avg7dVolume) / avg7dVolume * 100
}
// Get custody info
var custody models.AssetCustody
hasCustody := db.Where("asset_id = ?", asset.ID).First(&custody).Error == nil
// Get audit reports
var auditReports []models.AssetAuditReport
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
Order("report_date DESC").
Limit(10).
Find(&auditReports)
// Get product links
var productLinks []models.ProductLink
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
Order("display_order ASC, id ASC").
Find(&productLinks)
// Build response
detail := models.ProductDetailResponse{
// Basic Info
ID: int(asset.ID),
AssetCode: asset.AssetCode,
Name: asset.Name,
Subtitle: asset.Subtitle,
Description: asset.Description,
TokenSymbol: asset.TokenSymbol,
Decimals: asset.Decimals,
// Investment Parameters
UnderlyingAssets: asset.UnderlyingAssets,
PoolCapUSD: asset.PoolCapUSD,
RiskLevel: asset.RiskLevel,
RiskLabel: asset.RiskLabel,
TargetAPY: asset.TargetAPY,
// Contract Info
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
// Display Info
Category: asset.Category,
CategoryColor: asset.CategoryColor,
IconURL: asset.IconURL,
// Performance Data
CurrentAPY: perf.CurrentAPY,
TVLUSD: perf.TVLUSD,
Volume24hUSD: perf.Volume24hUSD,
VolumeChangeVsAvg: volumeChangeVsAvg,
CirculatingSupply: perf.CirculatingSupply,
PoolCapacityPercent: perf.PoolCapacityPercent,
CurrentPrice: perf.YTPrice,
}
// Add custody info if exists
if hasCustody {
lastAuditDate := ""
if custody.LastAuditDate.Time != nil {
lastAuditDate = custody.LastAuditDate.Time.Format("02 Jan 2006")
}
var additionalInfo map[string]interface{}
if custody.AdditionalInfo != "" {
_ = json.Unmarshal([]byte(custody.AdditionalInfo), &additionalInfo)
}
if additionalInfo == nil {
additionalInfo = map[string]interface{}{}
}
// 从 maturity_date 动态计算 days_remaining不再依赖存储值
if maturityStr, ok := additionalInfo["maturity_date"].(string); ok && maturityStr != "" {
if maturityDate, err := time.Parse("2006-01-02", maturityStr); err == nil {
days := int(time.Until(maturityDate).Hours() / 24)
additionalInfo["days_remaining"] = days
}
} else {
delete(additionalInfo, "days_remaining")
}
detail.Custody = &models.CustodyInfo{
CustodianName: custody.CustodianName,
CustodyType: custody.CustodyType,
CustodyLocation: custody.CustodyLocation,
AuditorName: custody.AuditorName,
LastAuditDate: lastAuditDate,
AuditReportURL: custody.AuditReportURL,
AdditionalInfo: additionalInfo,
}
}
// Add audit reports
detail.AuditReports = make([]models.AuditReportInfo, 0, len(auditReports))
for _, report := range auditReports {
detail.AuditReports = append(detail.AuditReports, models.AuditReportInfo{
ReportType: report.ReportType,
ReportTitle: report.ReportTitle,
ReportDate: report.ReportDate.Format("02 Jan 2006"),
AuditorName: report.AuditorName,
Summary: report.Summary,
ReportURL: report.ReportURL,
})
}
// Add product links
detail.ProductLinks = make([]models.ProductLinkInfo, 0, len(productLinks))
for _, link := range productLinks {
detail.ProductLinks = append(detail.ProductLinks, models.ProductLinkInfo{
LinkText: link.LinkText,
LinkURL: link.LinkURL,
Description: link.Description,
DisplayArea: link.DisplayArea,
DisplayOrder: link.DisplayOrder,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": detail,
})
}
// GetProductHistory returns daily APY/price history for a product (one point per day)
func GetProductHistory(c *gin.Context) {
db := common.GetDB()
id := c.Param("id")
// Pick the last snapshot of each calendar day (UTC), up to 30 days
var snapshots []models.APYSnapshot
if err := db.Raw(`
SELECT ap.* FROM apy_snapshots ap
INNER JOIN (
SELECT DATE(snapshot_time) AS snap_date, MAX(snapshot_time) AS latest
FROM apy_snapshots
WHERE asset_id = ?
GROUP BY DATE(snapshot_time)
ORDER BY snap_date DESC
LIMIT 30
) d ON DATE(ap.snapshot_time) = d.snap_date AND ap.snapshot_time = d.latest
WHERE ap.asset_id = ?
ORDER BY ap.snapshot_time ASC
`, id, id).Scan(&snapshots).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to fetch history",
})
return
}
type HistoryPoint struct {
Time string `json:"time"`
APY float64 `json:"apy"`
Price float64 `json:"price"`
}
points := make([]HistoryPoint, len(snapshots))
for i, s := range snapshots {
points[i] = HistoryPoint{
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
APY: s.APYValue,
Price: s.Price,
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": points,
})
}
// Helper function to format USD values
func formatUSD(value float64) string {
if value >= 1000000 {
return fmt.Sprintf("$%.1fM", value/1000000)
} else if value >= 1000 {
return fmt.Sprintf("$%.1fK", value/1000)
}
return fmt.Sprintf("$%.2f", value)
}
// GetDailyReturns returns daily net return data for a product by month.
// Query params: year (int), month (int). Defaults to current month.
func GetDailyReturns(c *gin.Context) {
db := common.GetDB()
id := c.Param("id")
now := time.Now().UTC()
year := now.Year()
month := int(now.Month())
if y, err := strconv.Atoi(c.Query("year")); err == nil {
year = y
}
if m, err := strconv.Atoi(c.Query("month")); err == nil && m >= 1 && m <= 12 {
month = m
}
startDate := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
endDate := startDate.AddDate(0, 1, 0)
// Include one day before month start for delta calculation
queryStart := startDate.AddDate(0, 0, -1)
var perfs []models.AssetPerformance
db.Where("asset_id = ? AND snapshot_date >= ? AND snapshot_date < ?", id, queryStart, endDate).
Order("snapshot_date ASC").
Find(&perfs)
// Map date string -> performance
type perfEntry struct {
ytPrice float64
hasData bool
}
perfMap := make(map[string]perfEntry)
for _, p := range perfs {
perfMap[p.SnapshotDate.Format("2006-01-02")] = perfEntry{ytPrice: p.YTPrice, hasData: true}
}
type DayReturn struct {
Date string `json:"date"`
YTPrice float64 `json:"ytPrice"`
DailyReturn float64 `json:"dailyReturn"`
HasData bool `json:"hasData"`
}
today := now.Format("2006-01-02")
var results []DayReturn
for d := startDate; d.Before(endDate); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
// Skip future days
if dateStr > today {
break
}
cur := perfMap[dateStr]
prev := perfMap[d.AddDate(0, 0, -1).Format("2006-01-02")]
dailyReturn := 0.0
if cur.hasData && prev.hasData && prev.ytPrice > 0 {
dailyReturn = (cur.ytPrice - prev.ytPrice) / prev.ytPrice * 100
}
results = append(results, DayReturn{
Date: dateStr,
YTPrice: cur.ytPrice,
DailyReturn: dailyReturn,
HasData: cur.hasData,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": results,
})
}

View File

@@ -0,0 +1,488 @@
package fundmarket
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/core/types"
"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"
"gorm.io/gorm"
)
const (
// YTAssetFactory on BSC Testnet
ytAssetFactoryAddressBSC = "0x37B2CD7D94ba1400a6FEB34804a32EfD555bbfc8"
// YTAssetFactory on Arbitrum Sepolia
ytAssetFactoryAddressArb = "0x37B2CD7D94ba1400a6FEB34804a32EfD555bbfc8"
// fastSnapshotInterval: price / TVL / APY — cheap single RPC call per asset
fastSnapshotInterval = 5 * time.Minute
// volumeSnapshotInterval: 24h volume via FilterLogs — heavier, runs less often
volumeSnapshotInterval = 30 * time.Minute
)
// Buy / Sell events — emitted by YT token contract
// Buy(address user, uint256 usdcAmount, uint256 ytAmount)
// Sell(address user, uint256 ytAmount, uint256 usdcAmount)
const buySellEventABIJSON = `[
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "user", "type": "address"},
{"indexed": false, "name": "usdcAmount", "type": "uint256"},
{"indexed": false, "name": "ytAmount", "type": "uint256"}
],
"name": "Buy",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "user", "type": "address"},
{"indexed": false, "name": "ytAmount", "type": "uint256"},
{"indexed": false, "name": "usdcAmount", "type": "uint256"}
],
"name": "Sell",
"type": "event"
}
]`
// blockTimeByChain returns approximate seconds-per-block for volume estimation
func blockTimeByChain(chainID int) float64 {
switch chainID {
case 97: return 3 // BSC Testnet ~3s/block
case 421614: return 0.25 // Arbitrum Sepolia ~0.25s/block
default: return 3
}
}
// YTAssetFactory.getVaultInfo(address) ABI
// Returns: exists(bool), totalAssets, idleAssets, managedAssets, totalSupply,
// hardCap, usdcPrice(8dec), ytPrice(30dec), nextRedemptionTime
const getVaultInfoABIJSON = `[{
"inputs":[{"internalType":"address","name":"_vault","type":"address"}],
"name":"getVaultInfo",
"outputs":[
{"internalType":"bool","name":"exists","type":"bool"},
{"internalType":"uint256","name":"totalAssets","type":"uint256"},
{"internalType":"uint256","name":"idleAssets","type":"uint256"},
{"internalType":"uint256","name":"managedAssets","type":"uint256"},
{"internalType":"uint256","name":"totalSupply","type":"uint256"},
{"internalType":"uint256","name":"hardCap","type":"uint256"},
{"internalType":"uint256","name":"usdcPrice","type":"uint256"},
{"internalType":"uint256","name":"ytPrice","type":"uint256"},
{"internalType":"uint256","name":"nextRedemptionTime","type":"uint256"}
],
"stateMutability":"view",
"type":"function"
}]`
// StartPriceSnapshot starts two background loops:
// - Fast loop (every 5min): price / TVL / APY via getVaultInfo
// - Volume loop (every 30min): 24h USDC volume via FilterLogs
//
// Call as: go fundmarket.StartPriceSnapshot(cfg)
func StartPriceSnapshot(cfg *config.Config) {
log.Println("=== Price Snapshot Service: fast=5min, volume=30min ===")
// Fast loop runs in a sub-goroutine so both loops start concurrently.
go func() {
runFastSnapshot(cfg)
t := time.NewTicker(fastSnapshotInterval)
defer t.Stop()
for range t.C {
runFastSnapshot(cfg)
}
}()
// Volume loop blocks this goroutine (keeps StartPriceSnapshot alive).
runVolumeSnapshot(cfg)
t := time.NewTicker(volumeSnapshotInterval)
defer t.Stop()
for range t.C {
runVolumeSnapshot(cfg)
}
}
// loadActiveAssets returns all active assets that have a contract address set.
func loadActiveAssets(database *gorm.DB, label string) ([]models.Asset, bool) {
var assets []models.Asset
if err := database.Where(
"is_active = ? AND contract_address IS NOT NULL AND contract_address != ''",
true,
).Find(&assets).Error; err != nil {
log.Printf("[%s] DB query error: %v", label, err)
return nil, false
}
if len(assets) == 0 {
log.Printf("[%s] No active assets with contract_address set, skipping", label)
return nil, false
}
return assets, true
}
// ── Fast snapshot (price / TVL / APY) ────────────────────────────────────────
func runFastSnapshot(cfg *config.Config) {
start := time.Now()
log.Printf("[FastSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
database := db.GetDB()
assets, ok := loadActiveAssets(database, "FastSnapshot")
if !ok {
return
}
factoryABI, err := abi.JSON(strings.NewReader(getVaultInfoABIJSON))
if err != nil {
log.Printf("[FastSnapshot] Parse factory ABI error: %v", err)
return
}
today := time.Now().Truncate(24 * time.Hour)
for _, asset := range assets {
rpcURL, factoryAddrStr := getSnapshotClientForChain(asset.ChainID, cfg)
if rpcURL == "" {
log.Printf("[FastSnapshot] %s: unsupported chain_id=%d, skipping", asset.AssetCode, asset.ChainID)
continue
}
client, err := ethclient.Dial(rpcURL)
if err != nil {
log.Printf("[FastSnapshot] %s: RPC connect error: %v", asset.AssetCode, err)
continue
}
factoryAddr := common.HexToAddress(factoryAddrStr)
if err := snapshotAssetFast(client, database, asset, factoryABI, factoryAddr, today); err != nil {
log.Printf("[FastSnapshot] %s error: %v", asset.AssetCode, err)
}
client.Close()
}
log.Printf("[FastSnapshot] Done in %v", time.Since(start))
}
func snapshotAssetFast(
client *ethclient.Client,
database *gorm.DB,
asset models.Asset,
factoryABI abi.ABI,
factoryAddr common.Address,
today time.Time,
) error {
ctx := context.Background()
vaultAddr := common.HexToAddress(asset.ContractAddress)
callData, err := factoryABI.Pack("getVaultInfo", vaultAddr)
if err != nil {
return fmt.Errorf("pack getVaultInfo: %w", err)
}
result, err := client.CallContract(ctx, ethereum.CallMsg{
To: &factoryAddr,
Data: callData,
}, nil)
if err != nil {
return fmt.Errorf("getVaultInfo call: %w", err)
}
decoded, err := factoryABI.Unpack("getVaultInfo", result)
if err != nil {
return fmt.Errorf("unpack getVaultInfo: %w", err)
}
if len(decoded) < 9 {
return fmt.Errorf("getVaultInfo returned %d values, expected 9", len(decoded))
}
info := vaultInfo{
exists: decoded[0].(bool),
totalAssets: bigIntToFloat(decoded[1].(*big.Int), 18),
totalSupply: bigIntToFloat(decoded[4].(*big.Int), 18),
hardCap: bigIntToFloat(decoded[5].(*big.Int), 18),
usdcPrice: bigIntToFloat(decoded[6].(*big.Int), 8),
ytPrice: bigIntToFloat(decoded[7].(*big.Int), 30),
}
if !info.exists {
return fmt.Errorf("vault %s not registered in factory", asset.ContractAddress)
}
poolCapPercent := 0.0
if info.hardCap > 0 {
poolCapPercent = info.totalSupply / info.hardCap * 100
}
apy := calcAPY(database, asset.ID, info.ytPrice, today)
log.Printf("[FastSnapshot] %s: ytPrice=%.8f USDC, supply=%.2f, TVL=$%.2f, APY=%.2f%%",
asset.AssetCode, info.ytPrice, info.totalSupply, info.totalAssets, apy)
// Upsert today's row — do NOT touch volume_24h_usd (managed by volume task).
todayDate := today.Format("2006-01-02")
var perf models.AssetPerformance
if err := database.Where("asset_id = ? AND snapshot_date = ?", asset.ID, todayDate).First(&perf).Error; err != nil {
// Row doesn't exist yet — create it. volume_24h_usd defaults to 0 until volume task runs.
perf = models.AssetPerformance{
AssetID: asset.ID,
CurrentAPY: apy,
TVLUSD: info.totalAssets,
CirculatingSupply: info.totalSupply,
PoolCapacityPercent: poolCapPercent,
YTPrice: info.ytPrice,
Volume24hUSD: 0,
SnapshotDate: today,
}
if err := database.Create(&perf).Error; err != nil {
return err
}
} else {
if err := database.Model(&perf).Updates(map[string]interface{}{
"current_apy": apy,
"tvlusd": info.totalAssets,
"circulating_supply": info.totalSupply,
"pool_capacity_percent": poolCapPercent,
"yt_price": info.ytPrice,
// volume_24h_usd intentionally omitted
}).Error; err != nil {
return err
}
}
// Sync pool_cap_usd in assets table.
if capUSD := info.hardCap * info.ytPrice; capUSD > 0 {
if err := database.Model(&models.Asset{}).Where("id = ?", asset.ID).
Update("pool_cap_usd", capUSD).Error; err != nil {
log.Printf("[FastSnapshot] %s: update pool_cap_usd error: %v", asset.AssetCode, err)
}
}
// Always insert a new record into apy_snapshots for chart history.
hourlySnap := models.APYSnapshot{
AssetID: asset.ID,
ChainID: asset.ChainID,
ContractAddress: asset.ContractAddress,
APYValue: apy,
Price: info.ytPrice,
TotalAssets: info.totalAssets,
TotalSupply: info.totalSupply,
SnapshotTime: time.Now(),
}
return database.Create(&hourlySnap).Error
}
// ── Volume snapshot (24h FilterLogs) ─────────────────────────────────────────
func runVolumeSnapshot(cfg *config.Config) {
start := time.Now()
log.Printf("[VolumeSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
database := db.GetDB()
assets, ok := loadActiveAssets(database, "VolumeSnapshot")
if !ok {
return
}
todayDate := time.Now().Truncate(24 * time.Hour).Format("2006-01-02")
for _, asset := range assets {
rpcURL, _ := getSnapshotClientForChain(asset.ChainID, cfg)
if rpcURL == "" {
log.Printf("[VolumeSnapshot] %s: unsupported chain_id=%d, skipping", asset.AssetCode, asset.ChainID)
continue
}
client, err := ethclient.Dial(rpcURL)
if err != nil {
log.Printf("[VolumeSnapshot] %s: RPC connect error: %v", asset.AssetCode, err)
continue
}
volume24h := calc24hVolume(client, asset, asset.ChainID)
client.Close()
// Update only volume_24h_usd in today's performance row.
res := database.Model(&models.AssetPerformance{}).
Where("asset_id = ? AND snapshot_date = ?", asset.ID, todayDate).
Update("volume_24h_usd", volume24h)
if res.Error != nil {
log.Printf("[VolumeSnapshot] %s: DB update error: %v", asset.AssetCode, res.Error)
continue
}
if res.RowsAffected == 0 {
// Fast snapshot hasn't run yet for today; volume will be set on next cycle.
log.Printf("[VolumeSnapshot] %s: no row for today yet, will retry next cycle", asset.AssetCode)
continue
}
log.Printf("[VolumeSnapshot] %s: volume=$%.2f updated", asset.AssetCode, volume24h)
}
log.Printf("[VolumeSnapshot] Done in %v", time.Since(start))
}
// ── Shared helpers ────────────────────────────────────────────────────────────
// getSnapshotClientForChain returns the RPC URL and factory address for the given chain ID
func getSnapshotClientForChain(chainID int, cfg *config.Config) (rpcURL, factoryAddr string) {
switch chainID {
case 97:
return cfg.BSCTestnetRPC, ytAssetFactoryAddressBSC
case 421614:
return cfg.ArbSepoliaRPC, ytAssetFactoryAddressArb
default:
return "", ""
}
}
// vaultInfo holds decoded output from YTAssetFactory.getVaultInfo()
type vaultInfo struct {
exists bool
totalAssets float64 // USDC, 18 dec on BSC
totalSupply float64 // YT tokens, 18 dec
hardCap float64 // YT tokens, 18 dec
usdcPrice float64 // 8 dec
ytPrice float64 // 30 dec
}
// calcAPY returns annualized APY (%) based on daily price change vs yesterday.
func calcAPY(database *gorm.DB, assetID uint, currentPrice float64, today time.Time) float64 {
yesterday := today.AddDate(0, 0, -1).Format("2006-01-02")
var prev models.AssetPerformance
if err := database.Where("asset_id = ? AND snapshot_date = ?", assetID, yesterday).First(&prev).Error; err != nil {
return 0
}
if prev.YTPrice <= 0 {
return 0
}
dailyReturn := (currentPrice - prev.YTPrice) / prev.YTPrice
return dailyReturn * 365 * 100
}
// maxFilterBlockRange is the max block range allowed per FilterLogs call.
// Most public RPC nodes cap this at 10,000 blocks.
const maxFilterBlockRange = int64(9000)
// chunkTimeout is the per-chunk RPC call timeout.
const chunkTimeout = 15 * time.Second
// calc24hVolume scans Buy + Sell events on the YT token contract for the last 24h
// and returns the total USDC volume. Paginates FilterLogs in 9000-block chunks to
// stay within RPC node limits. Each chunk gets its own timeout to prevent a slow
// early chunk from cancelling the rest of the scan.
func calc24hVolume(client *ethclient.Client, asset models.Asset, chainID int) float64 {
bctx, bcancel := context.WithTimeout(context.Background(), 10*time.Second)
currentBlock, err := client.BlockNumber(bctx)
bcancel()
if err != nil {
log.Printf("[Volume] %s: get block number error: %v", asset.AssetCode, err)
return 0
}
// Estimate fromBlock covering 24h + 20% buffer to handle block-time variance.
blockTime := blockTimeByChain(chainID)
blocksIn24h := int64(float64(86400) / blockTime * 1.2)
fromBlock := int64(currentBlock) - blocksIn24h
if fromBlock < 0 {
fromBlock = 0
}
eventABI, err := abi.JSON(strings.NewReader(buySellEventABIJSON))
if err != nil {
log.Printf("[Volume] %s: parse event ABI error: %v", asset.AssetCode, err)
return 0
}
buyTopic := eventABI.Events["Buy"].ID
sellTopic := eventABI.Events["Sell"].ID
contractAddr := common.HexToAddress(asset.ContractAddress)
usdcDecimals := int64(18)
toBlock := int64(currentBlock)
log.Printf("[Volume] %s: scanning blocks %d-%d (%d chunks), contract=%s",
asset.AssetCode, fromBlock, toBlock,
(toBlock-fromBlock)/maxFilterBlockRange+1,
contractAddr.Hex())
var totalVolume float64
var totalLogs int
// Paginate in chunks. Each chunk gets its own independent timeout so that a
// slow or failing chunk does not cancel subsequent chunks.
for start := fromBlock; start <= toBlock; start += maxFilterBlockRange {
end := start + maxFilterBlockRange - 1
if end > toBlock {
end = toBlock
}
query := ethereum.FilterQuery{
FromBlock: big.NewInt(start),
ToBlock: big.NewInt(end),
Addresses: []common.Address{contractAddr},
Topics: [][]common.Hash{{buyTopic, sellTopic}},
}
// Retry once on failure, each attempt with its own independent timeout.
var chunkLogs []types.Log
var fetchErr error
for attempt := 0; attempt < 2; attempt++ {
chunkCtx, chunkCancel := context.WithTimeout(context.Background(), chunkTimeout)
chunkLogs, fetchErr = client.FilterLogs(chunkCtx, query)
chunkCancel()
if fetchErr == nil {
break
}
log.Printf("[Volume] %s: filter logs [%d-%d] attempt %d error: %v",
asset.AssetCode, start, end, attempt+1, fetchErr)
}
if fetchErr != nil {
log.Printf("[Volume] %s: skipping chunk [%d-%d] after retries", asset.AssetCode, start, end)
continue
}
totalLogs += len(chunkLogs)
for _, vLog := range chunkLogs {
var eventDef abi.Event
switch vLog.Topics[0] {
case buyTopic:
eventDef = eventABI.Events["Buy"]
case sellTopic:
eventDef = eventABI.Events["Sell"]
default:
continue
}
decoded, err := eventDef.Inputs.NonIndexed().Unpack(vLog.Data)
if err != nil || len(decoded) < 2 {
continue
}
// Buy: [0]=usdcAmount, [1]=ytAmount
// Sell: [0]=ytAmount, [1]=usdcAmount
var usdcAmt *big.Int
if vLog.Topics[0] == buyTopic {
usdcAmt = decoded[0].(*big.Int)
} else {
usdcAmt = decoded[1].(*big.Int)
}
totalVolume += bigIntToFloat(usdcAmt, usdcDecimals)
}
}
log.Printf("[Volume] %s: total logs found=%d, volume=$%.2f", asset.AssetCode, totalLogs, totalVolume)
return totalVolume
}
// bigIntToFloat converts *big.Int to float64 by dividing by 10^decimals.
func bigIntToFloat(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
}

80
webapp-back/go.mod Normal file
View File

@@ -0,0 +1,80 @@
module github.com/gothinkster/golang-gin-realworld-example-app
go 1.24.0
require (
github.com/ethereum/go-ethereum v1.17.1
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.26.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gosimple/slug v1.15.0
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.44.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.30.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.20.0 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/consensys/gnark-crypto v0.18.1 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

12
webapp-back/go.mod.new Normal file
View File

@@ -0,0 +1,12 @@
module github.com/gothinkster/golang-gin-realworld-example-app
go 1.21
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
)

318
webapp-back/go.sum Normal file
View File

@@ -0,0 +1,318 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0=
github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law=
github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw=
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,157 @@
# Holder Scanner 使用说明
## 问题诊断
你遇到的问题是:**Scanner扫描器没有运行**,所以数据库中没有数据。
当前状态:
- ✅ API Server 正常运行
- ❌ Scanner 未启动(需要启动才能从区块链获取数据)
- ❌ 部署区块号可能不正确(需要更新)
## 解决步骤
### 第一步:获取正确的部署区块号
你数据库中的合约地址是:
- YT-A: `0x7f9eEA491eE53045594ee4669327f0355aCd0e58`
- YT-B: `0x20B94C5E5b7361552E0548161a58696aA6FeDBd4`
- YT-C: `0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C`
**重要**:你需要找到这些合约的**实际部署区块号**。
#### 方法 1通过 Arbiscan 查询
1. 访问 [Arbiscan Sepolia](https://sepolia.arbiscan.io/)
2. 搜索合约地址(例如:`0x7f9eEA491eE53045594ee4669327f0355aCd0e58`
3. 查看 "Contract Creation" 或 "Contract Creator" 信息
4. 找到 "Block" 号码
#### 方法 2使用 Web3 工具查询
```bash
# 使用 cast (foundry) 工具
cast block-number --rpc-url https://sepolia-rollup.arbitrum.io/rpc
# 或者使用 etherscan API
curl "https://api-sepolia.arbiscan.io/api?module=contract&action=getcontractcreation&contractaddresses=0x7f9eEA491eE53045594ee4669327f0355aCd0e58"
```
#### 方法 3临时方案不推荐
如果暂时无法获取准确的部署区块号,可以使用一个较新的区块号(例如最新区块 - 10000但这会导致
- ⚠️ 可能遗漏早期的持有者数据
- ⚠️ 首次扫描时间更短,但数据不完整
### 第二步:更新 `.env` 配置
编辑 `.env` 文件,更新部署区块号:
```bash
# Deployment Block Numbers
YT_VAULTS_DEPLOY_BLOCK=123456789 # 替换为 YT-A/B/C 的实际部署区块
YTLP_DEPLOY_BLOCK=123456789 # 替换为 ytLP 的实际部署区块
LENDING_DEPLOY_BLOCK=123456789 # 替换为 Lending 的实际部署区块
```
### 第三步:启动 Scanner
#### 方法 1使用快速启动脚本推荐
```bash
# Linux/Mac
./run-scanner.sh
# Windows (Git Bash)
bash run-scanner.sh
```
#### 方法 2手动编译并运行
```bash
# 编译
go build -o bin/holder-scanner cmd/scanner/main.go
# 运行
./bin/holder-scanner
```
#### 方法 3使用 start-holders.sh交互式
```bash
./start-holders.sh
# 选择: 2. Scanner only
```
### 第四步:验证数据
启动 Scanner 后,你应该看到类似的日志:
```
=== Holder Scanner Service ===
✓ Configuration loaded
✓ Database tables checked
🔗 Chain ID: 421614
📚 从数据库加载合约地址配置...
✅ [Scanner] 加载了 3 个 YT 资产
✓ YT-A: 0x7f9eEA491eE53045594ee4669327f0355aCd0e58
✓ YT-B: 0x20B94C5E5b7361552E0548161a58696aA6FeDBd4
✓ YT-C: 0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C
...
📊 开始首次扫描...
1. Scanning YT Vaults...
正在查询 YT-A (0x7f9eEA491eE53045594ee4669327f0355aCd0e58)...
发现 X 个新地址
余额>0: X 个持有者 ✅
```
等待首次扫描完成后,再次访问 API
```bash
curl http://localhost:8080/api/holders/YT-A
```
应该能看到持有者数据。
## 常见问题
### Q1: Scanner 报错 "failed to query logs"
**原因**:部署区块号设置太早,或 RPC 节点限制
**解决**
1. 检查部署区块号是否正确
2. 尝试使用更小的 `BatchSize`(在代码中默认 9999
3. 更换 RPC 节点
### Q2: 扫描速度很慢
**原因**:区块范围太大
**解决**
1. 确保使用准确的部署区块号(不要从太早的区块开始)
2. 检查网络连接
3. 考虑使用付费的 RPC 节点(更高的速率限制)
### Q3: 余额都是 0
**原因**:合约地址不正确,或合约不是 ERC20
**解决**
1. 验证合约地址是否正确
2. 在区块浏览器上检查合约是否是 ERC20 代币
3. 确认合约在对应的链上Arbitrum Sepolia
## 配置参数说明
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| `CHAIN_ID` | 链 ID421614=Arbitrum Sepolia, 97=BSC Testnet | 421614 |
| `YT_VAULTS_DEPLOY_BLOCK` | YT 代币合约部署区块号 | 227339300 |
| `YTLP_DEPLOY_BLOCK` | ytLP 合约部署区块号 | 227230270 |
| `LENDING_DEPLOY_BLOCK` | Lending 合约部署区块号 | 227746053 |
## 技术支持
如果遇到问题,请提供:
1. Scanner 完整日志
2. 合约地址
3. 部署区块号
4. 错误信息

View File

@@ -0,0 +1,454 @@
# Holders API - 持有者统计服务
## 📋 概述
这个模块提供了区块链代币持有者的统计和查询功能,包括:
- **YT 代币持有者** (YT-A, YT-B, YT-C)
- **ytLP 代币持有者**
- **Lending 提供者**
## 🏗️ 架构
```
┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Frontend │─────▶│ API Server │─────▶│ Database │
│ (Ant Design) │ │ (Gin + Gorm) │ │ (MySQL/PG) │
└─────────────────┘ └──────────────────┘ └──────────────┘
│ ▲
│ │
▼ │
┌──────────────────┐ │
│ Scanner Service │────────────┘
│ (Blockchain) │
└──────────────────┘
┌──────────────────┐
│ Arbitrum RPC │
│ (Sepolia) │
└──────────────────┘
```
## 📁 文件结构
```
holders/
├── routers.go # API 路由和处理函数
├── scanner.go # 区块链扫描器核心逻辑
└── README.md # 本文档
cmd/scanner/
└── main.go # 扫描器服务启动入口
models/
└── holder.go # 数据库模型定义
```
## 🔧 安装依赖
### 1. 安装 Go 依赖
```bash
cd /home/coder/myprojects/assetx/golang-gin-realworld
# 添加 ethereum 依赖
go get github.com/ethereum/go-ethereum
go get github.com/ethereum/go-ethereum/ethclient
go get github.com/ethereum/go-ethereum/accounts/abi
go get github.com/ethereum/go-ethereum/common
go get github.com/ethereum/go-ethereum/core/types
go get github.com/ethereum/go-ethereum/crypto
# 更新依赖
go mod tidy
```
### 2. 数据库迁移
数据库表会在启动时自动创建(通过 GORM AutoMigrate
```sql
CREATE TABLE holder_snapshots (
id BIGSERIAL PRIMARY KEY,
holder_address VARCHAR(42) NOT NULL,
token_type VARCHAR(50) NOT NULL,
token_address VARCHAR(42) NOT NULL,
balance VARCHAR(78) NOT NULL,
chain_id INTEGER NOT NULL,
first_seen BIGINT NOT NULL,
last_updated BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_holder_token (holder_address, token_type),
INDEX idx_token_time (token_type, last_updated)
);
```
## 🚀 启动服务
### 方法一:分别启动(推荐开发环境)
**1. 启动 API 服务器**
```bash
cd /home/coder/myprojects/assetx/golang-gin-realworld
# 设置环境变量
export DB_TYPE=mysql
export DB_HOST=localhost
export DB_PORT=3306
export DB_USER=root
export DB_PASSWORD=your_password
export DB_NAME=assetx
export GIN_MODE=release
export PORT=8080
# 启动 API 服务
go run main.go
```
**2. 启动扫描器服务**
```bash
cd /home/coder/myprojects/assetx/golang-gin-realworld
# 设置 RPC URL可选有默认值
export RPC_URL=https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07
# 启动扫描器
go run cmd/scanner/main.go
```
### 方法二:使用 PM2推荐生产环境
**1. 创建 PM2 配置文件**
```json
// ecosystem.holders.config.js
module.exports = {
apps: [
{
name: 'assetx-api',
script: './golang-gin-realworld-example-app',
env: {
DB_TYPE: 'mysql',
DB_HOST: 'localhost',
DB_PORT: '3306',
DB_USER: 'root',
DB_PASSWORD: 'your_password',
DB_NAME: 'assetx',
GIN_MODE: 'release',
PORT: '8080'
}
},
{
name: 'holder-scanner',
script: 'go',
args: 'run cmd/scanner/main.go',
env: {
DB_TYPE: 'mysql',
DB_HOST: 'localhost',
DB_PORT: '3306',
DB_USER: 'root',
DB_PASSWORD: 'your_password',
DB_NAME: 'assetx',
RPC_URL: 'https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07'
}
}
]
};
```
**2. 启动服务**
```bash
# 先编译 API 服务器
go build -o golang-gin-realworld-example-app
# 使用 PM2 启动
pm2 start ecosystem.holders.config.js
pm2 logs
pm2 status
```
## 📡 API 接口
### 1. GET /api/holders/stats
获取所有代币类型的统计信息。
**请求**
```bash
curl http://localhost:8080/api/holders/stats
```
**响应**
```json
{
"success": true,
"data": [
{
"token_type": "YT-A",
"holder_count": 156,
"total_balance": "1250000000000000000000"
},
{
"token_type": "YT-B",
"holder_count": 89,
"total_balance": "780000000000000000000"
}
]
}
```
### 2. GET /api/holders/:tokenType
获取特定代币的持有者列表。
**参数**
- `tokenType`: `YT-A` | `YT-B` | `YT-C` | `ytLP` | `Lending`
**请求**
```bash
curl http://localhost:8080/api/holders/YT-A
```
**响应**
```json
{
"success": true,
"data": [
{
"id": 1,
"holder_address": "0x1234567890abcdef1234567890abcdef12345678",
"token_type": "YT-A",
"token_address": "0x97204190B35D9895a7a47aa7BaC61ac08De3cF05",
"balance": "100000000000000000000",
"chain_id": 421614,
"first_seen": 1707800000,
"last_updated": 1707900000
}
]
}
```
### 3. POST /api/holders/update
手动触发数据更新(需要管理员权限)。
**请求**
```bash
curl -X POST http://localhost:8080/api/holders/update \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
**响应**
```json
{
"success": true,
"message": "Update triggered successfully",
"timestamp": 1707900000
}
```
## ⚙️ 配置说明
### 扫描器配置
`cmd/scanner/main.go` 中可以修改以下配置:
```go
scannerConfig := holders.Config{
RPCURL: "your_rpc_url", // RPC 节点地址
PollInterval: 10 * time.Second, // 轮询间隔10秒
BatchSize: 9999, // 每批次查询的区块数
// 代币合约地址
YTVaults: []holders.VaultConfig{
{Name: "YT-A", Address: "0x..."},
{Name: "YT-B", Address: "0x..."},
{Name: "YT-C", Address: "0x..."},
},
YTLPAddress: "0x...",
LendingAddress: "0x...",
// 部署区块号(从这里开始扫描)
DeploymentBlocks: holders.DeploymentBlocks{
YTVaults: 227339300,
YTLP: 227230270,
Lending: 227746053,
},
}
```
### 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `RPC_URL` | Arbitrum RPC 节点地址 | zan.top URL |
| `DB_TYPE` | 数据库类型 | `sqlite` |
| `DB_HOST` | 数据库主机 | `localhost` |
| `DB_PORT` | 数据库端口 | `3306` |
| `DB_USER` | 数据库用户名 | `root` |
| `DB_PASSWORD` | 数据库密码 | - |
| `DB_NAME` | 数据库名称 | `assetx` |
## 🔍 工作原理
### 扫描流程
1. **初始扫描**
- 从合约部署区块开始
- 扫描所有历史 Transfer/Supply 事件
- 记录所有曾经持有代币的地址
- 查询当前余额并保存到数据库
2. **增量扫描**
- 每 10 秒检查一次新区块
- 只扫描自上次扫描后的新区块
- 更新新地址和余额变化
- 持续追踪地址首次出现时间
3. **数据存储**
- 使用 `holder_snapshots` 表存储快照
- 余额以 wei 格式字符串存储(避免精度丢失)
- 记录首次持有时间和最后更新时间
- 支持唯一约束address + token_type
### 性能优化
- ✅ 批量查询区块事件(每次最多 9999 个区块)
- ✅ 增量扫描(只查询新区块)
- ✅ 地址缓存(减少重复查询)
- ✅ 并发控制(防止重复扫描)
- ✅ 速率限制(避免 RPC 限流)
## 📊 监控和日志
### 查看扫描器日志
```bash
# 实时查看日志
pm2 logs holder-scanner
# 或者直接运行时查看
go run cmd/scanner/main.go
```
### 日志示例
```
=== Holder Scanner Service ===
✓ Configuration loaded
✓ Database tables checked
🚀 Starting blockchain scanner...
=== Holder Scanner Started ===
RPC: https://api.zan.top/node/v1/arb/sepolia/...
Poll Interval: 10s
📊 Starting initial scan...
Current block: 227950000
1. Scanning YT Vaults...
Scanning YT-A (0x9720...)...
Querying blocks 227339300 -> 227950000 (total: 610700 blocks)
Querying blocks 227339300 - 227349299...
✓ Got 45 events
...
Found 23 new addresses, total tracking: 156
YT-A: 156 holders saved
2. Scanning ytLP...
ytLP: 67 holders saved
3. Scanning Lending...
Lending: 43 holders saved
📌 Last scanned block: 227950000
✓ Initial scan completed in 2m34s
⏰ Starting polling every 10s...
⏰ [15:30:45] No new blocks (current: 227950000)
⏰ [15:30:55] Found new blocks
🔄 Incremental scan: blocks 227950001 -> 227950015
✓ Incremental scan completed in 1.2s
```
## 🐛 故障排查
### 常见问题
**1. RPC 连接失败**
```
Error: failed to connect to Ethereum client
```
**解决**: 检查 `RPC_URL` 是否正确,网络是否通畅
**2. 数据库连接失败**
```
Error: failed to connect to database
```
**解决**: 检查数据库配置和权限
**3. 查询超时**
```
Error: context deadline exceeded
```
**解决**: 减小 `BatchSize` 参数(如改为 5000
**4. 内存占用过高**
```
OOM or high memory usage
```
**解决**: 增加批次间隔时间或减小批次大小
### 数据验证
**检查数据库中的数据**
```sql
-- 查看所有代币类型的持有者数量
SELECT token_type, COUNT(*) as count
FROM holder_snapshots
GROUP BY token_type;
-- 查看 YT-A 的前 10 名持有者
SELECT holder_address, balance
FROM holder_snapshots
WHERE token_type = 'YT-A'
ORDER BY CAST(balance AS DECIMAL) DESC
LIMIT 10;
-- 查看最近更新的记录
SELECT * FROM holder_snapshots
ORDER BY last_updated DESC
LIMIT 10;
```
## 🔐 安全注意事项
1. **RPC 密钥保护**
- 不要在代码中硬编码 RPC URL
- 使用环境变量或配置文件
- 限制 RPC API Key 的权限
2. **数据库安全**
- 使用强密码
- 限制数据库访问权限
- 定期备份数据
3. **API 权限**
- `/update` 接口需要管理员权限
- 建议添加请求频率限制
- 记录操作日志
## 📝 TODO
- [ ] 添加 WebSocket 支持(实时推送)
- [ ] 实现手动触发扫描的实际逻辑
- [ ] 添加 Prometheus 监控指标
- [ ] 支持多链配置
- [ ] 添加数据导出功能
## 📞 联系方式
如有问题请联系开发团队或查看项目文档。
---
**最后更新**: 2024-02-13

View File

@@ -0,0 +1,125 @@
package holders
import (
"fmt"
"log"
"time"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
)
// Asset represents a product/asset in the database
type Asset struct {
ID int64 `gorm:"column:id"`
AssetCode string `gorm:"column:asset_code"`
Name string `gorm:"column:name"`
TokenRole string `gorm:"column:token_role"`
ChainID int `gorm:"column:chain_id"`
ContractAddress string `gorm:"column:contract_address"`
DeployBlock *uint64 `gorm:"column:deploy_block"`
}
const zeroAddress = "0x0000000000000000000000000000000000000000"
// LoadConfigFromDB loads contract addresses and deploy blocks from database based on chain ID
func LoadConfigFromDB(chainID int64) (Config, error) {
db := common.GetDB()
log.Printf("📚 [Scanner] 从数据库加载配置 - Chain ID: %d", chainID)
switch chainID {
case 97, 421614:
// supported
default:
return Config{}, fmt.Errorf("unsupported chain ID: %d", chainID)
}
// Load YT assets by token_role filtered by chain_id
var assets []Asset
if err := db.Table("assets").
Where("token_role = ? AND chain_id = ? AND is_active = ?", "yt_token", chainID, true).
Find(&assets).Error; err != nil {
return Config{}, fmt.Errorf("failed to load assets: %w", err)
}
ytVaults := make([]VaultConfig, 0, len(assets))
for _, asset := range assets {
if asset.ContractAddress == "" || asset.ContractAddress == zeroAddress {
log.Printf("⚠️ [Scanner] 跳过 %s (地址未配置)", asset.AssetCode)
continue
}
if asset.DeployBlock == nil || *asset.DeployBlock == 0 {
log.Printf("⚠️ [Scanner] 跳过 %s (deploy_block 未配置)", asset.AssetCode)
continue
}
ytVaults = append(ytVaults, VaultConfig{
Name: asset.AssetCode,
Address: asset.ContractAddress,
DeployBlock: *asset.DeployBlock,
})
log.Printf(" ✓ %s: %s (部署区块: %d)", asset.AssetCode, asset.ContractAddress, *asset.DeployBlock)
}
log.Printf("✅ [Scanner] 加载了 %d 个 YT Vault", len(ytVaults))
// Load YTLPToken address from system_contracts
var ytLPContract struct {
Address string `gorm:"column:address"`
DeployBlock *uint64 `gorm:"column:deploy_block"`
}
ytLPAddress := ""
var ytLPDeployBlock uint64
err := db.Table("system_contracts").
Where("name = ? AND chain_id = ? AND is_active = ?", "YTLPToken", chainID, 1).
Select("address, deploy_block").
First(&ytLPContract).Error
if err != nil {
log.Printf("⚠️ [Scanner] 未找到 YTLPToken 配置")
} else if ytLPContract.Address == "" || ytLPContract.Address == zeroAddress {
log.Printf("⚠️ [Scanner] 跳过 ytLP (地址未配置)")
} else if ytLPContract.DeployBlock == nil || *ytLPContract.DeployBlock == 0 {
log.Printf("⚠️ [Scanner] 跳过 ytLP (deploy_block 未配置)")
} else {
ytLPAddress = ytLPContract.Address
ytLPDeployBlock = *ytLPContract.DeployBlock
log.Printf("✅ [Scanner] ytLP: %s (部署区块: %d)", ytLPAddress, ytLPDeployBlock)
}
rpcURL := getRPCURLForChain(chainID)
config := Config{
ChainID: int(chainID),
RPCURL: rpcURL,
YTVaults: ytVaults,
YTLPAddress: ytLPAddress,
DeploymentBlocks: DeploymentBlocks{
YTLP: ytLPDeployBlock,
},
PollInterval: 30 * time.Second,
BatchSize: 9999,
}
log.Printf("📊 [Scanner] 配置加载完成: YT Vaults=%d, ytLP=%s, RPC=%s",
len(config.YTVaults), config.YTLPAddress, config.RPCURL)
return config, nil
}
// getRPCURLForChain returns the RPC URL for the given chain ID
func getRPCURLForChain(chainID int64) string {
switch chainID {
case 421614:
return "https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07"
case 97:
return "https://api.zan.top/node/v1/bsc/testnet/baf84c429d284bb5b676cb8c9ca21c07"
default:
return ""
}
}
// TableName sets the table name for GORM
func (Asset) TableName() string {
return "assets"
}

View File

@@ -0,0 +1,132 @@
package holders
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// RegisterRoutes registers holders API routes
func RegisterRoutes(router *gin.RouterGroup) {
router.GET("/stats", GetStats)
router.GET("/:tokenType", GetHoldersByType)
router.POST("/update", UpdateHolders)
}
// GetStats returns aggregated statistics for all token types
func GetStats(c *gin.Context) {
db := common.GetDB()
log.Printf("📊 [API] 收到获取统计数据请求 - IP: %s", c.ClientIP())
var stats []models.HolderStats
err := db.Raw(`
SELECT
token_type,
COUNT(DISTINCT holder_address) as holder_count
FROM holder_snapshots
WHERE balance != '0' AND balance != '' AND LENGTH(balance) > 0
GROUP BY token_type
`).Scan(&stats).Error
if err != nil {
log.Printf("❌ [API] 查询统计数据失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to fetch stats",
})
return
}
log.Printf("✅ [API] 成功返回统计数据 - %d 个token类型", len(stats))
for _, stat := range stats {
log.Printf(" - %s: %d 个持有者", stat.TokenType, stat.HolderCount)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetHoldersByType returns holders for a specific token type
func GetHoldersByType(c *gin.Context) {
tokenType := c.Param("tokenType")
db := common.GetDB()
// 记录请求日志
log.Printf("🔍 [API] 收到获取持有者请求 - Token类型: %s, IP: %s", tokenType, c.ClientIP())
var holders []models.HolderSnapshot
err := db.Where("token_type = ? AND balance != ? AND balance != '' AND LENGTH(balance) > 0", tokenType, "0").
Order("LENGTH(balance) DESC, balance DESC").
Find(&holders).Error
if err != nil {
log.Printf("❌ [API] 查询持有者失败 - Token类型: %s, 错误: %v", tokenType, err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to fetch holders",
})
return
}
log.Printf("✅ [API] 成功返回 %d 个持有者 - Token类型: %s", len(holders), tokenType)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": holders,
})
}
// Global scanner instance
var globalScanner *Scanner
// StartAllScanners 为所有支持的链启动 holder 扫描器(后台常驻)
func StartAllScanners() {
supportedChains := []int64{97, 421614}
for _, chainID := range supportedChains {
config, err := LoadConfigFromDB(chainID)
if err != nil {
log.Printf("[HolderScanner] Chain %d 配置加载失败: %v", chainID, err)
continue
}
if len(config.YTVaults) == 0 && config.YTLPAddress == "" {
log.Printf("[HolderScanner] Chain %d 无活跃资产,跳过", chainID)
continue
}
go func(cfg Config) {
log.Printf("[HolderScanner] Chain %d 启动YTVaults=%d ytLP=%s",
cfg.ChainID, len(cfg.YTVaults), cfg.YTLPAddress)
if err := Start(cfg); err != nil {
log.Printf("[HolderScanner] Chain %d 异常退出: %v", cfg.ChainID, err)
}
}(config)
}
}
// UpdateHolders triggers blockchain data update (admin only)
func UpdateHolders(c *gin.Context) {
if globalScanner == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Scanner is running in background. It will process new blocks on next tick.",
"timestamp": time.Now().Unix(),
})
return
}
go func() {
if err := globalScanner.incrementalScan(c.Request.Context()); err != nil {
log.Printf("[HolderScanner] Manual update error: %v", err)
}
}()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Manual scan triggered.",
"timestamp": time.Now().Unix(),
})
}

View File

@@ -0,0 +1,616 @@
package holders
import (
"context"
"fmt"
"log"
"math/big"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
)
// Config holds scanner configuration
type Config struct {
ChainID int
RPCURL string
YTVaults []VaultConfig
YTLPAddress string
DeploymentBlocks DeploymentBlocks
PollInterval time.Duration
BatchSize int64
}
type VaultConfig struct {
Name string
Address string
DeployBlock uint64
}
type DeploymentBlocks struct {
YTLP uint64
}
// Scanner handles blockchain event scanning
type Scanner struct {
config Config
client *ethclient.Client
lastScannedBlock uint64
isScanning bool
mu sync.Mutex
// Address tracking
ytAddresses map[string]map[string]int64 // vault -> address -> firstSeen
lpAddresses map[string]int64 // address -> firstSeen
}
// Event topics
var (
transferEventTopic = crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)"))
)
// NewScanner creates a new blockchain scanner
func NewScanner(config Config) (*Scanner, error) {
client, err := ethclient.Dial(config.RPCURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to Ethereum client: %w", err)
}
return &Scanner{
config: config,
client: client,
ytAddresses: make(map[string]map[string]int64),
lpAddresses: make(map[string]int64),
}, nil
}
// loadStateFromDB loads the last scanned block from database
func (s *Scanner) loadStateFromDB() uint64 {
database := db.GetDB()
var state models.ScannerState
result := database.Where("chain_id = ? AND scanner_type = ?", s.config.ChainID, "holder").First(&state)
if result.Error != nil {
return 0
}
return state.LastScannedBlock
}
// saveStateToDB persists the last scanned block to database
func (s *Scanner) saveStateToDB(block uint64) {
database := db.GetDB()
state := models.ScannerState{
ScannerType: "holder",
ChainID: s.config.ChainID,
LastScannedBlock: block,
}
database.Where("chain_id = ? AND scanner_type = ?", s.config.ChainID, "holder").Assign(state).FirstOrCreate(&state)
database.Model(&state).Update("last_scanned_block", block)
}
// loadAddressesFromDB restores in-memory address maps from holder_snapshots
func (s *Scanner) loadAddressesFromDB() {
database := db.GetDB()
var snapshots []models.HolderSnapshot
database.Where("chain_id = ?", s.config.ChainID).Find(&snapshots)
for _, snap := range snapshots {
switch snap.TokenType {
case "ytLP":
s.lpAddresses[snap.HolderAddress] = snap.FirstSeen
default:
for _, vault := range s.config.YTVaults {
if vault.Name == snap.TokenType {
if s.ytAddresses[vault.Address] == nil {
s.ytAddresses[vault.Address] = make(map[string]int64)
}
s.ytAddresses[vault.Address][snap.HolderAddress] = snap.FirstSeen
}
}
}
}
log.Printf("▶️ 从数据库加载了 %d 个历史地址", len(snapshots))
}
// Start begins the scanning process
func Start(config Config) error {
scanner, err := NewScanner(config)
if err != nil {
return err
}
log.Println("=== Holder Scanner Started ===")
log.Printf("RPC: %s\n", config.RPCURL)
log.Printf("Poll Interval: %v\n", config.PollInterval)
// Check if we can resume from a previous scan
lastBlock := scanner.loadStateFromDB()
if lastBlock > 0 {
log.Printf("▶️ 发现上次扫描记录,从区块 %d 续扫...", lastBlock)
scanner.loadAddressesFromDB()
scanner.lastScannedBlock = lastBlock
} else {
// Fresh start: full initial scan from deploy blocks
log.Println("📊 首次扫描,从部署区块开始...")
startTime := time.Now()
if err := scanner.scanAll(context.Background(), true); err != nil {
return fmt.Errorf("initial scan failed: %w", err)
}
log.Printf("✓ 初始扫描完成,耗时 %v\n", time.Since(startTime))
}
// Start polling
ticker := time.NewTicker(config.PollInterval)
defer ticker.Stop()
log.Printf("⏰ 开始轮询,每 %v 扫一次新区块...\n", config.PollInterval)
for range ticker.C {
if err := scanner.incrementalScan(context.Background()); err != nil {
log.Printf("✗ Incremental scan error: %v\n", err)
}
}
return nil
}
// minUint64 returns the smaller of two uint64 values.
func minUint64(a, b uint64) uint64 {
if a < b {
return a
}
return b
}
// scanBatch scans ALL contract types for a single block range [fromBlock, toBlock].
// The range must be ≤ BatchSize. After this call, every contract has been scanned to toBlock.
func (s *Scanner) scanBatch(ctx context.Context, fromBlock, toBlock uint64) {
log.Printf(" [Batch] Scanning blocks %d → %d\n", fromBlock, toBlock)
s.scanYTVaultTransfers(ctx, fromBlock, toBlock)
s.scanYTLPTransfers(ctx, fromBlock, toBlock)
s.scanSwapEvents(ctx, fromBlock, toBlock)
// Checkpoint: all contracts have been scanned up to toBlock
s.mu.Lock()
s.lastScannedBlock = toBlock
s.mu.Unlock()
s.saveStateToDB(toBlock)
}
// scanAll performs a full scan from deployment blocks, saving a checkpoint after every batch.
// All contract types are scanned together per batch, so the checkpoint is always safe.
func (s *Scanner) scanAll(ctx context.Context, isInitial bool) error {
s.mu.Lock()
if s.isScanning {
s.mu.Unlock()
return fmt.Errorf("scan already in progress")
}
s.isScanning = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.isScanning = false
s.mu.Unlock()
}()
latestBlock, err := s.client.BlockNumber(ctx)
if err != nil {
return err
}
log.Printf("Current block: %d\n", latestBlock)
// Determine the earliest block we need to scan across all contracts
startBlock := s.lastScannedBlock + 1
if isInitial {
startBlock = latestBlock // will be lowered below
for _, v := range s.config.YTVaults {
startBlock = minUint64(startBlock, v.DeployBlock)
}
if s.config.YTLPAddress != "" && s.config.DeploymentBlocks.YTLP > 0 {
startBlock = minUint64(startBlock, s.config.DeploymentBlocks.YTLP)
}
}
if startBlock > latestBlock {
log.Printf("📌 No new blocks to scan (startBlock %d > latestBlock %d)\n", startBlock, latestBlock)
return nil
}
log.Printf("📊 Scanning blocks %d → %d in batches of %d\n", startBlock, latestBlock, s.config.BatchSize)
// Outer loop: one checkpoint per batch, all contracts scanned together
for batchFrom := startBlock; batchFrom <= latestBlock; {
batchTo := minUint64(batchFrom+uint64(s.config.BatchSize)-1, latestBlock)
s.scanBatch(ctx, batchFrom, batchTo)
batchFrom = batchTo + 1
// Rate limiting between batches
if batchFrom <= latestBlock {
time.Sleep(100 * time.Millisecond)
}
}
// Balance snapshots: run once after all event scanning is done
log.Printf("📊 Updating balance snapshots...\n")
for _, vault := range s.config.YTVaults {
if err := s.saveYTHolders(ctx, vault); err != nil {
log.Printf(" [Snapshot] %s error: %v", vault.Name, err)
}
}
if err := s.saveYTLPHolders(ctx); err != nil {
log.Printf(" [Snapshot] ytLP error: %v", err)
}
log.Printf("📌 Scan complete. Last scanned block: %d\n", latestBlock)
return nil
}
// incrementalScan scans new blocks since last scan.
// Incremental ranges are small (seconds of blocks), so a single batch suffices.
func (s *Scanner) incrementalScan(ctx context.Context) error {
s.mu.Lock()
if s.isScanning {
s.mu.Unlock()
log.Println("⏰ Skipping scan (previous scan still running)")
return nil
}
s.isScanning = true
lastBlock := s.lastScannedBlock
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.isScanning = false
s.mu.Unlock()
}()
latestBlock, err := s.client.BlockNumber(ctx)
if err != nil {
return err
}
if latestBlock <= lastBlock {
log.Printf("⏰ [%s] No new blocks (current: %d)\n", time.Now().Format("15:04:05"), latestBlock)
return nil
}
log.Printf("\n%s\n", strings.Repeat("=", 60))
log.Printf("⏰ [%s] Found new blocks %d → %d\n", time.Now().Format("15:04:05"), lastBlock+1, latestBlock)
log.Printf("%s\n", strings.Repeat("=", 60))
startTime := time.Now()
fromBlock := lastBlock + 1
// Incremental range may exceed BatchSize if server was down for a while; use batches
for batchFrom := fromBlock; batchFrom <= latestBlock; {
batchTo := minUint64(batchFrom+uint64(s.config.BatchSize)-1, latestBlock)
s.scanBatch(ctx, batchFrom, batchTo)
batchFrom = batchTo + 1
if batchFrom <= latestBlock {
time.Sleep(100 * time.Millisecond)
}
}
// Update balance snapshots
for _, vault := range s.config.YTVaults {
if err := s.saveYTHolders(ctx, vault); err != nil {
log.Printf(" [Snapshot] %s error: %v", vault.Name, err)
}
}
if err := s.saveYTLPHolders(ctx); err != nil {
log.Printf(" [Snapshot] ytLP error: %v", err)
}
log.Printf("✓ Incremental scan completed in %v\n", time.Since(startTime))
return nil
}
// scanYTVaultTransfers scans Transfer events for all YT vaults in the given block range.
// fromBlock/toBlock must already be a single batch (≤ BatchSize blocks).
// Each vault skips blocks before its own deployBlock.
// Does NOT save balance snapshots (call saveYTHolders separately after all batches complete).
func (s *Scanner) scanYTVaultTransfers(ctx context.Context, fromBlock, toBlock uint64) error {
for _, vault := range s.config.YTVaults {
// Skip if vault not yet deployed in this range
if vault.DeployBlock > toBlock {
continue
}
effectiveFrom := fromBlock
if vault.DeployBlock > effectiveFrom {
effectiveFrom = vault.DeployBlock
}
logs, err := s.queryBlockRange(ctx, common.HexToAddress(vault.Address), transferEventTopic, effectiveFrom, toBlock)
if err != nil {
log.Printf(" [Transfer] %s query error: %v", vault.Name, err)
continue
}
if s.ytAddresses[vault.Address] == nil {
s.ytAddresses[vault.Address] = make(map[string]int64)
}
for _, l := range logs {
toAddress := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
if toAddress == "0x0000000000000000000000000000000000000000" {
continue
}
if _, exists := s.ytAddresses[vault.Address][toAddress]; !exists {
block, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
if err == nil {
s.ytAddresses[vault.Address][toAddress] = int64(block.Time())
}
}
}
}
return nil
}
// scanYTLPTransfers scans Transfer events for the YT LP token in the given block range.
// Does NOT save balance snapshots (call saveYTLPHolders separately after all batches complete).
func (s *Scanner) scanYTLPTransfers(ctx context.Context, fromBlock, toBlock uint64) error {
if s.config.YTLPAddress == "" {
return nil
}
lpDeployBlock := s.config.DeploymentBlocks.YTLP
if lpDeployBlock > toBlock {
return nil
}
effectiveFrom := fromBlock
if lpDeployBlock > effectiveFrom {
effectiveFrom = lpDeployBlock
}
logs, err := s.queryBlockRange(ctx, common.HexToAddress(s.config.YTLPAddress), transferEventTopic, effectiveFrom, toBlock)
if err != nil {
log.Printf(" [Transfer] ytLP query error: %v", err)
return nil
}
for _, l := range logs {
toAddress := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
if toAddress == "0x0000000000000000000000000000000000000000" {
continue
}
if _, exists := s.lpAddresses[toAddress]; !exists {
block, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
if err == nil {
s.lpAddresses[toAddress] = int64(block.Time())
}
}
}
return nil
}
// queryBlockRange queries logs for a single block range (no internal batching).
// The caller is responsible for keeping the range within RPC limits (BatchSize).
func (s *Scanner) queryBlockRange(ctx context.Context, contractAddr common.Address, topic common.Hash, fromBlock, toBlock uint64) ([]types.Log, error) {
query := ethereum.FilterQuery{
FromBlock: big.NewInt(int64(fromBlock)),
ToBlock: big.NewInt(int64(toBlock)),
Addresses: []common.Address{contractAddr},
Topics: [][]common.Hash{{topic}},
}
logs, err := s.client.FilterLogs(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query logs [%d-%d]: %w", fromBlock, toBlock, err)
}
return logs, nil
}
// queryLogsInBatches queries logs in batches to avoid RPC limits.
// Used only by incremental scans where range size is unpredictable.
func (s *Scanner) queryLogsInBatches(ctx context.Context, contractAddr common.Address, topic common.Hash, fromBlock, toBlock uint64) ([]types.Log, error) {
var allLogs []types.Log
currentBlock := fromBlock
log.Printf(" Querying blocks %d -> %d (total: %d blocks)\n", fromBlock, toBlock, toBlock-fromBlock+1)
for currentBlock <= toBlock {
endBlock := currentBlock + uint64(s.config.BatchSize)
if endBlock > toBlock {
endBlock = toBlock
}
log.Printf(" Querying blocks %d - %d...\n", currentBlock, endBlock)
logs, err := s.queryBlockRange(ctx, contractAddr, topic, currentBlock, endBlock)
if err != nil {
return nil, err
}
allLogs = append(allLogs, logs...)
log.Printf(" ✓ Got %d events\n", len(logs))
currentBlock = endBlock + 1
// Rate limiting
if currentBlock <= toBlock {
time.Sleep(100 * time.Millisecond)
}
}
log.Printf(" Total: %d events\n\n", len(allLogs))
return allLogs, nil
}
// saveYTHolders queries balances and saves to database
func (s *Scanner) saveYTHolders(ctx context.Context, vault VaultConfig) error {
// ERC20 balanceOf ABI
balanceOfABI, _ := abi.JSON(strings.NewReader(`[{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]`))
database := db.GetDB()
now := time.Now().Unix()
holders := 0
totalAddresses := len(s.ytAddresses[vault.Address])
contractAddr := common.HexToAddress(vault.Address)
log.Printf("📞 [%s] 开始查询 %d 个地址的余额", vault.Name, totalAddresses)
log.Printf("📍 [%s] 合约地址: %s", vault.Name, vault.Address)
processedCount := 0
errorCount := 0
zeroBalanceCount := 0
for address, firstSeen := range s.ytAddresses[vault.Address] {
processedCount++
// Call balanceOf
data, err := balanceOfABI.Pack("balanceOf", common.HexToAddress(address))
if err != nil {
log.Printf("❌ [%s] Pack balanceOf 失败: %s - %v", vault.Name, address, err)
errorCount++
continue
}
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
To: &contractAddr,
Data: data,
}, nil)
if err != nil {
log.Printf("❌ [%s] CallContract 失败: %s - %v", vault.Name, address, err)
errorCount++
continue
}
balance := new(big.Int).SetBytes(result)
// Log balance query result
if processedCount <= 5 || balance.Cmp(big.NewInt(0)) > 0 {
log.Printf(" [%s] 地址: %s, 余额: %s", vault.Name, address[:10]+"...", balance.String())
}
if balance.Cmp(big.NewInt(0)) == 0 {
zeroBalanceCount++
continue
}
holders++
// Upsert to database: create if not exists, only update balance/last_updated if exists
holder := models.HolderSnapshot{
HolderAddress: address,
TokenType: vault.Name,
TokenAddress: vault.Address,
Balance: balance.String(),
ChainID: s.config.ChainID,
FirstSeen: firstSeen,
LastUpdated: now,
}
var existing models.HolderSnapshot
res := database.Where("holder_address = ? AND token_type = ?", address, vault.Name).First(&existing)
if res.Error != nil {
database.Create(&holder)
} else {
database.Model(&existing).Updates(map[string]interface{}{
"balance": balance.String(),
"last_updated": now,
})
}
}
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
log.Printf("📊 [%s] 查询完成统计:", vault.Name)
log.Printf(" • 总地址数: %d", totalAddresses)
log.Printf(" • 已处理: %d", processedCount)
log.Printf(" • 余额>0: %d ✅", holders)
log.Printf(" • 余额=0: %d", zeroBalanceCount)
log.Printf(" • 错误数: %d", errorCount)
log.Printf(" • 已保存到数据库: %d", holders)
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
return nil
}
// saveYTLPHolders saves ytLP holders to database
func (s *Scanner) saveYTLPHolders(ctx context.Context) error {
balanceOfABI, _ := abi.JSON(strings.NewReader(`[{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]`))
database := db.GetDB()
now := time.Now().Unix()
holders := 0
totalAddresses := len(s.lpAddresses)
contractAddr := common.HexToAddress(s.config.YTLPAddress)
log.Printf("📞 [ytLP] 开始查询 %d 个地址的余额", totalAddresses)
log.Printf("📍 [ytLP] 合约地址: %s", s.config.YTLPAddress)
processedCount := 0
errorCount := 0
zeroBalanceCount := 0
for address, firstSeen := range s.lpAddresses {
processedCount++
data, err := balanceOfABI.Pack("balanceOf", common.HexToAddress(address))
if err != nil {
log.Printf("❌ [ytLP] Pack balanceOf 失败: %s - %v", address, err)
errorCount++
continue
}
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
To: &contractAddr,
Data: data,
}, nil)
if err != nil {
log.Printf("❌ [ytLP] CallContract 失败: %s - %v", address, err)
errorCount++
continue
}
balance := new(big.Int).SetBytes(result)
// Log balance query result
if processedCount <= 5 || balance.Cmp(big.NewInt(0)) > 0 {
log.Printf(" [ytLP] 地址: %s, 余额: %s", address[:10]+"...", balance.String())
}
if balance.Cmp(big.NewInt(0)) == 0 {
zeroBalanceCount++
continue
}
holders++
holder := models.HolderSnapshot{
HolderAddress: address,
TokenType: "ytLP",
TokenAddress: s.config.YTLPAddress,
Balance: balance.String(),
ChainID: s.config.ChainID,
FirstSeen: firstSeen,
LastUpdated: now,
}
var existing models.HolderSnapshot
res := database.Where("holder_address = ? AND token_type = ?", address, "ytLP").First(&existing)
if res.Error != nil {
database.Create(&holder)
} else {
database.Model(&existing).Updates(map[string]interface{}{
"balance": balance.String(),
"last_updated": now,
})
}
}
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
log.Printf("📊 [ytLP] 查询完成统计:")
log.Printf(" • 总地址数: %d", totalAddresses)
log.Printf(" • 已处理: %d", processedCount)
log.Printf(" • 余额>0: %d ✅", holders)
log.Printf(" • 余额=0: %d", zeroBalanceCount)
log.Printf(" • 错误数: %d", errorCount)
log.Printf(" • 已保存到数据库: %d", holders)
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
return nil
}

View File

@@ -0,0 +1,99 @@
package holders
import (
"context"
"log"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// Swap(address account, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeBasisPoints)
// account/tokenIn/tokenOut are indexed → topics[1..3]
// amountIn/amountOut/feeBasisPoints are non-indexed → data[0..95]
var swapEventTopic = crypto.Keccak256Hash([]byte("Swap(address,address,address,uint256,uint256,uint256)"))
// scanSwapEvents scans Swap events from all YT vaults and upserts into yt_swap_records.
func (s *Scanner) scanSwapEvents(ctx context.Context, fromBlock, toBlock uint64) error {
if toBlock < fromBlock {
return nil
}
database := db.GetDB()
totalNew := 0
// Cache block timestamps to avoid repeated RPC calls for the same block
blockTimeCache := make(map[uint64]time.Time)
for _, vault := range s.config.YTVaults {
log.Printf(" [SwapScanner] %s blocks %d→%d", vault.Name, fromBlock, toBlock)
logs, err := s.queryBlockRange(ctx, common.HexToAddress(vault.Address), swapEventTopic, fromBlock, toBlock)
if err != nil {
log.Printf(" [SwapScanner] Error scanning %s: %v", vault.Name, err)
continue
}
for _, l := range logs {
// Validate topics and data length
if len(l.Topics) < 4 || len(l.Data) < 64 {
continue
}
account := common.BytesToAddress(l.Topics[1].Bytes()).Hex()
tokenIn := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
tokenOut := common.BytesToAddress(l.Topics[3].Bytes()).Hex()
amountIn := new(big.Int).SetBytes(l.Data[0:32])
amountOut := new(big.Int).SetBytes(l.Data[32:64])
// Get block time (cached per block number)
blockTime, ok := blockTimeCache[l.BlockNumber]
if !ok {
blk, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
if err == nil {
blockTime = time.Unix(int64(blk.Time()), 0)
blockTimeCache[l.BlockNumber] = blockTime
}
}
record := models.YTSwapRecord{
TxHash: l.TxHash.Hex(),
LogIndex: uint(l.Index),
ChainID: s.config.ChainID,
BlockNumber: l.BlockNumber,
BlockTime: blockTime,
VaultAddr: vault.Address,
Account: account,
TokenIn: strings.ToLower(tokenIn),
TokenOut: strings.ToLower(tokenOut),
AmountIn: amountIn.String(),
AmountOut: amountOut.String(),
}
// Skip if already exists (idempotent)
var existing models.YTSwapRecord
res := database.Where("tx_hash = ? AND log_index = ?", record.TxHash, record.LogIndex).First(&existing)
if res.Error == nil {
continue
}
if err := database.Create(&record).Error; err != nil {
if !strings.Contains(err.Error(), "Duplicate") {
log.Printf(" [SwapScanner] Save failed %s:%d: %v", record.TxHash, record.LogIndex, err)
}
continue
}
totalNew++
}
}
if totalNew > 0 {
log.Printf(" [SwapScanner] Saved %d new swap records", totalNew)
}
return nil
}

55
webapp-back/init_db.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
echo "🗄️ Initializing AssetX Database..."
# MySQL connection settings
MYSQL_USER="root"
MYSQL_PASSWORD="123456"
MYSQL_HOST="localhost"
MYSQL_PORT="3306"
DB_NAME="assetx"
# Check if MySQL is running
if ! mysqladmin ping -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" --silent 2>/dev/null; then
echo "❌ MySQL is not running or connection failed"
echo " Please start MySQL first: sudo service mysql start"
exit 1
fi
echo "✓ MySQL is running"
# Create database if not exists
echo "📝 Creating database..."
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS $DB_NAME DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null
if [ $? -eq 0 ]; then
echo "✓ Database '$DB_NAME' created/verified"
else
echo "❌ Failed to create database"
exit 1
fi
# Import schema if schema file exists
SCHEMA_FILE="../database-schema-v1.1-final.sql"
if [ -f "$SCHEMA_FILE" ]; then
echo "📥 Importing database schema..."
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$DB_NAME" < "$SCHEMA_FILE"
if [ $? -eq 0 ]; then
echo "✓ Schema imported successfully"
else
echo "⚠️ Schema import failed (might already exist)"
fi
else
echo "⚠️ Schema file not found: $SCHEMA_FILE"
echo " Tables will be created by GORM auto-migration"
fi
echo ""
echo "✅ Database initialization complete!"
echo ""
echo "Connection info:"
echo " Host: $MYSQL_HOST:$MYSQL_PORT"
echo " Database: $DB_NAME"
echo " User: $MYSQL_USER"
echo ""

View File

@@ -0,0 +1,174 @@
# Integration with Existing Code
## 发现holders 包已有类似实现
`holders/db_config.go` 中已经实现了从数据库加载合约地址的完整逻辑:
### holders/db_config.go 的实现
```go
// LoadConfigFromDB 从数据库加载配置(已存在)
func LoadConfigFromDB(chainID int64) (Config, error) {
// 1. 从 assets 表加载 YT-A, YT-B, YT-C
var assets []Asset
db.Table("assets").
Where("asset_code IN (?, ?, ?)", "YT-A", "YT-B", "YT-C").
Find(&assets)
// 2. 根据 chainID 选择合约地址
for _, asset := range assets {
address := asset.ContractAddressArb
if isBSC {
address = asset.ContractAddressBsc
}
ytVaults = append(ytVaults, VaultConfig{
Name: asset.AssetCode,
Address: address,
})
}
// 3. 从 lending_markets 表加载 Lending 合约
var lendingMarkets []LendingMarket
db.Table("lending_markets").
Where("is_active = ?", 1).
Find(&lendingMarkets)
lendingAddress := market.ContractAddressArb
if isBSC {
lendingAddress = market.ContractAddressBsc
}
return Config{
YTVaults: ytVaults,
LendingAddress: lendingAddress,
}
}
```
### lending/helpers.go 的实现(新建)
```go
// GetYTTokenInfo 从 assets 表获取 YT token 信息(新实现)
func GetYTTokenInfo(assetCode string) (*TokenInfo, error) {
var asset models.Asset
db.Where("asset_code = ? AND is_active = ?", assetCode, true).
First(&asset)
return &TokenInfo{
Symbol: asset.Name,
ContractAddressArb: asset.ContractAddressArb,
ContractAddressBsc: asset.ContractAddressBsc,
}
}
```
## 代码复用建议
### 选项 1统一使用 holders 包的结构体(推荐)
```go
// lending/helpers.go
import "github.com/gothinkster/golang-gin-realworld-example-app/holders"
func GetYTTokensFromHolders(chainID int64) ([]TokenInfo, error) {
// 复用 holders.LoadConfigFromDB
config, err := holders.LoadConfigFromDB(chainID)
if err != nil {
return nil, err
}
tokens := make([]TokenInfo, len(config.YTVaults))
for i, vault := range config.YTVaults {
tokens[i] = TokenInfo{
Symbol: vault.Name,
ContractAddressArb: vault.Address, // 已根据 chainID 选择
AssetCode: vault.Name,
}
}
return tokens, nil
}
```
### 选项 2共享数据模型
```go
// common/models.go - 创建共享的 Asset 模型
type Asset struct {
ID int64
AssetCode string
Name string
ContractAddressArb string
ContractAddressBsc string
}
// holders 和 lending 都使用这个共享模型
```
### 选项 3保持当前实现已完成
- ✅ lending 包独立实现
- ✅ 功能完整,逻辑清晰
- ⚠️ 与 holders 包有重复代码
## 数据库表结构(一致)
两个包都使用相同的表:
### assets 表
```sql
CREATE TABLE assets (
id BIGINT PRIMARY KEY,
asset_code VARCHAR(20), -- YT-A, YT-B, YT-C
name VARCHAR(255),
contract_address_arb VARCHAR(42),
contract_address_bsc VARCHAR(42),
is_active BOOLEAN
)
```
### lending_markets 表
```sql
CREATE TABLE lending_markets (
id BIGINT PRIMARY KEY,
market_name VARCHAR(100),
contract_address_arb VARCHAR(42),
contract_address_bsc VARCHAR(42),
is_active BOOLEAN
)
```
## Chain ID 处理
### holders 包
- 421614 = Arbitrum Sepolia
- 97 = BSC Testnet
### lending 包(需要添加)
```go
// helpers.go 添加 chain ID 支持
func GetContractAddress(tokenInfo TokenInfo, chainID int) string {
switch chainID {
case 421614: // Arbitrum Sepolia
return tokenInfo.ContractAddressArb
case 97: // BSC Testnet
return tokenInfo.ContractAddressBsc
case 42161: // Arbitrum One (mainnet)
return tokenInfo.ContractAddressArb
case 56: // BSC Mainnet
return tokenInfo.ContractAddressBsc
default:
return tokenInfo.ContractAddressArb
}
}
```
## 总结
### 已实现功能
- ✅ lending 包可以独立从 assets 表读取 YT tokens
- ✅ lending 包可以从 lending_markets 表读取市场配置
- ✅ USDC 地址已硬编码
- ✅ 与 holders 包的实现逻辑一致
### 建议优化(可选)
1. 考虑复用 holders.Asset 结构体
2. 添加对 holders.LoadConfigFromDB 的引用
3. 统一 chain ID 处理逻辑
### 当前状态
**可以直接使用**,无需修改。与 holders 包的实现独立但兼容。

View File

@@ -0,0 +1,285 @@
# Lending API Implementation
## Overview
This directory contains the backend implementation for the AssetX Lending Market system.
## API Endpoints
### Query Endpoints (Public)
#### Get User Position
```
GET /api/lending/position/:address
```
Returns user's lending position including:
- Supplied USDC balance
- Borrowed USDC balance
- Collateral balances (YT-A, YT-B, YT-C)
- Health Factor
- LTV (Loan-to-Value) ratio
- Supply/Borrow APY
**Response:**
```json
{
"success": true,
"data": {
"user_address": "0x...",
"wallet_address": "0x...",
"supplied_balance": "10000.00",
"supplied_balance_usd": 10000.00,
"borrowed_balance": "1000.00",
"borrowed_balance_usd": 1000.00,
"collateral_balances": {
"YT-A": {
"token_symbol": "YT-A",
"balance": "1000.00",
"balance_usd": 2000000.00,
"collateral_value": 1400000.00
}
},
"health_factor": 14.0,
"ltv": 10.0,
"supply_apy": 6.1,
"borrow_apy": 9.1
}
}
```
#### Get Lending Stats
```
GET /api/lending/stats
```
Returns lending market statistics:
- Total supplied/borrowed USD
- Total collateral USD
- Utilization rate
- Average APYs
- User counts
- Total TVL
**Response:**
```json
{
"success": true,
"data": {
"total_supplied_usd": 50000000.00,
"total_borrowed_usd": 30000000.00,
"total_collateral_usd": 80000000.00,
"utilization_rate": 60.0,
"avg_supply_apy": 6.1,
"avg_borrow_apy": 9.1,
"total_users": 1250,
"active_borrowers": 450,
"total_tvl": 130000000.00
}
}
```
#### Get Lending Markets
```
GET /api/lending/markets
```
Returns lending market configuration from `lending_markets` table.
#### Get All Tokens Info
```
GET /api/lending/tokens
```
Returns information about all supported tokens (USDC + YT tokens from `assets` table).
**Response:**
```json
{
"success": true,
"data": {
"stablecoins": [
{
"symbol": "USDC",
"name": "USD Coin",
"decimals": 6,
"contract_address_arb": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"contract_address_bsc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"
}
],
"yt_tokens": [
{
"symbol": "YT-A",
"name": "YT-A",
"decimals": 18,
"contract_address_arb": "0x...",
"contract_address_bsc": "0x...",
"asset_code": "YT-A"
}
]
}
}
```
#### Get Token Info
```
GET /api/lending/tokens/:assetCode
```
Returns information about a specific token (USDC or YT-A/YT-B/YT-C).
### Transaction Endpoints
#### Supply USDC
```
POST /api/lending/supply
Content-Type: application/json
{
"amount": "1000.00",
"tx_hash": "0x..."
}
```
#### Withdraw USDC
```
POST /api/lending/withdraw
Content-Type: application/json
{
"amount": "500.00",
"tx_hash": "0x..."
}
```
#### Supply Collateral
```
POST /api/lending/supply-collateral
Content-Type: application/json
{
"asset": "YT-A",
"amount": "100.00",
"tx_hash": "0x..."
}
```
#### Withdraw Collateral
```
POST /api/lending/withdraw-collateral
Content-Type: application/json
{
"asset": "YT-A",
"amount": "50.00",
"tx_hash": "0x..."
}
```
#### Borrow USDC
```
POST /api/lending/borrow
Content-Type: application/json
{
"amount": "1000.00",
"tx_hash": "0x..."
}
```
#### Repay USDC
```
POST /api/lending/repay
Content-Type: application/json
{
"amount": "500.00",
"tx_hash": "0x..."
}
```
## Files
- `models.go` - Data models and request/response structures
- `handlers.go` - HTTP request handlers
- `helpers.go` - Helper functions (token info, calculations, validation)
- `tokens.go` - Token information endpoints
- `routers.go` - Route documentation (routes registered in main.go)
- `README.md` - This file
## Current Implementation Status
### ✅ Completed
- API endpoint structure
- Request/response models
- Basic validation
- Mock data responses
- Database integration for token information
- USDC configuration (hardcoded as per frontend)
- YT token information from `assets` table
- Token validation against database
- Health factor and LTV calculation formulas
- Token info query endpoints
### ⏳ TODO
1. **Blockchain Integration**
- Connect to smart contracts
- Verify transactions on-chain
- Query real-time balances
- Calculate health factor from chain data
2. **Database Integration**
- Store transaction records
- Update lending_markets table
- Track user positions
- Generate statistics
3. **Authentication**
- Add wallet signature verification
- Implement user session management
4. **Business Logic**
- Interest accrual calculations
- Health factor monitoring
- Liquidation logic
- APY calculations
## Database Schema
The lending system uses the following tables from `database-schema-v1.1-final.sql`:
- `lending_markets` - Lending market configuration
- `transactions` - Transaction records
- `protocol_stats` - Protocol-level statistics
Note: User position data is primarily read from blockchain, with caching in database.
## Integration with Frontend
Frontend components that use these APIs:
- `/lending/supply/SupplyPanel.tsx` - Supply USDC
- `/lending/supply/WithdrawPanel.tsx` - Withdraw USDC
- `/lending/repay/RepayBorrowDebt.tsx` - Borrow/Repay USDC
- `/lending/repay/RepaySupplyCollateral.tsx` - Supply/Withdraw Collateral
- `/lending/BorrowMarket.tsx` - Display lending positions
## Testing
```bash
# Start the server
go run main.go
# Test endpoints
curl http://localhost:8080/api/lending/stats
curl http://localhost:8080/api/lending/position/0x1234...
# Test transactions (requires auth)
curl -X POST http://localhost:8080/api/lending/supply \
-H "Content-Type: application/json" \
-d '{"amount": "1000", "tx_hash": "0x..."}'
```
## Next Steps
1. Implement blockchain RPC integration
2. Add real transaction verification
3. Implement health factor calculations
4. Add liquidation monitoring
5. Integrate with frontend
6. Add comprehensive tests
7. Deploy to testnet

View File

@@ -0,0 +1,631 @@
package lending
import (
"context"
"crypto/ecdsa"
"fmt"
"log"
"math/big"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
dbpkg "github.com/gothinkster/golang-gin-realworld-example-app/common"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
const (
buyerLoopDelay = 10 * time.Second
buyerLookbackBlocks = int64(10000)
)
const collateralBuyerABIStr = `[
{"anonymous":false,"inputs":[
{"indexed":true,"name":"buyer","type":"address"},
{"indexed":true,"name":"asset","type":"address"},
{"indexed":false,"name":"baseAmount","type":"uint256"},
{"indexed":false,"name":"collateralAmount","type":"uint256"}
],"name":"BuyCollateral","type":"event"},
{"inputs":[],"name":"getReserves","outputs":[{"name":"","type":"int256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"targetReserves","outputs":[{"name":"","type":"uint104"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"}],"name":"getCollateralReserves","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"},{"name":"baseAmount","type":"uint256"}],"name":"quoteCollateral","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"asset","type":"address"},{"name":"minAmount","type":"uint256"},{"name":"baseAmount","type":"uint256"},{"name":"recipient","type":"address"}],"name":"buyCollateral","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[],"name":"baseToken","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"","type":"uint256"}],"name":"assetList","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"}
]`
const erc20BuyerABIStr = `[
{"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"stateMutability":"view","type":"function"}
]`
// BuyerBotStatus is returned by the status API
type BuyerBotStatus struct {
Running bool `json:"running"`
BuyerAddr string `json:"buyer_addr"`
ChainID int `json:"chain_id"`
ContractAddr string `json:"contract_addr"`
TotalBuys int64 `json:"total_buys"`
LastCheckTime time.Time `json:"last_check_time"`
LastBlockChecked uint64 `json:"last_block_checked"`
LoopDelayMs int `json:"loop_delay_ms"`
LookbackBlocks int64 `json:"lookback_blocks"`
SlippagePct int `json:"slippage_pct"`
Error string `json:"error,omitempty"`
}
type collateralBuyerBot struct {
mu sync.Mutex
running bool
stopChan chan struct{}
buyerAddr string
chainID int
contractAddr string
totalBuys int64
lastCheckTime time.Time
lastBlockChecked uint64
lastErr string
slippagePct int
}
var globalBuyer = &collateralBuyerBot{}
func GetBuyerBotStatus() BuyerBotStatus {
globalBuyer.mu.Lock()
defer globalBuyer.mu.Unlock()
return BuyerBotStatus{
Running: globalBuyer.running,
BuyerAddr: globalBuyer.buyerAddr,
ChainID: globalBuyer.chainID,
ContractAddr: globalBuyer.contractAddr,
TotalBuys: globalBuyer.totalBuys,
LastCheckTime: globalBuyer.lastCheckTime,
LastBlockChecked: globalBuyer.lastBlockChecked,
LoopDelayMs: int(buyerLoopDelay.Milliseconds()),
LookbackBlocks: buyerLookbackBlocks,
SlippagePct: globalBuyer.slippagePct,
Error: globalBuyer.lastErr,
}
}
func StartCollateralBuyerBot(cfg *appcfg.Config) {
// 优先用独立私钥,未配置则 fallback 到清算机器人私钥(不修改 cfg 原始值)
privKey := cfg.CollateralBuyerPrivateKey
if privKey == "" && cfg.LiquidatorPrivateKey != "" {
privKey = cfg.LiquidatorPrivateKey
log.Println("[BuyerBot] COLLATERAL_BUYER_PRIVATE_KEY 未配置,使用 LIQUIDATOR_PRIVATE_KEY")
}
if privKey == "" {
log.Println("[BuyerBot] 未配置私钥Bot 未启动")
globalBuyer.mu.Lock()
globalBuyer.lastErr = "未配置私钥Bot 未启动"
globalBuyer.mu.Unlock()
return
}
globalBuyer.mu.Lock()
if globalBuyer.running {
globalBuyer.mu.Unlock()
return
}
globalBuyer.stopChan = make(chan struct{})
globalBuyer.running = true
globalBuyer.lastErr = ""
globalBuyer.mu.Unlock()
go runBuyerBot(cfg, privKey)
log.Println("[BuyerBot] Started")
}
func StopCollateralBuyerBot() {
globalBuyer.mu.Lock()
defer globalBuyer.mu.Unlock()
if !globalBuyer.running {
return
}
close(globalBuyer.stopChan)
globalBuyer.running = false
log.Println("[BuyerBot] Stop signal sent")
}
func runBuyerBot(cfg *appcfg.Config, privKeyStr string) {
defer func() {
globalBuyer.mu.Lock()
globalBuyer.running = false
globalBuyer.mu.Unlock()
log.Println("[BuyerBot] Goroutine exited")
}()
privKeyHex := strings.TrimPrefix(privKeyStr, "0x")
privateKey, err := crypto.HexToECDSA(privKeyHex)
if err != nil {
log.Printf("[BuyerBot] Invalid private key: %v", err)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("invalid private key: %v", err)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
buyerAddr := crypto.PubkeyToAddress(privateKey.PublicKey)
lendingAddr, chainID, loadErr := loadBotContracts()
if loadErr != nil {
log.Printf("[BuyerBot] Load contracts: %v", loadErr)
globalBuyer.mu.Lock()
globalBuyer.lastErr = loadErr.Error()
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
rpcURL := getRPCURL(chainID)
if rpcURL == "" {
log.Printf("[BuyerBot] No RPC URL for chain %d", chainID)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
slippagePct := cfg.CollateralBuyerSlippage
if slippagePct <= 0 || slippagePct > 10 {
slippagePct = 1
}
globalBuyer.mu.Lock()
globalBuyer.buyerAddr = buyerAddr.Hex()
globalBuyer.chainID = chainID
globalBuyer.contractAddr = lendingAddr
globalBuyer.slippagePct = slippagePct
globalBuyer.mu.Unlock()
log.Printf("[BuyerBot] Buyer: %s | Chain: %d | Lending: %s | Slippage: %d%%",
buyerAddr.Hex(), chainID, lendingAddr, slippagePct)
lABI, err := abi.JSON(strings.NewReader(collateralBuyerABIStr))
if err != nil {
log.Printf("[BuyerBot] ABI parse error: %v", err)
globalBuyer.mu.Lock()
globalBuyer.lastErr = fmt.Sprintf("ABI parse error: %v", err)
globalBuyer.running = false
globalBuyer.mu.Unlock()
return
}
processedBlock := loadBuyerBlock(chainID)
for {
select {
case <-globalBuyer.stopChan:
return
default:
}
caughtUp := buyerTick(rpcURL, lABI, privateKey, buyerAddr,
lendingAddr, chainID, slippagePct, &processedBlock)
if caughtUp {
select {
case <-globalBuyer.stopChan:
return
case <-time.After(buyerLoopDelay):
}
}
}
}
func buyerTick(
rpcURL string,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
buyerAddr ethcommon.Address,
lendingAddr string,
chainID int,
slippagePct int,
processedBlock *uint64,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
log.Printf("[BuyerBot] RPC dial error: %v", err)
return true
}
defer client.Close()
currentBlock, err := client.BlockNumber(ctx)
if err != nil {
log.Printf("[BuyerBot] BlockNumber error: %v", err)
return true
}
if *processedBlock == 0 {
if currentBlock >= uint64(buyerLookbackBlocks) {
*processedBlock = currentBlock - uint64(buyerLookbackBlocks)
}
}
fromBlock := *processedBlock + 1
toBlock := fromBlock + uint64(buyerLookbackBlocks) - 1
if toBlock > currentBlock {
toBlock = currentBlock
}
globalBuyer.mu.Lock()
globalBuyer.lastCheckTime = time.Now()
globalBuyer.lastBlockChecked = toBlock
globalBuyer.mu.Unlock()
lendingContract := ethcommon.HexToAddress(lendingAddr)
// ── 1. 检查国库条件(轮询核心) ──────────────────────────────────────
reserves, targReserves, condErr := checkBuyCondition(ctx, client, lABI, lendingContract)
if condErr != nil {
log.Printf("[BuyerBot] 条件检查失败: %v", condErr)
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
return toBlock >= currentBlock
}
if reserves.Cmp(targReserves) >= 0 {
// 国库充足,静默推进
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
return toBlock >= currentBlock
}
log.Printf("[BuyerBot] 国库不足: reserves=%s < targetReserves=%s开始购买",
reserves.String(), targReserves.String())
saveBuyerBlock(chainID, toBlock)
*processedBlock = toBlock
// ── 2. 获取 baseToken 及精度 ─────────────────────────────────────────
eABI, eABIErr := abi.JSON(strings.NewReader(erc20BuyerABIStr))
if eABIErr != nil {
log.Printf("[BuyerBot] ERC20 ABI parse error: %v", eABIErr)
return toBlock >= currentBlock
}
baseTokenAddr, btErr := callViewAddress(ctx, client, lABI, lendingContract, "baseToken")
if btErr != nil {
log.Printf("[BuyerBot] baseToken error: %v", btErr)
return toBlock >= currentBlock
}
// ── 3. 扫描所有已配置资产,过滤出储备 > 0 的 ────────────────────────
type assetInfo struct {
addr ethcommon.Address
reserve *big.Int
symbol string
}
var assetsWithReserves []assetInfo
allAssets := getAllAssets(ctx, client, lABI, lendingContract)
for addr := range allAssets {
collRes, err := callViewRaw(ctx, client, lABI, lendingContract, "getCollateralReserves", addr)
if err != nil {
continue
}
reserve, ok := collRes[0].(*big.Int)
if !ok || reserve.Sign() == 0 {
continue
}
sym := getAssetSymbol(ctx, client, eABI, addr)
assetsWithReserves = append(assetsWithReserves, assetInfo{addr: addr, reserve: new(big.Int).Set(reserve), symbol: sym})
log.Printf("[BuyerBot] 可购买资产: %s 储备=%s", sym, reserve.String())
}
if len(assetsWithReserves) == 0 {
log.Println("[BuyerBot] 无可购买资产(储备均为零)")
return toBlock >= currentBlock
}
// ── 4. 一次性授权检查allowance < MaxUint256/2 才 approve──────────
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
halfMax := new(big.Int).Rsh(maxUint256, 1)
allowance, alwErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "allowance", buyerAddr, lendingContract)
if alwErr != nil {
log.Printf("[BuyerBot] allowance error: %v", alwErr)
return toBlock >= currentBlock
}
if allowance.Cmp(halfMax) < 0 {
log.Printf("[BuyerBot] 授权不足,执行 approve(MaxUint256)...")
chainIDBig := big.NewInt(int64(chainID))
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
if authErr != nil {
log.Printf("[BuyerBot] create transactor: %v", authErr)
return toBlock >= currentBlock
}
auth.GasLimit = 100000
erc20Bound := bind.NewBoundContract(baseTokenAddr, eABI, client, client, client)
approveTx, appErr := erc20Bound.Transact(auth, "approve", lendingContract, maxUint256)
if appErr != nil {
log.Printf("[BuyerBot] approve error: %v", appErr)
return toBlock >= currentBlock
}
approveCtx, approveCancel := context.WithTimeout(context.Background(), 60*time.Second)
_, waitErr := bind.WaitMined(approveCtx, client, approveTx)
approveCancel()
if waitErr != nil {
log.Printf("[BuyerBot] approve wait: %v", waitErr)
return toBlock >= currentBlock
}
log.Printf("[BuyerBot] 授权成功: %s", approveTx.Hash().Hex())
} else {
log.Println("[BuyerBot] 授权充足,跳过 approve")
}
// ── 5. 逐资产购买 ───────────────────────────────────────────────────
chainIDBig := big.NewInt(int64(chainID))
successCount := 0
for _, asset := range assetsWithReserves {
// 每次购买前重新读取余额
freshBalance, balErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "balanceOf", buyerAddr)
if balErr != nil {
log.Printf("[BuyerBot] balanceOf error: %v", balErr)
break
}
if freshBalance.Sign() == 0 {
log.Println("[BuyerBot] 买家余额耗尽,停止购买")
break
}
// minAmount = reserve * (100 - slippagePct) / 100
minAmount := new(big.Int).Mul(asset.reserve, big.NewInt(int64(100-slippagePct)))
minAmount.Div(minAmount, big.NewInt(100))
log.Printf("[BuyerBot] 购买 %s: balance=%s reserve=%s minAmount=%s",
asset.symbol, freshBalance.String(), asset.reserve.String(), minAmount.String())
actualPaid, actualReceived, txHash, gasUsed, blockNum, buyErr := executeBuy(
ctx, client, lABI, privateKey, buyerAddr,
lendingContract, asset.addr, minAmount, freshBalance,
chainIDBig,
)
if buyErr != nil {
log.Printf("[BuyerBot] 购买 %s 失败(跳过): %v", asset.symbol, buyErr)
// 失败时用时间戳生成唯一 key避免 uniqueIndex 冲突
failKey := fmt.Sprintf("FAILED_%s_%d", asset.addr.Hex()[:10], time.Now().UnixNano())
saveBuyRecord(chainID, failKey, buyerAddr, asset.addr, asset.symbol,
freshBalance, big.NewInt(0), 0, 0, "failed", buyErr.Error())
continue // 单资产失败不影响其他资产
}
log.Printf("[BuyerBot] %s 购买成功: 支付=%s 获得=%s tx=%s",
asset.symbol, actualPaid.String(), actualReceived.String(), txHash)
saveBuyRecord(chainID, txHash, buyerAddr, asset.addr, asset.symbol,
actualPaid, actualReceived, gasUsed, blockNum, "success", "")
globalBuyer.mu.Lock()
globalBuyer.totalBuys++
globalBuyer.mu.Unlock()
successCount++
}
log.Printf("[BuyerBot] 本轮完成: 成功 %d / %d 个资产", successCount, len(assetsWithReserves))
return toBlock >= currentBlock
}
// executeBuy 执行一次 buyCollateral 并从事件解析实际支付/获得量
func executeBuy(
ctx context.Context,
client *ethclient.Client,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
buyerAddr ethcommon.Address,
lendingContract ethcommon.Address,
asset ethcommon.Address,
minAmount *big.Int,
baseAmount *big.Int, // 买家全部余额作为上限
chainIDBig *big.Int,
) (actualPaid *big.Int, actualReceived *big.Int, txHash string, gasUsed, blockNum uint64, err error) {
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
if authErr != nil {
return nil, nil, "", 0, 0, fmt.Errorf("create transactor: %w", authErr)
}
auth.GasLimit = 300000
lendingBound := bind.NewBoundContract(lendingContract, lABI, client, client, client)
tx, txErr := lendingBound.Transact(auth, "buyCollateral", asset, minAmount, baseAmount, buyerAddr)
if txErr != nil {
return nil, nil, "", 0, 0, fmt.Errorf("buyCollateral: %w", txErr)
}
txHash = tx.Hash().Hex()
log.Printf("[BuyerBot] 交易已提交: %s", txHash)
receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 90*time.Second)
defer receiptCancel()
receipt, waitErr := bind.WaitMined(receiptCtx, client, tx)
if waitErr != nil {
return nil, nil, txHash, 0, 0, fmt.Errorf("wait mined: %w", waitErr)
}
gasUsed = receipt.GasUsed
blockNum = receipt.BlockNumber.Uint64()
if receipt.Status != 1 {
return nil, nil, txHash, gasUsed, blockNum,
fmt.Errorf("交易回滚 block %d, tx %s", blockNum, txHash)
}
log.Printf("[BuyerBot] 交易确认 Gas=%d Block=%d", gasUsed, blockNum)
// 从 BuyCollateral 事件解析实际数值
eventID := lABI.Events["BuyCollateral"].ID
for _, l := range receipt.Logs {
if len(l.Topics) < 1 || l.Topics[0] != eventID {
continue
}
decoded, decErr := lABI.Unpack("BuyCollateral", l.Data)
if decErr != nil || len(decoded) < 2 {
continue
}
paid, ok1 := decoded[0].(*big.Int)
received, ok2 := decoded[1].(*big.Int)
if ok1 && ok2 {
return paid, received, txHash, gasUsed, blockNum, nil
}
}
// BuyCollateral 事件未找到,用余额差估算(实际数量需链上核实)
log.Printf("[BuyerBot] 警告: BuyCollateral 事件解析失败 tx=%sreceivedAmount 记录为 0", txHash)
return baseAmount, big.NewInt(0), txHash, gasUsed, blockNum, nil
}
// ── 合约枚举 / 条件检查 helpers ──────────────────────────────────────────────
// getAllAssets 遍历 assetList(0,1,...) 直到 revert
func getAllAssets(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
contract ethcommon.Address) map[ethcommon.Address]struct{} {
result := make(map[ethcommon.Address]struct{})
zero := ethcommon.Address{}
for i := 0; i < 50; i++ {
res, err := callViewRaw(ctx, client, lABI, contract, "assetList", big.NewInt(int64(i)))
if err != nil {
break
}
addr, ok := res[0].(ethcommon.Address)
if !ok || addr == zero {
break
}
result[addr] = struct{}{}
}
log.Printf("[BuyerBot] assetList: %d 个资产", len(result))
return result
}
// checkBuyCondition 返回 (reserves int256, targetReserves, error)
func checkBuyCondition(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
addr ethcommon.Address) (*big.Int, *big.Int, error) {
res, err := callViewRaw(ctx, client, lABI, addr, "getReserves")
if err != nil {
return nil, nil, fmt.Errorf("getReserves: %w", err)
}
reserves, ok := res[0].(*big.Int)
if !ok {
return nil, nil, fmt.Errorf("getReserves type %T", res[0])
}
res2, err := callViewRaw(ctx, client, lABI, addr, "targetReserves")
if err != nil {
return nil, nil, fmt.Errorf("targetReserves: %w", err)
}
targReserves, ok2 := res2[0].(*big.Int)
if !ok2 {
return nil, nil, fmt.Errorf("targetReserves type %T", res2[0])
}
return reserves, targReserves, nil
}
// ── ABI call helpers ──────────────────────────────────────────────────────────
func callViewAddress(ctx context.Context, client *ethclient.Client, contractABI abi.ABI,
addr ethcommon.Address, method string, args ...interface{}) (ethcommon.Address, error) {
res, err := callViewRaw(ctx, client, contractABI, addr, method, args...)
if err != nil {
return ethcommon.Address{}, err
}
v, ok := res[0].(ethcommon.Address)
if !ok {
return ethcommon.Address{}, fmt.Errorf("%s type %T", method, res[0])
}
return v, nil
}
func callERC20BigInt(ctx context.Context, client *ethclient.Client, eABI abi.ABI,
addr ethcommon.Address, method string, args ...interface{}) (*big.Int, error) {
data, err := eABI.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("pack %s: %w", method, err)
}
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
if err != nil {
return nil, fmt.Errorf("call %s: %w", method, err)
}
decoded, err := eABI.Unpack(method, result)
if err != nil {
return nil, fmt.Errorf("unpack %s: %w", method, err)
}
v, ok := decoded[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("%s type %T", method, decoded[0])
}
return v, nil
}
func getAssetSymbol(ctx context.Context, client *ethclient.Client, eABI abi.ABI, asset ethcommon.Address) string {
data, _ := eABI.Pack("symbol")
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &asset, Data: data}, nil)
if err != nil {
return asset.Hex()[:10]
}
decoded, err := eABI.Unpack("symbol", result)
if err != nil || len(decoded) == 0 {
return asset.Hex()[:10]
}
s, ok := decoded[0].(string)
if !ok {
return asset.Hex()[:10]
}
return s
}
// ── DB helpers ────────────────────────────────────────────────────────────────
func loadBuyerBlock(chainID int) uint64 {
db := dbpkg.GetDB()
var state models.ScannerState
if err := db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").First(&state).Error; err != nil {
return 0
}
return state.LastScannedBlock
}
func saveBuyerBlock(chainID int, block uint64) {
db := dbpkg.GetDB()
state := models.ScannerState{
ScannerType: "collateral_buyer",
ChainID: chainID,
LastScannedBlock: block,
}
db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").Assign(state).FirstOrCreate(&state)
db.Model(&state).Updates(map[string]interface{}{
"last_scanned_block": block,
"updated_at": time.Now(),
})
}
func saveBuyRecord(chainID int, txHash string, buyerAddr, assetAddr ethcommon.Address,
assetSymbol string, paidAmount, receivedAmount *big.Int, gasUsed, blockNum uint64, status, errMsg string) {
record := models.CollateralBuyRecord{
ChainID: chainID,
TxHash: txHash,
BuyerAddr: buyerAddr.Hex(),
AssetAddr: assetAddr.Hex(),
AssetSymbol: assetSymbol,
PaidAmount: paidAmount.String(),
ReceivedAmount: receivedAmount.String(),
GasUsed: gasUsed,
BlockNumber: blockNum,
Status: status,
ErrorMessage: errMsg,
CreatedAt: time.Now(),
}
db := dbpkg.GetDB()
if dbErr := db.Create(&record).Error; dbErr != nil {
log.Printf("[BuyerBot] Save buy record error: %v", dbErr)
}
}

View File

@@ -0,0 +1,342 @@
package lending
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// GetUserPosition returns user's lending position
// GET /api/lending/position/:address
func GetUserPosition(c *gin.Context) {
address := c.Param("address")
if address == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "wallet address is required",
})
return
}
config := GetLendingMarketConfig()
position := &UserPosition{
UserAddress: address,
WalletAddress: address,
SuppliedBalance: "0",
SuppliedBalanceUSD: 0,
BorrowedBalance: "0",
BorrowedBalanceUSD: 0,
CollateralBalances: map[string]CollateralInfo{},
HealthFactor: 0,
LTV: 0,
SupplyAPY: config["base_supply_apy"].(float64),
BorrowAPY: config["base_borrow_apy"].(float64),
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": position,
})
}
// GetLendingStats returns lending market statistics
// GET /api/lending/stats
func GetLendingStats(c *gin.Context) {
config := GetLendingMarketConfig()
stats := &LendingStats{
TotalSuppliedUSD: 0,
TotalBorrowedUSD: 0,
TotalCollateralUSD: 0,
UtilizationRate: 0,
AvgSupplyAPY: config["base_supply_apy"].(float64),
AvgBorrowAPY: config["base_borrow_apy"].(float64),
TotalUsers: 0,
ActiveBorrowers: 0,
TotalTVL: 0,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetLendingMarkets returns lending market configuration.
// Contract addresses come from system_contracts; static config from GetLendingMarketConfig.
// GET /api/lending/markets
func GetLendingMarkets(c *gin.Context) {
db := common.GetDB()
var lc struct {
Address string `gorm:"column:address"`
ChainID int `gorm:"column:chain_id"`
}
db.Table("system_contracts").
Where("name = ? AND is_active = ?", "lendingProxy", true).
Select("address, chain_id").
First(&lc)
config := GetLendingMarketConfig()
config["contract_address"] = lc.Address
config["chain_id"] = lc.ChainID
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []interface{}{config},
})
}
// SupplyUSDC handles USDC supply (deposit) transaction
// POST /api/lending/supply
func SupplyUSDC(c *gin.Context) {
var req SupplyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user position
// TODO: Record transaction in database
c.JSON(http.StatusOK, SupplyResponse{
Success: true,
Message: "USDC supplied successfully",
})
}
// WithdrawUSDC handles USDC withdrawal transaction
// POST /api/lending/withdraw
func WithdrawUSDC(c *gin.Context) {
var req WithdrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user position
// TODO: Record transaction in database
c.JSON(http.StatusOK, WithdrawResponse{
Success: true,
Message: "USDC withdrawn successfully",
})
}
// SupplyCollateral handles collateral supply transaction
// POST /api/lending/supply-collateral
func SupplyCollateral(c *gin.Context) {
var req SupplyCollateralRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Validate asset type - check against assets table
if !ValidateYTToken(req.Asset) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
})
return
}
// Get token info from database
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch token information",
})
return
}
_ = tokenInfo // Will be used for blockchain validation
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
// TODO: Update user collateral position
// TODO: Record transaction in database
c.JSON(http.StatusOK, SupplyCollateralResponse{
Success: true,
Message: "Collateral supplied successfully",
})
}
// WithdrawCollateral handles collateral withdrawal transaction
// POST /api/lending/withdraw-collateral
func WithdrawCollateral(c *gin.Context) {
var req WithdrawCollateralRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Validate asset type - check against assets table
if !ValidateYTToken(req.Asset) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
})
return
}
// Get token info from database
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch token information",
})
return
}
_ = tokenInfo // Will be used for blockchain validation
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
// TODO: Check if withdrawal is safe (health factor)
// TODO: Update user collateral position
// TODO: Record transaction in database
c.JSON(http.StatusOK, WithdrawCollateralResponse{
Success: true,
Message: "Collateral withdrawn successfully",
})
}
// BorrowUSDC handles USDC borrow transaction
// POST /api/lending/borrow
func BorrowUSDC(c *gin.Context) {
var req BorrowRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Check collateral sufficiency
// TODO: Update user borrow position
// TODO: Record transaction in database
c.JSON(http.StatusOK, BorrowResponse{
Success: true,
Message: "USDC borrowed successfully",
})
}
// RepayUSDC handles USDC repayment transaction
// POST /api/lending/repay
func RepayUSDC(c *gin.Context) {
var req RepayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// TODO: Validate transaction on blockchain
// TODO: Update user borrow position
// TODO: Record transaction in database
c.JSON(http.StatusOK, RepayResponse{
Success: true,
Message: "USDC repaid successfully",
})
}
// GetLendingAPYHistory returns historical supply/borrow APY snapshots
// GET /api/lending/apy-history?period=1W&chain_id=97
func GetLendingAPYHistory(c *gin.Context) {
period := c.DefaultQuery("period", "1W")
chainId := parseChainID(c)
database := common.GetDB()
var usdcAsset models.Asset
if err := database.Where("asset_code = ?", "USDC").First(&usdcAsset).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "USDC asset not configured"})
return
}
now := time.Now()
var since time.Time
switch period {
case "1M":
since = now.AddDate(0, -1, 0)
case "1Y":
since = now.AddDate(-1, 0, 0)
default: // 1W
since = now.AddDate(0, 0, -7)
}
query := database.Where("asset_id = ? AND snapshot_time >= ?", usdcAsset.ID, since).
Order("snapshot_time ASC")
if chainId != 0 {
query = query.Where("chain_id = ?", chainId)
}
var snapshots []models.APYSnapshot
if err := query.Find(&snapshots).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch APY history"})
return
}
type DataPoint struct {
Time string `json:"time"`
SupplyAPY float64 `json:"supply_apy"`
BorrowAPY float64 `json:"borrow_apy"`
}
points := make([]DataPoint, 0, len(snapshots))
for _, s := range snapshots {
points = append(points, DataPoint{
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
SupplyAPY: s.SupplyAPY,
BorrowAPY: s.BorrowAPY,
})
}
var currentSupplyAPY, currentBorrowAPY float64
if len(snapshots) > 0 {
last := snapshots[len(snapshots)-1]
currentSupplyAPY = last.SupplyAPY
currentBorrowAPY = last.BorrowAPY
}
// APY change vs first point in period
var apyChange float64
if len(snapshots) > 1 {
apyChange = snapshots[len(snapshots)-1].SupplyAPY - snapshots[0].SupplyAPY
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"history": points,
"current_supply_apy": currentSupplyAPY,
"current_borrow_apy": currentBorrowAPY,
"apy_change": apyChange,
"period": period,
},
})
}

View File

@@ -0,0 +1,245 @@
package lending
import (
"context"
"encoding/hex"
"fmt"
"log"
"math/big"
"time"
"github.com/ethereum/go-ethereum"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// TokenInfo represents token contract information
type TokenInfo struct {
Symbol string
Name string
Decimals int
ContractAddress string
ChainID int
AssetCode string
}
// getRPCURL returns the RPC URL for a given chain ID from config
func getRPCURL(chainId int) string {
cfg := appcfg.AppConfig
switch chainId {
case 97: // BSC Testnet
if cfg != nil && cfg.BSCTestnetRPC != "" {
return cfg.BSCTestnetRPC
}
case 421614: // Arbitrum Sepolia
if cfg != nil && cfg.ArbSepoliaRPC != "" {
return cfg.ArbSepoliaRPC
}
}
return ""
}
// getContractAddressByChain returns the contract address from TokenInfo (chain-agnostic now)
func getContractAddressByChain(info TokenInfo, chainId int) string {
return info.ContractAddress
}
// fetchDecimalsOnChain calls decimals() on an ERC20 contract via RPC
// Returns error if chain call fails so caller can fall back to DB
func fetchDecimalsOnChain(contractAddress, rpcURL string) (int, error) {
if rpcURL == "" || contractAddress == "" {
return 0, fmt.Errorf("missing rpcURL or contractAddress")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return 0, fmt.Errorf("dial rpc: %w", err)
}
defer client.Close()
// decimals() selector = keccak256("decimals()")[0:4] = 313ce567
data, _ := hex.DecodeString("313ce567")
addr := ethcommon.HexToAddress(contractAddress)
result, err := client.CallContract(ctx, ethereum.CallMsg{
To: &addr,
Data: data,
}, nil)
if err != nil {
return 0, fmt.Errorf("call contract: %w", err)
}
if len(result) < 1 {
return 0, fmt.Errorf("empty result")
}
decimals := new(big.Int).SetBytes(result[len(result)-1:])
return int(decimals.Int64()), nil
}
// resolveDecimals tries chain first (if chainId != 0), falls back to dbDecimals
func resolveDecimals(contractAddress string, chainId, dbDecimals int) int {
if chainId == 0 {
return dbDecimals
}
rpcURL := getRPCURL(chainId)
if rpcURL == "" {
return dbDecimals
}
decimals, err := fetchDecimalsOnChain(contractAddress, rpcURL)
if err != nil {
log.Printf("⚠️ [Lending] 链上获取精度失败 %s (chain %d): %v使用数据库配置 %d", contractAddress, chainId, err, dbDecimals)
return dbDecimals
}
return decimals
}
// GetTokenInfoFromDB queries any token from assets table by asset_code
func GetTokenInfoFromDB(assetCode string) (*TokenInfo, error) {
db := common.GetDB()
var asset models.Asset
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
return nil, err
}
return &TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}, nil
}
// GetUSDCInfo returns USDC token information.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
// chainId == 0: DB only.
func GetUSDCInfo(chainId int) TokenInfo {
info, err := GetTokenInfoFromDB("USDC")
if err != nil {
log.Printf("⚠️ [Lending] USDC 不在 assets 表: %v使用默认值", err)
return TokenInfo{AssetCode: "USDC", Symbol: "USDC", Name: "USD Coin", Decimals: 18}
}
contractAddr := getContractAddressByChain(*info, chainId)
info.Decimals = resolveDecimals(contractAddr, chainId, info.Decimals)
return *info
}
// GetYTTokenInfo returns YT token information.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
func GetYTTokenInfo(assetCode string, chainId int) (*TokenInfo, error) {
db := common.GetDB()
var asset models.Asset
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
return nil, err
}
info := &TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
return info, nil
}
// GetAllStablecoins returns all active stablecoin tokens (token_role = 'stablecoin').
func GetAllStablecoins(chainId int) ([]TokenInfo, error) {
db := common.GetDB()
var assets []models.Asset
if err := db.Where("token_role = ? AND is_active = ?", "stablecoin", true).Find(&assets).Error; err != nil {
return nil, err
}
tokens := make([]TokenInfo, 0, len(assets))
for _, asset := range assets {
info := TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
tokens = append(tokens, info)
}
return tokens, nil
}
// GetAllYTTokens returns all active YT tokens.
// chainId != 0: tries on-chain decimals() first, falls back to DB.
func GetAllYTTokens(chainId int) ([]TokenInfo, error) {
db := common.GetDB()
var assets []models.Asset
if err := db.Where("token_role = ? AND is_active = ?", "yt_token", true).Find(&assets).Error; err != nil {
return nil, err
}
tokens := make([]TokenInfo, 0, len(assets))
for _, asset := range assets {
info := TokenInfo{
Symbol: asset.TokenSymbol,
Name: asset.Name,
Decimals: asset.Decimals,
ContractAddress: asset.ContractAddress,
ChainID: asset.ChainID,
AssetCode: asset.AssetCode,
}
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
tokens = append(tokens, info)
}
return tokens, nil
}
// ValidateYTToken checks if the given asset code is a valid YT token
func ValidateYTToken(assetCode string) bool {
db := common.GetDB()
var count int64
db.Model(&models.Asset{}).
Where("asset_code = ? AND token_role = ? AND is_active = ?", assetCode, "yt_token", true).
Count(&count)
return count > 0
}
// GetLendingMarketConfig returns lending market configuration
func GetLendingMarketConfig() map[string]interface{} {
return map[string]interface{}{
"market_name": "AssetX Lending Market",
"base_supply_apy": 6.1,
"base_borrow_apy": 9.1,
"borrow_collateral_factor": 0.7,
"liquidate_collateral_factor": 0.75,
"liquidation_penalty": 0.1,
"kink_rate": 0.8,
}
}
// CalculateHealthFactor calculates health factor
func CalculateHealthFactor(collateralValueUSD, borrowedValueUSD float64) float64 {
if borrowedValueUSD == 0 {
return 999999.0
}
config := GetLendingMarketConfig()
liquidationThreshold := config["liquidate_collateral_factor"].(float64)
return (collateralValueUSD * liquidationThreshold) / borrowedValueUSD
}
// CalculateLTV calculates Loan-to-Value ratio
func CalculateLTV(collateralValueUSD, borrowedValueUSD float64) float64 {
if collateralValueUSD == 0 {
return 0
}
return (borrowedValueUSD / collateralValueUSD) * 100
}
// GetContractAddress returns contract address for the given chain
func GetContractAddress(token TokenInfo, chainID int) string {
return getContractAddressByChain(token, chainID)
}

View File

@@ -0,0 +1,692 @@
package lending
import (
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"log"
"math/big"
"reflect"
"strings"
"sync"
"time"
"gorm.io/gorm/clause"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
dbpkg "github.com/gothinkster/golang-gin-realworld-example-app/common"
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
const (
botLoopDelay = 5 * time.Second
botLookbackBlocks = int64(10000)
)
// minimal ABI used by the liquidation bot
const lendingBotABI = `[
{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isLiquidatable","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"absorber","type":"address"},{"internalType":"address[]","name":"accounts","type":"address[]"}],"name":"absorbMultiple","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Withdraw","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Supply","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"SupplyCollateral","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"WithdrawCollateral","type":"event"}
]`
// BotStatus is returned by the status API
type BotStatus struct {
Running bool `json:"running"`
LiquidatorAddr string `json:"liquidator_addr"`
ChainID int `json:"chain_id"`
ContractAddr string `json:"contract_addr"`
TotalLiquidations int64 `json:"total_liquidations"`
LastCheckTime time.Time `json:"last_check_time"`
LastBlockChecked uint64 `json:"last_block_checked"`
LoopDelayMs int `json:"loop_delay_ms"`
LookbackBlocks int64 `json:"lookback_blocks"`
Error string `json:"error,omitempty"`
}
// liquidationBot holds mutable bot state
type liquidationBot struct {
mu sync.Mutex
running bool
stopChan chan struct{}
liquidatorAddr string
chainID int
contractAddr string
totalLiquidations int64
lastCheckTime time.Time
lastBlockChecked uint64
lastErr string
}
var globalBot = &liquidationBot{}
// GetBotStatus returns a snapshot of the current bot state
func GetBotStatus() BotStatus {
globalBot.mu.Lock()
defer globalBot.mu.Unlock()
return BotStatus{
Running: globalBot.running,
LiquidatorAddr: globalBot.liquidatorAddr,
ChainID: globalBot.chainID,
ContractAddr: globalBot.contractAddr,
TotalLiquidations: globalBot.totalLiquidations,
LastCheckTime: globalBot.lastCheckTime,
LastBlockChecked: globalBot.lastBlockChecked,
LoopDelayMs: int(botLoopDelay.Milliseconds()),
LookbackBlocks: botLookbackBlocks,
Error: globalBot.lastErr,
}
}
// StartLiquidationBot starts the bot goroutine; idempotent if already running
func StartLiquidationBot(cfg *appcfg.Config) {
if cfg.LiquidatorPrivateKey == "" {
log.Println("[LiquidationBot] LIQUIDATOR_PRIVATE_KEY not set, bot disabled")
globalBot.mu.Lock()
globalBot.lastErr = "LIQUIDATOR_PRIVATE_KEY not configured"
globalBot.mu.Unlock()
return
}
globalBot.mu.Lock()
if globalBot.running {
globalBot.mu.Unlock()
return
}
globalBot.stopChan = make(chan struct{})
globalBot.running = true
globalBot.lastErr = ""
globalBot.mu.Unlock()
go runBot(cfg)
log.Println("[LiquidationBot] Started")
}
// StopLiquidationBot signals the bot to stop
func StopLiquidationBot() {
globalBot.mu.Lock()
defer globalBot.mu.Unlock()
if !globalBot.running {
return
}
close(globalBot.stopChan)
globalBot.running = false
log.Println("[LiquidationBot] Stop signal sent")
}
func runBot(cfg *appcfg.Config) {
defer func() {
globalBot.mu.Lock()
globalBot.running = false
globalBot.mu.Unlock()
log.Println("[LiquidationBot] Goroutine exited")
}()
// Parse private key
privKeyHex := strings.TrimPrefix(cfg.LiquidatorPrivateKey, "0x")
privateKey, err := crypto.HexToECDSA(privKeyHex)
if err != nil {
log.Printf("[LiquidationBot] Invalid private key: %v", err)
globalBot.mu.Lock()
globalBot.lastErr = fmt.Sprintf("invalid private key: %v", err)
globalBot.running = false
globalBot.mu.Unlock()
return
}
liquidatorAddr := crypto.PubkeyToAddress(privateKey.PublicKey)
// Load contract addresses from DB
lendingAddr, chainID, loadErr := loadBotContracts()
if loadErr != nil {
log.Printf("[LiquidationBot] Load contracts: %v", loadErr)
globalBot.mu.Lock()
globalBot.lastErr = loadErr.Error()
globalBot.running = false
globalBot.mu.Unlock()
return
}
rpcURL := getRPCURL(chainID)
if rpcURL == "" {
log.Printf("[LiquidationBot] No RPC URL for chain %d", chainID)
globalBot.mu.Lock()
globalBot.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID)
globalBot.running = false
globalBot.mu.Unlock()
return
}
globalBot.mu.Lock()
globalBot.liquidatorAddr = liquidatorAddr.Hex()
globalBot.chainID = chainID
globalBot.contractAddr = lendingAddr
globalBot.mu.Unlock()
log.Printf("[LiquidationBot] Liquidator: %s | Chain: %d | Lending: %s",
liquidatorAddr.Hex(), chainID, lendingAddr)
// Parse ABI
lABI, err := abi.JSON(strings.NewReader(lendingBotABI))
if err != nil {
log.Printf("[LiquidationBot] ABI parse error: %v", err)
globalBot.mu.Lock()
globalBot.lastErr = fmt.Sprintf("ABI parse error: %v", err)
globalBot.running = false
globalBot.mu.Unlock()
return
}
// Load last processed block from DB; 0 means first run
processedBlock := loadLiquidationBlock(chainID)
for {
select {
case <-globalBot.stopChan:
return
default:
}
caughtUp := botTick(rpcURL, lABI, privateKey, liquidatorAddr, lendingAddr, chainID, &processedBlock)
if caughtUp {
// Normal pace
select {
case <-globalBot.stopChan:
return
case <-time.After(botLoopDelay):
}
}
// Not caught up: loop immediately to process next chunk
}
}
// botTick processes one chunk of blocks. Returns true when caught up to chain head.
func botTick(
rpcURL string,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
liquidatorAddr ethcommon.Address,
lendingAddr string,
chainID int,
processedBlock *uint64,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
log.Printf("[LiquidationBot] RPC dial error: %v", err)
return true
}
defer client.Close()
currentBlock, err := client.BlockNumber(ctx)
if err != nil {
log.Printf("[LiquidationBot] BlockNumber error: %v", err)
return true
}
// On first run (processedBlock == 0), start from current - lookback
if *processedBlock == 0 {
if currentBlock >= uint64(botLookbackBlocks) {
*processedBlock = currentBlock - uint64(botLookbackBlocks)
}
}
if currentBlock <= *processedBlock {
return true // already up to date
}
// Scan at most botLookbackBlocks blocks per tick
fromBlock := *processedBlock + 1
toBlock := fromBlock + uint64(botLookbackBlocks) - 1
if toBlock > currentBlock {
toBlock = currentBlock
}
globalBot.mu.Lock()
globalBot.lastCheckTime = time.Now()
globalBot.lastBlockChecked = toBlock
globalBot.mu.Unlock()
lendingContract := ethcommon.HexToAddress(lendingAddr)
// ── 1. 扫描事件,将新地址存入 known_borrowers永久积累──────────────
log.Printf("[LiquidationBot] Scanning %d~%d (chain head: %d)", fromBlock, toBlock, currentBlock)
newAddrs := queryUniqueAddresses(ctx, client, lABI, lendingContract, fromBlock, toBlock)
if len(newAddrs) > 0 {
saveKnownBorrowers(chainID, newAddrs)
}
// ── 2. 对所有历史地址检查 isLiquidatable含价格变动导致健康因子归零的账户
allBorrowers := loadKnownBorrowers(chainID)
liquidated, txHash, gasUsed, blockNum, execErr := checkAndAbsorb(
ctx, client, lABI, privateKey, liquidatorAddr, lendingContract, chainID, allBorrowers,
)
// Always save progress, even if no liquidation happened
saveLiquidationBlock(chainID, toBlock)
*processedBlock = toBlock
if execErr != nil {
log.Printf("[LiquidationBot] Execution error: %v", execErr)
if len(liquidated) > 0 {
saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "failed", execErr.Error())
}
return toBlock >= currentBlock
}
if len(liquidated) > 0 {
saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "success", "")
globalBot.mu.Lock()
globalBot.totalLiquidations += int64(len(liquidated))
globalBot.mu.Unlock()
}
return toBlock >= currentBlock
}
const isLiquidatableMaxConcurrent = 10 // 并行检查上限,作为 Multicall3 失败时的兜底
const multicallBatchSize = 500 // 每批 Multicall3 最多打包的地址数
// Multicall3 已部署在所有主流 EVM 链的固定地址
var multicall3Addr = ethcommon.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11")
const multicall3ABIStr = `[{
"inputs": [{"components": [
{"internalType":"address","name":"target","type":"address"},
{"internalType":"bool","name":"allowFailure","type":"bool"},
{"internalType":"bytes","name":"callData","type":"bytes"}
],"name":"calls","type":"tuple[]"}],
"name":"aggregate3",
"outputs": [{"components": [
{"internalType":"bool","name":"success","type":"bool"},
{"internalType":"bytes","name":"returnData","type":"bytes"}
],"name":"returnData","type":"tuple[]"}],
"stateMutability":"payable","type":"function"
}]`
// mc3Call 是 Multicall3.aggregate3 的输入结构体(字段名需与 ABI 组件名 lowercase 匹配)
type mc3Call struct {
Target ethcommon.Address
AllowFailure bool
CallData []byte
}
// multicallIsLiquidatable 用 Multicall3 批量检查,每批 500 个地址只发 1 个 RPC
// 失败时自动降级到并发 goroutine 方案
func multicallIsLiquidatable(
ctx context.Context,
client *ethclient.Client,
lABI abi.ABI,
lendingContract ethcommon.Address,
addresses []ethcommon.Address,
) []ethcommon.Address {
m3ABI, err := abi.JSON(strings.NewReader(multicall3ABIStr))
if err != nil {
log.Printf("[LiquidationBot] multicall3 ABI parse error: %v降级到并发模式", err)
return parallelIsLiquidatable(ctx, client, lABI, lendingContract, addresses)
}
var liquidatable []ethcommon.Address
totalBatches := (len(addresses) + multicallBatchSize - 1) / multicallBatchSize
for i := 0; i < len(addresses); i += multicallBatchSize {
end := i + multicallBatchSize
if end > len(addresses) {
end = len(addresses)
}
batch := addresses[i:end]
batchIdx := i/multicallBatchSize + 1
// 构造调用列表
calls := make([]mc3Call, len(batch))
for j, addr := range batch {
data, packErr := lABI.Pack("isLiquidatable", addr)
if packErr != nil {
continue
}
calls[j] = mc3Call{Target: lendingContract, AllowFailure: true, CallData: data}
}
packed, packErr := m3ABI.Pack("aggregate3", calls)
if packErr != nil {
log.Printf("[LiquidationBot] multicall3 pack error batch %d/%d: %v", batchIdx, totalBatches, packErr)
continue
}
mc3 := multicall3Addr
raw, callErr := client.CallContract(ctx, ethereum.CallMsg{To: &mc3, Data: packed}, nil)
if callErr != nil {
log.Printf("[LiquidationBot] multicall3 rpc error batch %d/%d: %v", batchIdx, totalBatches, callErr)
continue
}
unpacked, unpackErr := m3ABI.Unpack("aggregate3", raw)
if unpackErr != nil || len(unpacked) == 0 {
log.Printf("[LiquidationBot] multicall3 unpack error batch %d/%d: %v", batchIdx, totalBatches, unpackErr)
continue
}
// go-ethereum 返回动态 struct 类型,通过反射访问字段
rv := reflect.ValueOf(unpacked[0])
for j := 0; j < rv.Len() && j < len(batch); j++ {
elem := rv.Index(j)
if !elem.FieldByName("Success").Bool() {
continue
}
retData := elem.FieldByName("ReturnData").Bytes()
// isLiquidatable 返回 boolABI 编码为 32 字节,最后一字节 1=true
if len(retData) >= 32 && retData[31] == 1 {
log.Printf("[LiquidationBot] Liquidatable: %s", batch[j].Hex())
liquidatable = append(liquidatable, batch[j])
}
}
log.Printf("[LiquidationBot] Multicall3 batch %d/%d (%d addresses)", batchIdx, totalBatches, len(batch))
}
return liquidatable
}
// parallelIsLiquidatable 是 Multicall3 不可用时的兜底方案
func parallelIsLiquidatable(
ctx context.Context,
client *ethclient.Client,
lABI abi.ABI,
lendingContract ethcommon.Address,
addresses []ethcommon.Address,
) []ethcommon.Address {
type result struct {
addr ethcommon.Address
isLiq bool
}
resultCh := make(chan result, len(addresses))
sem := make(chan struct{}, isLiquidatableMaxConcurrent)
var wg sync.WaitGroup
for _, addr := range addresses {
addr := addr
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
isLiq, _ := callViewBool(ctx, client, lABI, lendingContract, "isLiquidatable", addr)
resultCh <- result{addr, isLiq}
}()
}
wg.Wait()
close(resultCh)
var liquidatable []ethcommon.Address
for r := range resultCh {
if r.isLiq {
log.Printf("[LiquidationBot] Liquidatable: %s", r.addr.Hex())
liquidatable = append(liquidatable, r.addr)
}
}
return liquidatable
}
// checkAndAbsorb 检查所有已知地址,对可清算账户执行 absorbMultiple
func checkAndAbsorb(
ctx context.Context,
client *ethclient.Client,
lABI abi.ABI,
privateKey *ecdsa.PrivateKey,
liquidatorAddr ethcommon.Address,
lendingContract ethcommon.Address,
chainID int,
addresses []ethcommon.Address,
) (liquidated []ethcommon.Address, txHash string, gasUsed uint64, blockNum uint64, err error) {
if len(addresses) == 0 {
return
}
log.Printf("[LiquidationBot] Checking %d known borrowers via Multicall3...", len(addresses))
toAbsorb := multicallIsLiquidatable(ctx, client, lABI, lendingContract, addresses)
if len(toAbsorb) == 0 {
log.Println("[LiquidationBot] No liquidatable accounts")
return
}
log.Printf("[LiquidationBot] Liquidating %d accounts...", len(toAbsorb))
chainIDBig := big.NewInt(int64(chainID))
boundContract := bind.NewBoundContract(lendingContract, lABI, client, client, client)
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
if authErr != nil {
err = fmt.Errorf("create transactor: %w", authErr)
return
}
auth.GasLimit = uint64(300000 + 150000*uint64(len(toAbsorb)))
tx, txErr := boundContract.Transact(auth, "absorbMultiple", liquidatorAddr, toAbsorb)
if txErr != nil {
err = fmt.Errorf("absorbMultiple: %w", txErr)
return
}
log.Printf("[LiquidationBot] Tx sent: %s", tx.Hash().Hex())
txHash = tx.Hash().Hex()
liquidated = toAbsorb
receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer receiptCancel()
receipt, waitErr := bind.WaitMined(receiptCtx, client, tx)
if waitErr != nil {
log.Printf("[LiquidationBot] Wait receipt error: %v", waitErr)
return
}
gasUsed = receipt.GasUsed
blockNum = receipt.BlockNumber.Uint64()
if receipt.Status == 1 {
log.Printf("[LiquidationBot] Success! Gas: %d | Block: %d", gasUsed, blockNum)
} else {
err = fmt.Errorf("transaction reverted at block %d", blockNum)
log.Printf("[LiquidationBot] Tx reverted: %s", txHash)
}
return
}
// queryUniqueAddresses queries 4 event types and returns deduplicated addresses
func queryUniqueAddresses(ctx context.Context, client *ethclient.Client, lABI abi.ABI, contractAddr ethcommon.Address, fromBlock, toBlock uint64) []ethcommon.Address {
query := ethereum.FilterQuery{
FromBlock: new(big.Int).SetUint64(fromBlock),
ToBlock: new(big.Int).SetUint64(toBlock),
Addresses: []ethcommon.Address{contractAddr},
Topics: [][]ethcommon.Hash{{
lABI.Events["Withdraw"].ID,
lABI.Events["Supply"].ID,
lABI.Events["SupplyCollateral"].ID,
lABI.Events["WithdrawCollateral"].ID,
}},
}
logs, err := client.FilterLogs(ctx, query)
if err != nil {
log.Printf("[LiquidationBot] FilterLogs error: %v", err)
return nil
}
seen := make(map[ethcommon.Address]struct{})
for _, l := range logs {
// topic[1] = first indexed addr, topic[2] = second indexed addr
if len(l.Topics) >= 2 {
seen[ethcommon.BytesToAddress(l.Topics[1].Bytes())] = struct{}{}
}
if len(l.Topics) >= 3 {
seen[ethcommon.BytesToAddress(l.Topics[2].Bytes())] = struct{}{}
}
}
addrs := make([]ethcommon.Address, 0, len(seen))
for addr := range seen {
addrs = append(addrs, addr)
}
log.Printf("[LiquidationBot] Events [%d~%d]: %d logs → %d unique addresses",
fromBlock, toBlock, len(logs), len(addrs))
return addrs
}
// callViewBool calls a view function and returns bool result
func callViewBool(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) (bool, error) {
res, err := callViewRaw(ctx, client, contractABI, addr, method, args...)
if err != nil {
return false, err
}
if len(res) == 0 {
return false, fmt.Errorf("empty result")
}
v, ok := res[0].(bool)
if !ok {
return false, fmt.Errorf("unexpected type %T", res[0])
}
return v, nil
}
// callViewRaw calls a view function and returns decoded values
func callViewRaw(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) ([]interface{}, error) {
data, err := contractABI.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("pack %s: %w", method, err)
}
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
if err != nil {
return nil, fmt.Errorf("call %s: %w", method, err)
}
decoded, err := contractABI.Unpack(method, result)
if err != nil {
return nil, fmt.Errorf("unpack %s: %w", method, err)
}
return decoded, nil
}
// saveKnownBorrowers 批量 upsert新地址插入已存在的只更新 last_seen_at
// 使用单条 SQL 替代 N+1 查询,几千地址也只需 ceil(N/200) 次 DB 请求
func saveKnownBorrowers(chainID int, addrs []ethcommon.Address) {
if len(addrs) == 0 {
return
}
now := time.Now()
records := make([]models.KnownBorrower, len(addrs))
for i, addr := range addrs {
records[i] = models.KnownBorrower{
ChainID: chainID,
Address: addr.Hex(),
FirstSeenAt: now,
LastSeenAt: now,
}
}
database := dbpkg.GetDB()
if err := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "chain_id"}, {Name: "address"}},
DoUpdates: clause.Assignments(map[string]interface{}{"last_seen_at": now}),
}).CreateInBatches(records, 200).Error; err != nil {
log.Printf("[LiquidationBot] saveKnownBorrowers error: %v", err)
}
}
// loadKnownBorrowers returns all addresses ever seen for a chain
func loadKnownBorrowers(chainID int) []ethcommon.Address {
database := dbpkg.GetDB()
var borrowers []models.KnownBorrower
database.Where("chain_id = ?", chainID).Find(&borrowers)
addrs := make([]ethcommon.Address, len(borrowers))
for i, b := range borrowers {
addrs[i] = ethcommon.HexToAddress(b.Address)
}
if len(addrs) > 0 {
log.Printf("[LiquidationBot] Loaded %d known borrowers from DB", len(addrs))
}
return addrs
}
// loadLiquidationBlock reads the last processed block from scanner_state
func loadLiquidationBlock(chainID int) uint64 {
database := dbpkg.GetDB()
var state models.ScannerState
if err := database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").First(&state).Error; err != nil {
return 0
}
return state.LastScannedBlock
}
// saveLiquidationBlock persists the last processed block to scanner_state
func saveLiquidationBlock(chainID int, block uint64) {
database := dbpkg.GetDB()
state := models.ScannerState{
ScannerType: "liquidation",
ChainID: chainID,
LastScannedBlock: block,
}
database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").Assign(state).FirstOrCreate(&state)
database.Model(&state).Updates(map[string]interface{}{
"last_scanned_block": block,
"updated_at": time.Now(),
})
}
// loadBotContracts loads lendingProxy address from system_contracts
func loadBotContracts() (lendingAddr string, chainID int, err error) {
database := dbpkg.GetDB()
type row struct {
ChainID int
Address string
}
var r row
if dbErr := database.Table("system_contracts").
Where("name = ? AND is_active = ?", "lendingProxy", true).
Select("chain_id, address").
First(&r).Error; dbErr != nil {
err = fmt.Errorf("lendingProxy not found in system_contracts: %w", dbErr)
return
}
if r.Address == "" {
err = fmt.Errorf("lendingProxy address is empty")
return
}
lendingAddr = r.Address
chainID = r.ChainID
return
}
// saveLiquidationRecord persists a liquidation event to DB
func saveLiquidationRecord(chainID int, txHash string, liquidatorAddr ethcommon.Address, accounts []ethcommon.Address, gasUsed, blockNum uint64, status, errMsg string) {
addrs := make([]string, len(accounts))
for i, a := range accounts {
addrs[i] = a.Hex()
}
addrsJSON, _ := json.Marshal(addrs)
record := models.LiquidationRecord{
ChainID: chainID,
TxHash: txHash,
LiquidatorAddr: liquidatorAddr.Hex(),
AccountCount: len(accounts),
Accounts: string(addrsJSON),
GasUsed: gasUsed,
BlockNumber: blockNum,
Status: status,
ErrorMessage: errMsg,
CreatedAt: time.Now(),
}
database := dbpkg.GetDB()
if dbErr := database.Create(&record).Error; dbErr != nil {
log.Printf("[LiquidationBot] Save record error: %v", dbErr)
}
}

View File

@@ -0,0 +1,113 @@
package lending
// UserPosition represents user's lending position (from chain)
type UserPosition struct {
UserAddress string `json:"user_address"`
WalletAddress string `json:"wallet_address"`
SuppliedBalance string `json:"supplied_balance"` // USDC supplied
SuppliedBalanceUSD float64 `json:"supplied_balance_usd"` // USD value
BorrowedBalance string `json:"borrowed_balance"` // USDC borrowed
BorrowedBalanceUSD float64 `json:"borrowed_balance_usd"` // USD value
CollateralBalances map[string]CollateralInfo `json:"collateral_balances"` // YT-A, YT-B, YT-C
HealthFactor float64 `json:"health_factor"`
LTV float64 `json:"ltv"` // Loan-to-Value ratio
SupplyAPY float64 `json:"supply_apy"`
BorrowAPY float64 `json:"borrow_apy"`
}
// CollateralInfo represents collateral asset information
type CollateralInfo struct {
TokenSymbol string `json:"token_symbol"` // YT-A, YT-B, YT-C
Balance string `json:"balance"` // Token balance
BalanceUSD float64 `json:"balance_usd"` // USD value
CollateralValue float64 `json:"collateral_value"` // Collateral value (with factor)
}
// LendingStats represents lending market statistics
type LendingStats struct {
TotalSuppliedUSD float64 `json:"total_supplied_usd"`
TotalBorrowedUSD float64 `json:"total_borrowed_usd"`
TotalCollateralUSD float64 `json:"total_collateral_usd"`
UtilizationRate float64 `json:"utilization_rate"`
AvgSupplyAPY float64 `json:"avg_supply_apy"`
AvgBorrowAPY float64 `json:"avg_borrow_apy"`
TotalUsers int `json:"total_users"`
ActiveBorrowers int `json:"active_borrowers"`
TotalTVL float64 `json:"total_tvl"`
}
// Supply request/response
type SupplyRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type SupplyResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Withdraw request/response
type WithdrawRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type WithdrawResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// SupplyCollateral request/response
type SupplyCollateralRequest struct {
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type SupplyCollateralResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// WithdrawCollateral request/response
type WithdrawCollateralRequest struct {
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type WithdrawCollateralResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Borrow request/response
type BorrowRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type BorrowResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}
// Repay request/response
type RepayRequest struct {
Amount string `json:"amount" binding:"required"`
TxHash string `json:"tx_hash"`
}
type RepayResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *UserPosition `json:"data,omitempty"`
}

View File

@@ -0,0 +1,14 @@
package lending
// Routing is now handled in main.go
// All lending routes are registered directly in main.go:
//
// GET /api/lending/position/:address - Get user lending position
// GET /api/lending/stats - Get lending market statistics
// GET /api/lending/markets - Get lending markets configuration
// POST /api/lending/supply - Supply USDC
// POST /api/lending/withdraw - Withdraw USDC
// POST /api/lending/supply-collateral - Supply collateral (YT tokens)
// POST /api/lending/withdraw-collateral - Withdraw collateral
// POST /api/lending/borrow - Borrow USDC
// POST /api/lending/repay - Repay USDC

View File

@@ -0,0 +1,208 @@
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
}

View File

@@ -0,0 +1,80 @@
package lending
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetTokensInfo returns information about all supported tokens
// GET /api/lending/tokens?chain_id=97
func GetTokensInfo(c *gin.Context) {
chainId := parseChainID(c)
stablecoins, err := GetAllStablecoins(chainId)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch stablecoins",
})
return
}
ytTokens, err := GetAllYTTokens(chainId)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "failed to fetch YT tokens",
})
return
}
allTokens := make([]TokenInfo, 0, len(stablecoins)+len(ytTokens))
allTokens = append(allTokens, stablecoins...)
allTokens = append(allTokens, ytTokens...)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"stablecoins": stablecoins,
"yt_tokens": ytTokens,
"all_tokens": allTokens,
},
})
}
// GetTokenInfo returns information about a specific token
// GET /api/lending/tokens/:assetCode?chain_id=97
func GetTokenInfo(c *gin.Context) {
assetCode := c.Param("assetCode")
chainId := parseChainID(c)
info, err := GetTokenInfoFromDB(assetCode)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "token not found",
})
return
}
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": info,
})
}
// parseChainID reads chain_id from query string, returns 0 if not provided
func parseChainID(c *gin.Context) int {
s := c.Query("chain_id")
if s == "" {
return 0
}
id, err := strconv.Atoi(s)
if err != nil {
return 0
}
return id
}

BIN
webapp-back/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because it is too large Load Diff

200
webapp-back/main.go Normal file
View File

@@ -0,0 +1,200 @@
package main
import (
"log"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/admin"
"github.com/gothinkster/golang-gin-realworld-example-app/alp"
"github.com/gothinkster/golang-gin-realworld-example-app/articles"
"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/fundmarket"
"github.com/gothinkster/golang-gin-realworld-example-app/holders"
"github.com/gothinkster/golang-gin-realworld-example-app/lending"
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
"github.com/gothinkster/golang-gin-realworld-example-app/points"
"github.com/gothinkster/golang-gin-realworld-example-app/users"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) {
// Original tables
users.AutoMigrate()
db.AutoMigrate(&articles.ArticleModel{})
db.AutoMigrate(&articles.TagModel{})
db.AutoMigrate(&articles.FavoriteModel{})
db.AutoMigrate(&articles.ArticleUserModel{})
db.AutoMigrate(&articles.CommentModel{})
// AssetX core tables
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.HolderSnapshot{})
db.AutoMigrate(&models.Asset{})
db.AutoMigrate(&models.AssetPerformance{})
db.AutoMigrate(&models.APYSnapshot{})
db.AutoMigrate(&models.AssetCustody{})
db.AutoMigrate(&models.AssetAuditReport{})
// Config / content tables
db.AutoMigrate(&models.SystemContract{})
db.AutoMigrate(&models.ProductLink{})
// Points system tables
db.AutoMigrate(&models.PointsRule{})
db.AutoMigrate(&models.Season{})
db.AutoMigrate(&models.VIPTier{})
db.AutoMigrate(&models.InviteCode{})
db.AutoMigrate(&models.Invitation{})
db.AutoMigrate(&models.UserPointsSummary{})
db.AutoMigrate(&models.UserPointsRecord{})
db.AutoMigrate(&models.UserTeam{})
// ALP pool snapshots
db.AutoMigrate(&models.ALPSnapshot{})
// Liquidation bot records
db.AutoMigrate(&models.LiquidationRecord{})
db.AutoMigrate(&models.KnownBorrower{})
// Collateral buyer bot records
db.AutoMigrate(&models.CollateralBuyRecord{})
// Scanner state: drop old single-column unique index, then migrate with composite index
db.Exec("ALTER TABLE scanner_state DROP INDEX `uni_chain_id`") // ignore error if not exists
db.AutoMigrate(&models.ScannerState{})
// YT Swap event records (for "Your Total Earning" calculation)
db.AutoMigrate(&models.YTSwapRecord{})
// Audit / ops
db.AutoMigrate(&models.OperationLog{})
log.Println("✓ Database migration completed")
}
func main() {
cfg := config.Load()
log.Println("✓ Configuration loaded")
var db *gorm.DB
if cfg.DBType == "mysql" {
db = common.InitMySQL()
} else {
db = common.Init()
}
Migrate(db)
sqlDB, err := db.DB()
if err != nil {
log.Println("failed to get sql.DB:", err)
} else {
defer sqlDB.Close()
}
common.InitRedis()
// Start holder scanner (continuous block scanning)
go holders.StartAllScanners()
// Start background snapshot services (run every hour)
go fundmarket.StartPriceSnapshot(cfg)
go alp.StartALPSnapshot(cfg)
go lending.StartLendingAPYSnapshot(cfg)
// Start liquidation bot (requires LIQUIDATOR_PRIVATE_KEY)
go lending.StartLiquidationBot(cfg)
// Start collateral buyer bot (requires COLLATERAL_BUYER_PRIVATE_KEY)
go lending.StartCollateralBuyerBot(cfg)
if cfg.GinMode == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
}))
r.RedirectTrailingSlash = false
// Serve uploaded files
r.Static("/uploads", "./uploads")
v1 := r.Group("/api")
// Public routes
users.UsersRegister(v1.Group("/users"))
articles.ArticlesAnonymousRegister(v1.Group("/articles"))
articles.TagsAnonymousRegister(v1.Group("/tags"))
users.ProfileRetrieveRegister(v1.Group("/profiles"))
v1.GET("/holders/stats", holders.GetStats)
v1.GET("/holders/:tokenType", holders.GetHoldersByType)
v1.GET("/contracts", admin.GetContracts)
v1.GET("/fundmarket/products", fundmarket.GetProducts)
v1.GET("/fundmarket/stats", fundmarket.GetStats)
v1.GET("/fundmarket/products/:id", fundmarket.GetProductByID)
v1.GET("/fundmarket/products/:id/history", fundmarket.GetProductHistory)
v1.GET("/fundmarket/products/:id/daily-returns", fundmarket.GetDailyReturns)
v1.GET("/fundmarket/net-deposited", fundmarket.GetNetDeposited)
v1.GET("/alp/stats", alp.GetALPStats)
v1.GET("/alp/history", alp.GetALPHistory)
v1.GET("/lending/position/:address", lending.GetUserPosition)
v1.GET("/lending/stats", lending.GetLendingStats)
v1.GET("/lending/markets", lending.GetLendingMarkets)
v1.GET("/lending/apy-history", lending.GetLendingAPYHistory)
v1.GET("/lending/tokens", lending.GetTokensInfo)
v1.GET("/lending/tokens/:assetCode", lending.GetTokenInfo)
v1.POST("/lending/supply", lending.SupplyUSDC)
v1.POST("/lending/withdraw", lending.WithdrawUSDC)
v1.POST("/lending/supply-collateral", lending.SupplyCollateral)
v1.POST("/lending/withdraw-collateral", lending.WithdrawCollateral)
v1.POST("/lending/borrow", lending.BorrowUSDC)
v1.POST("/lending/repay", lending.RepayUSDC)
v1.POST("/points/wallet-register", points.WalletRegister)
v1.GET("/points/dashboard", points.GetDashboard)
v1.GET("/points/leaderboard", points.GetLeaderboard)
v1.GET("/points/invite-code", points.GetInviteCode)
v1.POST("/points/bind-invite", points.BindInvite)
v1.GET("/points/team", points.GetTeamTVL)
v1.GET("/points/activities", points.GetActivities)
// Admin API (has its own auth + RequireAdmin middleware internally)
admin.RegisterRoutes(v1)
// Protected routes
v1.Use(middleware.AuthMiddleware(true))
{
users.UserRegister(v1.Group("/user"))
users.ProfileRegister(v1.Group("/profiles"))
articles.ArticlesRegister(v1.Group("/articles"))
holdersGroup := v1.Group("/holders")
holdersGroup.POST("/update", middleware.RequireAdmin(), holders.UpdateHolders)
}
r.GET("/api/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong", "version": "1.0.0-assetx"})
})
port := ":" + cfg.Port
log.Printf("✓ Server starting on port %s\n", cfg.Port)
if err := r.Run(port); err != nil {
log.Fatal("failed to start server:", err)
}
}

View File

@@ -0,0 +1,230 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/config"
)
// AuthResponse represents the response from auth center
type AuthResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data AuthData `json:"data"`
}
type AuthData struct {
Email string `json:"email"`
Roles []string `json:"roles"`
Status string `json:"status"`
TokenInfo TokenInfo `json:"tokenInfo"`
UserID int64 `json:"userID"`
Username string `json:"username"`
}
type TokenInfo struct {
Exp string `json:"exp"`
Iat string `json:"iat"`
Jti string `json:"jti"`
}
// UserContext represents the user data stored in gin context
type UserContext struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
// Simple in-memory cache (in production, use Redis)
var tokenCache = make(map[string]*CacheEntry)
type CacheEntry struct {
UserData *UserContext
ExpiresAt time.Time
}
// hashToken creates SHA256 hash of token
func hashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
// validateTokenWithAuthCenter validates token with auth center
func validateTokenWithAuthCenter(token string) (*AuthData, error) {
cfg := config.AppConfig
url := fmt.Sprintf("%s/api/auth/validate-token", cfg.AuthCenterURL)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("auth service unavailable: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth failed: %s", string(body))
}
var authResp AuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if !authResp.Success {
return nil, fmt.Errorf("token validation failed: %s", authResp.Message)
}
return &authResp.Data, nil
}
// AuthMiddleware validates JWT token from auth center
func AuthMiddleware(required bool) gin.HandlerFunc {
return func(c *gin.Context) {
// Extract token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if required {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Authorization header required",
})
c.Abort()
return
}
c.Next()
return
}
// Parse Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
if required {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Invalid authorization header format",
})
c.Abort()
return
}
c.Next()
return
}
token := parts[1]
tokenHash := hashToken(token)
// Check cache first
if cached, exists := tokenCache[tokenHash]; exists {
if time.Now().Before(cached.ExpiresAt) {
// Cache hit and not expired
c.Set("user", cached.UserData)
c.Next()
return
}
// Cache expired, remove it
delete(tokenCache, tokenHash)
}
// Validate with auth center
authData, err := validateTokenWithAuthCenter(token)
if err != nil {
if required {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Token validation failed",
"error": err.Error(),
})
c.Abort()
return
}
c.Next()
return
}
// Create user context
userCtx := &UserContext{
UserID: authData.UserID,
Username: authData.Username,
Email: authData.Email,
Roles: authData.Roles,
}
// Cache the result (5 minutes)
tokenCache[tokenHash] = &CacheEntry{
UserData: userCtx,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
// Set user in context
c.Set("user", userCtx)
c.Next()
}
}
// GetCurrentUser retrieves user from context
func GetCurrentUser(c *gin.Context) (*UserContext, bool) {
userVal, exists := c.Get("user")
if !exists {
return nil, false
}
user, ok := userVal.(*UserContext)
return user, ok
}
// RequireAdmin middleware to check admin role
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := GetCurrentUser(c)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Authentication required",
})
c.Abort()
return
}
// Check if user has admin role
isAdmin := false
for _, role := range user.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "Admin access required",
})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1,21 @@
-- ============================================================
-- Migration: add product_links table + footer_links.page column
-- ============================================================
-- 1. 新建 product_links 表
CREATE TABLE IF NOT EXISTS `product_links` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`asset_id` BIGINT UNSIGNED NOT NULL,
`link_text` VARCHAR(100) NOT NULL,
`link_url` TEXT NOT NULL,
`display_order` INT NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `idx_product_links_asset_id` (`asset_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. footer_links 增加 page 列(如果还没有)
ALTER TABLE `footer_links`
ADD COLUMN IF NOT EXISTS `page` VARCHAR(50) NOT NULL DEFAULT '' AFTER `id`;

View File

@@ -0,0 +1,16 @@
-- Seed: product_links for YT-A(7), YT-B(8), YT-C(9)
INSERT INTO `product_links` (`asset_id`, `link_text`, `link_url`, `display_order`, `is_active`) VALUES
(7, 'Smart Contract', 'https://www.baidu.com', 1, 1),
(7, 'Compliance', 'https://www.baidu.com', 2, 1),
(7, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
(7, 'Protocol Information', 'https://www.baidu.com', 4, 1),
(8, 'Smart Contract', 'https://www.baidu.com', 1, 1),
(8, 'Compliance', 'https://www.baidu.com', 2, 1),
(8, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
(8, 'Protocol Information', 'https://www.baidu.com', 4, 1),
(9, 'Smart Contract', 'https://www.baidu.com', 1, 1),
(9, 'Compliance', 'https://www.baidu.com', 2, 1),
(9, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
(9, 'Protocol Information', 'https://www.baidu.com', 4, 1);

205
webapp-back/models/asset.go Normal file
View File

@@ -0,0 +1,205 @@
package models
import (
"time"
)
// Asset represents the assets table
type Asset struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetCode string `gorm:"size:20;not null;unique;index" json:"asset_code"`
Name string `gorm:"size:255;not null" json:"name"`
Subtitle string `gorm:"type:text" json:"subtitle"`
Description string `gorm:"type:text" json:"description"`
TokenSymbol string `gorm:"size:20" json:"token_symbol"`
Decimals int `gorm:"default:18" json:"decimals"`
ChainID int `gorm:"default:97" json:"chain_id"`
ContractAddress string `gorm:"size:42" json:"contract_address"`
DeployBlock *uint64 `json:"deploy_block"`
Category string `gorm:"size:100" json:"category"`
CategoryColor string `gorm:"size:50" json:"category_color"`
IconURL string `gorm:"type:text" json:"icon_url"`
UnderlyingAssets string `gorm:"size:255" json:"underlying_assets"`
TargetAPY float64 `gorm:"type:decimal(10,2)" json:"target_apy"`
PoolCapUSD float64 `gorm:"type:decimal(30,2)" json:"pool_cap_usd"`
RiskLevel int `json:"risk_level"`
RiskLabel string `gorm:"size:50" json:"risk_label"`
TokenRole string `gorm:"size:20;default:'product'" json:"token_role"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsFeatured bool `gorm:"default:false" json:"is_featured"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Asset) TableName() string {
return "assets"
}
// AssetPerformance represents the asset_performance table
type AssetPerformance struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index" json:"asset_id"`
CurrentAPY float64 `gorm:"type:decimal(10,2)" json:"current_apy"`
TVLUSD float64 `gorm:"type:decimal(30,2)" json:"tvl_usd"`
TotalInvestedUSD float64 `gorm:"type:decimal(30,2)" json:"total_invested_usd"`
InvestorCount int `gorm:"default:0" json:"investor_count"`
CumulativeYieldUSD float64 `gorm:"type:decimal(30,2)" json:"cumulative_yield_usd"`
Yield24hUSD float64 `gorm:"type:decimal(30,2)" json:"yield_24h_usd"`
PoolCapacityPercent float64 `gorm:"type:decimal(10,4)" json:"pool_capacity_percent"`
CirculatingSupply float64 `gorm:"type:decimal(30,18)" json:"circulating_supply"`
YTPrice float64 `gorm:"column:yt_price;type:decimal(30,18);default:0" json:"yt_price"`
Volume24hUSD float64 `gorm:"column:volume_24h_usd;type:decimal(30,2);default:0" json:"volume_24h_usd"`
SnapshotDate time.Time `gorm:"not null" json:"snapshot_date"`
CreatedAt time.Time `json:"created_at"`
}
func (AssetPerformance) TableName() string {
return "asset_performance"
}
// ProductResponse is the response format for fund market products
type ProductResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TokenSymbol string `json:"tokenSymbol"`
Decimals int `json:"decimals"`
ContractAddress string `json:"contractAddress"`
ChainID int `json:"chainId"`
TokenRole string `json:"token_role"`
Category string `json:"category"`
CategoryColor string `json:"categoryColor"`
IconURL string `json:"iconUrl"`
YieldAPY string `json:"yieldAPY"`
PoolCap string `json:"poolCap"`
Risk string `json:"risk"`
RiskLevel int `json:"riskLevel"`
CirculatingSupply string `json:"circulatingSupply"`
PoolCapacityPercent float64 `json:"poolCapacityPercent"`
}
// StatsResponse is the response format for fund market stats
type StatsResponse struct {
Label string `json:"label"`
Value string `json:"value"`
Change string `json:"change"`
IsPositive bool `json:"isPositive"`
}
// AssetCustody represents the asset_custody table
type AssetCustody struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index" json:"asset_id"`
CustodianName string `gorm:"size:255" json:"custodian_name"`
CustodianAddress string `gorm:"type:text" json:"custodian_address"`
CustodianLicense string `gorm:"size:255" json:"custodian_license"`
CustodyType string `gorm:"size:100" json:"custody_type"`
CustodyLocation string `gorm:"size:255" json:"custody_location"`
AuditorName string `gorm:"size:255" json:"auditor_name"`
LastAuditDate NullTime `json:"last_audit_date"`
AuditReportURL string `gorm:"type:text" json:"audit_report_url"`
AdditionalInfo string `gorm:"type:json" json:"additional_info"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AssetCustody) TableName() string {
return "asset_custody"
}
// AssetAuditReport represents the asset_audit_reports table
type AssetAuditReport struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index" json:"asset_id"`
ReportType string `gorm:"size:50;not null" json:"report_type"`
ReportTitle string `gorm:"size:255;not null" json:"report_title"`
ReportDate time.Time `gorm:"not null" json:"report_date"`
ReportURL string `gorm:"type:text" json:"report_url"`
AuditorName string `gorm:"size:255" json:"auditor_name"`
Summary string `gorm:"type:text" json:"summary"`
IsActive bool `gorm:"default:true" json:"is_active"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AssetAuditReport) TableName() string {
return "asset_audit_reports"
}
// ProductDetailResponse is the detailed response for a single product
type ProductDetailResponse struct {
// Basic Info
ID int `json:"id"`
AssetCode string `json:"assetCode"`
Name string `json:"name"`
Subtitle string `json:"subtitle"`
Description string `json:"description"`
TokenSymbol string `json:"tokenSymbol"`
Decimals int `json:"decimals"`
// Investment Parameters
UnderlyingAssets string `json:"underlyingAssets"`
PoolCapUSD float64 `json:"poolCapUsd"`
RiskLevel int `json:"riskLevel"`
RiskLabel string `json:"riskLabel"`
TargetAPY float64 `json:"targetApy"`
// Contract Info
ContractAddress string `json:"contractAddress"`
ChainID int `json:"chainId"`
// Display Info
Category string `json:"category"`
CategoryColor string `json:"categoryColor"`
IconURL string `json:"iconUrl"`
// Performance Data (from blockchain/database)
CurrentAPY float64 `json:"currentApy"`
TVLUSD float64 `json:"tvlUsd"`
Volume24hUSD float64 `json:"volume24hUsd"`
VolumeChangeVsAvg float64 `json:"volumeChangeVsAvg"`
CirculatingSupply float64 `json:"circulatingSupply"`
PoolCapacityPercent float64 `json:"poolCapacityPercent"`
CurrentPrice float64 `json:"currentPrice"` // e.g., 1.04 USDC per token
// Custody Info
Custody *CustodyInfo `json:"custody,omitempty"`
// Audit Reports
AuditReports []AuditReportInfo `json:"auditReports"`
// Product Links
ProductLinks []ProductLinkInfo `json:"productLinks"`
}
// ProductLinkInfo represents a single product link item
type ProductLinkInfo struct {
LinkText string `json:"linkText"`
LinkURL string `json:"linkUrl"`
Description string `json:"description"`
DisplayArea string `json:"displayArea"`
DisplayOrder int `json:"displayOrder"`
}
// CustodyInfo represents custody information
type CustodyInfo struct {
CustodianName string `json:"custodianName"`
CustodyType string `json:"custodyType"`
CustodyLocation string `json:"custodyLocation"`
AuditorName string `json:"auditorName"`
LastAuditDate string `json:"lastAuditDate"`
AuditReportURL string `json:"auditReportUrl,omitempty"`
AdditionalInfo map[string]interface{} `json:"additionalInfo,omitempty"`
}
// AuditReportInfo represents audit report information
type AuditReportInfo struct {
ReportType string `json:"reportType"`
ReportTitle string `json:"reportTitle"`
ReportDate string `json:"reportDate"`
AuditorName string `json:"auditorName"`
Summary string `json:"summary"`
ReportURL string `json:"reportUrl"`
}

View File

@@ -0,0 +1,22 @@
package models
import "time"
// CollateralBuyRecord records each collateral purchase made by the buyer bot
type CollateralBuyRecord struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ChainID int `gorm:"index" json:"chain_id"`
TxHash string `gorm:"size:66;uniqueIndex" json:"tx_hash"`
BuyerAddr string `gorm:"size:42" json:"buyer_addr"`
AssetAddr string `gorm:"size:42;index" json:"asset_addr"`
AssetSymbol string `gorm:"size:50" json:"asset_symbol"`
PaidAmount string `gorm:"size:78" json:"paid_amount"` // USDC paid, base units
ReceivedAmount string `gorm:"size:78" json:"received_amount"` // collateral received, base units
GasUsed uint64 `json:"gas_used"`
BlockNumber uint64 `json:"block_number"`
Status string `gorm:"size:20;default:'success'" json:"status"` // success / failed
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (CollateralBuyRecord) TableName() string { return "collateral_buy_records" }

View File

@@ -0,0 +1,217 @@
package models
import (
"time"
)
// ALPSnapshot records periodic ALP pool stats for APR calculation
type ALPSnapshot struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PoolValue float64 `gorm:"type:decimal(30,18)" json:"pool_value"` // getAumInUsdy(true), 18 dec
UsdySupply float64 `gorm:"type:decimal(30,18)" json:"usdy_supply"` // USDY.totalSupply(), 18 dec
FeeSurplus float64 `gorm:"type:decimal(30,18)" json:"fee_surplus"` // poolValue - usdySupply
ALPPrice float64 `gorm:"type:decimal(30,18)" json:"alp_price"` // getPrice(false), 30 dec
SnapshotTime time.Time `gorm:"not null;index" json:"snapshot_time"`
CreatedAt time.Time `json:"created_at"`
}
func (ALPSnapshot) TableName() string {
return "alp_snapshots"
}
// APYSnapshot represents the apy_snapshots table
type APYSnapshot struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index:idx_asset_time" json:"asset_id"`
ChainID int `gorm:"not null" json:"chain_id"`
ContractAddress string `gorm:"size:42" json:"contract_address"`
APYValue float64 `gorm:"type:decimal(10,4)" json:"apy_value"`
SupplyAPY float64 `gorm:"type:decimal(20,4)" json:"supply_apy"`
BorrowAPY float64 `gorm:"type:decimal(20,4)" json:"borrow_apy"`
TotalAssets float64 `gorm:"type:decimal(30,18)" json:"total_assets"`
TotalSupply float64 `gorm:"type:decimal(30,18)" json:"total_supply"`
Price float64 `gorm:"type:decimal(30,18)" json:"price"`
SnapshotTime time.Time `gorm:"not null;index:idx_asset_time" json:"snapshot_time"`
CreatedAt time.Time `json:"created_at"`
}
func (APYSnapshot) TableName() string {
return "apy_snapshots"
}
// ProductLink represents the product_links table — per-asset links shown on the product detail page
type ProductLink struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index" json:"asset_id"`
LinkText string `gorm:"size:100;not null" json:"link_text"`
LinkURL string `gorm:"type:text;not null" json:"link_url"`
Description string `gorm:"size:500" json:"description"`
DisplayArea string `gorm:"size:20;default:protocol" json:"display_area"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ProductLink) TableName() string {
return "product_links"
}
// KLineData represents the kline_data table
type KLineData struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AssetID uint `gorm:"not null;index:idx_asset_time" json:"asset_id"`
ChainID int `gorm:"not null" json:"chain_id"`
ContractAddress string `gorm:"size:42" json:"contract_address"`
Timeframe string `gorm:"size:10;not null" json:"timeframe"`
OpenTime time.Time `gorm:"not null;index:idx_asset_time" json:"open_time"`
CloseTime time.Time `gorm:"not null" json:"close_time"`
OpenPrice float64 `gorm:"type:decimal(30,18);not null" json:"open_price"`
HighPrice float64 `gorm:"type:decimal(30,18);not null" json:"high_price"`
LowPrice float64 `gorm:"type:decimal(30,18);not null" json:"low_price"`
ClosePrice float64 `gorm:"type:decimal(30,18);not null" json:"close_price"`
Volume float64 `gorm:"type:decimal(30,18)" json:"volume"`
Trades int `json:"trades"`
CreatedAt time.Time `json:"created_at"`
}
func (KLineData) TableName() string {
return "kline_data"
}
// PointsRule represents the points_rules table
type PointsRule struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
RuleName string `gorm:"size:100;not null;unique" json:"rule_name"`
RuleType string `gorm:"size:50;not null" json:"rule_type"`
BasePoints int `gorm:"not null" json:"base_points"`
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
Conditions string `gorm:"type:json" json:"conditions"`
Description string `gorm:"type:text" json:"description"`
ValidFrom NullTime `json:"valid_from"`
ValidUntil NullTime `json:"valid_until"`
IsActive bool `gorm:"default:true" json:"is_active"`
Priority int `gorm:"default:0" json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PointsRule) TableName() string {
return "points_rules"
}
// Role represents the roles table
type Role struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
RoleName string `gorm:"size:50;not null;unique" json:"role_name"`
Description string `gorm:"type:text" json:"description"`
Permissions string `gorm:"type:json" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Role) TableName() string {
return "roles"
}
// UserRole represents the user_roles table
type UserRole struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;uniqueIndex:uk_user_role" json:"user_id"`
RoleID uint `gorm:"not null;uniqueIndex:uk_user_role" json:"role_id"`
AssignedAt time.Time `gorm:"not null" json:"assigned_at"`
AssignedBy uint `json:"assigned_by"`
CreatedAt time.Time `json:"created_at"`
}
func (UserRole) TableName() string {
return "user_roles"
}
// Session represents the sessions table
type Session struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
SessionToken string `gorm:"size:255;not null;unique" json:"session_token"`
WalletAddress string `gorm:"size:42;not null" json:"wallet_address"`
SignMessage string `gorm:"type:text" json:"sign_message"`
Signature string `gorm:"size:132" json:"signature"`
SignatureHash string `gorm:"size:66" json:"signature_hash"`
IPAddress string `gorm:"size:45" json:"ip_address"`
UserAgent string `gorm:"type:text" json:"user_agent"`
ExpiresAt *time.Time `gorm:"index" json:"expires_at"`
LastActivityAt *time.Time `json:"last_activity_at"`
CreatedAt time.Time `json:"created_at"`
}
func (Session) TableName() string {
return "sessions"
}
// Transaction represents the transactions table
type Transaction struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index:idx_user_time" json:"user_id"`
TxHash string `gorm:"size:66;not null;unique" json:"tx_hash"`
ChainID int `gorm:"not null" json:"chain_id"`
BlockNumber int64 `gorm:"not null" json:"block_number"`
TxType string `gorm:"size:50;not null" json:"tx_type"`
AssetID *uint `json:"asset_id"`
FromAddress string `gorm:"size:42;not null" json:"from_address"`
ToAddress string `gorm:"size:42;not null" json:"to_address"`
Amount float64 `gorm:"type:decimal(30,18)" json:"amount"`
TokenSymbol string `gorm:"size:20" json:"token_symbol"`
GasUsed int64 `json:"gas_used"`
GasPrice float64 `gorm:"type:decimal(30,18)" json:"gas_price"`
Status string `gorm:"size:20;not null" json:"status"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
Metadata string `gorm:"type:json" json:"metadata"`
ConfirmedAt time.Time `gorm:"not null;index:idx_user_time" json:"confirmed_at"`
CreatedAt time.Time `json:"created_at"`
}
func (Transaction) TableName() string {
return "transactions"
}
// OperationLog represents the operation_logs table
type OperationLog struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID *uint `gorm:"index" json:"user_id"`
OperationType string `gorm:"size:50;not null" json:"operation_type"`
TargetType string `gorm:"size:50" json:"target_type"`
TargetID *uint `json:"target_id"`
Action string `gorm:"size:100;not null" json:"action"`
Changes string `gorm:"type:json" json:"changes"`
IPAddress string `gorm:"size:45" json:"ip_address"`
UserAgent string `gorm:"type:text" json:"user_agent"`
Status string `gorm:"size:20;not null" json:"status"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (OperationLog) TableName() string {
return "operation_logs"
}
// UserActivity represents the user_activities table
type UserActivity struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index:idx_user_time" json:"user_id"`
ActivityType string `gorm:"size:50;not null" json:"activity_type"`
ActivityData string `gorm:"type:json" json:"activity_data"`
AssetID *uint `json:"asset_id"`
Amount float64 `gorm:"type:decimal(30,18)" json:"amount"`
PointsEarned int `gorm:"default:0" json:"points_earned"`
ReferenceType string `gorm:"size:50" json:"reference_type"`
ReferenceID *uint `json:"reference_id"`
IPAddress string `gorm:"size:45" json:"ip_address"`
CreatedAt time.Time `gorm:"index:idx_user_time" json:"created_at"`
}
func (UserActivity) TableName() string {
return "user_activities"
}

View File

@@ -0,0 +1,43 @@
package models
import (
"time"
)
// HolderSnapshot represents token holders data from blockchain
type HolderSnapshot struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
HolderAddress string `gorm:"size:42;not null;index:idx_holder_token" json:"holder_address"`
TokenType string `gorm:"size:50;not null;index:idx_holder_token,idx_token_time" json:"token_type"`
TokenAddress string `gorm:"size:42;not null" json:"token_address"`
Balance string `gorm:"type:varchar(78);not null" json:"balance"`
ChainID int `gorm:"not null" json:"chain_id"`
FirstSeen int64 `gorm:"not null" json:"first_seen"`
LastUpdated int64 `gorm:"not null;index:idx_token_time" json:"last_updated"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (HolderSnapshot) TableName() string {
return "holder_snapshots"
}
// HolderStats represents aggregated statistics for each token type
type HolderStats struct {
TokenType string `json:"token_type"`
HolderCount int `json:"holder_count"`
TotalBalance string `json:"total_balance,omitempty"`
}
// ScannerState persists scanner progress so it can resume after restart
type ScannerState struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ScannerType string `gorm:"column:scanner_type;not null;default:'holder';uniqueIndex:idx_chain_scanner"`
ChainID int `gorm:"column:chain_id;uniqueIndex:idx_chain_scanner;not null"`
LastScannedBlock uint64 `gorm:"column:last_scanned_block;not null;default:0"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (ScannerState) TableName() string {
return "scanner_state"
}

View File

@@ -0,0 +1,17 @@
package models
import "time"
// KnownBorrower stores every address that has ever interacted with the lending contract.
// The liquidation bot checks ALL known borrowers for isLiquidatable on every tick,
// so accounts whose health factor drops due to oracle price changes are still caught
// even if they haven't had a recent on-chain transaction.
type KnownBorrower struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ChainID int `gorm:"uniqueIndex:idx_known_borrower" json:"chain_id"`
Address string `gorm:"size:42;uniqueIndex:idx_known_borrower" json:"address"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
func (KnownBorrower) TableName() string { return "known_borrowers" }

View File

@@ -0,0 +1,22 @@
package models
import "time"
// LiquidationRecord stores each batch liquidation execution
type LiquidationRecord struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ChainID int `gorm:"index" json:"chain_id"`
TxHash string `gorm:"size:66;uniqueIndex" json:"tx_hash"`
LiquidatorAddr string `gorm:"size:42" json:"liquidator_addr"`
AccountCount int `json:"account_count"`
Accounts string `gorm:"type:text" json:"accounts"` // JSON array of addresses
GasUsed uint64 `json:"gas_used"`
BlockNumber uint64 `json:"block_number"`
Status string `gorm:"size:20;default:'success'" json:"status"` // success / failed
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (LiquidationRecord) TableName() string {
return "liquidation_records"
}

View File

@@ -0,0 +1,67 @@
package models
import (
"database/sql/driver"
"encoding/json"
"strings"
"time"
)
// NullTime wraps *time.Time with custom JSON and GORM handling:
// - JSON marshal: nil → "1970-01-01T00:00:00Z", non-nil → RFC3339
// - JSON unmarshal: null / "" / "1970-01-01..." → nil (stored as NULL in DB)
// - GORM Scan/Value: nil ↔ NULL column
type NullTime struct {
Time *time.Time
}
func (nt NullTime) MarshalJSON() ([]byte, error) {
if nt.Time == nil {
return []byte(`"1970-01-01T00:00:00Z"`), nil
}
return json.Marshal(nt.Time.Format(time.RFC3339))
}
func (nt *NullTime) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
nt.Time = nil
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
if s == "" || strings.HasPrefix(s, "1970-01-01") {
nt.Time = nil
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return err
}
nt.Time = &t
return nil
}
// Value implements driver.Valuer so GORM writes NULL for nil.
func (nt NullTime) Value() (driver.Value, error) {
if nt.Time == nil {
return nil, nil
}
return *nt.Time, nil
}
// Scan implements sql.Scanner so GORM reads NULL as nil.
func (nt *NullTime) Scan(value interface{}) error {
if value == nil {
nt.Time = nil
return nil
}
t, ok := value.(time.Time)
if !ok {
nt.Time = nil
return nil
}
nt.Time = &t
return nil
}

View File

@@ -0,0 +1,262 @@
package models
import (
"time"
)
// Season represents the seasons table
type Season struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SeasonNumber int `gorm:"not null;unique;index" json:"season_number"`
SeasonName string `gorm:"size:100;not null" json:"season_name"`
StartTime time.Time `gorm:"not null" json:"start_time"`
EndTime time.Time `gorm:"not null" json:"end_time"`
Status string `gorm:"size:20;default:'upcoming'" json:"status"` // upcoming, active, ended
TotalRewards float64 `gorm:"type:decimal(30,2)" json:"total_rewards"`
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
Description string `gorm:"type:text" json:"description"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Season) TableName() string {
return "seasons"
}
// VIPTier represents the vip_tiers table
type VIPTier struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TierName string `gorm:"size:50;not null" json:"tier_name"`
TierLevel int `gorm:"not null;unique;index" json:"tier_level"`
MinPoints int64 `gorm:"not null;index" json:"min_points"`
MaxPoints *int64 `json:"max_points"`
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
Perks string `gorm:"type:json" json:"perks"`
Icon string `gorm:"size:255" json:"icon"`
Color string `gorm:"size:50" json:"color"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (VIPTier) TableName() string {
return "vip_tiers"
}
// UserTeam represents the user_teams table
type UserTeam struct {
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
TeamTVLUSD float64 `gorm:"type:decimal(30,2);default:0.00" json:"team_tvl_usd"`
TeamTargetTVLUSD float64 `gorm:"type:decimal(30,2);default:10000000.00" json:"team_target_tvl_usd"`
TeamMembersCount int `gorm:"default:0" json:"team_members_count"`
WhalesCount int `gorm:"default:0" json:"whales_count"`
WhalesTarget int `gorm:"default:3" json:"whales_target"`
TradersCount int `gorm:"default:0" json:"traders_count"`
TradersTarget int `gorm:"default:3" json:"traders_target"`
UsersCount int `gorm:"default:0" json:"users_count"`
UsersTarget int `gorm:"default:3" json:"users_target"`
LastCalculatedAt *time.Time `json:"last_calculated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserTeam) TableName() string {
return "user_teams"
}
// UserPointsSummary represents the user_points_summary table
type UserPointsSummary struct {
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
TotalPoints int64 `gorm:"default:0" json:"total_points"`
HoldingPoints int64 `gorm:"default:0" json:"holding_points"`
LpPoints int64 `gorm:"default:0" json:"lp_points"`
LendingPoints int64 `gorm:"default:0" json:"lending_points"`
TradingPoints int64 `gorm:"default:0" json:"trading_points"`
InvitationPoints int64 `gorm:"default:0" json:"invitation_points"`
BonusPoints int64 `gorm:"default:0" json:"bonus_points"`
GlobalRank *int `json:"global_rank"`
SeasonRank *int `json:"season_rank"`
CurrentSeason int `gorm:"default:1" json:"current_season"`
TotalTrades int `gorm:"default:0" json:"total_trades"`
TotalHoldingDays int `gorm:"default:0" json:"total_holding_days"`
TotalInvites int `gorm:"default:0" json:"total_invites"`
LastCalculatedAt *time.Time `json:"last_calculated_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserPointsSummary) TableName() string {
return "user_points_summary"
}
// UserPointsRecord represents the user_points_records table
type UserPointsRecord struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
WalletAddress string `gorm:"size:42;not null;index" json:"wallet_address"`
PointsChange int `gorm:"not null" json:"points_change"`
PointsBefore int `gorm:"not null" json:"points_before"`
PointsAfter int `gorm:"not null" json:"points_after"`
SourceType string `gorm:"size:50;not null" json:"source_type"`
MultiplierApplied float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier_applied"`
SourceID *uint `json:"source_id"`
RuleID *uint `json:"rule_id"`
Description string `gorm:"type:text" json:"description"`
Metadata string `gorm:"type:json" json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
func (UserPointsRecord) TableName() string {
return "user_points_records"
}
// Invitation represents the invitations table
type Invitation struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ReferrerWallet string `gorm:"size:42;not null;index" json:"referrer_wallet"`
RefereeWallet string `gorm:"size:42;not null;unique;index" json:"referee_wallet"`
InviteCode string `gorm:"size:20;not null" json:"invite_code"`
BindSignature string `gorm:"type:text" json:"bind_signature"`
BindHash string `gorm:"size:66" json:"bind_hash"`
Status string `gorm:"size:50;default:'active'" json:"status"`
ReferrerRewardPoints int `gorm:"default:0" json:"referrer_reward_points"`
RefereeRewardPoints int `gorm:"default:0" json:"referee_reward_points"`
BoundAt time.Time `gorm:"not null" json:"bound_at"`
CreatedAt time.Time `json:"created_at"`
}
func (Invitation) TableName() string {
return "invitations"
}
// InviteCode represents the invite_codes table
type InviteCode struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
WalletAddress string `gorm:"size:42;not null;unique;index" json:"wallet_address"`
Code string `gorm:"size:20;not null;unique;index" json:"code"`
MaxUses int `gorm:"default:-1" json:"max_uses"` // -1 = unlimited
UsedCount int `gorm:"default:0" json:"used_count"`
ExpiresAt NullTime `json:"expires_at"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
func (InviteCode) TableName() string {
return "invite_codes"
}
// =====================
// Response DTOs
// =====================
// DashboardResponse is the response for GET /api/points/dashboard
type DashboardResponse struct {
TotalPoints int64 `json:"totalPoints"`
GlobalRank int `json:"globalRank"`
TopPercentage string `json:"topPercentage"`
MemberTier string `json:"memberTier"`
VIPLevel int `json:"vipLevel"`
PointsToNextTier int64 `json:"pointsToNextTier"`
NextTier string `json:"nextTier"`
Season SeasonInfo `json:"season"`
}
// SeasonInfo contains season information
type SeasonInfo struct {
SeasonNumber int `json:"seasonNumber"`
SeasonName string `json:"seasonName"`
IsLive bool `json:"isLive"`
EndTime time.Time `json:"endTime"`
DaysRemaining int `json:"daysRemaining"`
HoursRemaining int `json:"hoursRemaining"`
}
// LeaderboardResponse is the response for GET /api/points/leaderboard
type LeaderboardResponse struct {
TopUsers []LeaderboardUser `json:"topUsers"`
MyRank int `json:"myRank"`
MyPoints int64 `json:"myPoints"`
}
// LeaderboardUser represents a user in the leaderboard
type LeaderboardUser struct {
Rank int `json:"rank"`
WalletAddress string `json:"address"`
Points int64 `json:"points"`
}
// InviteCodeResponse is the response for GET /api/points/invite-code
type InviteCodeResponse struct {
Code string `json:"code"`
UsedCount int `json:"usedCount"`
MaxUses int `json:"maxUses"`
}
// TeamTVLResponse is the response for GET /api/points/team
type TeamTVLResponse struct {
CurrentTVL string `json:"currentTVL"`
TargetTVL string `json:"targetTVL"`
ProgressPercent float64 `json:"progressPercent"`
TotalMembers int `json:"totalMembers"`
Roles []RoleCount `json:"roles"`
}
// RoleCount represents count for each role
type RoleCount struct {
Icon string `json:"icon"`
Label string `json:"label"`
Current int `json:"current"`
Target int `json:"target"`
}
// ActivitiesResponse is the response for GET /api/points/activities
type ActivitiesResponse struct {
Activities []ActivityRecord `json:"activities"`
Pagination PaginationInfo `json:"pagination"`
}
// ActivityRecord represents a single activity
type ActivityRecord struct {
Type string `json:"type"`
UserAddress string `json:"userAddress"`
FriendAddress string `json:"friendAddress,omitempty"`
InviteCode string `json:"inviteCode,omitempty"`
Points int `json:"points"`
CreatedAt time.Time `json:"createdAt"`
}
// PaginationInfo contains pagination metadata
type PaginationInfo struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
TotalPage int `json:"totalPage"`
}
// BindInviteRequest is the request body for POST /api/points/bind-invite
type BindInviteRequest struct {
Code string `json:"code" binding:"required"`
Signature string `json:"signature" binding:"required"`
}
// HoldersSnapshot represents the holders_snapshots table
// 每小时快照:记录用户持有 YT/LP/Lending 代币的余额及积分计算结果
type HoldersSnapshot struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
WalletAddress string `gorm:"size:42;not null;index" json:"wallet_address"`
TokenType string `gorm:"size:20;not null" json:"token_type"`
TokenAddress string `gorm:"size:42;not null" json:"token_address"`
ChainID int `gorm:"not null" json:"chain_id"`
Balance float64 `gorm:"type:decimal(30,18);not null" json:"balance"`
BalanceUSD float64 `gorm:"type:decimal(30,2)" json:"balance_usd"`
HoldingDurationHours int `json:"holding_duration_hours"`
PointsMultiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"points_multiplier"`
EarnedPoints int `json:"earned_points"`
SnapshotTime time.Time `gorm:"not null;index" json:"snapshot_time"`
SeasonID *uint `gorm:"index" json:"season_id"`
CreatedAt time.Time `json:"created_at"`
}
func (HoldersSnapshot) TableName() string {
return "holders_snapshots"
}

View File

@@ -0,0 +1,20 @@
package models
import "time"
// SystemContract is the central registry for all infrastructure contract addresses.
// One row per (name, chain_id) pair.
// Replaces the former alp_pools and lending_markets tables (which had only one row each).
type SystemContract struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:100;not null;uniqueIndex:uk_name_chain" json:"name"`
ChainID int `gorm:"not null;uniqueIndex:uk_name_chain" json:"chain_id"`
Address string `gorm:"size:42" json:"address"`
DeployBlock *uint64 `json:"deploy_block"`
Description string `gorm:"type:text" json:"description"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemContract) TableName() string { return "system_contracts" }

View File

@@ -0,0 +1,27 @@
package models
import (
"time"
)
// User represents the users table
// Web3 native: wallet_address is the primary key (no numeric id, no auth_user_id)
type User struct {
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
Nickname string `gorm:"size:100" json:"nickname"`
Avatar string `gorm:"type:text" json:"avatar"`
Bio string `gorm:"type:text" json:"bio"`
ReferrerWallet *string `gorm:"size:42;index" json:"referrer_wallet,omitempty"`
InviteCode string `gorm:"size:20;unique;index" json:"invite_code"`
DirectInvitesCount int `gorm:"default:0" json:"direct_invites_count"`
MemberTier string `gorm:"size:50;default:'Bronze'" json:"member_tier"`
VIPLevel int `gorm:"default:1" json:"vip_level"`
TotalPoints int64 `gorm:"default:0" json:"total_points"`
GlobalRank *int `gorm:"index" json:"global_rank,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string {
return "users"
}

View File

@@ -0,0 +1,24 @@
package models
import "time"
// YTSwapRecord records each Swap event emitted by YT Vault contracts.
// Swap(address account, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeBasisPoints)
// account/tokenIn/tokenOut are indexed (topics), amountIn/amountOut are in data.
type YTSwapRecord struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TxHash string `gorm:"size:66;uniqueIndex:uk_tx_log" json:"tx_hash"`
LogIndex uint `gorm:"uniqueIndex:uk_tx_log" json:"log_index"`
ChainID int `gorm:"index" json:"chain_id"`
BlockNumber uint64 `json:"block_number"`
BlockTime time.Time `gorm:"index" json:"block_time"`
VaultAddr string `gorm:"size:42;index" json:"vault_addr"`
Account string `gorm:"size:42;index" json:"account"`
TokenIn string `gorm:"size:42" json:"token_in"`
TokenOut string `gorm:"size:42" json:"token_out"`
AmountIn string `gorm:"size:78" json:"amount_in"` // wei string, base units
AmountOut string `gorm:"size:78" json:"amount_out"` // wei string, base units
CreatedAt time.Time `json:"created_at"`
}
func (YTSwapRecord) TableName() string { return "yt_swap_records" }

View File

@@ -0,0 +1,541 @@
package points
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"github.com/gothinkster/golang-gin-realworld-example-app/models"
)
// GetDashboard returns user points dashboard
// GET /api/points/dashboard?wallet_address=0x...
func GetDashboard(c *gin.Context) {
db := common.GetDB()
user, found := getUserByWallet(c)
if !found {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "User not found",
})
return
}
vipLevel := user.VIPLevel
if vipLevel < 1 {
vipLevel = 1
}
var currentTier models.VIPTier
db.Where("tier_level = ?", vipLevel).First(&currentTier)
var nextTier models.VIPTier
var pointsToNextTier int64
var nextTierName string
if db.Where("tier_level = ?", vipLevel+1).First(&nextTier).Error == nil {
pointsToNextTier = nextTier.MinPoints - user.TotalPoints
nextTierName = nextTier.TierName
} else {
pointsToNextTier = 0
nextTierName = "Max Level"
}
var season models.Season
db.Where("status = ?", "active").First(&season)
now := time.Now()
timeRemaining := season.EndTime.Sub(now)
daysRemaining := int(timeRemaining.Hours() / 24)
hoursRemaining := int(timeRemaining.Hours()) % 24
var totalUsers int64
db.Model(&models.User{}).Count(&totalUsers)
topPercentage := "N/A"
globalRank := 0
if user.GlobalRank != nil {
globalRank = *user.GlobalRank
if totalUsers > 0 {
percentage := float64(*user.GlobalRank) / float64(totalUsers) * 100
topPercentage = fmt.Sprintf("%.0f%%", percentage)
}
}
response := models.DashboardResponse{
TotalPoints: user.TotalPoints,
GlobalRank: globalRank,
TopPercentage: topPercentage,
MemberTier: user.MemberTier,
VIPLevel: vipLevel,
PointsToNextTier: pointsToNextTier,
NextTier: nextTierName,
Season: models.SeasonInfo{
SeasonNumber: season.SeasonNumber,
SeasonName: season.SeasonName,
IsLive: season.Status == "active",
EndTime: season.EndTime,
DaysRemaining: daysRemaining,
HoursRemaining: hoursRemaining,
},
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
// GetLeaderboard returns top performers leaderboard
// GET /api/points/leaderboard?limit=5&wallet_address=0x...
func GetLeaderboard(c *gin.Context) {
db := common.GetDB()
limitStr := c.DefaultQuery("limit", "5")
limit, _ := strconv.Atoi(limitStr)
var topUsers []models.User
db.Where("global_rank IS NOT NULL").
Order("global_rank ASC").
Limit(limit).
Find(&topUsers)
var leaderboard []models.LeaderboardUser
for _, u := range topUsers {
rank := 0
if u.GlobalRank != nil {
rank = *u.GlobalRank
}
leaderboard = append(leaderboard, models.LeaderboardUser{
Rank: rank,
WalletAddress: formatAddress(u.WalletAddress),
Points: u.TotalPoints,
})
}
currentUser, _ := getUserByWallet(c)
myRank := 0
if currentUser.GlobalRank != nil {
myRank = *currentUser.GlobalRank
}
response := models.LeaderboardResponse{
TopUsers: leaderboard,
MyRank: myRank,
MyPoints: currentUser.TotalPoints,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
// GetInviteCode returns user's invite code information
// GET /api/points/invite-code?wallet_address=0x...
func GetInviteCode(c *gin.Context) {
db := common.GetDB()
user, found := getUserByWallet(c)
if !found {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "User not found",
})
return
}
var inviteCode models.InviteCode
err := db.Where("wallet_address = ?", user.WalletAddress).First(&inviteCode).Error
if err != nil {
// Create a system-generated invite code
code := strings.ToUpper(common.RandString(8))
defaultExpiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
inviteCode = models.InviteCode{
WalletAddress: user.WalletAddress,
Code: code,
MaxUses: -1,
UsedCount: 0,
IsActive: true,
ExpiresAt: models.NullTime{Time: &defaultExpiry},
}
if err := db.Create(&inviteCode).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to create invite code",
})
return
}
// Also sync to users.invite_code
db.Model(&user).Update("invite_code", code)
}
response := models.InviteCodeResponse{
Code: inviteCode.Code,
UsedCount: inviteCode.UsedCount,
MaxUses: inviteCode.MaxUses,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
// BindInvite binds an invite code to current user
// POST /api/points/bind-invite
func BindInvite(c *gin.Context) {
db := common.GetDB()
var req struct {
Code string `json:"code" binding:"required"`
Signature string `json:"signature" binding:"required"`
WalletAddress string `json:"wallet_address"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Invalid request body",
})
return
}
// Look up user by wallet_address
var user models.User
walletAddr := strings.ToLower(strings.TrimSpace(req.WalletAddress))
if walletAddr == "" {
// Try query param fallback
walletAddr = strings.ToLower(strings.TrimSpace(c.Query("wallet_address")))
}
if walletAddr == "" || db.Where("wallet_address = ?", walletAddr).First(&user).Error != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "User not found. Please connect your wallet first.",
})
return
}
// Check if user already has a referrer
if user.ReferrerWallet != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Already bound to a referrer",
})
return
}
// Find invite code
var inviteCode models.InviteCode
if err := db.Where("code = ? AND is_active = ?", req.Code, true).First(&inviteCode).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Invalid invite code",
})
return
}
// Check if max uses reached
if inviteCode.MaxUses != -1 && inviteCode.UsedCount >= inviteCode.MaxUses {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Invite code max uses reached",
})
return
}
// Prevent self-referral
if inviteCode.WalletAddress == user.WalletAddress {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Cannot use your own invite code",
})
return
}
// Create invitation record
invitation := models.Invitation{
ReferrerWallet: inviteCode.WalletAddress,
RefereeWallet: user.WalletAddress,
InviteCode: req.Code,
BindSignature: req.Signature,
Status: "active",
ReferrerRewardPoints: 100,
RefereeRewardPoints: 50,
BoundAt: time.Now(),
}
if err := db.Create(&invitation).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to bind invite code",
})
return
}
// Update user referrer
db.Model(&user).Update("referrer_wallet", inviteCode.WalletAddress)
// Update invite code used count
db.Model(&inviteCode).Update("used_count", inviteCode.UsedCount+1)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Invite code bound successfully",
})
}
// GetTeamTVL returns team TVL statistics
// GET /api/points/team?wallet_address=0x...
func GetTeamTVL(c *gin.Context) {
db := common.GetDB()
user, _ := getUserByWallet(c)
var team models.UserTeam
if err := db.Where("wallet_address = ?", user.WalletAddress).First(&team).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": models.TeamTVLResponse{
CurrentTVL: "$0",
TargetTVL: "$10M",
ProgressPercent: 0,
TotalMembers: 0,
Roles: []models.RoleCount{},
},
})
return
}
progressPercent := 0.0
if team.TeamTargetTVLUSD > 0 {
progressPercent = (team.TeamTVLUSD / team.TeamTargetTVLUSD) * 100
}
response := models.TeamTVLResponse{
CurrentTVL: formatUSD(team.TeamTVLUSD),
TargetTVL: formatUSD(team.TeamTargetTVLUSD),
ProgressPercent: math.Round(progressPercent*100) / 100,
TotalMembers: team.TeamMembersCount,
Roles: []models.RoleCount{
{
Icon: "/icons/user/icon-whale.svg",
Label: "Whales",
Current: team.WhalesCount,
Target: team.WhalesTarget,
},
{
Icon: "/icons/user/icon-trader.svg",
Label: "Traders",
Current: team.TradersCount,
Target: team.TradersTarget,
},
{
Icon: "/icons/user/icon-user.svg",
Label: "Users",
Current: team.UsersCount,
Target: team.UsersTarget,
},
},
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
// GetActivities returns user points activities
// GET /api/points/activities?wallet_address=0x...&type=all&page=1&pageSize=10
func GetActivities(c *gin.Context) {
db := common.GetDB()
activityType := c.DefaultQuery("type", "all")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("pageSize", "10")
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
offset := (page - 1) * pageSize
user, _ := getUserByWallet(c)
query := db.Model(&models.UserPointsRecord{}).Where("wallet_address = ?", user.WalletAddress)
if activityType == "referrals" {
query = query.Where("source_type = ?", "invitation")
} else if activityType == "deposits" {
query = query.Where("source_type IN ?", []string{"deposit", "holding"})
}
var total int64
query.Count(&total)
var records []models.UserPointsRecord
query.Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&records)
activities := make([]models.ActivityRecord, 0)
for _, record := range records {
activity := models.ActivityRecord{
Type: record.SourceType,
UserAddress: formatAddress(user.WalletAddress),
Points: record.PointsChange,
CreatedAt: record.CreatedAt,
}
if record.SourceType == "invitation" && record.SourceID != nil {
var invitation models.Invitation
if db.Where("id = ?", *record.SourceID).First(&invitation).Error == nil {
activity.FriendAddress = formatAddress(invitation.RefereeWallet)
activity.InviteCode = invitation.InviteCode
}
}
activities = append(activities, activity)
}
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
response := models.ActivitiesResponse{
Activities: activities,
Pagination: models.PaginationInfo{
Page: page,
PageSize: pageSize,
Total: int(total),
TotalPage: totalPages,
},
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
// Helper function to format wallet address for display
func formatAddress(address string) string {
if len(address) < 10 {
return address
}
return address[:6] + "..." + address[len(address)-4:]
}
// Helper function to format USD values
func formatUSD(value float64) string {
if value >= 1000000 {
return fmt.Sprintf("$%.1fM", value/1000000)
} else if value >= 1000 {
return fmt.Sprintf("$%.1fK", value/1000)
}
return fmt.Sprintf("$%.2f", value)
}
// getUserByWallet looks up a user by wallet_address query param,
// falls back to demo user if not provided
func getUserByWallet(c *gin.Context) (models.User, bool) {
db := common.GetDB()
var user models.User
walletAddress := strings.ToLower(strings.TrimSpace(c.Query("wallet_address")))
if walletAddress != "" {
if err := db.Where("wallet_address = ?", walletAddress).First(&user).Error; err == nil {
return user, true
}
}
// Fall back to first demo user
err := db.Order("created_at ASC").First(&user).Error
return user, err == nil
}
// walletAddressToInt64 is no longer needed (no auth_user_id in new schema)
// kept for reference only
// WalletRegister creates or finds a user by wallet address
// POST /api/points/wallet-register
func WalletRegister(c *gin.Context) {
db := common.GetDB()
var req struct {
WalletAddress string `json:"wallet_address"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.WalletAddress) == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "wallet_address is required",
})
return
}
walletAddress := strings.ToLower(strings.TrimSpace(req.WalletAddress))
// Check if user already exists
var user models.User
err := db.Where("wallet_address = ?", walletAddress).First(&user).Error
if err != nil {
// Create new user with system-generated invite code
code := strings.ToUpper(common.RandString(8))
user = models.User{
WalletAddress: walletAddress,
InviteCode: code,
MemberTier: "Bronze",
VIPLevel: 1,
TotalPoints: 0,
}
if createErr := db.Create(&user).Error; createErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Failed to create user",
})
return
}
// Create invite_codes record
defaultExpiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
inviteCodeRecord := models.InviteCode{
WalletAddress: walletAddress,
Code: code,
MaxUses: -1,
UsedCount: 0,
IsActive: true,
ExpiresAt: models.NullTime{Time: &defaultExpiry},
}
db.Create(&inviteCodeRecord)
}
// Get invite code from invite_codes table
var inviteCodeRecord models.InviteCode
inviteCode := user.InviteCode
usedCount := 0
if db.Where("wallet_address = ?", user.WalletAddress).First(&inviteCodeRecord).Error == nil {
inviteCode = inviteCodeRecord.Code
usedCount = inviteCodeRecord.UsedCount
}
globalRank := 0
if user.GlobalRank != nil {
globalRank = *user.GlobalRank
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"walletAddress": user.WalletAddress,
"inviteCode": inviteCode,
"usedCount": usedCount,
"memberTier": user.MemberTier,
"vipLevel": user.VIPLevel,
"totalPoints": user.TotalPoints,
"globalRank": globalRank,
},
})
}

161
webapp-back/readme.md Normal file
View File

@@ -0,0 +1,161 @@
# ![RealWorld Example App](logo.png)
[![CI](https://github.com/gothinkster/golang-gin-realworld-example-app/actions/workflows/ci.yml/badge.svg)](https://github.com/gothinkster/golang-gin-realworld-example-app/actions/workflows/ci.yml)
[![Coverage Status](https://coveralls.io/repos/github/gothinkster/golang-gin-realworld-example-app/badge.svg?branch=main)](https://coveralls.io/github/gothinkster/golang-gin-realworld-example-app?branch=main)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/gothinkster/golang-gin-realworld-example-app/blob/main/LICENSE)
[![GoDoc](https://godoc.org/github.com/gothinkster/golang-gin-realworld-example-app?status.svg)](https://godoc.org/github.com/gothinkster/golang-gin-realworld-example-app)
> ### Golang/Gin codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
This codebase was created to demonstrate a fully fledged fullstack application built with **Golang/Gin** including CRUD operations, authentication, routing, pagination, and more.
## Recent Updates
This project has been modernized with the following updates:
- **Go 1.21+**: Updated from Go 1.15 to require Go 1.21 or higher
- **GORM v2**: Migrated from deprecated jinzhu/gorm v1 to gorm.io/gorm v2
- **JWT v5**: Updated from deprecated dgrijalva/jwt-go to golang-jwt/jwt/v5 (fixes CVE-2020-26160)
- **Validator v10**: Updated validator tags and package to match gin v1.10.0
- **Latest Dependencies**: All dependencies updated to their 2025 production-stable versions
- **RealWorld API Spec Compliance**:
- `GET /profiles/:username` now supports optional authentication (anonymous access allowed)
- `POST /users/login` returns 401 Unauthorized on failure (previously 403)
- `GET /articles/feed` registered as dedicated authenticated route
- `DELETE /articles/:slug` and `DELETE /articles/:slug/comments/:id` return empty response body
## Test Coverage
The project maintains high test coverage across all core packages:
| Package | Coverage |
|---------|----------|
| `articles` | 93.4% |
| `users` | 99.5% |
| `common` | 85.7% |
| **Total** | **90.0%** |
To generate a coverage report locally, run:
```bash
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
```
## Dependencies (2025 Stable Versions)
| Package | Version | Release Date | Known Issues |
|---------|---------|--------------|--------------|
| [gin-gonic/gin](https://github.com/gin-gonic/gin) | v1.10.0 | 2024-05 | None; v1.11 has experimental HTTP/3 support |
| [gorm.io/gorm](https://gorm.io/) | v1.25.12 | 2024-08 | None; v1.30+ has breaking changes |
| [golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | v5.2.1 | 2024-06 | None; v5.3 only bumps Go version requirement |
| [go-playground/validator/v10](https://github.com/go-playground/validator) | v10.24.0 | 2024-12 | None; v10.30+ requires Go 1.24 |
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | v0.32.0 | 2025-01 | None; keep updated for security fixes |
| [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) | v1.5.7 | 2024-09 | None; requires cgo; use glebarez/sqlite for pure Go |
| [gosimple/slug](https://github.com/gosimple/slug) | v1.15.0 | 2024-12 | None |
| [stretchr/testify](https://github.com/stretchr/testify) | v1.10.0 | 2024-10 | None; v2 still in development |
# Directory structure
```
.
├── gorm.db
├── hello.go
├── common
│ ├── utils.go //small tools function
│ └── database.go //DB connect manager
├── users
| ├── models.go //data models define & DB operation
| ├── serializers.go //response computing & format
| ├── routers.go //business logic & router binding
| ├── middlewares.go //put the before & after logic of handle request
| └── validators.go //form/json checker
├── ...
...
```
# Getting started
## Install Golang
Make sure you have Go 1.21 or higher installed.
https://golang.org/doc/install
## Environment Config
Environment variables can be set directly in your shell or via a `.env` file (requires a tool like `source` or `direnv`).
Available environment variables:
```bash
PORT=8080 # Server port (default: 8080)
GIN_MODE=debug # Gin mode: debug or release
DB_PATH=./data/gorm.db # SQLite database path (default: ./data/gorm.db)
TEST_DB_PATH=./data/test.db # Optional: SQLite database path used for tests
```
Example usage:
```bash
# Option 1: Set environment variables directly
export PORT=3000
export DB_PATH=./data/myapp.db
go run hello.go
# Option 2: Inline with command
PORT=3000 go run hello.go
```
See `.env.example` for a complete template.
## Install Dependencies
From the project root, run:
```
go build ./...
go test ./...
go mod tidy
```
## Run the Server
```bash
# Using default port 8080
go run hello.go
# Using custom port
PORT=3000 go run hello.go
```
## Testing
From the project root, run:
```
go test ./...
```
or
```
go test ./... -cover
```
or
```
go test -v ./... -cover
```
depending on whether you want to see test coverage and how verbose the output you want.
## Todo
- More elegance config
- ProtoBuf support
- Code structure optimize (I think some place can use interface)
- Continuous integration (done)
## Test Coverage
Current test coverage (2026):
- **Total**: 89.2%
- **articles**: 92.1%
- **users**: 99.5%
- **common**: 85.7%
Run coverage report:
```bash
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
```

View File

@@ -0,0 +1,22 @@
@echo off
chcp 65001 >nul
echo === Holder Scanner 启动脚本 ===
echo.
REM 编译 Scanner
echo 📦 正在编译 Scanner...
go build -o bin\holder-scanner.exe cmd\scanner\main.go
if %errorlevel% neq 0 (
echo ❌ 编译失败
pause
exit /b 1
)
echo ✓ 编译成功
echo.
echo 🚀 正在启动 Scanner...
echo ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
echo.
bin\holder-scanner.exe

View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Holder Scanner 快速启动脚本
# Quick start script for Holder Scanner
echo "=== Holder Scanner 启动脚本 ==="
echo ""
# 加载 .env 文件
if [ -f .env ]; then
echo "📄 正在加载 .env 配置..."
export $(grep -v '^#' .env | xargs)
echo "✓ 配置已加载"
else
echo "⚠️ .env 文件未找到,使用默认配置"
fi
# 显示当前配置
echo ""
echo "📊 当前配置:"
echo " 数据库: $DB_TYPE://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
echo " Chain ID: ${CHAIN_ID:-421614}"
echo " 部署区块号:"
echo " • YT Vaults: ${YT_VAULTS_DEPLOY_BLOCK:-227339300}"
echo " • ytLP: ${YTLP_DEPLOY_BLOCK:-227230270}"
echo " • Lending: ${LENDING_DEPLOY_BLOCK:-227746053}"
echo ""
# 编译并运行
echo "📦 正在编译 Scanner..."
go build -o bin/holder-scanner cmd/scanner/main.go
if [ $? -eq 0 ]; then
echo "✓ 编译成功"
echo ""
echo "🚀 正在启动 Scanner..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
./bin/holder-scanner
else
echo "❌ 编译失败"
exit 1
fi

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
# http://stackoverflow.com/a/21142256/2055281
echo "mode: atomic" > coverage.out
for d in $(find ./* -maxdepth 10 -type d); do
if ls $d/*.go &> /dev/null; then
go test -coverprofile=profile.out -covermode=atomic $d
if [ -f profile.out ]; then
echo "$(pwd)"
cat profile.out | grep -v "mode: " >> coverage.out
rm profile.out
fi
fi
done

View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Check if all Go files are properly formatted
unformatted=$(gofmt -l .)
echo "$unformatted"
if [ -n "$unformatted" ]; then
echo "There is unformatted code, you should use 'go fmt ./...' to format it."
echo "Unformatted files:"
echo "$unformatted"
exit 1
else
echo "Codes are formatted."
exit 0
fi

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -e
# Ensure tmp directory exists
mkdir -p ./tmp
# Clean up database
rm -f ./data/gorm.db
# Download Postman collection
echo "Downloading Postman collection..."
curl -L -s https://raw.githubusercontent.com/gothinkster/realworld/main/api/Conduit.postman_collection.json -o ./tmp/Conduit.postman_collection.json
# Build the application
echo "Building application..."
go build -o app hello.go
# Start the server
echo "Starting server..."
PORT=8080 ./app &
SERVER_PID=$!
# Cleanup function to kill server on exit
cleanup() {
echo "Stopping server..."
kill $SERVER_PID
rm -f app
}
trap cleanup EXIT
# Wait for server to be ready
echo "Waiting for server to be ready..."
for i in {1..30}; do
if curl -s http://localhost:8080/api/ping > /dev/null; then
echo "Server is up!"
break
fi
sleep 1
done
# Run Newman
echo "Running API tests..."
# Check if newman is available
if ! command -v newman &> /dev/null; then
echo "newman not found, trying npx..."
npx newman run ./tmp/Conduit.postman_collection.json \
--global-var "APIURL=http://localhost:8080/api" \
--global-var "EMAIL=test@example.com" \
--global-var "PASSWORD=password" \
--global-var "USERNAME=testuser" \
--delay-request 50
else
newman run ./tmp/Conduit.postman_collection.json \
--global-var "APIURL=http://localhost:8080/api" \
--global-var "EMAIL=test@example.com" \
--global-var "PASSWORD=password" \
--global-var "USERNAME=testuser" \
--delay-request 50
fi

View File

@@ -0,0 +1,31 @@
-- 为 assets 表添加部署区块字段
-- Add deployment block fields to assets table
-- 1. 添加部署区块字段
ALTER TABLE `assets`
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
-- 2. 更新已知的部署区块号
UPDATE `assets` SET `deploy_block_arb` = 83949096 WHERE `asset_code` = 'YT-A';
UPDATE `assets` SET `deploy_block_arb` = 83949087 WHERE `asset_code` = 'YT-B';
-- UPDATE `assets` SET `deploy_block_arb` = 83949000 WHERE `asset_code` = 'YT-C'; -- 请替换为实际区块号
-- 3. 为 alp_pools 表添加部署区块字段
ALTER TABLE `alp_pools`
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
-- 更新 ytLP 部署区块(如果知道的话)
-- UPDATE `alp_pools` SET `deploy_block_arb` = 83949000 WHERE `pool_name` = 'ytLP' AND `is_active` = 1;
-- 4. 为 lending_markets 表添加部署区块字段
ALTER TABLE `lending_markets`
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
-- 更新 Lending 部署区块(如果知道的话)
-- UPDATE `lending_markets` SET `deploy_block_arb` = 83949000 WHERE `is_active` = 1;
-- 5. 查看更新结果
SELECT asset_code, contract_address_arb, deploy_block_arb FROM `assets` WHERE asset_code IN ('YT-A', 'YT-B', 'YT-C');

View File

@@ -0,0 +1,78 @@
#!/bin/bash
# Holders 服务启动脚本
echo "=== Starting Holders Services ==="
# 检查环境变量
if [ -z "$DB_PASSWORD" ]; then
echo "⚠️ Warning: DB_PASSWORD not set, using default"
export DB_PASSWORD="your_password"
fi
# 设置默认环境变量
export DB_TYPE=${DB_TYPE:-mysql}
export DB_HOST=${DB_HOST:-localhost}
export DB_PORT=${DB_PORT:-3306}
export DB_USER=${DB_USER:-root}
export DB_NAME=${DB_NAME:-assetx}
export GIN_MODE=${GIN_MODE:-release}
export PORT=${PORT:-8080}
export RPC_URL=${RPC_URL:-https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07}
echo "Configuration:"
echo " DB: $DB_TYPE://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
echo " API Port: $PORT"
echo " RPC: ${RPC_URL:0:50}..."
echo ""
# 创建 bin 目录
mkdir -p bin
# 编译服务
echo "📦 Building services..."
go build -o bin/api-server main.go
go build -o bin/holder-scanner cmd/scanner/main.go
echo "✓ Build completed"
echo ""
# 选择启动方式
echo "Choose start method:"
echo "1. API Server only"
echo "2. Scanner only"
echo "3. Both (in background)"
read -p "Enter choice [1-3]: " choice
case $choice in
1)
echo "🚀 Starting API Server..."
./bin/api-server
;;
2)
echo "🚀 Starting Scanner..."
./bin/holder-scanner
;;
3)
echo "🚀 Starting both services in background..."
nohup ./bin/api-server > logs/api-server.log 2>&1 &
API_PID=$!
echo " API Server started (PID: $API_PID)"
nohup ./bin/holder-scanner > logs/holder-scanner.log 2>&1 &
SCANNER_PID=$!
echo " Scanner started (PID: $SCANNER_PID)"
echo ""
echo "Services running:"
echo " API: http://localhost:$PORT"
echo " Logs: tail -f logs/api-server.log"
echo " tail -f logs/holder-scanner.log"
echo ""
echo "To stop:"
echo " kill $API_PID $SCANNER_PID"
;;
*)
echo "Invalid choice"
exit 1
;;
esac

24
webapp-back/start.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
echo "========================================="
echo "AssetX API Server"
echo "========================================="
# Load environment variables
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
fi
# Check if MySQL is running (optional)
echo "Checking MySQL connection..."
mysql -h$DB_HOST -P$DB_PORT -u$DB_USER -p$DB_PASSWORD -e "SELECT 1" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✓ MySQL connection successful"
else
echo "⚠ MySQL connection failed - using SQLite"
export DB_TYPE=sqlite
fi
# Run the application
echo "Starting server on port $PORT..."
go run main.go

View File

@@ -0,0 +1,141 @@
// 合约验证和部署区块查询工具
// Contract verification and deployment block query tool
const { ethers } = require("ethers");
// 配置
const RPC_URL = "https://sepolia-rollup.arbitrum.io/rpc";
// 你数据库中的合约地址
const CONTRACTS = {
"YT-A": "0x7f9eEA491eE53045594ee4669327f0355aCd0e58",
"YT-B": "0x20B94C5E5b7361552E0548161a58696aA6FeDBd4",
"YT-C": "0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C",
"ytLP": "0x102e3F25Ef0ad9b0695C8F2daF8A1262437eEfc3",
"Lending": "0xCb4E7B1069F6C26A1c27523ce4c8dfD884552d1D"
};
// ERC20 ABI (只包含必要的函数)
const ERC20_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)"
];
async function verifyContract(provider, name, address) {
console.log(`\n━━━ ${name} ━━━`);
console.log(`地址: ${address}`);
try {
// 检查合约代码
const code = await provider.getCode(address);
if (code === "0x") {
console.log("❌ 错误: 该地址没有合约代码(可能是错误的地址)");
return null;
}
console.log("✅ 合约代码存在");
// 尝试读取 ERC20 信息(仅适用于 YT 和 ytLP
if (name.startsWith("YT-") || name === "ytLP") {
try {
const contract = new ethers.Contract(address, ERC20_ABI, provider);
const [tokenName, symbol, totalSupply] = await Promise.all([
contract.name(),
contract.symbol(),
contract.totalSupply()
]);
console.log(` 名称: ${tokenName}`);
console.log(` 符号: ${symbol}`);
console.log(` 总供应量: ${ethers.formatEther(totalSupply)} (${totalSupply.toString()})`);
} catch (error) {
console.log("⚠️ 无法读取 ERC20 信息(可能不是标准 ERC20");
}
}
// 尝试获取创建交易(需要手动在 Arbiscan 查询)
console.log(`\n🔍 在 Arbiscan 上查询部署信息:`);
console.log(` https://sepolia.arbiscan.io/address/${address}`);
return { address, hasCode: true };
} catch (error) {
console.log(`❌ 错误: ${error.message}`);
return null;
}
}
async function suggestDeploymentBlock(provider) {
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("📍 部署区块建议");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
const currentBlock = await provider.getBlockNumber();
console.log(`当前最新区块: ${currentBlock}`);
console.log(`\n如果你不知道确切的部署区块号,可以使用以下策略:\n`);
console.log(`方案 1 (推荐): 在 Arbiscan 上查询准确的部署区块`);
console.log(` - 访问上面的链接,查看 "Contract Creation" 交易`);
console.log(` - 使用准确的部署区块可以确保不遗漏任何数据\n`);
console.log(`方案 2 (临时): 使用较近的区块号进行测试`);
console.log(` - 从较新的区块开始扫描(例如: ${currentBlock - 100000}`);
console.log(` - 可以快速测试功能,但可能遗漏早期持有者数据\n`);
console.log(`方案 3 (保守): 使用较早的区块号`);
console.log(` - 从一个足够早的区块开始(例如: 227000000`);
console.log(` - 可能扫描大量无关区块,但能确保完整性`);
}
async function main() {
console.log("=== 合约验证工具 ===\n");
console.log(`RPC: ${RPC_URL}\n`);
const provider = new ethers.JsonRpcProvider(RPC_URL);
// 验证连接
try {
const network = await provider.getNetwork();
console.log(`✅ 已连接到网络: ${network.name} (Chain ID: ${network.chainId})\n`);
} catch (error) {
console.log(`❌ 无法连接到 RPC: ${error.message}`);
process.exit(1);
}
// 验证每个合约
const results = {};
for (const [name, address] of Object.entries(CONTRACTS)) {
const result = await verifyContract(provider, name, address);
results[name] = result;
}
// 显示摘要
console.log("\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("📊 验证摘要");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
const validContracts = Object.entries(results).filter(([_, r]) => r?.hasCode);
const invalidContracts = Object.entries(results).filter(([_, r]) => !r?.hasCode);
console.log(`✅ 有效合约: ${validContracts.length}/${Object.keys(CONTRACTS).length}`);
validContracts.forEach(([name]) => console.log(`${name}`));
if (invalidContracts.length > 0) {
console.log(`\n❌ 无效合约: ${invalidContracts.length}`);
invalidContracts.forEach(([name]) => console.log(`${name}`));
}
// 部署区块建议
await suggestDeploymentBlock(provider);
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("📝 下一步");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
console.log("1. 在 Arbiscan 上查询每个合约的部署区块号");
console.log("2. 更新 .env 文件中的部署区块号:");
console.log(" YT_VAULTS_DEPLOY_BLOCK=<实际区块号>");
console.log(" YTLP_DEPLOY_BLOCK=<实际区块号>");
console.log(" LENDING_DEPLOY_BLOCK=<实际区块号>");
console.log("3. 运行 Scanner:");
console.log(" ./run-scanner.sh");
console.log("");
}
main().catch(console.error);

12
webapp-back/users/doc.go Normal file
View File

@@ -0,0 +1,12 @@
/*
The user module containing the user CRU operation.
model.go: definition of orm based data model
routers.go: router binding and core logic
serializers.go: definition the schema of return data
validators.go: definition the validator of form data
*/
package users

View File

@@ -0,0 +1,75 @@
package users
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
)
// Extract token from Authorization header or query parameter
func extractToken(c *gin.Context) string {
// Check Authorization header first
bearerToken := c.GetHeader("Authorization")
if len(bearerToken) > 6 && strings.ToUpper(bearerToken[0:6]) == "TOKEN " {
return bearerToken[6:]
}
// Check query parameter
token := c.Query("access_token")
if token != "" {
return token
}
return ""
}
// A helper to write user_id and user_model to the context
func UpdateContextUserModel(c *gin.Context, my_user_id uint) {
var myUserModel UserModel
if my_user_id != 0 {
db := common.GetDB()
db.First(&myUserModel, my_user_id)
}
c.Set("my_user_id", my_user_id)
c.Set("my_user_model", myUserModel)
}
// You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware
//
// r.Use(AuthMiddleware(true))
func AuthMiddleware(auto401 bool) gin.HandlerFunc {
return func(c *gin.Context) {
UpdateContextUserModel(c, 0)
tokenString := extractToken(c)
if tokenString == "" {
if auto401 {
c.AbortWithStatus(http.StatusUnauthorized)
}
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(common.JWTSecret), nil
})
if err != nil {
if auto401 {
c.AbortWithStatus(http.StatusUnauthorized)
}
return
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
my_user_id := uint(claims["id"].(float64))
UpdateContextUserModel(c, my_user_id)
}
}
}

154
webapp-back/users/models.go Normal file
View File

@@ -0,0 +1,154 @@
package users
import (
"errors"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Models should only be concerned with database schema, more strict checking should be put in validator.
//
// More detail you can find here: http://jinzhu.me/gorm/models.html#model-definition
//
// HINT: If you want to split null and "", you should use *string instead of string.
type UserModel struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email;uniqueIndex"`
Bio string `gorm:"column:bio;size:1024"`
Image *string `gorm:"column:image"`
PasswordHash string `gorm:"column:password;not null"`
}
// A hack way to save ManyToMany relationship,
// gorm will build the alias as FollowingBy <-> FollowingByID <-> "following_by_id".
//
// DB schema looks like: id, created_at, updated_at, deleted_at, following_id, followed_by_id.
//
// Retrieve them by:
//
// db.Where(FollowModel{ FollowingID: v.ID, FollowedByID: u.ID, }).First(&follow)
// db.Where(FollowModel{ FollowedByID: u.ID, }).Find(&follows)
//
// More details about gorm.Model: http://jinzhu.me/gorm/models.html#conventions
type FollowModel struct {
gorm.Model
Following UserModel
FollowingID uint
FollowedBy UserModel
FollowedByID uint
}
// Migrate the schema of database if needed
func AutoMigrate() {
db := common.GetDB()
db.AutoMigrate(&UserModel{})
db.AutoMigrate(&FollowModel{})
}
// What's bcrypt? https://en.wikipedia.org/wiki/Bcrypt
// Golang bcrypt doc: https://godoc.org/golang.org/x/crypto/bcrypt
// You can change the value in bcrypt.DefaultCost to adjust the security index.
//
// err := userModel.setPassword("password0")
func (u *UserModel) setPassword(password string) error {
if len(password) == 0 {
return errors.New("password should not be empty!")
}
bytePassword := []byte(password)
// Make sure the second param `bcrypt generator cost` between [4, 32)
passwordHash, _ := bcrypt.GenerateFromPassword(bytePassword, bcrypt.DefaultCost)
u.PasswordHash = string(passwordHash)
return nil
}
// Database will only save the hashed string, you should check it by util function.
//
// if err := serModel.checkPassword("password0"); err != nil { password error }
func (u *UserModel) checkPassword(password string) error {
bytePassword := []byte(password)
byteHashedPassword := []byte(u.PasswordHash)
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
}
// You could input the conditions and it will return an UserModel in database with error info.
//
// userModel, err := FindOneUser(&UserModel{Username: "username0"})
func FindOneUser(condition interface{}) (UserModel, error) {
db := common.GetDB()
var model UserModel
err := db.Where(condition).First(&model).Error
return model, err
}
// You could input an UserModel which will be saved in database returning with error info
//
// if err := SaveOne(&userModel); err != nil { ... }
func SaveOne(data interface{}) error {
db := common.GetDB()
err := db.Save(data).Error
return err
}
// You could update properties of an UserModel to database returning with error info.
//
// err := db.Model(userModel).Updates(UserModel{Username: "wangzitian0"}).Error
func (model *UserModel) Update(data interface{}) error {
db := common.GetDB()
err := db.Model(model).Updates(data).Error
return err
}
// You could add a following relationship as userModel1 following userModel2
//
// err = userModel1.following(userModel2)
func (u UserModel) following(v UserModel) error {
db := common.GetDB()
var follow FollowModel
err := db.FirstOrCreate(&follow, &FollowModel{
FollowingID: v.ID,
FollowedByID: u.ID,
}).Error
return err
}
// You could check whether userModel1 following userModel2
//
// followingBool = myUserModel.isFollowing(self.UserModel)
func (u UserModel) isFollowing(v UserModel) bool {
db := common.GetDB()
var follow FollowModel
db.Where(FollowModel{
FollowingID: v.ID,
FollowedByID: u.ID,
}).First(&follow)
return follow.ID != 0
}
// You could delete a following relationship as userModel1 following userModel2
//
// err = userModel1.unFollowing(userModel2)
func (u UserModel) unFollowing(v UserModel) error {
db := common.GetDB()
err := db.Where("following_id = ? AND followed_by_id = ?", v.ID, u.ID).Delete(&FollowModel{}).Error
return err
}
// You could get a following list of userModel
//
// followings := userModel.GetFollowings()
func (u UserModel) GetFollowings() []UserModel {
db := common.GetDB()
var follows []FollowModel
var followings []UserModel
db.Preload("Following").Where(FollowModel{
FollowedByID: u.ID,
}).Find(&follows)
for _, follow := range follows {
followings = append(followings, follow.Following)
}
return followings
}

View File

@@ -0,0 +1,137 @@
package users
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
"net/http"
)
func UsersRegister(router *gin.RouterGroup) {
router.POST("", UsersRegistration)
router.POST("/", UsersRegistration)
router.POST("/login", UsersLogin)
}
func UserRegister(router *gin.RouterGroup) {
router.GET("", UserRetrieve)
router.GET("/", UserRetrieve)
router.PUT("", UserUpdate)
router.PUT("/", UserUpdate)
}
func ProfileRetrieveRegister(router *gin.RouterGroup) {
router.GET("/:username", ProfileRetrieve)
}
func ProfileRegister(router *gin.RouterGroup) {
router.POST("/:username/follow", ProfileFollow)
router.DELETE("/:username/follow", ProfileUnfollow)
}
func ProfileRetrieve(c *gin.Context) {
username := c.Param("username")
userModel, err := FindOneUser(&UserModel{Username: username})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
return
}
profileSerializer := ProfileSerializer{c, userModel}
c.JSON(http.StatusOK, gin.H{"profile": profileSerializer.Response()})
}
func ProfileFollow(c *gin.Context) {
username := c.Param("username")
userModel, err := FindOneUser(&UserModel{Username: username})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
return
}
myUserModel := c.MustGet("my_user_model").(UserModel)
err = myUserModel.following(userModel)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ProfileSerializer{c, userModel}
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
}
func ProfileUnfollow(c *gin.Context) {
username := c.Param("username")
userModel, err := FindOneUser(&UserModel{Username: username})
if err != nil {
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
return
}
myUserModel := c.MustGet("my_user_model").(UserModel)
err = myUserModel.unFollowing(userModel)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
serializer := ProfileSerializer{c, userModel}
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
}
func UsersRegistration(c *gin.Context) {
userModelValidator := NewUserModelValidator()
if err := userModelValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
if err := SaveOne(&userModelValidator.userModel); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
c.Set("my_user_model", userModelValidator.userModel)
serializer := UserSerializer{c}
c.JSON(http.StatusCreated, gin.H{"user": serializer.Response()})
}
func UsersLogin(c *gin.Context) {
loginValidator := NewLoginValidator()
if err := loginValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
userModel, err := FindOneUser(&UserModel{Email: loginValidator.userModel.Email})
if err != nil {
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
return
}
if userModel.checkPassword(loginValidator.User.Password) != nil {
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
return
}
UpdateContextUserModel(c, userModel.ID)
serializer := UserSerializer{c}
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
}
func UserRetrieve(c *gin.Context) {
serializer := UserSerializer{c}
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
}
func UserUpdate(c *gin.Context) {
myUserModel := c.MustGet("my_user_model").(UserModel)
userModelValidator := NewUserModelValidatorFillWith(myUserModel)
if err := userModelValidator.Bind(c); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
userModelValidator.userModel.ID = myUserModel.ID
if err := myUserModel.Update(userModelValidator.userModel); err != nil {
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
return
}
UpdateContextUserModel(c, myUserModel.ID)
serializer := UserSerializer{c}
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
}

View File

@@ -0,0 +1,66 @@
package users
import (
"github.com/gin-gonic/gin"
"github.com/gothinkster/golang-gin-realworld-example-app/common"
)
type ProfileSerializer struct {
C *gin.Context
UserModel
}
// Declare your response schema here
type ProfileResponse struct {
ID uint `json:"-"`
Username string `json:"username"`
Bio string `json:"bio"`
Image string `json:"image"`
Following bool `json:"following"`
}
// Put your response logic including wrap the userModel here.
func (self *ProfileSerializer) Response() ProfileResponse {
myUserModel := self.C.MustGet("my_user_model").(UserModel)
image := ""
if self.Image != nil {
image = *self.Image
}
profile := ProfileResponse{
ID: self.ID,
Username: self.Username,
Bio: self.Bio,
Image: image,
Following: myUserModel.isFollowing(self.UserModel),
}
return profile
}
type UserSerializer struct {
c *gin.Context
}
type UserResponse struct {
Username string `json:"username"`
Email string `json:"email"`
Bio string `json:"bio"`
Image string `json:"image"`
Token string `json:"token"`
}
func (self *UserSerializer) Response() UserResponse {
myUserModel := self.c.MustGet("my_user_model").(UserModel)
image := ""
if myUserModel.Image != nil {
image = *myUserModel.Image
}
user := UserResponse{
Username: myUserModel.Username,
Email: myUserModel.Email,
Bio: myUserModel.Bio,
Image: image,
Token: common.GenToken(myUserModel.ID),
}
return user
}

Some files were not shown because too many files have changed in this diff Show More