系列导航
| 顺序 | 文章 | 内容 |
|---|---|---|
| 1 | 零代码建立全景:账户、代币、链、转账与兑换,以及智能合约在项目中的意义 | |
| 2 | MyToken 代币合约 + MiniSwap 兑换合约(AMM、恒定乘积、滑点)、部署与测试 | |
| 3 | 钱包连接(MetaMask / TronLink)、读链与写链、转账与兑换面板、SSE 推送 | |
| 4 | 📍当前:后端(backend)详解 | 多链 listener:事件订阅、确认数与重组、兑换记录、原生币扫描、TRON 适配、踩坑实录 |
读完本篇你将学会
- 说清楚 Web3 应用为什么还需要一个后端(索引器 Indexer),以及它该做什么、不该做什么。
- 理解后端如何通过 JSON-RPC(HTTP / WebSocket)与节点通信,以及"轮询"和"订阅"两种获取事件方式的成本差异。
- 看懂事件同步的核心设计:信任边界、确认数、区块重组、两段式推进进度。
- 用"唯一索引 + 数据库事务"保证数据不重复、不丢失。
- 看懂三类 listener:代币 Transfer 事件、MiniSwap 的 Swap 事件、原生币(ETH / TRX)逐块扫描。
- 了解同一套代码如何同时服务 anvil、Sepolia 和 TRON Nile 三条链,以及 TRON 的特殊之处。
- 从真实踩过的坑里,学会排查"记录晚了 5 分钟""429 限流""接口返回 null"这类问题。
前置知识
建议先按顺序读完前三篇。本篇默认你已经知道:
- 合约与 ABI:合约是部署在链上的程序,ABI 是它的"接口说明书"(第 2 篇)。
- 事件(日志):MTK 合约每次转账都会发出 Transfer 事件,MiniSwap 每次兑换都会发出 Swap 事件;
topics存 indexed 参数,data存其余参数(第 2 篇)。 - JSON-RPC:程序和区块链节点对话的报文格式(第 3 篇)。
- 钱包与签名:前端通过 MetaMask / TronLink 签名、发送交易(第 3 篇)。
- 交易上链过程:签名 → 广播 → 打包进区块 → 生成回执(第 1~3 篇)。
后端是用 Go 写的,你只需要看得懂基本语法;每段代码都会配文字说明。
阅读说明:文中代码摘自本项目源码(
backend/);为突出主线,部分片段省略了错误处理(标注为"节选"),完整代码以源码为准。 日志与接口返回除特别标注"示意"的以外,均为真实运行结果。文中不出现任何私钥或 API Key,RPC 地址里的 Key 一律写成<API Key>。
目录
- 定位:Web3 应用为什么还需要后端
- 技术栈与目录结构
- 启动流程:main.go
- 配置:多链、多资产
- 数据层:五张表与 models
- 与节点通信:JSON-RPC over HTTP / WebSocket
- ★ 事件同步:listener 的核心设计
- ★ 兑换事件同步:从 Swap 事件到"我的兑换记录"
- ★ 原生币扫描器与追块优化
- TRON 适配:同一套 listener 跑在 Nile 上
- ★ 链上转账:后端的 8 个步骤
- 接口层:路由、统一返回结构与 SSE 推送
- 可靠性:重试、超时、幂等、限速
- ★ 踩坑实录
- 动手 Demo:观察后端如何工作
- 常见问题
- 系列总结
一、定位:Web3 应用为什么还需要后端
这一章要解决的问题:数据明明都在链上,为什么还要写一个后端?它的边界在哪里?
1.1 一句话定位
后端是链上数据的"抄写员"和"翻译员"。 业内把这类服务叫索引器(Indexer):它持续读取链上数据,按业务需要整理成便于查询的形式,就像图书馆给海量藏书编的"索引卡片"。
它不碰钱、不碰私钥,只做两件事:
- 抄写:在 anvil / Sepolia / Nile 三条链上,把 MTK、USDT 的 Transfer 事件、MiniSwap 的 Swap 事件、原生币(ETH / TRX)转账抄进 MySQL。
- 翻译:给地址配上昵称,按"某地址的转账记录""我的兑换记录"这样的业务需求组织数据,通过 HTTP 接口提供给前端,并在有新数据时用 SSE 主动通知页面刷新。
1.2 链上已经有数据了,为什么还要抄一份?
链上数据的组织方式是"按区块、按交易",适合验证,不适合按业务查询。对比一下:
| 需求 | 直接查链 | 查后端数据库 |
|---|---|---|
| "某地址的全部转账记录,按时间倒序,分页" | 很难:链上没有这种索引,只能从部署区块开始逐段扫描事件,又慢又耗 RPC 额度 | 一条 SQL,毫秒级 |
| "我的兑换记录" | 要扫 MiniSwap 全部 Swap 事件再按用户筛 | swaps 表按 user_id 查 |
| 原生币转账历史 | 原生币转账不产生事件,eth_getLogs 查不到,只能逐块翻交易 | transactions 表 |
| 显示昵称 | 链上根本没有昵称 | users 表 |
| 跨链合并展示(anvil + Sepolia + Nile) | 要分别查三条链再合并,而且 TRON 的接口还不一样 | 一张表,network 字段区分 |
| 查余额 | ✅ eth_call 免费且权威 | ❌ 不存余额,也不应该存 |
所以分工是:余额、报价走链;历史记录、昵称走后端。
1.3 后端在整个系统中的位置
┌──────────┐ 签名转账 / 兑换(不经过后端) ┌──────────────────────────────┐
│ 前端 │ ───────────────────────────▶ │ 区块链:MTK / USDT / MiniSwap │
│ │ │ emit Transfer / Swap 事件 │
│ │ │ 原生币转账(无事件) │
│ │ └──────────────┬───────────────┘
│ │ │ ① WebSocket 推送(eth_subscribe)
│ │ │ ② 确认后 eth_getLogs 查询
│ │ │ ③ 原生币:eth_getBlockByNumber 逐块扫描
│ │ GET /api/transactions、/api/swaps ┌───────▼────────────┐
│ │ ◀────────────────────────────────── │ Go 后端 │
│ │ SSE /api/events(progress/tx/swap)│ listener × N + Gin │
└──────────┘ ◀────────────────────────────────── └───────┬────────────┘
│ 写入
┌──────────────▼───────────────┐
│ MySQL:users / user_networks │
│ transactions / swaps / │
│ sync_states │
└───────────────────────────────┘
注意图中的方向:前端转账、兑换不经过后端,直接发到链上;后端只从链上"读",再把整理好的数据提供给前端。
最重要的原则:链上才是真相,数据库只是副本。
- 数据库可以随时删掉重建:后端会从合约部署区块开始,把所有事件重新抄一遍(原生币例外,见第九章)。
- 后端停机期间用户照样能转账、兑换;重启后会自动补上漏掉的记录。
- 后端只读链,从不向链发交易,所以不需要私钥,也不花 Gas。(唯一的例外是独立的部署工具
cmd/deploy-nile,它不属于常驻服务,见第十章。)
小结:后端 = 索引器。链是"原件",数据库是为了查询方便而做的"副本",副本丢了可以从原件重新抄。
二、技术栈与目录结构
这一章要解决的问题:后端用了哪些工具,代码分别放在哪里。
| 技术 | 作用 |
|---|---|
| Go 1.27 | 语言 |
| go-ethereum(geth)v1.17 | 与链交互:ethclient 连接节点、订阅事件、查询日志 |
| Gin | HTTP 路由 |
| GORM + MySQL | ORM 与数据库 |
golang.org/x/time/rate | 令牌桶限速(RPC 每秒额度) |
golang.org/x/sync/errgroup | 原生币扫描时并发读区块 |
| air | 开发时热重载(make backend) |
其中 go-ethereum 是以太坊官方的 Go 实现,它的 ethclient 包相当于 Go 版的 ethers Provider:把 JSON-RPC 调用封装成普通的 Go 函数。ORM(对象关系映射) 让你用结构体操作数据库表,不必手写全部 SQL。
backend/
├── main.go # 启动:配置 → 数据库 → 事件中心 → 每个资产一个 listener → 路由 → HTTP
├── config/config.go # 读取 .env:多链、多资产配置(<NAME>_* 环境变量)
├── models/ # 数据层
│ ├── core.go # 全局 DB + Init():连接 MySQL、建表
│ ├── user.go # User、UserNetwork 表 + EnsureUser / RegisterUser 幂等创建
│ ├── transaction.go # Transaction 表(代币与原生币转账)
│ ├── swap.go # Swap 表(兑换记录)
│ └── sync_state.go # SyncState 表(同步进度)
├── listener/ # ★ 链上数据同步
│ ├── listener.go # 公共逻辑:连接、推进进度、查询日志、解析 Transfer / Swap 并入库
│ ├── subscribe.go # WebSocket 订阅模式(配置了 WS 地址时使用)
│ ├── poll.go # HTTP 轮询模式(没有 WS 时,例如 Nile)
│ ├── native.go # 原生币逐块扫描(含 TRON 的 TRX / TRC-10 区分)
│ └── ratelimit.go # RPC 令牌桶限速
├── events/hub.go # 事件广播中心:listener 发布 progress / tx / swap,SSE 推给页面
├── controllers/
│ ├── types.go # 统一返回结构 Response{code, msg, data}
│ └── api/ # User / Network / Transaction / Swap / Event 五个控制器
├── routers/api.go # 路由注册
├── middlewares/cors.go # 跨域
├── cmd/deploy-nile/ # 独立工具:用 TRON HTTP API 部署 MTK、MiniSwap 到 Nile
└── .env.example / .air.toml / Dockerfile
sql/01_schema.sql # 建表脚本(与 models 的 AutoMigrate 结果一致,二选一)
整个后端可以分成两半:listener/ 负责"写"(链 → 数据库),controllers/ + routers/ 负责"读"(数据库 → 前端),events/ 是两者之间的"门铃"——有新数据入库时按一下,页面就知道该刷新了。本篇的重点是 listener/。
三、启动流程:main.go
这一章要解决的问题:后端启动时按什么顺序做了哪些事。
// main.go(节选)
func main() {
cfg := config.Load() // 1. 读配置
if err := models.Init(cfg.MySQLDSN); err != nil { // 2. 连数据库、建表
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 3. 每个"链 + 资产"一个 listener goroutine,某个出错不影响其他;
// 节点暂时连不上时 listener 会在后台自动重试,因此启动顺序无所谓
hub := events.NewHub() // listener 发布事件,/api/events 推给页面
var listeners []*listener.Listener
for _, n := range cfg.Networks {
interval := cfg.PollInterval
if n.Native {
interval = cfg.NativePollInterval // 原生币扫描成本高,单独设置更长的轮询间隔
}
l := listener.New(n, models.DB, hub, cfg.Batch, interval, cfg.CheckpointInterval)
listeners = append(listeners, l)
go l.Run(ctx)
}
router := gin.Default() // 4. 注册路由
router.Use(middlewares.Cors())
routers.ApiInit(router, listeners, hub)
srv := &http.Server{Addr: ":" + cfg.Port, Handler: router}
go func() { srv.ListenAndServe() }() // 5. 启动 HTTP 服务
<-ctx.Done() // 6. 收到 Ctrl+C → 优雅退出
hub.Close() // 先关 Hub,SSE 长连接才会结束,否则 Shutdown 要等到超时
srv.Shutdown(...)
}
goroutine(Go 协程) 是 Go 里的轻量级线程,go l.Run(ctx) 表示"在后台并行运行这个函数"。
两件事并行运行:listener goroutine 负责"写"(链 → 数据库),HTTP 服务负责"读"(数据库 → 前端)。它们主要通过数据库打交道;唯一的"直连"是事件中心 Hub——listener 入库后发布事件,SSE 接口把事件转给页面。
注意这里是"每个资产一个 listener",而不是"每条链一个"。以当前配置为例,一共启动 9 个 listener:
| 链 | listener |
|---|---|
| anvil | MTK |
| sepolia | MTK、USDT(仅已登记用户)、MiniSwap(Swap 事件)、原生币 ETH |
| nile | USDT(仅已登记用户)、MTK、MiniSwap(Swap 事件)、原生币 TRX |
启动日志的格式如下(示意:按源码中的 log.Printf 格式整理,合约地址已缩写):
[anvil/MTK] 开始监听合约 0xDc64…F6C9(chainId=31337,确认数=0,方式=WebSocket 订阅,范围=全部转账)
[sepolia/MiniSwap] 开始监听合约 0x6569…eaFB(chainId=11155111,确认数=2,方式=WebSocket 订阅,范围=全部兑换)
[sepolia/原生币] 开始监听合约 0xEeee…EEeE(chainId=11155111,确认数=2,方式=HTTP 轮询,逐块扫描原生币转账,范围=仅已登记用户)
[nile/USDT] 开始监听合约 0xECa9…E05F(chainId=3448148188,确认数=19,方式=HTTP 轮询,范围=仅已登记用户)
HTTP 服务监听 :8080
[sepolia/MiniSwap] 已通过 WebSocket 订阅 Swap 事件,区块 X 之后的事件由节点推送
日志前缀 sepolia/MTK 里的 MTK 是 listener 连上节点后调用合约 symbol() 读出来的;MiniSwap 不是代币,前缀固定为 MiniSwap;原生币固定为 原生币。"区块 X 之后的事件由节点推送"这句话很关键,第七章会解释它的含义。
四、配置:多链、多资产
这一章要解决的问题:后端要监听哪几条链、哪些合约、从哪个区块开始,这些参数从哪里来。
4.1 <NAME>_* 命名规则
NETWORKS 决定启用哪些链;每条链的参数都以"链名大写 + 下划线"作前缀,也就是 <NAME>_*。以 backend/.env.example 为准(Key 已隐去):
# ===== 通用 =====
PORT=8080
NETWORKS=anvil,sepolia,nile # 启用哪些链;某条链没配 RPC_URL 或 CONTRACT 会自动跳过
BATCH=1000 # 每次 eth_getLogs 最多查多少个区块
POLL_INTERVAL=12s # 轮询间隔 / 订阅模式下检查确认数的间隔
NATIVE_POLL_INTERVAL=60s # 原生币扫描的轮询间隔(逐块读完整区块,比代币同步贵得多)
CHECKPOINT_INTERVAL=5m # 订阅模式下,空闲时多久推进一次进度
# ===== 本地链 anvil(chainId 31337)=====
ANVIL_RPC_URL=http://127.0.0.1:8545
ANVIL_WS_URL=ws://127.0.0.1:8545
ANVIL_CONTRACT= # 由 contracts/export-abi.sh 31337 打印
ANVIL_CONFIRMATIONS=0 # 本地链不会重组,不用等确认
# ===== Sepolia(chainId 11155111)=====
SEPOLIA_RPC_URL= # https://sepolia.infura.io/v3/<API Key>
SEPOLIA_WS_URL= # wss://sepolia.infura.io/ws/v3/<API Key>
SEPOLIA_RPC_RATE=400 # HTTP RPC 每秒最多消耗多少 credits(0 = 不限速)
SEPOLIA_CONTRACT= # 多个合约用逗号分隔,例如 MTK,USDT
SEPOLIA_REGISTERED_ONLY=0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0 # Aave 测试 USDT:只同步已登记用户
SEPOLIA_CONFIRMATIONS=2
SEPOLIA_NATIVE=true # 是否逐块扫描 ETH 转账
# ===== TRON Nile(chainId 3448148188 = 0xcd8690dc)=====
NILE_RPC_URL=https://nile.trongrid.io/jsonrpc # 只读的 JSON-RPC,不支持 WebSocket,不要配 NILE_WS_URL
NILE_CONTRACT= # 例如 USDT,MTK(均为去掉 41 前缀的 0x 形式)
NILE_REGISTERED_ONLY=0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f
NILE_NATIVE=true
NILE_START_BLOCK=0 # ⚠️ 必须填最近的区块号,否则 USDT 要从 0 补查几千万个区块
NILE_CONFIRMATIONS=19 # TRON 区块获得 19 个超级代表确认后不可逆(约 1 分钟)
.env.example 里没有列出、但代码支持的还有 <NAME>_SWAP(MiniSwap 合约地址)和 <NAME>_TRON_API(TRON HTTP API 地址,默认由 NILE_RPC_URL 去掉 /jsonrpc 推出)。当前三条链部署的合约:
| 链 | <NAME>_CONTRACT | <NAME>_SWAP(MiniSwap) | <NAME>_NATIVE |
|---|---|---|---|
| anvil | MTK 0xdc64…f6c9 | 未部署(本地只演示转账) | 未开启 |
| sepolia | MTK 0xcbee…2447、USDT 0xaa8e…33d0 | 0x65691a568b9ea2075325ffc4433c0f4f4c9eeafb | true |
| nile | USDT 0xeca9…e05f、MTK 0xd666…11b1 | 0xbbfaa366406e2fe989c46828175b92c2388416d3 | true |
几个新词先简单认识一下,第七章会详细讲:
- START_BLOCK(起始区块):合约部署所在的区块。在它之前合约还不存在,不可能有事件,所以从这里开始扫描。
- CONFIRMATIONS(确认数):一笔交易所在区块之后又出了几个新块。等的块越多,这笔交易被"撤销"的可能越小。
- BATCH:一次
eth_getLogs最多查多少个区块,控制单次请求的范围。 - REGISTERED_ONLY(仅已登记用户):只入库涉及"在本链登记过的用户"的转账,第五章讲
user_networks时细说。
4.2 一份配置 = 一个"链 + 资产"
config.Load() 把上面的环境变量拆成多份 NetworkConfig,每份对应一个 listener:
// config/config.go(节选)
type NetworkConfig struct {
Name string // anvil / sepolia / nile,写入 transactions.network
ChainID uint64 // 期望的链 ID,启动时与节点返回值比对
RPCURL string // HTTP 地址:普通查询都走它
WSURL string // WebSocket 地址:配置后改用订阅,留空则退回轮询
Contract common.Address // 代币合约地址;同步原生币时为 NativeAddress(0xEeee…EEeE)
StartBlock uint64 // 合约部署区块
Confirmations uint64 // 确认数
RPCRate int // 每秒 credits 额度,同一个 RPC 地址的所有 listener 共享
Native bool // 是否是原生币扫描器
TronAPI string // TRON 原生 HTTP API 地址(区分 TRX 与 TRC-10 用),非 TRON 链为空
Swap bool // 是否是 MiniSwap:监听 Swap 事件写 swaps 表
RegisteredOnly bool // 只入库涉及已登记用户的转账
}
拆分逻辑(节选):
for _, c := range contracts { // <NAME>_CONTRACT 里的每个代币一份
nc := base
nc.Contract = c
nc.RegisteredOnly = registeredOnly[c]
cfg.Networks = append(cfg.Networks, nc)
}
if swap != "" { // <NAME>_SWAP:兑换合约一份
nc := base
nc.Contract = common.HexToAddress(swap)
nc.Swap = true
cfg.Networks = append(cfg.Networks, nc)
}
if native { // <NAME>_NATIVE=true:原生币一份
nc := base
nc.Contract = NativeAddress
nc.Native = true
nc.RegisteredOnly = true // 原生币只能逐块扫描全部交易,必须按用户过滤
cfg.Networks = append(cfg.Networks, nc)
}
为什么原生币也要一个"合约地址"? 原生币(ETH / TRX)没有合约,但交易表、同步进度表都按合约区分。项目沿用业界常见约定(1inch、0x 等都用它),用 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE 作占位地址。前端、后端、数据库三处统一使用它。
确认数的默认值(没配 <NAME>_CONFIRMATIONS 时):
// 本地链不会重组,不需要等确认;公链等 2 个块,兼顾实时性与安全性。
// TRON 是 DPoS:区块获得 2/3 以上超级代表(27 个中的 19 个)确认后才不可逆(solidified),
// 出块间隔 3 秒,19 个块约 1 分钟。
func defaultConfirmations(name string) uint64 {
switch name {
case "anvil":
return 0
case "nile":
return 19
}
return 2
}
新增一条链时,要在 knownChainIDs 里登记"名称 → chainId"(目前是 anvil: 31337、sepolia: 11155111、nile: 3448148188),再在 .env 里配 <NAME>_* 即可;前端的 settings/networkSetting.ts 也要同步登记。
| 配置 | 从哪里来 |
|---|---|
*_CONTRACT、*_START_BLOCK | 部署合约后 export-abi.sh / deploy-nile 打印 |
*_SWAP | 部署 MiniSwap 后写入 deployments.json 的 swap.address |
SEPOLIA_RPC_URL、SEPOLIA_WS_URL | 在 Infura 申请的 API Key 拼出来 |
NILE_RPC_URL | TronGrid 公共地址 https://nile.trongrid.io/jsonrpc |
ANVIL_*_URL | anvil 固定是本机 8545 端口,HTTP 和 WebSocket 共用 |
五、数据层:五张表与 models
这一章要解决的问题:链上的数据抄到数据库里,要存成什么样子?怎样保证不重复、能续传?
五张表各司其职:
| 表 | 作用 | 数据从哪来 |
|---|---|---|
users | 地址 ↔ 昵称 | 页面登记、listener 遇到新地址时自动创建 |
user_networks | 用户在哪条链上"登记"过 | 页面连接钱包时 POST /api/users 带 network |
transactions | 转账记录(代币 + 原生币) | listener:Transfer 事件、原生币逐块扫描 |
swaps | 兑换记录 | listener:MiniSwap 的 Swap 事件 |
sync_states | 每个"链 + 合约"抄到哪个区块了 | listener |
建表有两种方式,结果完全一致、二选一:后端启动时 models.Init 调用 GORM 的 AutoMigrate 自动建表,或者手动执行 mysql -uroot -p < sql/01_schema.sql。
5.1 users:地址 ↔ 昵称
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Address string `gorm:"type:char(42);uniqueIndex;not null" json:"address"` // 统一小写 0x 形式(TRON 地址也转成 0x)
Nickname string `gorm:"type:varchar(32);not null" json:"nickname"`
CreatedAt time.Time `json:"createdAt"`
}
链上没有"用户"的概念,只有地址。后端把地址映射成用户、分配随机昵称("形容词 + 动物#4 位数字",例如"勇敢的鹦鹉#3717")。同一个私钥在不同链上推导出的是同一个 20 字节地址,所以 users 表不区分链;TRON 的 T… 地址也转换成去掉 41 前缀的 0x 形式保存(见第十章)。
幂等(Idempotent):同一个操作执行一次和执行多次,结果完全一样。就像电梯的"上楼"按钮,按一次和按十次效果相同。后端大量依赖这个性质,因为重启、重试都可能让同一件事被做多次。
幂等创建用户:
// models/user.go(节选)
func EnsureUser(db *gorm.DB, address string) (*User, error) {
addr := strings.ToLower(address)
if addr == ZeroAddress {
return nil, nil // 零地址(铸造的 from)不是真实用户
}
u := User{Address: addr, Nickname: randomNickname()}
// INSERT ... ON DUPLICATE KEY 什么都不做:地址已存在时不会覆盖原昵称
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&u).Error; err != nil {
return nil, err
}
var saved User
db.Where("address = ?", addr).First(&saved) // 再按地址查出来
return &saved, nil
}
为什么用"先插入、冲突忽略、再查询",而不是"先查、没有再插"? 因为后者在并发时,两个请求可能同时查到"不存在",然后都去插入。依靠 address 唯一索引,数据库层面保证只会插入一条。
5.2 user_networks:按链登记,决定"谁的转账值得抄"
有些资产不是我们自己发行的:USDT 是整个测试网共用的代币,原生币更是每个区块都有大量转账。如果全量同步,数据库会被陌生人的转账塞满(.env.example 注释里记录了 Nile USDT 实测约每分钟 2 笔)。所以这类资产只抄"在本链登记过的用户"相关的转账。
// UserNetwork 记录用户在哪条链上"登记"过:在页面上用这个地址连接了该链的钱包。
type UserNetwork struct {
UserID uint `gorm:"primaryKey"`
Network string `gorm:"type:varchar(16);primaryKey;index"`
CreatedAt time.Time
}
// RegisterUser 与 EnsureUser 相同,并把用户登记到指定的链(幂等):页面连接钱包的账户走这里。
func RegisterUser(db *gorm.DB, address, network string) (*User, error) {
u, err := EnsureUser(db, address)
...
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&UserNetwork{UserID: u.ID, Network: network})
return u, nil
}
为什么登记必须按链区分? 源码注释给了一个很具体的例子:anvil 的测试账户私钥是公开的,同一个私钥在 Nile 上推导出同一个地址。如果登记不分链,别人在 Nile 上用这个公开私钥转 TRX,也会被记成你的交易。所以"在本地链连过钱包"只登记 anvil。
还要注意谁会被登记:
- 页面上连接钱包的账户:
POST /api/users带network,调用RegisterUser,登记到当前链。 - 页面上查询对方地址、listener 遇到新地址:只
EnsureUser(分配昵称),不登记。否则查一下水龙头这类高频地址,它在整条链上的转账都会被同步进来。
RegisteredOnly 的过滤发生在"入库之前、查区块时间之前",被丢弃的事件既不写库,也不多花 RPC 请求:
// listener/listener.go(节选)
// registeredSet 用一次查询找出 addrs 中"在本链登记过"的用户地址
err := l.db.WithContext(ctx).Table("users AS u").
Joins("JOIN user_networks AS n ON n.user_id = u.id AND n.network = ?", l.cfg.Name).
Where("u.address IN ?", addrs).Pluck("u.address", &known).Error
局限也要说清楚:登记之前的历史转账不会补录。对"只关心自己账户"的学习项目来说,这是用完整性换数据量的合理取舍。
5.3 transactions:转账的抄本
type Transaction struct {
ID uint `gorm:"primaryKey"`
Network string // anvil / sepolia / nile
Contract string // 发出事件的合约(小写);原生币为 0xeeee…eeee
// (chain_id, tx_hash, log_index) 唯一确定一条事件 → 唯一索引保证幂等
ChainID uint64 `gorm:"uniqueIndex:uk_chain_tx_log,priority:1"`
TxHash string `gorm:"uniqueIndex:uk_chain_tx_log,priority:2"`
LogIndex uint `gorm:"uniqueIndex:uk_chain_tx_log,priority:3"`
FromUserID *uint // 铸造时 from 是零地址,不对应用户,所以可空
ToUserID uint
FromAddr, ToAddr string
Amount string // uint256 最大 78 位,用字符串保存避免精度丢失
BlockNumber uint64
BlockTime time.Time
}
为什么唯一键是 (chain_id, tx_hash, log_index) 三个字段? 一笔交易可能发出多条事件,光靠 tx_hash 区分不开;log_index 是这条事件在区块里的序号;再加上 chain_id,就能在多条链之间也唯一定位一条事件。这相当于给每条事件发了一个"身份证号"。
原生币转账没有日志,也就没有 log_index。代码用 uint32 最大值 4294967295 占位:同一笔交易里如果还有代币事件(log_index 从 0 递增),不会与它冲突。
一条真实记录(Sepolia 上收款人1 转 2500 MTK 给收款人2):
| 字段 | 值 |
|---|---|
| network / chain_id | sepolia / 11155111 |
| tx_hash | 0x80e152bb556f0d778c136a9461f75cbd080b6a7ebd077a02f2a309f97394f3a5 |
| log_index | 63 |
| from_user_id → 昵称 | 105 → 勇敢的鹦鹉#3717 |
| to_user_id → 昵称 | 106 → 迷糊的水獭#7322 |
| amount | 2500000000000000000000(= 2500 MTK) |
| block_number | 11786846 |
5.4 swaps:兑换记录
// models/swap.go(节选)
type Swap struct {
Network string
Contract string // MiniSwap 合约地址(小写)
// 与 transactions 一样:(chain_id, tx_hash, log_index) 唯一确定一条事件
ChainID uint64 `gorm:"uniqueIndex:uk_swap_chain_tx_log,priority:1"`
TxHash string `gorm:"uniqueIndex:uk_swap_chain_tx_log,priority:2"`
LogIndex uint `gorm:"uniqueIndex:uk_swap_chain_tx_log,priority:3"`
UserID uint
UserAddr string
// 资产地址(小写);原生币记为 0xeeee…eeee,与 transactions.contract、前端资产配置一致
TokenIn, TokenOut string
AmountIn, AmountOut string // 最小单位,字符串保存不丢精度
BlockNumber uint64
BlockTime time.Time
}
为什么兑换要单独建表,而不是只看 transactions? 看一笔"卖 USDT 买 ETH"的兑换:用户把 USDT 转给 MiniSwap 会产生 USDT 的 Transfer 事件,transactions 表能看到;但 MiniSwap 把 ETH 转给用户,是合约内部转账(internal transaction),不是一笔独立交易,原生币扫描器看不到。只有 Swap 事件完整记录了"卖了什么、卖多少、买到什么、买到多少"。详见第八章。
5.5 sync_states:同步进度(书签)
type SyncState struct {
Network string `gorm:"primaryKey"`
Contract string `gorm:"primaryKey"`
LastBlock uint64 // 已同步到的区块
UpdatedAt time.Time
}
可以把它想象成读书时夹的书签:链是一本不断变厚的书,每个区块是一页,last_block 记录"已经读完到第几页"。
后端每处理完一段区块,就把书签往后挪。重启后从书签处继续,停机期间的转账不会漏。按 (network, contract) 记录:同一条链上 MTK、USDT、MiniSwap、原生币各有各的书签;重新部署合约后,新合约要从它自己的部署区块开始同步。
小结:users 负责"翻译",user_networks 决定"谁的转账值得抄",transactions 和 swaps 负责"抄写",sync_states 负责"记住抄到哪儿"。地址唯一索引、事件三元组唯一索引让重复写入自动变成无害操作。
六、与节点通信:JSON-RPC over HTTP / WebSocket
这一章要解决的问题:后端通过什么"语言"和什么"线路"跟区块链节点说话,每种方式的代价是什么。
后端和链之间说的"语言"是 JSON-RPC(报文格式、与 REST 的区别见第 3 篇)。和前端不同的是:前端经钱包转发请求,后端直连 RPC 节点,而且同时用到了 HTTP 和 WebSocket 两种传输方式。
打个比方:JSON-RPC 是"说什么话",HTTP 和 WebSocket 是"用什么线路说"——同样一句话,可以写信(HTTP)寄过去,也可以打一通一直不挂的电话(WebSocket)说。
6.1 后端用到的 JSON-RPC 方法
| 方法 | 作用 | 代码位置 |
|---|---|---|
eth_chainId | 启动时校验节点是不是配置的那条链 | listener.go 的 checkChainID |
eth_blockNumber | 最新区块号,用来计算安全区块(确认数) | advance、subscribeOnce |
eth_getLogs | 按"合约地址 + 事件签名 + 区块范围"查询 Transfer / Swap 事件 | syncRange |
eth_getBlockByNumber | ① 只取区块时间(日志里没有时间);② 原生币:取完整交易列表 | blockTime、native.go 的 fetchBlocks |
eth_getTransactionReceipt | 原生币:确认交易执行成功(status=1);回填旧数据的合约地址 | nativeTransfers、backfillContract |
eth_call | 读代币的 decimals()、symbol() | loadTokenInfo |
eth_subscribe | 订阅事件,由节点主动推送 | subscribeOnce |
另外,TRON 链上还会调用一个非 JSON-RPC 的接口:TRON 原生 HTTP API POST /wallet/gettransactionbyid,用来区分 TRX 与 TRC-10 转账(见第十章)。
后端从不调用 eth_sendRawTransaction:它只读链、不发交易,所以不需要私钥,也不花 Gas。
6.2 HTTP 与 WebSocket:两种传输方式
同一份 JSON-RPC 报文,既可以通过 HTTP 发送,也可以通过 WebSocket 发送。区别在于连接方式:
| 对比 | HTTP | WebSocket |
|---|---|---|
| 连接方式 | 每次请求一问一答,答完就结束 | 先用一次 HTTP 请求"升级"成 WebSocket(握手,返回 101 Switching Protocols),之后这条连接一直保持 |
| 谁能先说话 | 只能客户端发起 | 双方都可以,节点能主动推送 |
| 适合 | 查询 | 订阅新事件(eth_subscribe) |
| Infura 计费 | 按方法计费 | 建立连接计一次(ws_newConnection),之后按方法 / 推送计费 |
后端的分工:查询一律走 HTTP(l.client),WebSocket 只用来接收推送(subscribeOnce 里单独建立的 ws)。这样 WebSocket 断线重连时,不会影响正在进行的查询。HTTP 查询还会经过令牌桶限速(第十三章),WebSocket 请求很少,不限速。
6.3 后端用到的节点
| 节点 | HTTP 地址(配置项) | WebSocket 地址(配置项) |
|---|---|---|
| anvil(本地链) | http://127.0.0.1:8545(ANVIL_RPC_URL) | ws://127.0.0.1:8545(ANVIL_WS_URL),与 HTTP 共用端口 |
| Infura(Sepolia) | https://sepolia.infura.io/v3/<API Key> | wss://sepolia.infura.io/ws/v3/<API Key> |
| TronGrid(Nile) | https://nile.trongrid.io/jsonrpc(NILE_RPC_URL) | 不支持,只能 HTTP 轮询 |
Infura 是一家节点服务商:你不必自己运行以太坊节点,用它提供的地址就能访问链。TronGrid 是 TRON 官方的节点服务,它的 /jsonrpc 路径提供了兼容以太坊的只读 JSON-RPC。
Infura 的 Key 写在 backend/.env 里,只存在于服务器上,不会发到用户浏览器。这也是"需要 Key 的节点由后端访问、前端借用钱包的节点"的原因。
6.4 Infura 计费项 ws_newConnection 是什么
在 Infura 的每日用量统计里,除了各个 JSON-RPC 方法,还会看到一个 ws_newConnection。
它不是 JSON-RPC 方法,而是 Infura 的一个计费项:每新建立一条 WebSocket 连接,按次收一笔费用。可以这样理解:JSON-RPC 方法是"打电话时说的话",ws_newConnection 是"接通电话"这个动作本身。
本项目中产生这项计费的地方都在后端(前端经钱包用的是钱包自己的节点,不消耗你的 Infura 额度):
| 何时新建 WebSocket 连接 | 代码位置 | 次数 |
|---|---|---|
| 建立订阅用的长连接(顺带校验链 ID) | backend/listener/subscribe.go 的 subscribeOnce() | 每个订阅模式的 listener 启动 1 次 |
| 断线后自动重连 | 同上 | 每次重连 1 次 |
早期版本启动时还会为了校验链 ID 单独连一次 WebSocket,现在改为在订阅这条连接上顺带校验,省掉一次计费。开发时用 make backend(air 热重载)频繁修改代码,每保存一次后端就会重启一次,这一项就会跟着增加;正常长期运行时,它只在启动和偶尔断线重连时产生。
小结:查询走 HTTP(一问一答),接收推送走 WebSocket(长连接、节点可主动说话)。节点服务按调用计费,所以"少发请求"是后端设计的重要目标——这正是下一章的出发点。
七、★ 事件同步:listener 的核心设计
这一章要解决的问题:怎样又省又稳地把链上事件抄进数据库——不漏、不重、不写入作废数据,还尽量少花 RPC 额度。
这是全系列概念最密集的一章。先用一个类比建立直觉,再逐个看代码。本章以 Transfer 事件为例,Swap 事件走的是完全相同的流程(第八章),原生币因为没有事件另有一套(第九章)。
7.1 先建立直觉:一个"抄写员"的工作方式
把 listener 想象成一位在档案馆工作的抄写员,任务是把一本不断加页的公告簿(区块链)里所有"MTK 转账公告"(Transfer 事件)抄进自己的笔记本(数据库)。档案馆里有好几位抄写员,各抄各的:有人抄 MTK,有人抄 USDT,有人抄"兑换公告",还有人专门逐页翻看"没贴公告的现金往来"(原生币)。
他会遇到几个现实问题,每个问题对应本章的一个设计:
| 抄写员遇到的问题 | 对应的技术概念 | 本章小节 |
|---|---|---|
| 要么隔一会儿去翻一遍公告簿,要么请管理员有新公告时打电话通知他 | 轮询(poll)vs 订阅(subscribe) | 7.2 |
| 公告簿里什么公告都有,他只关心 MTK 转账 | 过滤条件(合约地址 + 事件签名) | 7.3 |
| 电话接通之前的旧公告,管理员不会再打电话说,他得自己翻 | 信任边界 trustAfter、补查历史 | 7.4 |
| 最新几页偶尔会被撕掉重写,刚贴出来的公告不一定算数 | 区块重组(reorg)、确认数 | 7.5 |
| 没接到电话的那些页,能不能不翻、直接把书签往后挪?挪到哪里才安全? | advance 两段式推进、subscriptionLag | 7.6 |
| 抄一条公告要同时改好几处,中途被打断怎么办 | 数据库事务、唯一索引幂等 | 7.7 |
带着这张表往下读,每一节都是在回答其中一个问题。
7.2 两种获取事件的方式:轮询 vs 订阅
- 轮询(Polling):每隔固定时间主动去问"有没有新事件"。像每隔几分钟去信箱看一眼。
- 订阅(Subscription):告诉节点"有我关心的事件就通知我",之后由节点主动推送。像让快递员到了直接打电话。
| 对比 | HTTP 轮询(poll.go) | WebSocket 订阅(subscribe.go) |
|---|---|---|
| 原理 | 每隔 12 秒问节点"有没有新事件" | 节点在有匹配事件时主动推送 |
| 没有转账时 | 每轮都要 eth_blockNumber + eth_getLogs | 只有每 5 分钟一次 eth_blockNumber |
| Sepolia 空闲一天的 Infura 用量(估算) | 约 240 万 Credits | 约 2.3 万 Credits |
| 何时使用 | 未配置 *_WS_URL 时(例如 Nile) | 配置了 *_WS_URL(anvil、Sepolia) |
两种方式共用同一套入库逻辑,区别只在"怎么知道有新事件"。原生币没有事件可订阅,永远走轮询。
轮询的问题在于:绝大多数时候合约并没有新转账,但每 12 秒仍要问一次,这些请求几乎都是"白问"。订阅把"有没有"这件事交给节点判断,后端平时几乎不用发请求。
真实对比(Infura 后台的每日用量统计):
| 日期 | 同步方式 | Infura 用量 | 主要构成 |
|---|---|---|---|
| 2026-09-26(改造前,统计截至截图时,后端只运行了几个小时) | HTTP 轮询 | 约 37.6 万 Credits | eth_getLogs 占 74%、eth_blockNumber 占 24% |
| 2026-09-27(改造后,统计截至截图时) | WebSocket 订阅 | 5,560 Credits | eth_blockNumber 2,800、eth_getLogs 2,040、ws_newConnection 480、eth_call 160、eth_getTransactionReceipt 80 |
两张截图的统计时长不同,不能直接相除,但数量级的差距一目了然。(这是当时只有 MTK 一个 listener 时的数据;现在 Sepolia 上还有 USDT、MiniSwap、原生币扫描器,总用量会更高,其中原生币扫描是大头,见第九章。)
7.3 过滤条件:只要本合约的指定事件
链上每个区块里有各种合约发出的各种事件。后端通过两个条件把范围缩小:
- 合约地址:只要本 listener 负责的合约发出的日志。
- topics[0]:事件签名的哈希。第 2 篇讲过,日志的第一个 topic 就是事件签名(去掉参数名和 indexed)的 Keccak256 哈希,用它就能认出"这是一条 Transfer 事件"还是"一条 Swap 事件"。
// listener/listener.go(节选)
// Transfer 事件签名的哈希 = 日志的 topics[0]
var transferTopic = crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)"))
// = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
// MiniSwap 的 Swap 事件签名哈希
var swapTopic = crypto.Keccak256Hash([]byte("Swap(address,address,address,uint256,uint256)"))
// eventTopic 返回要监听的事件签名:兑换合约监听 Swap,代币合约监听 Transfer。
func (l *Listener) eventTopic() common.Hash {
if l.cfg.Swap {
return swapTopic
}
return transferTopic
}
func (l *Listener) filterQuery(from, to uint64) ethereum.FilterQuery {
q := ethereum.FilterQuery{
Addresses: []common.Address{l.cfg.Contract}, // 只要本合约发出的
Topics: [][]common.Hash{{l.eventTopic()}}, // topics[0] = 事件签名
}
if from > 0 || to > 0 {
q.FromBlock = new(big.Int).SetUint64(from)
q.ToBlock = new(big.Int).SetUint64(to)
}
return q
}
订阅和查询用的是同一个过滤条件。区别只是:订阅时不带区块范围(filterQuery(0, 0),表示"以后的都要");查询时带上 [from, to] 区块范围。
7.4 订阅模式的完整流程
先认识一个关键概念:
信任边界(trustAfter):订阅建立那一刻链上的最新区块号。它把区块分成两部分:
- 边界之后的新区块:只要有匹配的事件,节点一定会推送过来。所以"没收到推送"就等于"没有事件"——这部分区块可以信任订阅。
- 边界之前的旧区块:订阅不会补推历史事件,节点对它们"只字不提"。所以这部分区块不能信任订阅,必须自己用
eth_getLogs补查。
回到抄写员的比喻:他和管理员约好"从现在起有新公告就打电话"。挂电话前,他在公告簿上夹一张便签,写着"第 X 页之后靠电话"。第 X 页及之前的内容,他得自己翻。
// listener/subscribe.go(节选:省略了错误处理和超时封装)
func (l *Listener) subscribeOnce(ctx context.Context) (established bool, err error) {
ws := ethclient.DialContext(ctx, l.cfg.WSURL) // ① 建立 WebSocket 连接
checkChainID(ctx, ws, l.cfg) // 顺带校验链 ID,不再单独连一次
logsCh := make(chan types.Log, 128)
sub := ws.SubscribeFilterLogs(ctx, l.filterQuery(0, 0), logsCh) // ② eth_subscribe("logs")
head := l.client.BlockNumber(ctx)
l.trustAfter = head // ③ 记下"信任边界"
defer func() { l.trustAfter = math.MaxUint64 }() // 断线后恢复为"所有区块都要查"
ok := l.tryAdvance(ctx) // ④ 补查订阅之前的历史区块
checkpoint := time.NewTicker(l.checkpointInterval) // 每 5 分钟
recheck := time.NewTicker(l.pollInterval) // 有待确认事件或同步失败时每 12 秒
// 统一规则:同步失败或仍有事件在等确认,就让 recheck 继续跑;否则停下
rechecking := true
schedule := func(ok bool) {
rechecking = !ok || len(l.pending) > 0
if rechecking {
recheck.Reset(l.pollInterval)
} else {
recheck.Stop()
}
}
schedule(ok)
for {
select {
case err := <-sub.Err(): // 连接断了 → 返回,外层重连
return true, err
case lg := <-logsCh: // ⑤ 收到推送
l.onLog(lg) // 记下"区块 X 有事件,等确认"
if l.cfg.Confirmations == 0 {
schedule(l.tryAdvance(ctx)) // 本地链不用等,立即入库
} else if !rechecking {
rechecking = true // 只在 recheck 停着时启动它,
recheck.Reset(l.pollInterval) // 已在运行就不重置节奏
}
case <-recheck.C: // ⑥ 检查事件是否已有足够确认
l.drain(logsCh)
schedule(l.tryAdvance(ctx))
case <-checkpoint.C: // ⑦ 空闲时推进进度
l.drain(logsCh) // 先收下已到达、未处理的推送
schedule(l.tryAdvance(ctx))
}
}
}
逐步说明:
- ① ②:建立 WebSocket 长连接,并发出
eth_subscribe。从这一刻起,新事件会被推送到logsCh通道(channel,Go 里在 goroutine 之间传数据的"管道")。 - ③:记下当前最新区块作为信任边界。启动日志里的"区块 X 之后的事件由节点推送"就是这一步打印的。
- ④:从书签处开始,用
eth_getLogs补查到信任边界(首次启动或断线期间的区块)。 - ⑤ ⑥ ⑦:进入循环,同时等三种信号——收到推送、到了"复查确认"的时间、到了"空闲推进"的时间。
select表示"哪个先来就处理哪个"。 schedule:每次同步之后都用同一条规则决定 recheck 开关。这条规则是踩坑之后才统一的,第十四章会讲当初为什么出错。
为什么顺序必须是"先订阅 → 记边界 → 再补查"? 如果先补查再订阅,补查结束到订阅建立之间的那几秒里发生的转账,既不在补查范围内,也不会被推送,就会永久漏掉。先订阅就保证了:边界之后的事件一定会被推送,边界之前的由补查负责。
错误顺序:补查到区块 100 ──(几秒空档:区块 101 的转账没人管)──▶ 订阅建立
正确顺序:订阅建立(边界 = 100,之后的都会推送)──▶ 补查到区块 100
(上图为示意,区块号仅用于说明。)
另外,连接一旦断开,代码会把 trustAfter 恢复成"最大值",意思是"现在没有订阅保障,所有区块都必须真正查询",等重连后再重新设定边界。
7.5 为什么收到推送不直接入库?——确认数与区块重组
// onLog 记录一条推送的事件。这里不直接入库:推送的事件可能还没有足够确认,
// 等确认后统一用 eth_getLogs 按区块范围查询入库,和轮询模式走同一条路径,重组时也不会写入作废的数据。
func (l *Listener) onLog(lg types.Log) {
if lg.Removed {
// 区块重组:节点撤回了之前推送的事件。确认后再查询时它自然不会出现
return
}
l.pending[lg.BlockNumber] = struct{}{}
}
区块重组(reorg):公链上偶尔会出现两个验证者同时出块,网络最终只保留其中一条链,另一条链上的交易会被"撤销"。如果收到推送就立即入库,就可能把作废的交易写进数据库。
打个比方:公告栏上最新贴出的几张告示,偶尔会被管理员撕下来换成另一版;越往前的告示,被撕掉的可能性越小。所以抄写员的策略是:最新的几张先不抄,等它后面又贴了几张再抄。
确认数(Confirmations) 就是"后面又贴了几张"的数量。区块 N 之后每多出一个块,就多一个确认。三条链的取值各有道理:
| 链 | 确认数 | 出块间隔 | 等待时间 | 为什么 |
|---|---|---|---|---|
| anvil | 0 | 按配置 | 0 | 本地只有一个出块者,不会重组 |
| Sepolia | 2 | 约 12 秒 | 约 24 秒 | 兼顾实时性与安全性(学习项目的取舍;生产环境常等到 finalized) |
| Nile | 19 | 3 秒 | 约 1 分钟 | TRON 是 DPoS(委托权益证明),区块得到 27 个超级代表中 19 个(超过 2/3)确认后才不可逆 |
所以推送只当作"通知":等这笔交易之后又出了足够多的块,再用 eth_getLogs 正式查询。那时查到的就是稳定的结果。
代码里的 pending 就是抄写员的"待办清单":只记下"区块 X 有我的事件",而不是事件本身。等区块 X 足够"老"了,再去正式查一遍。
这样设计还有一个好处:订阅模式和轮询模式最终都通过 eth_getLogs 入库,走同一条代码路径,只需要保证这一条路径正确即可。
7.6 advance:把进度推进到"安全区块"
先定义两个词:
- head(最新区块):链上当前最新的区块号。
- safe(安全区块):
head - 确认数。编号不超过 safe 的区块已经有足够确认,可以放心入库。
advance 的任务是:把书签从当前位置推进到 safe。这是 listener 最核心的函数,轮询、订阅、原生币扫描都调用它:
// listener/listener.go(节选:省略了错误处理)
func (l *Listener) advance(ctx context.Context) error {
head := l.client.BlockNumber(ctx) // 链上最新区块
safe := head - l.cfg.Confirmations // 安全区块:只处理已有足够确认的
from := l.nextBlock(safe) // 书签 + 1
// 第 1 段:必须真正查询的区块
// - 轮询模式:全部区块(trustAfter = 最大值)
// - 订阅模式:订阅建立之前的区块 + 收到过推送的区块
logsUpTo := min(safe, max(l.trustAfter, l.lastPendingAtOrBelow(safe)))
for from <= logsUpTo {
to := min(logsUpTo, from+l.batch-1) // 每批最多 BATCH 个区块
l.syncRange(ctx, from, to) // eth_getLogs(原生币则是逐块扫描)+ 入库 + 更新书签
from = to + 1
}
// 第 2 段:订阅保证其中没有事件的区块 → 只更新书签,不花 eth_getLogs
if trustTo := min(safe, head-subscriptionLag); from <= trustTo {
saveProgress(l.db, l.cfg.Name, l.contract, trustTo)
}
// 已达到确认数的待办清空,并通过 SSE 推送最新进度
for b := range l.pending { if b <= safe { delete(l.pending, b) } }
l.publishProgress()
return nil
}
为什么要分两段? 因为要推进的区块里,有的"必须亲自查",有的"可以不查直接跳过":
| 段 | 包含哪些区块 | 怎么处理 | 花不花 eth_getLogs |
|---|---|---|---|
| 第 1 段 | 信任边界之前的区块(订阅不负责)+ 收到过推送、且已达到确认数的区块 | 用 eth_getLogs 真正查询、解析、入库 | 花 |
| 第 2 段 | 其余区块:在信任边界之后、没收到过推送,说明订阅保证了其中没有本合约的事件 | 不查,直接把书签挪过去 | 不花 |
读 logsUpTo 那一行时可以这样理解:第 1 段的终点 = "信任边界"和"最后一个已确认的推送区块"中较大的那个,但不能超过 safe。
- 轮询模式没有订阅,
trustAfter是最大值,所以第 1 段就是全部区块,一个都不能跳。 - 订阅模式下,平时没人转账时第 1 段为空,只有第 2 段在推进书签——这就是订阅模式每 5 分钟只需要一次
eth_blockNumber的原因。
用数轴来理解(订阅模式,某次 checkpoint 时):
区块号 ──────────────────────────────────────────────────────────────▶
书签 trustAfter 推送过事件的区块 safe head
│ 第1段:getLogs │ ● │ │
├──────────────────┤ │ │
│ │ 第2段:订阅保证没事件,直接挪书签 │ │
│ ├────────────────────────────────────────┤ │
└─ 等确认─┘
补充说明图中的 ●:一旦某个推送过事件的区块达到确认数(编号 ≤ safe),lastPendingAtOrBelow 就会返回它,第 1 段会一直延伸到这个区块,保证它被 eth_getLogs 真正查询;还没达到确认数的推送区块先不查,免得为它前面没有事件的区块白白花 eth_getLogs。
subscriptionLag 是什么? 第 2 段的终点不是 safe,而是 min(safe, head - subscriptionLag)。subscriptionLag = 2:推送从节点传到后端有延迟,所以只把"比最新块至少旧 2 块"的区块当作"确认没有事件",避免推送还在路上时书签就越过了它。
类比:管理员的电话可能晚几秒才打过来。抄写员不能因为"刚才没接到电话"就断定最新一页没有公告——他只对"至少两页之前"的内容下这个结论。对 Sepolia(确认数 2)来说两者相同;对 anvil(确认数 0)来说,这个缓冲才真正起作用。
小结(advance):书签的推进分两步——需要查的区块真查(第 1 段),订阅担保过的区块直接跳(第 2 段);而"担保"只对订阅建立之后、并且足够旧的区块成立。
7.7 syncRange + saveLog:解析事件并入库
advance 决定了"查哪些区块",真正干活的是 syncRange(查询并入库一段区块)和 saveLog(解析并保存一条日志)。
// listener/listener.go(节选)
func (l *Listener) syncRange(ctx context.Context, from, to uint64) error {
if l.cfg.Native {
return l.syncNativeRange(ctx, from, to) // 原生币走逐块扫描(第九章)
}
logs := l.client.FilterLogs(ctx, l.filterQuery(from, to)) // eth_getLogs
if l.cfg.RegisteredOnly {
logs = l.filterRegisteredLogs(ctx, logs) // USDT:只留涉及已登记用户的
}
// 建用户 + 写记录 + 更新书签放在同一个数据库事务里:要么全部成功,要么全部回滚
err := l.db.Transaction(func(tx *gorm.DB) error {
for _, lg := range logs {
l.saveLog(ctx, tx, lg, blockTimes) // Transfer → transactions;Swap → swaps
}
return saveProgress(tx, l.cfg.Name, l.contract, to)
})
if err != nil {
l.newEvents = l.newEvents[:0] // 事务回滚,这批数据没有真正入库,不能推送
return err
}
l.commitNewTxs(to) // 事务提交后,才把新记录通过 SSE 推给页面
return nil
}
数据库事务(Transaction):把多次数据库操作打包成一个整体,要么全部生效,要么全部撤销。就像银行转账"扣款"和"入账"必须同时成功。
为什么要放在一个事务里? 如果"交易已入库但书签没更新",重启后会重复处理(靠唯一索引兜底,问题不大);如果"书签已更新但交易没入库",就会永久漏数据。事务保证两者同进同退。
为什么推送要等事务提交之后? 页面收到 tx 事件就会去查 /api/transactions。如果事务还没提交(或者最终回滚了),页面就会查不到,甚至看到一条根本不存在的记录。所以新记录先攒在 newEvents 里,提交成功才发布,回滚就丢弃。
解析一条 Transfer 日志(对照第 2 篇中讲过的真实日志):
// listener/listener.go(节选:省略了错误处理)
func (l *Listener) saveLog(ctx context.Context, tx *gorm.DB, lg types.Log, ...) (int, error) {
if l.cfg.Swap {
return l.saveSwap(ctx, tx, lg, blockTimes) // 兑换合约:见第八章
}
if lg.Removed || len(lg.Topics) != 3 {
return 0, nil
}
// topics[1] = from,topics[2] = to(indexed 参数,左侧补零到 32 字节,取后 20 字节)
from, to := topicAddress(lg.Topics[1]), topicAddress(lg.Topics[2])
// data = value(非 indexed 参数)
value := new(big.Int).SetBytes(lg.Data)
blockTime := l.blockTime(ctx, lg.BlockNumber, ...) // 日志里没有时间,要查区块
return l.saveTransfer(tx, transfer{From: from, To: to, Value: value, TxHash: lg.TxHash.Hex(), ...})
}
// saveTransfer:代币转账和原生币转账共用的入库函数
func (l *Listener) saveTransfer(tx *gorm.DB, t transfer) (int, error) {
fromUser := models.EnsureUser(tx, t.From) // 链上地址 → 链下用户(没有就创建)
toUser := models.EnsureUser(tx, t.To)
record := models.Transaction{ Network: l.cfg.Name, Contract: l.contract, ChainID: l.cfg.ChainID, ... }
// 依靠 (chain_id, tx_hash, log_index) 唯一索引做幂等:重复同步同一区块不会产生重复数据
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&record)
if res.Error == nil && res.RowsAffected > 0 { // 真正新写入的才推送 tx 事件
l.newEvents = append(l.newEvents, events.Event{Type: "tx", Data: events.Tx{...}})
}
return int(res.RowsAffected), res.Error
}
解析过程就是把第 2 篇讲过的日志结构"反过来读":
| 日志字段 | 真实值(2500 MTK 那笔) | 解析后写入 |
|---|---|---|
topics[1] | 0x000…00f78c9972…df9f74bf2 | from_addr = 0xf78c…4bf2 → 勇敢的鹦鹉#3717 |
topics[2] | 0x000…00455bddc5…9993d6a0 | to_addr = 0x455b…d6a0 → 迷糊的水獭#7322 |
data | 0x…878678326eac900000 | amount = "2500000000000000000000" |
logIndex | 0x3f | log_index = 63 |
本章小结:
- 订阅负责"及时知道哪里有事件",
eth_getLogs负责"正式、可靠地拿到事件"——推送只是通知,入库一律走查询。 - 信任边界之后的区块靠订阅担保,之前的靠补查;顺序必须是"先订阅、再补查"。
- 确认数让后端只处理足够"老"的区块,躲开区块重组;三条链按各自的共识机制取 0 / 2 / 19。
- advance 两段式:需要查的真查,订阅担保过的直接挪书签,
subscriptionLag给推送延迟留出余量。 - 事务 + 唯一索引:书签和数据同进同退,重复处理也不会产生重复数据;事务提交后才推送,页面收到的事件一定对应已入库的数据。
八、★ 兑换事件同步:从 Swap 事件到"我的兑换记录"
这一章要解决的问题:用户在 MiniSwap 上兑换之后,页面上的"我的兑换记录"是怎么来的?
8.1 为什么需要一个专门的 Swap listener
回顾第 2 篇:MiniSwap 每完成一次兑换,就发出一条事件:
event Swap(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut);
看一笔"卖 0.2 ETH 买 USDT"的兑换,后端各个 listener 分别能看到什么:
| 资金流向 | 本质 | 谁能看到 |
|---|---|---|
| 用户 → MiniSwap:0.2 ETH | 交易本身的 value | 原生币扫描器(to 是合约,from 是已登记用户) |
| MiniSwap → 用户:145.491046 USDT | 合约调用 USDT 的 transfer,产生 Transfer 事件 | USDT listener |
| 合在一起:"用 0.2 ETH 换了 145.49 USDT" | Swap 事件 | 只有 Swap listener |
反过来,如果是"卖 USDT 买 ETH",MiniSwap 转给用户的 ETH 是合约内部转账,不在区块的交易列表里,原生币扫描器根本看不到。所以兑换必须以 Swap 事件为准,单独抄进 swaps 表。
实现上,Swap listener 和代币 listener 是同一个 Listener 结构体,只是配置里 Swap = true:订阅、确认数、两段式推进、限速、重连全部复用,区别只在三处:
eventTopic()返回swapTopic而不是transferTopic;saveLog转给saveSwap,写 swaps 表;- 发布的 SSE 事件类型是
swap而不是tx。
8.2 事件解码:3 个 indexed + 2 个非 indexed
Swap 事件有 3 个 indexed 参数,加上 topics[0] 的事件签名,一共 4 个 topic;两个 uint256 按 ABI 编码依次放在 data 里,每个 32 字节,一共 64 字节:
topics[0] = keccak256("Swap(address,address,address,uint256,uint256)")
topics[1] = user (左侧补零到 32 字节)
topics[2] = tokenIn
topics[3] = tokenOut
data = amountIn(前 32 字节) + amountOut(后 32 字节)
// listener/listener.go(节选)
func (l *Listener) saveSwap(ctx context.Context, tx *gorm.DB, lg types.Log, blockTimes map[uint64]time.Time) (int, error) {
// 形状不对的日志直接跳过:防御"同名但参数不同"的事件
if lg.Removed || len(lg.Topics) != 4 || len(lg.Data) != 64 {
return 0, nil
}
user := topicAddress(lg.Topics[1])
blockTime, err := l.blockTime(ctx, lg.BlockNumber, blockTimes)
...
u, err := models.EnsureUser(tx, user)
...
record := models.Swap{
Network: l.cfg.Name, Contract: l.contract, ChainID: l.cfg.ChainID,
TxHash: lg.TxHash.Hex(), LogIndex: lg.Index,
UserID: u.ID, UserAddr: user,
TokenIn: assetAddress(lg.Topics[2]), TokenOut: assetAddress(lg.Topics[3]),
AmountIn: new(big.Int).SetBytes(lg.Data[:32]).String(),
AmountOut: new(big.Int).SetBytes(lg.Data[32:]).String(),
BlockNumber: lg.BlockNumber, BlockTime: blockTime,
}
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&record)
if res.Error == nil && res.RowsAffected > 0 {
l.newEvents = append(l.newEvents, events.Event{Type: "swap", Data: events.SwapTx{
Network: l.cfg.Name, TxHash: record.TxHash, User: user, TokenIn: record.TokenIn, TokenOut: record.TokenOut,
}})
}
return int(res.RowsAffected), res.Error
}
注意 Swap listener 的范围是"全部兑换"(日志里的 范围=全部兑换),不做 RegisteredOnly 过滤:MiniSwap 是我们自己部署的合约,兑换量不大,全量同步就是"链上账本的完整副本"。
8.3 原生币用 0xeeee 占位
合约里原生币用 address(0) 表示(第 2 篇),但后端和前端都用 0xeeee…eeee 表示原生币。解码时统一转换:
// assetAddress 把事件里的资产地址转成库里的形式:合约用 address(0) 表示原生币,库里统一记为 0xeeee…
func assetAddress(t common.Hash) string {
a := topicAddress(t)
if a == topicAddress(common.Hash{}) {
return strings.ToLower(config.NativeAddress.Hex())
}
return a
}
为什么不直接用 address(0)? 因为 0 地址在 transactions 表里已经有含义了——它是"铸造"的 from(EnsureUser 遇到零地址返回 nil,不建用户)。用 0xeeee… 和 transactions.contract 里原生币的占位保持一致,前端按资产地址查精度、符号时也只需要一套映射。
8.4 入库唯一键与金额精度
swaps 表同样以 (chain_id, tx_hash, log_index) 为唯一键(uk_swap_chain_tx_log),配合 ON CONFLICT DO NOTHING:重启补查、重连重扫同一区块都不会写出重复的兑换记录。
金额以最小单位的字符串保存。同一笔兑换两边精度可能不同:USDT 是 6 位小数,ETH / MTK 是 18 位,TRX 是 6 位。所以 /api/swaps 直接返回原始值,由前端按资产精度换算(与 /api/transactions 由后端换算 amountDisplay 不同,这里前端本来就要按资产配置显示符号和图标,一并换算更自然)。
一条真实记录(GET /api/swaps 返回,部署账户卖 6 USDT 买 ETH):
{
"id": 1,
"network": "sepolia",
"contract": "0x65691a568b9ea2075325ffc4433c0f4f4c9eeafb",
"chainId": 11155111,
"txHash": "0xaa8f8c9f19198a097f69192af7bef6c5c4b231d8320bc6038c0c194b28196d2b",
"logIndex": 32,
"user": "0x83da6626ef0721da9d452a7eedc4f291a3f9d88c",
"tokenIn": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0",
"tokenOut": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"amountIn": "6000000",
"amountOut": "5911277476273859",
"blockNumber": 11811953,
"blockTime": "2026-09-30T11:17:24+08:00",
"createdAt": "2026-09-30T11:17:51.309+08:00"
}
读法:tokenIn 是 Sepolia 的 USDT,amountIn = 6000000 ÷ 10^6 = 6 USDT;tokenOut 是 0xeeee…,即 ETH,amountOut = 5911277476273859 ÷ 10^18 ≈ 0.005911 ETH。blockTime(上链时间)和 createdAt(入库时间)相差 27 秒,正好是"等 2 个确认(约 24 秒)+ recheck 的 12 秒节奏"。
8.5 推送 swap 事件给前端
入库事务提交后,Hub 广播一条 swap 事件,只带页面判断"要不要刷新"所需的字段:
event: swap
data: {"network":"sepolia","txHash":"0xaa8f…6d2b","user":"0x83da…d88c","tokenIn":"0xaa8e…33d0","tokenOut":"0xeeee…eeee"}
(示意:字段来自 events.SwapTx,哈希与地址已缩写。)
前端兑换面板在用户发出兑换后,会先插入一条"确认中"的乐观记录;收到 user 是自己的 swap 事件后,重新拉 /api/swaps,真实记录替换掉占位记录(前端细节见第 3 篇)。
完整链路:
MiniSwap.swap() ─emit Swap─▶ 区块 N
│ WebSocket 推送(Nile 为 12 秒轮询)
▼
Swap listener:pending[N] ─等确认(Sepolia 2 块 / Nile 19 块)─▶ eth_getLogs(MiniSwap, swapTopic)
│ saveSwap:解码 → 0x0 转 0xeeee → EnsureUser → INSERT swaps(唯一键幂等)
▼ 事务提交
Hub.Publish(swap) ─SSE─▶ 页面 ─GET /api/swaps─▶ "我的兑换记录"
小结:兑换记录 = Swap 事件的抄本。它复用了代币 listener 的全部可靠性设计,只换了事件签名、解码方式和目标表;用 Swap 事件而不是拼凑 Transfer,是因为合约内部转出的原生币只有事件里才有完整记录。
九、★ 原生币扫描器与追块优化
这一章要解决的问题:ETH / TRX 转账没有事件,后端怎么发现它们?落后几千个区块时,怎样又快又不漏地追上?
9.1 为什么原生币只能扫块
代币转账和原生币转账在链上的样子完全不同:
| 对比 | 代币转账(MTK / USDT) | 原生币转账(ETH / TRX) |
|---|---|---|
| 本质 | 调用合约的 transfer,合约改账本并 emit 事件 | 交易本身的 value 字段,不执行任何合约代码 |
| 有没有日志 | 有 Transfer 事件 | 没有 |
| 怎么查 | eth_getLogs(按合约 + 事件签名,一次查上千块) | 只能逐块 eth_getBlockByNumber(n, true) 读完整交易列表 |
| 能不能订阅 | 能(eth_subscribe logs) | 不能 |
打个比方:代币转账像"在公告栏贴了公告",抄写员可以只看公告;原生币转账像"两个人当面交现金",没有任何公告,抄写员只能一页一页翻完整的流水账。
所以原生币扫描器每处理一个区块,就要发一次请求。Sepolia 12 秒一个块,每天约 7200 次;Nile 3 秒一个块,每天约 28800 次(数字来自 .env.example 注释)。为了控制成本和数据量,它有几个特别的设计:
- 只记录已登记用户:
RegisteredOnly强制为 true(第五章)。 - 首次启动不补历史:没有书签时直接从当前安全区块开始(从 START_BLOCK 补历史意味着每个区块一次请求,代价太大)。
- 单独的轮询间隔:
NATIVE_POLL_INTERVAL默认 60 秒,每轮一次处理多个区块,省下大量eth_blockNumber,代价是入库最多慢一个间隔。 - 看不到内部转账:合约转出的 ETH 不在交易列表里——这正是第八章需要 Swap 事件的原因。
9.2 筛选:金额 → 用户 → 回执
// listener/native.go(节选)
func (l *Listener) nativeTransfers(ctx context.Context, blocks []*nativeBlock) ([]transfer, error) {
// 1. 先按金额筛:绝大多数交易是合约调用,value 为 0
for _, b := range blocks {
for _, t := range b.Transactions {
if t.To == nil || t.Value == nil || t.Value.ToInt().Sign() == 0 {
continue
}
candidates = append(candidates, transfer{..., LogIndex: nativeLogIndex})
}
}
// 2. 再按用户筛:一次查询拿到这批地址中在本链登记过的用户
users := l.registeredSet(ctx, addrs)
// 3. 最后查回执:转给合约的 ETH 可能因合约 revert 而退回,status=0 的交易并没有真正转账
for _, t := range candidates {
if 不涉及已登记用户 { continue }
receipt := l.client.TransactionReceipt(ctx, common.HexToHash(t.TxHash))
if receipt.Status != types.ReceiptStatusSuccessful { continue }
if l.cfg.TronAPI != "" && !l.isTronTRXTransfer(ctx, t.TxHash) {
continue // TRON:TRC-10 转账等其他类型,不是 TRX(见第十章)
}
out = append(out, t)
}
return out, nil
}
筛选顺序是"便宜的先做":金额判断不花任何请求;用户判断是一次数据库查询;回执是一次 RPC——只有前两关都通过的少数交易才需要查回执。
9.3 追块优化:从逐块串行到批内并发
最早的实现是逐块串行读取:读完区块 N 再读 N+1。读区块的耗时几乎全是网络往返(TronGrid 实测每次约 200ms),所以串行最多每秒 5 块,实测约 4 块/秒。后端停机一段时间后,Nile 每分钟新增 20 个块,落后几千块时要追很久。
现在的做法:每批 20 块,批内 5 个并发读取。
// listener/native.go(节选)
const nativeBatchSize = 20 // 每读取多少个区块提交一次事务、推进一次进度
const nativeFetchConcurrency = 5 // 同时读取多少个区块
// fetchBlocks 并发读取 [from, to] 的完整区块,结果按区块号顺序返回。
// 任意一个区块读取失败,整批作废(进度不推进),下一轮从这批的起点重来,保证不漏块。
func (l *Listener) fetchBlocks(ctx context.Context, from, to uint64) ([]*nativeBlock, error) {
blocks := make([]*nativeBlock, to-from+1)
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(nativeFetchConcurrency)
for n := from; n <= to; n++ {
g.Go(func() error {
b, err := callRPC(gctx, func(ctx context.Context) (*nativeBlock, error) {
var b *nativeBlock
err := l.client.Client().CallContext(ctx, &b, "eth_getBlockByNumber", hexutil.EncodeUint64(n), true)
...
})
if err != nil {
return fmt.Errorf("读取区块 %d 失败: %w", n, err)
}
blocks[n-from] = b // 每个 goroutine 只写自己的下标,不需要加锁
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return blocks, nil
}
syncNativeRange 外层按 20 块一批循环:读块(并发)→ 筛选 → 同一事务里写交易 + 推进书签 → 下一批。
原理:为什么并发能提速? 每次读块的 200ms 里,后端几乎什么也不做,只是在等网络。串行时同一时刻只有一个请求"在路上";5 并发时同时有 5 个在路上,理论上限是 5 × 5 = 25 块/秒。实测约 21 块/秒,比串行快 5 倍左右。达不到理论上限的原因(分析,未单独测量):每批 20 块要等最慢的那个请求返回才能进入下一批(批与批之间有"屏障"),加上每批一次数据库事务,以及少数候选交易的回执查询是串行的。
为什么不会漏块? 这是并发最容易出错的地方,代码靠三点保证:
- 结果按下标存放:
blocks[n-from] = b,不管哪个请求先返回,最终顺序都和区块号一致。 - 整批失败不推进进度:
errgroup里任何一个区块读取失败,g.Wait()返回错误,整批作废,saveProgress根本不会执行;下一轮从这批的起点重来。已写入的前几批不受影响(它们各自的事务已提交)。 - 重来也不怕重复:重扫已处理过的区块时,唯一索引让重复写入变成无害操作。
打个比方:抄写员请了 5 个助手同时翻 20 页流水账。只要有一个助手没翻完,这 20 页就算"没翻",书签不动;全部翻完、抄完,才把书签挪到第 20 页之后。
为什么没用 JSON-RPC 批量请求(一个 HTTP 请求里塞多个调用)? 源码注释给出了原因:实测 Infura 在限流时会让批量响应缺少部分结果,逐个请求出错时更容易定位和重试。
并发会不会触发 429? 不会失控。配置了 <NAME>_RPC_RATE 的链,所有 HTTP 请求都要先从令牌桶取令牌(第十三章),5 个 goroutine 同时发请求也只是在桶前排队,不会超过每秒额度。Sepolia 配置了 400 credits/秒,一次 eth_getBlockByNumber 按 80 计,也就是每秒最多约 5 次读块——Sepolia 的追块速度实际上受限于额度而不是并发数;Nile 的示例配置没有设置 NILE_RPC_RATE(0 = 不限速),并发优化主要在这里见效。
落后较多时(一次要补扫 100 块以上)会打印开始 / 结束日志,便于观察追块速度(格式示意):
[nile/原生币] 落后 1200 个区块,开始补扫 71404000→71405199
[nile/原生币] 补扫到区块 71405199,用时 57s
(区块号与用时为示意,按 21 块/秒估算。)
小结:原生币没有事件,只能逐块扫描;用"金额 → 用户 → 回执"三道筛子控制成本;追块时批内并发把速度从约 4 块/秒提到约 21 块/秒,而"整批成功才推进进度"保证了并发不会漏块。
十、TRON 适配:同一套 listener 跑在 Nile 上
这一章要解决的问题:TRON 不是以太坊,为什么后端几乎不用改代码就能同步 Nile?不一样的地方又是怎么处理的?
10.1 TronGrid 的 /jsonrpc:只读、够用
TRON 提供了一个兼容以太坊的 JSON-RPC 端点(https://nile.trongrid.io/jsonrpc)。eth_chainId、eth_blockNumber、eth_getLogs、eth_getBlockByNumber、eth_getTransactionReceipt 都能用,所以 go-ethereum 的 ethclient 可以直接连上它,整套 listener 原样复用。
但它有两个限制:
| 限制 | 影响 | 后端的处理 |
|---|---|---|
| 只读:不支持发交易 | 前端要用 TronLink / tronWeb 发交易;部署合约也不能用 forge | 后端本来就只读,不受影响;部署另写工具(10.5) |
| 不支持 WebSocket 订阅 | 不能 eth_subscribe | 不配 NILE_WS_URL,自动退回 12 秒 HTTP 轮询 |
还有一个细节:TRON 返回的区块里 stateRoot 是 "0x"(空),go-ethereum 的 HeaderByNumber 解析时会报 hex string has length 0。所以取区块时间时不用 HeaderByNumber,而是直接发 eth_getBlockByNumber,只解析 timestamp 字段:
// listener/listener.go(节选)
type blockTimestamp struct {
Timestamp hexutil.Uint64 `json:"timestamp"`
}
err := l.client.Client().CallContext(ctx, &b, "eth_getBlockByNumber", hexutil.EncodeUint64(number), false)
这个写法以太坊和 TRON 都适用。
10.2 地址:T 开头的 Base58 与 0x 形式
TRON 的地址长这样:TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf(Nile 测试 USDT)。它和以太坊地址其实是同一个 20 字节,只是编码方式不同:
TRON Base58 地址 = Base58( 0x41 + 20 字节地址 + 校验和 )
校验和 = sha256(sha256(0x41 + 20 字节)) 的前 4 字节
cmd/deploy-nile/tron.go 里的 tronBase58 就是按这个公式实现的。JSON-RPC 与数据库统一使用去掉 41 前缀的 0x 形式,例如上面的 USDT 在 .env 里写成 0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f。所以后端完全不需要处理 Base58,users.address、transactions.from_addr 都是 0x 形式;转换发生在前端展示时(/api/users/counterparties 返回的地址也是 0x 小写,由前端按网络转成 T… 形式)。
10.3 TRX 与 TRC-10:JSON-RPC 分不清,要问原生 API
这是 TRON 适配里最隐蔽的坑。TRON 除了 TRX 和 TRC-20(合约代币,相当于 ERC-20)之外,还有一种系统级代币 TRC-10,它不需要合约,转账也不产生事件。问题在于:
TRON 的 JSON-RPC 把 TRX 转账(
TransferContract)和 TRC-10 转账(TransferAssetContract)转换成了完全相同的格式:value = 金额、input = 0x,回执也一样。
如果只看 JSON-RPC,有人给你转 100 个某 TRC-10 代币,会被误记成 100 TRX。所以原生币扫描器在 TRON 上多了一步:用 TRON 原生 HTTP API 查交易类型。
// listener/native.go(节选)
// isTronTRXTransfer 用 TRON 原生 HTTP API 查询交易类型,只有 TransferContract 才是 TRX 转账。
func (l *Listener) isTronTRXTransfer(ctx context.Context, txHash string) (bool, error) {
body, _ := json.Marshal(map[string]string{"value": strings.TrimPrefix(txHash, "0x")})
// POST <TronAPI>/wallet/gettransactionbyid
...
c := tx.RawData.Contract
return len(c) == 1 && c[0].Type == "TransferContract", nil
}
TronAPI 默认由 NILE_RPC_URL 去掉 /jsonrpc 得到(https://nile.trongrid.io),也可以用 NILE_TRON_API 单独配置。这一步只对"金额 > 0、涉及已登记用户、执行成功"的少数候选交易执行,不会显著增加请求量。
10.4 确认数 19:DPoS 的"不可逆"
以太坊 PoS 的最终确定(finalized)需要约两个 epoch(十几分钟);TRON 用的是 DPoS(委托权益证明):27 个超级代表(Super Representative)轮流出块,一个区块被 2/3 以上(19 个)超级代表确认后就不可逆(TRON 称为 solidified)。出块间隔 3 秒,19 个块约 1 分钟。所以 Nile 的默认确认数是 19。
对用户来说,这意味着 Nile 上的转账、兑换大约 1 分钟后才会出现在记录里(再加上 12 秒轮询的间隔);前端的 waitIndexed 兜底等待时间也因此设为 3 分钟。
10.5 部署工具 cmd/deploy-nile
后端目录里还有一个独立的命令行工具,它不属于常驻服务,只在部署合约时手动运行:
make deploy-nile # = go run ./cmd/deploy-nile 部署 MyToken
make deploy-swap-nile # = go run ./cmd/deploy-nile swap 部署 MiniSwap 并注入三个池子的流动性
为什么不用 forge script? Foundry 通过 eth_sendRawTransaction 广播以太坊格式的交易,而 TRON 的交易结构(protobuf)、签名对象、广播接口都不同,TronGrid 的 /jsonrpc 也不支持发交易。所以它走 TRON 原生 HTTP API,流程和以太坊一一对应:
/wallet/deploycontract或/wallet/triggersmartcontract:由节点构造一笔未签名交易,返回 txID;- 本地用私钥对 txID 签名(txID = sha256(raw_data),签名算法与以太坊相同,都是 secp256k1);
/wallet/broadcasttransaction:广播;/wallet/gettransactioninfobyid:轮询到交易被打包,拿到执行结果和合约地址。
私钥通过环境变量 TRON_PRIVATE_KEY 传入,make 目标会在终端里提示输入(不回显、不落盘),并且只应使用测试账户。swap 子命令可以重复执行:已部署、已授权、已有流动性的步骤会自动跳过——TRON 上每一步都要燃烧 TRX,中途失败补足余额后重跑即可,已完成的步骤不会重复花钱。部署结果写入前端的 deployments.json,再按提示把地址填进 backend/.env 的 NILE_CONTRACT / NILE_SWAP。
小结:TRON 适配的核心是"能复用的都复用"——JSON-RPC 兼容层让 listener 原样运行;不兼容的地方逐个打补丁:没有 WS 就轮询,stateRoot 为空就只取时间戳,TRX 与 TRC-10 分不清就问原生 API,DPoS 就用 19 个确认,发交易就走原生 HTTP API。
十一、★ 链上转账:后端的 8 个步骤
这一章要解决的问题:把前面的零件串起来,跟着一笔真实转账走一遍后端。
先回顾一次转账的全流程。编号全系列统一:①~⑧ 是前端和钱包签名、广播,⑪~⑭ 是合约执行,⑮ 是前端拿到回执,⑯~⑲ 是本章的主角:后端接力,⑳~㉒ 是后端通知前端、前端查询并展示记录。
sequenceDiagram
autonumber
actor A as 用户A(发送方)
participant FE as Vue前端 + ethers
participant MM as MetaMask
participant RPC as RPC节点
participant Chain as 区块链网络(mempool / 出块)
participant EVM as EVM:MyToken.sol
participant BE as Go listener
participant DB as MySQL
A->>FE: 填写对方地址 B、金额,点击“转账”
FE->>FE: ABI 编码 calldata = 0xa9059cbb + B + 金额
FE->>RPC: eth_estimateGas / 获取 nonce 和 Gas 价格
FE->>MM: 请求签名,交易的 to = 合约地址(不是 B)
MM->>A: 弹窗:显示 Gas 费并请求确认
A->>MM: 确认(私钥在本地签名,不外传)
MM->>RPC: eth_sendRawTransaction(已签名的交易)
RPC-->>FE: 立即返回 txHash(状态:已广播)
RPC->>Chain: 校验签名、nonce 和 ETH 余额,放入 mempool 并广播
Chain->>EVM: 验证者把交易打包进区块,每个节点都执行一遍
EVM->>EVM: 按函数选择器路由到 transfer,msg.sender = A
EVM->>EVM: 检查余额,不足则 revert(状态回滚,Gas 照扣)
EVM->>EVM: SSTORE:_balances[A] 减少,_balances[B] 增加
EVM->>EVM: emit Transfer(A, B, 金额),写入交易回执的日志
Chain-->>FE: tx.wait() 拿到回执 status = 1(状态:已上链),刷新双方余额
RPC-->>BE: WebSocket 推送 Transfer 事件(eth_subscribe logs;Nile 等无 WS 的链改为定时轮询)
BE->>RPC: 等够确认数后 eth_getLogs(合约地址, Transfer topic)
RPC-->>BE: 返回日志:topics = [事件签名, A, B],data = 金额
BE->>DB: 解码后 INSERT transactions(按 chain_id + tx_hash + log_index 幂等),与书签同一事务
BE-->>FE: 事务提交后,SSE 推送 tx 事件(/api/events)
FE->>BE: GET /api/transactions?address=A(以及 B)
BE-->>FE: 返回带昵称的记录,列表显示这笔转账
接着第 3 篇前端"交易已上链"(⑮)之后,看后端如何接力。以 Sepolia、确认数 2 为例:
步骤 1:收到节点推送
交易被打包进区块 N 的那一刻,Infura 通过 WebSocket 推送这条 Transfer 日志,后端打印(示意):
[sepolia/MTK] 收到推送:区块 11786846,交易 0x80e152bb…,等待 2 个确认后入库
pending 记下 11786846;recheck 如果停着,就启动它。
步骤 2:等待确认
recheck 每 12 秒调用一次 advance:eth_blockNumber 得到 head,计算 safe = head - 2。在 safe < 11786846 之前什么也不做;因为 pending 不为空,schedule 会让 recheck 一直跑下去。
步骤 3:确认达成,查询日志
大约 24 秒后,safe >= 11786846。advance 发现有推送过的区块已经安全,于是对 [书签+1, 11786846] 执行 eth_getLogs,拿到正式的日志。
步骤 4:解析日志
saveLog 从 topics 和 data 中解出 from、to、value;再用 eth_getBlockByNumber 查出区块时间。
步骤 5:地址映射成用户
EnsureUser 把两个地址映射成 users 表的记录。收款人第一次出现时,会自动创建并分配一个随机昵称(但不会登记到 user_networks)。
步骤 6:写入交易 + 更新书签(同一事务)
后端打印(示意,起始区块取决于当时的书签位置):
[sepolia/MTK] 同步区块 11786800→11786846,事件 1 条(涉及已登记用户 1 条),新写入 1 条
pending 里的 11786846 被清掉;下一次 schedule 发现没有待确认事件,recheck 停下。
步骤 7:SSE 通知前端
事务提交后,commitNewTxs 把攒着的 tx 事件交给 Hub,SSE 推给所有在线页面:
event: tx
data: {"network":"sepolia","contract":"0xcbee…2447","txHash":"0x80e152bb…","from":"0xf78c…4bf2","to":"0x455b…d6a0"}
(示意:字段来自 events.Tx。)
前端的 waitIndexed 收到 txHash 相同的 tx 事件,进度条亮起"后端已入库"。兜底:推送断开或事件被丢弃时,它每 10 秒查一次 /api/transactions,最多等 3 分钟。(前端这一侧的代码见第 3 篇。)
步骤 8:接口返回(带昵称的记录)
{
"code": 200,
"msg": "ok",
"data": [
{
"network": "sepolia",
"txHash": "0x80e152bb…",
"logIndex": 63,
"blockNumber": 11786846,
"from": "0xf78c997295ae31e35a4e9debaa773d1df9f74bf2",
"fromNickname": "勇敢的鹦鹉#3717",
"to": "0x455bddc544682651f75af54a0c2124289993d6a0",
"toNickname": "迷糊的水獭#7322",
"amount": "2500000000000000000000",
"amountDisplay": "2500"
}
]
}
时间线汇总
T+0s 用户在 MetaMask 确认,交易广播
T+12s 交易进入区块 N → 前端:打包上链;后端:收到推送(步骤 1)
T+24s 区块 N+1
T+36s 区块 N+2,safe = N → 后端:recheck 发现已确认,查询、解析、入库(步骤 2~6)
T+36s SSE 推送 tx 事件 → 前端:后端已入库,刷新列表(步骤 7~8)
(示意:recheck 按 12 秒节奏检查,实际入库时刻会在 safe 达成后的 0~12 秒内。第八章那条真实兑换记录从上链到入库用了 27 秒,与此吻合。)
三条链的对比:
| 链 | 发现方式 | 确认等待 | 从上链到入库大约 |
|---|---|---|---|
| anvil | WebSocket 推送 | 0 | 几乎立即 |
| Sepolia | WebSocket 推送 | 2 块(约 24 秒) | 半分钟左右 |
| Nile | 12 秒 HTTP 轮询 | 19 块(约 1 分钟) | 1 分钟多 |
| 原生币 | 60 秒轮询逐块扫描 | 同所在链 | 再多最多 1 分钟 |
小结:从用户看来,"转账成功"和"列表里出现记录"之间有几十秒的间隔,这段时间后端在等确认——这是用一点延迟换取数据可靠。有了 SSE,入库的那一刻页面就能收到通知,不必再靠前端高频轮询。
十二、接口层:路由、统一返回结构与 SSE 推送
这一章要解决的问题:数据进了数据库之后,前端怎样把它查出来;有新数据时,前端怎么第一时间知道。
12.1 路由
// routers/api.go
func ApiInit(router *gin.Engine, listeners []*listener.Listener, hub *events.Hub) {
router.GET("/healthz", ...) // K8s 健康检查
apiRoutes := router.Group("/api")
{
apiRoutes.POST("/users", api.UserController{}.Create)
apiRoutes.GET("/users/counterparties", api.UserController{}.Counterparties)
apiRoutes.GET("/networks", api.NetworkController{Listeners: listeners}.Index)
apiRoutes.GET("/transactions", api.TransactionController{Listeners: listeners}.Index)
apiRoutes.GET("/swaps", api.SwapController{}.Index)
// SSE 长连接:推送同步进度与新交易,页面据此刷新,替代定时轮询
apiRoutes.GET("/events", api.EventController{Hub: hub}.Stream)
}
}
| 接口 | 作用 |
|---|---|
POST /api/users | 创建用户、分配昵称;带 network 时同时登记到这条链(连接钱包时调用) |
GET /api/users/counterparties?network=&contract=&exclude= | 某条链、某些资产合约里出现过的用户(排除自己),供"对方地址"下拉框,按最近交易倒序 |
GET /api/networks | 每个 listener 的同步进度 |
GET /api/transactions?address=&network=&contract= | 某地址的转账记录(转出 + 转入) |
GET /api/swaps?address=&network= | 某地址的兑换记录 |
GET /api/events | SSE 长连接:progress / tx / swap / ready |
12.2 统一返回结构
// controllers/types.go
type Response[T any] struct {
Code int `json:"code"` // 与 HTTP 状态码一致,成功为 200
Msg string `json:"msg"`
Data T `json:"data"`
}
func Success[T any](c *gin.Context, data T) {
c.JSON(http.StatusOK, Response[T]{Code: http.StatusOK, Msg: "ok", Data: data})
}
func Fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response[any]{Code: status, Msg: msg, Data: nil})
}
所有接口(SSE 除外)都返回 {code, msg, data} 这一种形状,前端只需要一套处理逻辑(见第 3 篇"统一请求封装")。列表接口没有数据时返回 [] 而不是 null——这条规矩是踩坑换来的,见第十四章。
12.3 交易记录查询
// controllers/api/transactionController.go(节选)
func (con TransactionController) Index(c *gin.Context) {
address := strings.ToLower(c.Query("address"))
...
// 一对多关联查询:该用户转出的 + 转入的;Preload 一次性带出双方用户(昵称)
q := models.DB.Preload("FromUser").Preload("ToUser").
Where("(from_user_id = ? OR to_user_id = ?)", user.ID, user.ID)
if network := c.Query("network"); network != "" {
q = q.Where("network = ?", network)
}
// contract 可传多个(逗号分隔):重新部署后,旧合约的记录不会混进来
if raw := c.Query("contract"); raw != "" {
q = q.Where("contract IN ?", parseContracts(raw))
}
q.Order("block_number DESC, log_index DESC").Limit(limit).Find(&txs)
...
controllers.Success(c, out)
}
排序用 block_number DESC, log_index DESC:区块号越大越新;同一区块内,按事件序号排。这比按"入库时间"排序更准确,因为补查时入库顺序不等于链上发生的顺序。/api/swaps 用的是同样的排序。
金额换算用 decimal,而不是 float64,而且精度按资产区分:
// float64 只有约 15~17 位有效数字,1e24 级别的 uint256 会丢精度
func formatAmount(raw string, decimals int32) string {
v, ok := new(big.Int).SetString(raw, 10)
if !ok {
return raw
}
return decimal.NewFromBigInt(v, -decimals).String() // MTK: "2500000000000000000000" → "2500"
}
精度 decimals 来自对应的 listener:它连上节点后调用合约的 decimals() 读出来(USDT 是 6,MTK 是 18,原生币没有合约可读:ETH 按 18 位,TRON 系的链按 tronNativeDecimals = 6 位(1 TRX = 10^6 sun)。早期版本原生币一律按 18 位处理,TRX 记录的 amountDisplay 会差 10^12 倍,已修复。前端交易列表用的是原始值 amount 按自己的资产配置换算(TxList 里的 fromUnits),所以即使在修复前页面显示也是正确的)。这与第 3 篇前端用 bigint 处理金额是同一个道理。
12.4 SSE:让后端主动"按门铃"
SSE(Server-Sent Events,服务器发送事件) 是一个"不结束的 HTTP 响应":服务器按固定格式一条条往外写,浏览器用原生的 EventSource 接收,断线会自动重连。
为什么用 SSE 而不是 WebSocket? 这里只需要"服务端 → 浏览器"单向推送。SSE 不需要额外依赖,也不用自己实现心跳协议和断线重连(源码注释原话的意思)。
整条链路分三段:
listener ──Publish──▶ events.Hub(广播中心)──channel──▶ EventController.Stream ──SSE──▶ 浏览器 EventSource
四种事件:
| event | 何时发 | data 字段 | 页面怎么用 |
|---|---|---|---|
ready | 连接建立时立即发一条 | {} | 确认推送可用,拉一次完整数据作基准 |
progress | listener 的同步进度变化时(与上次相同则不发) | network、contract、lastBlock、latestBlock | 更新"已同步至区块" |
tx | 新转账入库、事务提交后 | network、contract、txHash、from、to | 与自己相关就刷新列表 / 完成进度条 |
swap | 新兑换入库、事务提交后 | network、txHash、user、tokenIn、tokenOut | 刷新"我的兑换记录" |
Hub 的核心只有几十行:
// events/hub.go(节选)
const subscriberBuffer = 64 // 每个订阅者最多积压多少条事件
// Publish 把事件发给所有订阅者;某个订阅者积压已满时跳过它,不阻塞发布方。
func (h *Hub) Publish(e Event) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs {
select {
case ch <- e:
default: // 这个页面太慢,丢掉这条,绝不能让 listener 卡住
}
}
}
丢事件不会有问题吗? 不会出错,最多晚一点。设计上"事件只是提醒页面该刷新了,不是数据的唯一来源":页面连上(或重连)时先收到 ready、拉一次完整数据;之后事件只触发增量刷新。真正的数据永远来自 /api/transactions、/api/swaps。
SSE 接口本身还有三个细节:
// controllers/api/eventController.go(节选)
h.Set("Content-Type", "text/event-stream")
h.Set("X-Accel-Buffering", "no") // 告诉 nginx 不要缓冲,否则事件攒在代理里页面迟迟收不到
write(c, "ready", map[string]any{}) // 连上立即发 ready
keepalive := time.NewTicker(25 * time.Second) // 每 25 秒发一行 ": keepalive" 注释,
// nginx / Ingress 默认 60 秒没数据就断开
前端全页面共用一条 EventSource(hooks/useBackendEvents.ts);推送不可用时(例如旧版后端、代理不支持),各订阅者退回 15 秒轮询。
12.5 接口一览(真实返回)
$ curl -s -X POST localhost:8080/api/users -d '{"address":"0x83da6626ef0721da9d452a7eedc4f291a3f9d88c"}'
{"code":200,"msg":"ok","data":{"id":98,"address":"0x83da…d88c","nickname":"合约持有人","createdAt":"…"}}
$ curl -s -X POST localhost:8080/api/users -d '{"address":"abc"}'
{"code":400,"msg":"address 不是合法的以太坊地址","data":null}
$ curl -s localhost:8080/api/networks # 2026-09-30 实测,每个 listener 一项(节选)
{"code":200,"msg":"ok","data":[
{"network":"anvil","chainId":31337,"contract":"0xdc64…f6c9","lastBlock":95789,"latestBlock":95791},
{"network":"sepolia","chainId":11155111,"contract":"0xcbee…2447","lastBlock":11812341,"latestBlock":11812343},
{"network":"sepolia","chainId":11155111,"contract":"0x6569…eafb","lastBlock":11812341,"latestBlock":11812343},
{"network":"sepolia","chainId":11155111,"contract":"0xeeee…eeee","lastBlock":11812339,"latestBlock":11812341},
{"network":"nile","chainId":3448148188,"contract":"0xd666…11b1","lastBlock":71405267,"latestBlock":71405286},
{"network":"nile","chainId":3448148188,"contract":"0xeeee…eeee","lastBlock":71405262,"latestBlock":71405281},
…]}
lastBlock 是书签位置,latestBlock 是链上最新区块,两者的差距反映了确认数和同步节奏:
- Sepolia 代币 / MiniSwap 相差 2,正好是确认数;
- Nile 相差 19,同样是确认数;
- 原生币(
0xeeee…)的latestBlock比代币的"旧"几个块,因为它每 60 秒才查一次最新区块。
小结:接口层和普通 Web2 后端基本一样——统一返回结构、按用户关联查询、decimal 处理大数;SSE 让"有新数据"这件事从"前端每隔几秒问一次"变成"后端入库即通知"。
十三、可靠性:重试、超时、幂等、限速
这一章要解决的问题:后端要 7×24 小时和外部节点打交道,网络出问题时怎样不丢数据、不卡死。
一个与外部节点打交道的后台服务,必须假设"网络随时会出问题"。下面这些问题都是本项目开发中真实遇到并修复的:
| 问题 | 真实现象 | 解决办法 |
|---|---|---|
| 启动时节点连不上 | anvil 后启动,或 Infura 限流时后端刚好重启 → 这条链被永久跳过,Sepolia 漏掉一笔 8100 MTK 的转账 | connectWithRetry:连接失败按 1s→2s→…→1min 指数退避重试,直到连上 |
| RPC 请求卡死 | 节点接受连接却不响应 → listener 永久卡住,日志里什么都没有 | callRPC:每次 RPC 调用最多等 30 秒 |
| 限流 429 | 同一条链多个 listener 同时补查历史,一秒内的请求超过节点额度 | BATCH 调到 1000 减少请求次数;令牌桶统一限速(13.1) |
| WebSocket 断线 | 网络抖动、Infura 断开空闲连接 | runSubscribing 自动重连,重连后重新设定信任边界、从书签补查 |
| 重复入库 | 重启、重连、重试都可能重复处理同一区块 | (chain_id, tx_hash, log_index) 唯一索引 + ON CONFLICT DO NOTHING |
| 区块重组 | 公链偶发,已推送的事件被撤回 | 等确认数后再用 eth_getLogs 正式查询 |
| 并发读块漏块 | 原生币批内并发,某个块读取失败 | 整批作废、不推进进度,下一轮重来(第九章) |
| 页面收到"幽灵记录" | 事务回滚了,但事件已经推给页面 | 新记录攒在 newEvents,事务提交后才发布,回滚则丢弃 |
几个术语:
- 限流(Rate Limit):节点服务商限制单位时间内的请求数,超过后返回 HTTP 状态码 429(Too Many Requests)。
- 超时(Timeout):给一次请求设定最长等待时间,到点还没回应就放弃,避免无限等待。
- 指数退避(Exponential Backoff):失败后等待时间逐次翻倍(1 秒、2 秒、4 秒……),既能尽快恢复,又不会在节点故障时疯狂重试把它压垮。
超时封装:
const rpcTimeout = 30 * time.Second
// 超时按"每次调用"单独计算而不是按"整轮同步":补查大量区块总耗时再长也是正常的
func callRPC[T any](ctx context.Context, fn func(context.Context) (T, error)) (T, error) {
ctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
return fn(ctx)
}
// 使用:
head, err := callRPC(ctx, l.client.BlockNumber)
连接重试:
// listener/listener.go(节选:真实代码用 select 监听 ctx,以便 Ctrl+C 时立即退出)
func (l *Listener) connectWithRetry(ctx context.Context) bool {
backoff := time.Second
for {
err := l.connect(ctx) // 连 HTTP 节点,并校验链 ID
if err == nil {
return true
}
if errors.Is(err, errChainIDMismatch) { // 配置错误(例如把 Sepolia 地址配给了 anvil),重试也没用
return false
}
log.Printf("⚠️ [%s] 连接节点失败,%s 后重试: %v", l.label, backoff, err)
time.Sleep(backoff)
backoff = min(backoff*2, time.Minute)
}
}
注意这里区分了两类错误:网络类错误(暂时连不上)值得重试;配置类错误(链 ID 对不上)重试一万次也不会好,应该直接放弃并报错。
13.1 令牌桶:按 credits 统一限速
节点服务商除了每日额度,还有"每秒额度":Infura 免费套餐约每秒 500 credits(以控制台为准),而一次 eth_getLogs 就要 255。同一条链上有 MTK、USDT、MiniSwap、原生币 4 个 listener,它们各自重试、互不知情;补查历史时只要有两个 listener 在同一秒里各发一次 eth_getLogs,就是 510 credits,超过额度触发 429。
令牌桶(Token Bucket) 是经典的限速算法:桶里按固定速度往里加令牌,每个请求要先取走相应数量的令牌才能发出;桶空了就排队等。它既限制了平均速度,又允许短时间的突发(桶里攒着的令牌)。
打个比方:抄写员们共用一张"每秒 400 点"的借阅卡。翻一次大目录(eth_getLogs)扣 255 点,查一页(其他方法)扣 80 点;点数不够就在门口等一会儿,而不是冲进去被管理员赶出来(429)。
// listener/ratelimit.go(节选)
var rpcCredits = map[string]int{"eth_getLogs": 255} // 参考 Infura 计费表
const defaultCredits = 80 // 未列出的方法按 80 计
// limiters:RPC 地址 → 令牌桶。同一个 Key 的请求共享每秒额度,所以按完整地址区分
var limiters sync.Map
func limiterFor(url string, creditsPerSec int) *rate.Limiter {
// 桶容量 = 每秒额度,但必须能容纳单价最高的请求,否则 WaitN 永远等不到
burst := max(creditsPerSec, rpcCredits["eth_getLogs"])
l, _ := limiters.LoadOrStore(url, rate.NewLimiter(rate.Limit(creditsPerSec), burst))
return l.(*rate.Limiter)
}
func (t *limitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body)) // 读过的 body 要放回去,否则发出去的是空请求
cost := min(requestCredits(body), t.limiter.Burst()) // 解析 JSON-RPC 的 method,按单价计费
if err := t.limiter.WaitN(req.Context(), cost); err != nil {
return nil, err
}
return t.base.RoundTrip(req)
}
三个设计要点:
- 放在 HTTP 传输层(
http.RoundTripper),而不是各个调用点:go-ethereum 的每个方法(BlockNumber、FilterLogs……)最终都走这里,不会漏掉。 - 按 RPC 地址共享:同一个地址(含同一个 API Key)的所有 listener 共用一个桶,这样多个 listener 加起来也不会超额。
- 按方法计费,而不是按请求次数:一次
eth_getLogs的成本是普通请求的 3 倍多,只数次数会低估。
配置:<NAME>_RPC_RATE,Sepolia 示例为 400(给 500 的额度留一点余量),0 表示不限速;WebSocket 连接只用于订阅、请求很少,不限速。listener/ratelimit_test.go 里有对应的单元测试。
小结:可靠性的核心思路是——网络问题靠重试和超时扛过去,额度问题靠令牌桶提前排队,重复处理靠幂等兜底,数据不一致靠事务避免,链上的不确定性靠确认数规避。
十四、★ 踩坑实录
这一章要解决的问题:真实开发里遇到过哪些"看起来很怪"的问题,它们是怎么被定位和修复的。每个坑都按"现象 → 原因 → 修复 → 教训"来讲。
14.1 兑换记录晚了 5 分钟才入库:recheck 被提前停掉
现象:Sepolia 上的兑换记录,正常情况下上链后半分钟左右入库;但有一笔"卖 1000 MTK 买 USDT"的兑换,等了 5 分钟才出现。数据库里的时间戳留下了证据(GET /api/swaps 真实返回):
| 兑换 | blockTime(上链) | createdAt(入库) | 延迟 |
|---|---|---|---|
| 卖 6 USDT 买 ETH | 11:17:24 | 11:17:51 | 27 秒 |
| 卖 500 MTK 买 ETH | 11:27:48 | 11:28:25 | 37 秒 |
| 卖 1000 MTK 买 USDT | 11:29:12 | 11:34:19 | 5 分 7 秒 |
| 卖 0.01 ETH 买 MTK | 11:37:36 | 11:38:13 | 37 秒 |
| 卖 0.2 ETH 买 USDT | 11:39:00 | 11:39:37 | 37 秒 |
5 分钟,正好是 CHECKPOINT_INTERVAL 的默认值。这说明这条事件不是被 recheck(12 秒)处理的,而是等到了下一次 checkpoint。
原因:回看 7.4 节,订阅模式有两个定时器:recheck 负责"有事件在等确认时每 12 秒检查一次",checkpoint 负责"空闲时每 5 分钟推进一次"。旧代码里,各个分支各自决定 recheck 的开关,规则不统一:
// 旧代码(节选)
ok := l.tryAdvance(ctx)
...
if ok {
recheck.Stop() // ← 只看"补查是否成功",没看 pending 里还有没有待确认的事件
}
for {
select {
case lg := <-logsCh:
l.onLog(lg)
...
if !ok || len(l.pending) > 0 {
recheck.Reset(l.pollInterval) // ← 每来一条推送都重置一次节奏
}
case <-recheck.C:
l.drain(logsCh)
if l.tryAdvance(ctx) && len(l.pending) == 0 { recheck.Stop() }
case <-checkpoint.C:
l.drain(logsCh) // ← drain 可能收进新的待确认事件
if !l.tryAdvance(ctx) { recheck.Reset(l.pollInterval) } // ← 成功时不管 pending
}
}
问题有两处:
- 只看"同步是否成功"就停掉 recheck。
pending是 Listener 的字段,WebSocket 断线重连时不会清空;重连后第一次补查成功了,旧代码就把 recheck 停掉——可pending里还留着断线前收到、尚未确认的事件。checkpoint 分支里drain收进来的新事件也一样没人启动 recheck。这些事件只能等下一次 checkpoint,也就是 5 分钟后。 - 每来一条推送就
Reset一次。对 USDT 这种几乎每个区块都有推送的热门合约,recheck 会被不停往后推,迟迟不触发。
(当时那一笔具体是从哪个分支漏掉的,现场日志没有保留,需要确认;源码注释记录的是"重连时 pending 里还留着事件"这一场景。)
修复:把"要不要继续 recheck"收敛成一条规则,每次同步之后都用它判断:
// listener/subscribe.go(当前代码)
// 每次同步后都按同一条规则决定 recheck 开关:同步失败或仍有事件在等确认就继续,否则停下。
rechecking := true
schedule := func(ok bool) {
rechecking = !ok || len(l.pending) > 0
if rechecking {
recheck.Reset(l.pollInterval)
} else {
recheck.Stop()
}
}
初始补查、收到推送(本地链)、recheck 触发、checkpoint 触发,四个地方统一调用 schedule(l.tryAdvance(ctx))。收到推送时,只在 recheck 停着时启动它,已在运行就不重置节奏。
教训:同一个状态(这里是"recheck 开 / 关")如果由多个分支各自决定,迟早会有一个分支忘了考虑某个条件。把判断收敛成一个函数,让所有分支都走它。另外,createdAt - blockTime 这种"入库延迟"是非常好用的排查指标,出现"恰好等于某个定时器周期"的延迟,基本就能锁定是哪个定时器在兜底。
14.2 Infura 返回 429:多个 listener "各自为战"
现象:后端停机一段时间后重启,Sepolia 的几个 listener 同时开始补查历史,日志里陆续出现 429 Too Many Requests,同步反复失败重试。
原因:MTK、USDT、MiniSwap、原生币 4 个 listener 用的是同一个 Infura 地址(同一个 API Key),每秒额度是共享的;但它们各自发请求、各自重试,互相不知道对方。一次 eth_getLogs 就是 255 credits,两个 listener 同一秒各发一次就超过了约 500 credits/秒的额度。失败后大家又差不多同时重试,再次撞车。
修复:见 13.1 节的令牌桶——在 HTTP 传输层按"RPC 地址"共享一个桶、按方法计费,请求在发出前排队,而不是发出去再被拒绝。配合 BATCH=1000(一次 eth_getLogs 查 1000 个区块),补查的请求次数也大幅减少。
教训:外部服务的限额是"按 Key 算"的,而不是"按你的模块算"的。凡是多个组件共用一个外部额度,限速就必须放在它们共同经过的地方。
14.3 部署时 "in-flight transaction limit reached":EIP-7702 委托账户
现象:用 forge script 把 MiniSwap 部署到 Sepolia 并注入三个池子的流动性,脚本里有十几笔交易(部署、approve、addLiquidity……)。批量广播时节点报错 in-flight transaction limit reached,部分交易没发出去。
原因:部署账户开启了 EIP-7702 委托(例如 MetaMask 智能账户)。EIP-7702 允许普通账户(EOA)临时"挂上"一段合约代码;为了防范相关风险,节点对这类账户只允许同时存在 1 笔待打包(pending)交易。而 forge 默认是一口气把所有交易都广播出去。
修复:Makefile 里的部署命令加上 --slow——每笔交易上链后再发下一笔:
# --slow:每笔交易上链后再发下一笔。开启了 EIP-7702 委托(如 MetaMask 智能账户)的地址,节点只允许 1 笔待打包交易,
# 批量发送会报 in-flight transaction limit reached。中途失败可用 FORGE_ARGS=--resume 从未发送的交易续跑。
deploy-swap-sepolia:
cd contracts && ... forge script script/DeploySwap.s.sol --rpc-url sepolia --account deployer --broadcast --slow $(FORGE_ARGS)
教训:这个坑不在后端代码里,但它说明"同一个地址"在链上的行为可能因为账户类型而不同。部署脚本最好默认 --slow + 支持 --resume,慢一点但可恢复。
14.4 Nile USDT 转账成功却返回 false
现象:MiniSwap 在 Nile 上调用 USDT 的 transfer,交易执行成功、余额也确实变了,但函数返回值是 false。如果合约按 OpenZeppelin SafeERC20 的习惯"返回 false 就 revert",所有涉及 USDT 的兑换都会失败。
原因:TRON 上的这个 USDT 合约实现不规范(已在 Nile 实测)。
修复(在合约里):MiniSwap 不信任返回值,改用"余额差值"判断到账:转账前后各读一次余额,差值才是真正到账的数量;到账为 0 视为失败。细节见第 2 篇。
对后端的影响:没有影响。后端只认链上的 Transfer 事件,从不看函数返回值——这也再次说明了"以事件为准"的好处:事件是合约真正执行后的记录,返回值只是给调用方的一个"说法"。
14.5 counterparties 返回 null,整个页面停止更新
现象:切换到一个还没有任何交易的网络 / 资产时,页面报错,之后余额、列表、进度全部不再刷新。
原因:/api/users/counterparties 在没有数据时,Go 里一个未初始化的切片 var out []CounterpartyDTO 是 nil,json.Marshal 会把 nil 切片序列化成 null 而不是 []。前端拿到 data: null 后直接 .filter(...),抛出异常,打断了后续的整条刷新流程。
修复:后端把结果初始化为空切片;前端也做了兜底(见提交记录 21a2bc4)。
// controllers/api/userController.go(节选)
// 初始化为空切片:没有任何交易对方时返回 [],而不是 nil(会被序列化成 null,前端当数组用就会报错)
out := []CounterpartyDTO{}
err := models.DB.Raw(`SELECT u.address, u.nickname, MAX(p.block_number) AS last_block ...`).Scan(&out).Error
/api/transactions、/api/swaps 在"用户不存在"时也都显式返回 []TxDTO{} / []models.Swap{}。
教训:Go 的 nil 切片和空切片在 JSON 里是两个东西(null vs [])。列表接口要么永远返回数组,要么在接口文档里写清楚可能为 null——前者对前端友好得多。
小结:五个坑里,两个是"状态与定时器"的逻辑问题,一个是"共享外部额度"的工程问题,两个是"链 / 账户 / 合约不按常理出牌"的生态问题。共同的排查手段是:看时间戳、看日志、回到源码逐个分支核对条件。
十五、动手 Demo:观察后端如何工作
这一章要解决的问题:亲手验证前面讲的每个设计。建议按顺序做。
Demo 1:看一笔转账从推送到入库
make anvil # 终端 1
make backend # 终端 2,观察日志
在终端 3 用命令行直接转账(不经过页面)。anvil 的内置测试账户由节点托管,可以用 --unlocked 让节点代为签名,命令里不需要出现私钥:
cast send 0xdc64a140aa3e981100a9beca4e685f962f0cf6c9 "transfer(address,uint256)" \
0x70997970c51812dc3a010c7d01b50e0d17dc79c8 1000000000000000000 \
--unlocked --from 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 --rpc-url anvil
终端 2 立刻出现(真实日志,当时的日志前缀还是链名,现在是 anvil/MTK):
[anvil] 收到推送:区块 2797,交易 0xc4ad58f8fd3c…,等待 0 个确认后入库
[anvil] 同步区块 2795→2797,事件 1 条,新写入 1 条
这两行分别对应第十一章的步骤 1 和步骤 6;因为 anvil 确认数为 0,中间没有等待。
Demo 2:验证幂等——重新同步不会产生重复数据
-- 记下当前记录数
SELECT COUNT(*) FROM transactions WHERE network = 'anvil';
-- 把书签拨回部署区块之前,强制后端重新扫描全部区块
UPDATE sync_states SET last_block = 0 WHERE network = 'anvil';
重启后端,预期日志会显示它重新扫描了全部区块,并且"事件 N 条,新写入 0 条";再查记录数,应该不变。这就是唯一索引 + ON CONFLICT DO NOTHING 的作用(这个实验请你亲自跑一遍验证)。
Demo 3:验证"数据库只是副本"
- 停掉后端,在页面上转一笔账:转账照样成功(前端直接和链交互)。
- 启动后端:日志显示它补查了停机期间的区块,这笔记录被补进数据库。
Demo 4:用 curl 看 SSE 推送
curl -N localhost:8080/api/events
-N 表示不缓冲输出。连上后会立刻看到 event: ready,之后每当同步进度变化会有 event: progress;这时在页面上转一笔账或兑换一次,入库时就能看到 event: tx / event: swap。没有事件时每 25 秒会出现一行 : keepalive。
Demo 5:查自己的兑换记录,算一算入库延迟
curl -s "localhost:8080/api/swaps?address=<你的地址>&network=sepolia"
-- 每笔兑换从上链到入库用了多久:正常应在半分钟左右(Sepolia),Nile 1 分多钟
SELECT network, tx_hash, block_time, created_at,
TIMESTAMPDIFF(SECOND, block_time, created_at) AS delay_s
FROM swaps ORDER BY id DESC LIMIT 10;
如果某一笔的延迟恰好接近 300 秒,回头看看第十四章 14.1 节。
Demo 6:切换成轮询模式,对比两种方式
把 backend/.env 里的 SEPOLIA_WS_URL 注释掉,重启后端,日志里的方式变成 方式=HTTP 轮询。之后可以在 Infura 后台的 Stats 页面,对比两种模式下每天的 Credits 消耗。
Demo 7:查看同步进度
SELECT network, contract, last_block, updated_at FROM sync_states ORDER BY network, contract;
每个"链 + 合约"一行,同一条链上 MTK、USDT、MiniSwap、原生币(0xeeee…)各有各的书签。也可以直接看 GET /api/networks(第十二章 12.5 节)。
十六、常见问题
Q:后端为什么不直接从前端接收"我转账了"的通知然后入库? 前端的通知不可信:用户可以伪造请求,说自己转了一万 MTK。后端只相信链上的事件。而且用户可能用别的钱包、别的页面、命令行转账,后端通过监听合约事件就能全部覆盖。
Q:后端需要私钥吗?要付 Gas 吗?
常驻服务都不需要,它只读链(订阅、查询),从不发交易。cmd/deploy-nile 是单独运行的部署工具,需要部署账户私钥(运行时从终端输入,不写入文件)。
Q:能不能用后端数据库里的数据算余额?
技术上可以(把所有转入减去转出),但不应该:数据库有延迟、可能漏数据;USDT 和原生币还只记录了"已登记用户"登记之后的转账,原生币也看不到合约内部转账。余额以链上 balanceOf / eth_getBalance 为准。
Q:为什么我的 USDT / ETH 转账没出现在列表里?
先确认两件事:① 这个地址是否在当前链上连接过钱包(user_networks 里有没有这条链的登记);② 这笔转账是否发生在登记之后。USDT、原生币都只同步已登记用户、且不补登记之前的历史。另外原生币每 60 秒扫描一次,Nile 还要等 19 个确认,稍等一两分钟再看。
Q:兑换时合约转给我的 ETH,为什么交易列表里没有? 那是合约内部转账,不是一笔独立交易,原生币扫描器看不到。它完整地记录在"我的兑换记录"(swaps 表)里,见第八章。
Q:后端可以部署多个副本吗?
HTTP 接口可以,但 listener 目前是单实例设计:多副本虽然靠唯一索引不会写出重复数据,但会重复消耗 RPC 额度、争抢书签;SSE 事件也只会从"正好同步到这条数据"的那个副本发出。需要扩容时,应把 listener 拆成独立的单副本服务,或引入选主机制。所以 K8s 部署里后端设为 1 个副本、更新策略用 Recreate。
Q:anvil 重置(不带 --state 重启)后怎么办?
链上数据清空了,但数据库里还有旧记录,书签也比链上最新块还大。后端会提示"已同步到区块 X,但链上最新只有 Y。若是 anvil 重置,请执行 sql/03_reset_local.sql 并重新部署"。按提示清空本地链相关的数据,再重新部署合约、更新 .env。
Q:Nile 上首次启动为什么卡住很久?
多半是 NILE_START_BLOCK 还是 0。USDT 不是我们部署的合约,从 0 开始会补查几千万个区块。按 .env.example 的提示,把它改成最近的区块号。
十七、系列总结
作为系列的最后一篇,这一章把四篇文章串起来,回顾一笔转账、一次兑换的完整旅程。
17.1 四篇文章各讲了什么
| 篇目 | 主题 | 核心收获 |
|---|---|---|
| 第 1 篇 | Web3 链上转账完整流程(小白版) | 建立整体认识:账户、代币、合约、链,以及智能合约在项目中的意义 |
| 第 2 篇 | 智能合约(contracts)详解 | MyToken 是"链上的账本程序",MiniSwap 是"链上的自动做市商":恒定乘积、手续费、滑点、余额差值 |
| 第 3 篇 | 前端(frontend)详解 | 前端通过 RPC 读链、通过钱包签名写链:MetaMask / TronLink、转账与兑换面板、SSE 驱动刷新 |
| 第 4 篇 | 后端(backend)详解 | 后端是索引器:订阅事件、等确认、幂等入库;兑换记录、原生币扫描、TRON 适配、限速与踩坑 |
17.2 一笔转账 / 一次兑换的完整旅程
| 阶段 | 发生在哪里 | 转账 | 兑换 | 详见 |
|---|---|---|---|---|
| 发起 | 前端 | 连接钱包 → 登记用户 → 读余额 → 选对方 → 签名并广播 | 报价(getAmountOut)→ 设滑点 → 必要时先 approve → 签名 swap | 第 3 篇 |
| 执行 | 合约 | transfer → 检查余额 → 改账本 → emit Transfer | swap → 按 x·y=k 算出 amountOut → 滑点 / 截止时间检查 → 收付资产 → emit Swap | 第 2 篇 |
| 确认 | 前端 | 等回执 → 刷新余额 | 等回执 → 插入"确认中"占位记录 | 第 3 篇 |
| 索引 | 后端 | 推送 / 轮询 → 等确认 → eth_getLogs → 解析 → 写 transactions | 同左,事件换成 Swap → 写 swaps | 本篇第七、八章 |
| 通知 | 后端 → 前端 | SSE tx 事件 | SSE swap 事件 | 本篇第十二章 |
| 展示 | 前端 + 后端 | /api/transactions → 带昵称的交易列表 | /api/swaps → "我的兑换记录"替换占位 | 第 3 篇、本篇 |
一句话串起来:前端让用户签名并把交易送上链,合约在链上改账本并发出事件,后端听到事件、等它稳定后抄进数据库并按门铃通知页面,前端再把整理好的记录展示给用户。 钱只在链上流动,前端和后端都不"经手"资金。
17.3 进阶学习方向
读完这个系列,如果想继续深入,可以沿着下面几个方向走:
- 可升级合约(Upgradeable Contracts):合约部署后代码不可修改,实际项目常用"代理合约 + 实现合约"的模式实现升级。可以从 OpenZeppelin 的代理模式(如 UUPS、Transparent Proxy)入手,重点理解存储布局冲突等风险。
- 专业索引服务(如 The Graph):本篇手写的 listener 就是一个最小的索引器。The Graph 等服务把"订阅事件 → 解析 → 存储 → 提供查询接口"做成了通用框架,适合事件种类多、查询复杂的项目。对比它和本项目的实现,能更好地理解索引器的通用问题(重组处理、断点续传、多链)。
- 内部交易追踪(trace):本项目看不到合约内部转出的原生币,只能靠 Swap 事件补全。
debug_traceTransaction、trace_block等接口能还原内部调用,但需要节点支持且成本更高,可以研究它与"只看事件"的取舍。 - 前端工程化(wagmi / TypeChain):wagmi 为 React 提供了钱包连接、读写合约的现成 Hooks;TypeChain 可以根据 ABI 自动生成 TypeScript 类型,替代手写接口,减少"ABI 与代码不一致"的错误。
- 后端高可用(多副本 listener 选主):本项目的 listener 是单实例。若要多副本部署,需要引入选主(Leader Election)机制,例如基于数据库锁或 Kubernetes Lease,保证同一时刻只有一个副本在同步,其余副本待命接管。
感谢读完整个系列。希望你现在能从合约、前端、后端三个角度,完整地说清楚"一笔链上转账、一次链上兑换到底发生了什么"。