系列导航

顺序文章内容
1Web3 链上转账完整流程(小白版)零代码建立全景:账户、代币、链、转账与兑换,以及智能合约在项目中的意义
2智能合约(contracts)详解MyToken 代币合约 + MiniSwap 兑换合约(AMM、恒定乘积、滑点)、部署与测试
3前端(frontend)详解钱包连接(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>。


目录

  1. 定位:Web3 应用为什么还需要后端
  2. 技术栈与目录结构
  3. 启动流程:main.go
  4. 配置:多链、多资产
  5. 数据层:五张表与 models
  6. 与节点通信:JSON-RPC over HTTP / WebSocket
  7. ★ 事件同步:listener 的核心设计
  8. ★ 兑换事件同步:从 Swap 事件到"我的兑换记录"
  9. ★ 原生币扫描器与追块优化
  10. TRON 适配:同一套 listener 跑在 Nile 上
  11. ★ 链上转账:后端的 8 个步骤
  12. 接口层:路由、统一返回结构与 SSE 推送
  13. 可靠性:重试、超时、幂等、限速
  14. ★ 踩坑实录
  15. 动手 Demo:观察后端如何工作
  16. 常见问题
  17. 系列总结

一、定位:Web3 应用为什么还需要后端

这一章要解决的问题:数据明明都在链上,为什么还要写一个后端?它的边界在哪里?

1.1 一句话定位

后端是链上数据的"抄写员"和"翻译员"。 业内把这类服务叫索引器(Indexer):它持续读取链上数据,按业务需要整理成便于查询的形式,就像图书馆给海量藏书编的"索引卡片"。

它不碰钱、不碰私钥,只做两件事:

  1. 抄写:在 anvil / Sepolia / Nile 三条链上,把 MTK、USDT 的 Transfer 事件、MiniSwap 的 Swap 事件、原生币(ETH / TRX)转账抄进 MySQL。
  2. 翻译:给地址配上昵称,按"某地址的转账记录""我的兑换记录"这样的业务需求组织数据,通过 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 连接节点、订阅事件、查询日志
GinHTTP 路由
GORM + MySQLORM 与数据库
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
anvilMTK
sepoliaMTK、USDT(仅已登记用户)、MiniSwap(Swap 事件)、原生币 ETH
nileUSDT(仅已登记用户)、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
anvilMTK 0xdc64…f6c9未部署(本地只演示转账)未开启
sepoliaMTK 0xcbee…2447、USDT 0xaa8e…33d00x65691a568b9ea2075325ffc4433c0f4f4c9eeafbtrue
nileUSDT 0xeca9…e05f、MTK 0xd666…11b10xbbfaa366406e2fe989c46828175b92c2388416d3true

几个新词先简单认识一下,第七章会详细讲:

  • 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_URLTronGrid 公共地址 https://nile.trongrid.io/jsonrpc
ANVIL_*_URLanvil 固定是本机 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_idsepolia / 11155111
tx_hash0x80e152bb556f0d778c136a9461f75cbd080b6a7ebd077a02f2a309f97394f3a5
log_index63
from_user_id → 昵称105 → 勇敢的鹦鹉#3717
to_user_id → 昵称106 → 迷糊的水獭#7322
amount2500000000000000000000(= 2500 MTK)
block_number11786846

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 发送。区别在于连接方式:

对比HTTPWebSocket
连接方式每次请求一问一答,答完就结束先用一次 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 两段式推进、subscriptionLag7.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 万 Creditseth_getLogs 占 74%、eth_blockNumber 占 24%
2026-09-27(改造后,统计截至截图时)WebSocket 订阅5,560 Creditseth_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 之后每多出一个块,就多一个确认。三条链的取值各有道理:

链确认数出块间隔等待时间为什么
anvil0按配置0本地只有一个出块者,不会重组
Sepolia2约 12 秒约 24 秒兼顾实时性与安全性(学习项目的取舍;生产环境常等到 finalized)
Nile193 秒约 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…df9f74bf2from_addr = 0xf78c…4bf2 → 勇敢的鹦鹉#3717
topics[2]0x000…00455bddc5…9993d6a0to_addr = 0x455b…d6a0 → 迷糊的水獭#7322
data0x…878678326eac900000amount = "2500000000000000000000"
logIndex0x3flog_index = 63

本章小结:

  1. 订阅负责"及时知道哪里有事件",eth_getLogs 负责"正式、可靠地拿到事件"——推送只是通知,入库一律走查询。
  2. 信任边界之后的区块靠订阅担保,之前的靠补查;顺序必须是"先订阅、再补查"。
  3. 确认数让后端只处理足够"老"的区块,躲开区块重组;三条链按各自的共识机制取 0 / 2 / 19。
  4. advance 两段式:需要查的真查,订阅担保过的直接挪书签,subscriptionLag 给推送延迟留出余量。
  5. 事务 + 唯一索引:书签和数据同进同退,重复处理也不会产生重复数据;事务提交后才推送,页面收到的事件一定对应已入库的数据。

八、★ 兑换事件同步:从 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:订阅、确认数、两段式推进、限速、重连全部复用,区别只在三处:

  1. eventTopic() 返回 swapTopic 而不是 transferTopic;
  2. saveLog 转给 saveSwap,写 swaps 表;
  3. 发布的 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 块要等最慢的那个请求返回才能进入下一批(批与批之间有"屏障"),加上每批一次数据库事务,以及少数候选交易的回执查询是串行的。

为什么不会漏块? 这是并发最容易出错的地方,代码靠三点保证:

  1. 结果按下标存放:blocks[n-from] = b,不管哪个请求先返回,最终顺序都和区块号一致。
  2. 整批失败不推进进度:errgroup 里任何一个区块读取失败,g.Wait() 返回错误,整批作废,saveProgress 根本不会执行;下一轮从这批的起点重来。已写入的前几批不受影响(它们各自的事务已提交)。
  3. 重来也不怕重复:重扫已处理过的区块时,唯一索引让重复写入变成无害操作。

打个比方:抄写员请了 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,流程和以太坊一一对应:

  1. /wallet/deploycontract 或 /wallet/triggersmartcontract:由节点构造一笔未签名交易,返回 txID;
  2. 本地用私钥对 txID 签名(txID = sha256(raw_data),签名算法与以太坊相同,都是 secp256k1);
  3. /wallet/broadcasttransaction:广播;
  4. /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 秒,与此吻合。)

三条链的对比:

链发现方式确认等待从上链到入库大约
anvilWebSocket 推送0几乎立即
SepoliaWebSocket 推送2 块(约 24 秒)半分钟左右
Nile12 秒 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/eventsSSE 长连接: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连接建立时立即发一条{}确认推送可用,拉一次完整数据作基准
progresslistener 的同步进度变化时(与上次相同则不发)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)
}

三个设计要点:

  1. 放在 HTTP 传输层(http.RoundTripper),而不是各个调用点:go-ethereum 的每个方法(BlockNumber、FilterLogs……)最终都走这里,不会漏掉。
  2. 按 RPC 地址共享:同一个地址(含同一个 API Key)的所有 listener 共用一个桶,这样多个 listener 加起来也不会超额。
  3. 按方法计费,而不是按请求次数:一次 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 买 ETH11:17:2411:17:5127 秒
卖 500 MTK 买 ETH11:27:4811:28:2537 秒
卖 1000 MTK 买 USDT11:29:1211:34:195 分 7 秒
卖 0.01 ETH 买 MTK11:37:3611:38:1337 秒
卖 0.2 ETH 买 USDT11:39:0011:39:3737 秒

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

问题有两处:

  1. 只看"同步是否成功"就停掉 recheck。pending 是 Listener 的字段,WebSocket 断线重连时不会清空;重连后第一次补查成功了,旧代码就把 recheck 停掉——可 pending 里还留着断线前收到、尚未确认的事件。checkpoint 分支里 drain 收进来的新事件也一样没人启动 recheck。这些事件只能等下一次 checkpoint,也就是 5 分钟后。
  2. 每来一条推送就 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:验证"数据库只是副本"

  1. 停掉后端,在页面上转一笔账:转账照样成功(前端直接和链交互)。
  2. 启动后端:日志显示它补查了停机期间的区块,这笔记录被补进数据库。

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 Transferswap → 按 x·y=k 算出 amountOut → 滑点 / 截止时间检查 → 收付资产 → emit Swap第 2 篇
确认前端等回执 → 刷新余额等回执 → 插入"确认中"占位记录第 3 篇
索引后端推送 / 轮询 → 等确认 → eth_getLogs → 解析 → 写 transactions同左,事件换成 Swap → 写 swaps本篇第七、八章
通知后端 → 前端SSE tx 事件SSE swap 事件本篇第十二章
展示前端 + 后端/api/transactions → 带昵称的交易列表/api/swaps → "我的兑换记录"替换占位第 3 篇、本篇

一句话串起来:前端让用户签名并把交易送上链,合约在链上改账本并发出事件,后端听到事件、等它稳定后抄进数据库并按门铃通知页面,前端再把整理好的记录展示给用户。 钱只在链上流动,前端和后端都不"经手"资金。

17.3 进阶学习方向

读完这个系列,如果想继续深入,可以沿着下面几个方向走:

  1. 可升级合约(Upgradeable Contracts):合约部署后代码不可修改,实际项目常用"代理合约 + 实现合约"的模式实现升级。可以从 OpenZeppelin 的代理模式(如 UUPS、Transparent Proxy)入手,重点理解存储布局冲突等风险。
  2. 专业索引服务(如 The Graph):本篇手写的 listener 就是一个最小的索引器。The Graph 等服务把"订阅事件 → 解析 → 存储 → 提供查询接口"做成了通用框架,适合事件种类多、查询复杂的项目。对比它和本项目的实现,能更好地理解索引器的通用问题(重组处理、断点续传、多链)。
  3. 内部交易追踪(trace):本项目看不到合约内部转出的原生币,只能靠 Swap 事件补全。debug_traceTransaction、trace_block 等接口能还原内部调用,但需要节点支持且成本更高,可以研究它与"只看事件"的取舍。
  4. 前端工程化(wagmi / TypeChain):wagmi 为 React 提供了钱包连接、读写合约的现成 Hooks;TypeChain 可以根据 ABI 自动生成 TypeScript 类型,替代手写接口,减少"ABI 与代码不一致"的错误。
  5. 后端高可用(多副本 listener 选主):本项目的 listener 是单实例。若要多副本部署,需要引入选主(Leader Election)机制,例如基于数据库锁或 Kubernetes Lease,保证同一时刻只有一个副本在同步,其余副本待命接管。

感谢读完整个系列。希望你现在能从合约、前端、后端三个角度,完整地说清楚"一笔链上转账、一次链上兑换到底发生了什么"。