Node.js,Ethers.js: 解析以太坊区块链数据

用 ethers v6 取区块、交易和事件日志。重点在两处容易写错的地方:parseLog 返回的 args 是数组不是键值对,以及判断重组要拿本地记的哈希去比,不是去问节点父块还在不在。

预计
7 分钟

下面这段解析事件日志的代码,跑起来会打印出一串 undefined: undefined

能跑通,但什么也打不出来
for (const arg of parsedLog.args) {
console.log(`${arg.name}: ${arg.value}`);
}

args 的类型是 Result,而 ResultArray 的子类。遍历它拿到的是解码后的值本身 —— 一个 bigint、一个 0x 开头的地址字符串 —— 不是 { name, value } 这样的包装对象。参数名得另外取,取法在下面第三节。

这篇按 ethers v6 写。v6 在 2023 年初取代 v5 成为 npm 上的默认版本,两者之间是破坏性变更,而网上大量示例还停在 v5:

v5 v6
ethers.providers.JsonRpcProvider ethers.JsonRpcProvider
ethers.providers.Web3Provider ethers.BrowserProvider
ethers.utils.formatEther ethers.formatEther
ethers.utils.Interface ethers.Interface
BigNumber(库自带的大数类) 原生 bigint
contract.functions.foo() contract.foo.staticCall() 等按方法分派

规律是 providers.utils. 这两个命名空间被摊平到了顶层。照着 v5 的例子写 v6,第一行 new ethers.providers.JsonRpcProvider(...) 就会报 Cannot read properties of undefined

连节点这件事,换的只是一个 URL

npm i ethers 之后:

连到一个 JSON-RPC 端点
import { JsonRpcProvider, formatEther, Interface } from 'ethers';
const provider = new JsonRpcProvider('https://mainnet.infura.io/v3/<your-project-id>');

自己跑一个 geth,还是用 Infura、Alchemy 这类托管节点,对上层代码没有区别。区别在别处:托管节点按请求计费,也按请求限流,而下面每一次 getBlockgetTransaction 都是一次独立的 JSON-RPC 往返。

取区块拿到的是交易哈希,不是交易

取一个区块,再取其中一笔交易
const block = await provider.getBlock(20000000);
if (!block) throw new Error('节点上没有这个区块');
console.log(block.timestamp); // 秒级 Unix 时间戳
console.log(block.transactions.length); // 这一块里有多少笔交易
console.log(block.transactions[0]); // '0x…',是交易哈希,不是交易对象
const tx = await provider.getTransaction(block.transactions[0]);
console.log(tx?.from, tx?.to, tx && formatEther(tx.value));

第 6 行是最容易踩的一脚:block.transactions 是一个哈希字符串数组,不是交易对象数组。底层的 eth_getBlockByNumber 第二个参数为 false 时只返回哈希,想要每一笔交易的详情,就得再发一轮 getTransaction。第 5 行的 .length 因此是「有多少笔」,不是「有多少字节」。

getBlock 在区块不存在时返回 null,所以第 2 行那个判空不是防御性代码,是类型上就要求你处理的分支。

block.timestamp 的单位是秒,而 Date 要毫秒,中间那个 1000 是链上时间戳最常见的一处错位。

block.miner 这个字段在合并(The Merge)之后仍然在,但含义变了:它不再是「谁挖出了这块」,而是这一块的 coinbase 地址,由区块提议者指定。同一时期 block.difficulty 恒为 0,原先那个位置上的随机数改由 block.prevRandao 提供。

日志在链上没有名字,ABI 是解码的必要输入

一条日志在链上只有两样东西:topics(最多四个 32 字节的槽)和 data(一段不定长字节)。事件叫什么、参数叫什么、参数是什么类型,链上一个字都没存。topics[0] 是事件签名的 keccak256,带 indexed 的参数按顺序占掉 topics[1..3],其余参数 ABI 编码之后拼进 data

所以解码必须先有 ABI。它不是「用了会方便一点」,是没有它就无从下手。

解析一笔交易收据里的日志
const iface = new Interface([
'event Transfer(address indexed from, address indexed to, uint256 value)',
]);
const receipt = await provider.getTransactionReceipt(txHash);
for (const log of receipt!.logs) {
const parsed = iface.parseLog(log);
if (parsed === null) continue;
console.log(parsed.name); // 'Transfer'
console.log(parsed.args.toObject()); // { from: '0x…', to: '0x…', value: 1000000n }
console.log(parsed.args.value); // 1000000n
console.log(parsed.args[2]); // 同一个值,按位置取
for (const input of parsed.fragment.inputs) {
console.log(`${input.name}: ${parsed.args.getValue(input.name)}`);
}
}

第 8 行的 parseLog 在日志和这份 ABI 对不上时返回 null,不抛异常,所以第 9 行那个分支必须写。一笔交易的收据里通常混着好几个合约发出的日志,一个只装了 TransferInterface 遇到别的事件就返回 null —— 开头那段代码之所以会崩,一半原因在这儿。

第 12 行的 toObject() 是把 Result 变成键值对的正规写法。第 13、14 行说明同一个值有两条取法:按名字,或者按下标。但只有 ABI 里写了参数名才有名字可用,从 abi.json 里读进来的 ABI 常常是没有的。

参数名和值都要,就走第 17 行那条路:fragment.inputs 给出参数的声明顺序和名字,args.getValue(name) 按名取值。getValue 比直接写 args.foo 稳,因为参数万一叫 length 或者 map,属性访问拿到的会是 Array 自己的成员。

判断重组要跟自己的记录比,不是去问节点父块还在不在

有一种流传很广的重组检测写法:拿到新块之后,用它的 parentHash 去查父块,查不到就说明发生了重组。

这个判断永远不会成立。eth_getBlockByHash 查的是节点数据库里的区块,而节点在重组之后并不会立刻把被废弃的那一支删掉,侧链上的块照样查得到。于是这段代码看起来在做安全检查,实际上一次都不会触发 —— 它是个只会给出否定答案的检测器。

重组的定义是「同一个高度上的区块被换掉了」。既然是「换掉了」,判据就只能来自你自己上一轮记下的东西

用本地的 number → hash 表判断重组
const recent = new Map<number, string>(); // 只留最近 DEPTH 块
const DEPTH = 64;
provider.on('block', async (n: number) => {
const block = await provider.getBlock(n);
if (!block?.hash) return;
const mine = recent.get(n - 1);
if (mine && mine !== block.parentHash) {
let fork = n - 1;
while (fork > n - DEPTH) {
const canonical = await provider.getBlock(fork);
if (canonical && canonical.hash === recent.get(fork)) break;
recent.delete(fork);
fork -= 1;
}
console.log(`重组:${fork} 之后的区块都要重新处理一遍`);
}
recent.set(n, block.hash);
for (const h of recent.keys()) if (h < n - DEPTH) recent.delete(h);
});

第 9 行是全部的判据:新块声称的父哈希,和我上一轮为 n - 1 记下的哈希对不上。第 11 到 13 行往回走,一层层拿当前链上的区块和自己的记录比,第一个对得上的高度就是分叉点,它之后的数据全部作废。第 20 行是这套东西成立的前提 —— 每一轮都得把自己看到的哈希记下来,否则下一轮无从比起。

DEPTH 决定了你认多深的重组。合并之后以太坊有了真正的最终性:eth_getBlockByNumber 除了 latest 还认 safefinalized 两个区块标签,finalized 那个高度已经被三分之二以上的质押量投票确认,通常落后链头两个 epoch、约 12.8 分钟。所以索引程序真正省事的做法不是把重组处理得多漂亮,而是只把 finalized 之前的数据当成定局。

消费的是事件日志而不是区块的话,还有一条更短的路:JSON-RPC 的日志对象上带一个 removed 字段,某条日志因重组而作废时它是 true。这是日志层面判断重组的标准信号,不必自己维护哈希表。

这套写法的几笔账

每个数据点都是一次往返。 一个区块里上百笔交易,全要详情就是上百次 RPC 加最开始那一次。托管节点按请求计费也按请求限流,扫一段历史很容易先撞上 429,而不是先撞上带宽。想快就得自己攒批量请求,或者改用 getLogs 按事件过滤,一次拿回一整段区间。

getLogs 有区间上限。 各家节点服务对单次 eth_getLogs 的区块跨度和返回条数都设了限制,扫全历史必须自己切窗口、自己接住「这段太大了」的报错并二分重试。这段重试逻辑通常比解析代码本身还长。

ABI 得自己管。 解码依赖 ABI,而合约会升级,代理合约背后的实现会换。ABI 和链上字节码对不上的时候,parseLog 只是安静地返回 null,不会告诉你为什么 —— 这和「这条日志不属于这个合约」是同一个返回值。

重组之后的补偿逻辑比检测难。 找出分叉点只是第一步,把已经写进库的数据回滚到那个高度、并且保证这中间没有把脏数据发给下游,才是真正花时间的部分。这也是很多项目最后不自己扫链、改用 TheGraph 一: 架构解析里那类索引服务的原因。

这篇归在链上基础设施下。同一主题里最近的另外两篇是基于 MetaMask 理解钱包工作原理TheGraph 二:subgraph 四大关键定义数据