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