一、这一阶段要解决什么问题
上一篇完成了项目基础环境搭建,包括:
- 创建 Go 项目;
- 编写
/health健康检查接口; - 使用 Docker Compose 启动 PostgreSQL;
- 建立 Go 与 PostgreSQL 的连接;
- 通过 Git 和 GitHub 管理代码。
但那时系统中只有一条手动插入的测试数据,还不能说明它真正具备区块链数据处理能力。
因此,这一阶段的目标是打通下面这条链路:
Ethereum 主网 RPC ↓ 获取 USDT Transfer 事件日志 ↓ Go 解析交易哈希、地址和金额 ↓ 写入 PostgreSQL ↓ 通过 HTTP API 查询完成这一步后,ChainPay 才算从普通数据库项目进入真正的链上数据处理阶段。
二、当前使用的技术
本阶段主要使用:
- Go;
- go-ethereum;
- PostgreSQL 17;
- pgx / pgxpool;
- Docker Compose;
- Ethereum JSON-RPC;
- PowerShell 与 curl。
项目当前监听 Ethereum 主网,并以 USDT 作为第一个 ERC-20 代币样本。
USDT 主网合约地址为:
0xdAC17F958D2ee523a2206206994597C13D831ec7USDT 使用 6 位小数精度,也就是说,链上原始值1000000对应1 USDT。
三、先验证 Ethereum RPC 是否可用
在接入 Go 代码前,我先在 PowerShell 中直接调用 Ethereum JSON-RPC:
'{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' | curl.exe --noproxy "*" -X POST ` -H "Content-Type: application/json" ` --data-binary "@-" ` https://ethereum-rpc.publicnode.com接口正常返回:
{"jsonrpc":"2.0","result":"0x18fbbd5","id":1}其中result是十六进制区块高度。能够返回区块号,说明当前网络可以访问该 RPC。
这里遇到过一个 PowerShell 参数转义问题:直接在命令中使用--data '{...}'时,请求体可能没有按预期传递,服务端会返回 JSON 解析错误。后来改为通过管道和--data-binary "@-"传入请求体,问题得到解决。
四、在 Go 中连接 Ethereum 主网
首先安装 go-ethereum:
go get github.com/ethereum/go-ethereum@v1.17.8然后使用ethclient创建 Ethereum 客户端:
func newEthereumClient(rpcURL string) (*ethclient.Client, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := ethclient.DialContext(ctx, rpcURL) if err != nil { return nil, err } if _, err := client.ChainID(ctx); err != nil { client.Close() return nil, err } return client, nil }程序启动时优先读取环境变量:
rpcURL := os.Getenv("ETHEREUM_RPC_URL") if rpcURL == "" { rpcURL = "https://ethereum-rpc.publicnode.com" }这样做的好处是,后面更换 RPC 服务时不需要修改核心代码。
服务启动成功后,终端显示:
ethereum RPC connected chainpay listening on http://127.0.0.1:8080测试最新区块接口:
curl.exe --noproxy "*" http://127.0.0.1:8080/chain/head实际返回:
{"chain_id":"1","block_number":26197043}chain_id为1,说明当前连接的是 Ethereum 主网,而不是测试网络。
五、理解 ERC-20 Transfer 事件
ERC-20 转账通常会产生如下事件:
event Transfer( address indexed from, address indexed to, uint256 value );在 Ethereum 日志中:
topics[0]是事件签名哈希;topics[1]是转出地址from;topics[2]是接收地址to;data保存原始转账数量value。
Go 中可以通过 Keccak-256 计算事件签名:
var transferEventTopic = crypto.Keccak256Hash( []byte("Transfer(address,address,uint256)"), )然后构造过滤条件,只查询 USDT 合约的Transfer日志:
query := ethereum.FilterQuery{ FromBlock: new(big.Int).SetUint64(fromBlock), ToBlock: new(big.Int).SetUint64(toBlock), Addresses: []common.Address{ common.HexToAddress(usdtContractAddress), }, Topics: [][]common.Hash{ {transferEventTopic}, }, } logs, err := client.FilterLogs(ctx, query)这一步同时通过“合约地址”和“事件签名”进行过滤,避免把其他合约事件混入结果。
六、解析真实 USDT 转账
获取日志后,需要解析以下字段:
- 代币合约地址;
- 交易哈希;
- 日志索引;
- 区块号和区块哈希;
- 转出地址;
- 接收地址;
- 原始金额;
- 按 USDT 精度转换后的金额。
核心解析逻辑如下:
value := new(big.Int).SetBytes(eventLog.Data) transfer := ChainTransfer{ TokenAddress: eventLog.Address.Hex(), TokenSymbol: "USDT", TxHash: eventLog.TxHash.Hex(), LogIndex: eventLog.Index, BlockNumber: eventLog.BlockNumber, BlockHash: eventLog.BlockHash.Hex(), FromAddress: common.BytesToAddress(eventLog.Topics[1].Bytes()).Hex(), ToAddress: common.BytesToAddress(eventLog.Topics[2].Bytes()).Hex(), ValueRaw: value.String(), Amount: formatTokenAmount(value, 6), }金额转换没有使用float64。因为代币金额可能很大,浮点数可能造成精度损失,所以项目使用big.Int保存原始整数,再通过字符串移动小数点。
调用最近区块的查询接口:
$response = curl.exe --noproxy "*" http://127.0.0.1:8080/chain/usdt/transfers | ConvertFrom-Json $response | Select-Object token_address, from_block, to_block, total_logs, count $response.data[0] | ConvertTo-Json一次实际查询得到:
token_address : 0xdAC17F958D2ee523a2206206994597C13D831ec7 from_block : 26197658 to_block : 26197662 total_logs : 389 count : 20其中total_logs表示 RPC 返回的日志总数,接口为了避免响应过大,只展示最近解析出的 20 条。
七、将真实链上交易写入 PostgreSQL
完成读取和解析后,下一步是把转账保存到transfers表。
一条链上日志可以通过下面三个字段唯一定位:
chain_id + tx_hash + log_index因此,插入 SQL 使用了:
ON CONFLICT ( chain_id, tx_hash, log_index ) DO NOTHING即使同一个区块范围被重复同步,也不会在数据库中生成重复记录。
目前通过下面的接口手动触发同步:
curl.exe --noproxy "*" -X POST http://127.0.0.1:8080/sync/usdt第一次实际同步结果为:
{ "from_block": 26198014, "to_block": 26198018, "total_logs": 546, "inserted": 546, "duplicates": 0, "failed": 0 }这表示系统在 5 个已确认区块中找到了 546 条 USDT 转账日志,并且全部成功写入 PostgreSQL。
为了降低最新区块发生链重组所带来的影响,当前同步范围会落后最新区块 12 个确认区块。
八、通过查询接口验证入库结果
同步完成后,可以通过/transfers查询数据库:
curl.exe --noproxy "*" "http://127.0.0.1:8080/transfers?limit=3"返回结果中已经包含真实的:
- USDT 合约地址;
- 交易哈希;
- 日志索引;
- 区块高度;
- 转出地址;
- 接收地址;
- 转账金额。
接口还支持按地址和最小金额筛选:
curl.exe --noproxy "*" "http://127.0.0.1:8080/transfers?address=0xYourAddress&limit=10" curl.exe --noproxy "*" "http://127.0.0.1:8080/transfers?min_amount=100000&limit=10"其中:
limit的范围为 1~100;address同时匹配转出地址和接收地址;min_amount用于筛选金额不小于指定值的交易。
九、本阶段遇到的问题
1. RPC 偶尔返回 EOF
运行 Go 服务时曾出现:
initialize ethereum client: Post "https://ethereum-rpc.publicnode.com": EOF重新运行后连接成功,说明这更可能是公共 RPC 或网络的临时波动,而不是代码语法错误。
当前只是手动重试,后续还需要实现:
- 指数退避重试;
- 多 RPC 节点切换;
- 请求限流;
- 超时和错误分类。
2. localhost 请求被代理转发
直接调用本地接口时,系统代理曾把请求错误转发到其他端口,因此 curl 命令统一增加:
--noproxy "*"这样可以让 localhost 请求绕过代理。
3. 仅同步“最近 5 个区块”可能遗漏数据
第一次同步区间是:
26198014 ~ 26198018第二次同步区间是:
26198025 ~ 26198029由于两次调用之间链上已经产生了多个新区块,中间的26198019~26198024没有被同步。
这说明“每次只读取最新 5 个区块”只能用于原型验证,不能作为最终的可靠同步方案。
下一阶段需要使用sync_state表保存最后同步到的区块号。程序再次运行时,应从“上次区块号 + 1”继续同步,而不是重新根据最新区块倒推。
4. 交易时间还不是真实区块时间
当前查询结果中的transaction_time可能使用数据库入库时间作为回退值。最终版本还需要根据区块头获取真实区块时间并写入occurred_at。
十、当前项目进度
完成本阶段后,项目已经具备:
连接真实 Ethereum 主网 ↓ 获取真实 USDT Transfer 日志 ↓ 解析链上交易数据 ↓ 写入 PostgreSQL ↓ 通过 API 查询和筛选这已经形成第一个可运行的数据闭环,但距离完整的大额交易监测系统仍有一段距离。
下一阶段计划实现:
- 使用
sync_state记录同步进度; - 实现断点续传和连续区块同步;
- 补充真实区块时间;
- 将手动同步升级为后台自动同步;
- 增加 RPC 重试和备用节点;
- 实现大额交易阈值规则和告警记录。
十一、总结
这一阶段最重要的进展,不是单纯增加了几个 API,而是让项目第一次处理并保存了真实链上数据。
我也进一步理解了几个关键点:
- 区块链节点适合提供原始、可信的链上数据;
- PostgreSQL 适合保存面向业务查询、筛选和分析的结构化数据;
- ERC-20 事件需要根据 topics 和 data 分别解析;
- 代币金额应避免直接使用浮点数;
- 可靠的区块同步不能只读取“最近几个区块”,必须保存同步状态;
- 能够查询链上数据只是第一步,可靠同步、分析和告警才是系统后续的核心。
下一篇将继续实现sync_state断点续传,让系统能够从上次同步位置继续工作,避免遗漏中间区块。