Node.js,Ethers.js: 解析以太坊区块链数据
用 ethers v6 取区块、交易和事件日志。重点在两处容易写错的地方:parseLog 返回的 args 是数组不是键值对,以及判断重组要拿本地记的哈希去比,不是去问节点父块还在不在。
下面这段解析事件日志的代码,跑起来会打印出一串 undefined: undefined:
for (const arg of parsedLog.args) { console.log(`${arg.name}: ${arg.value}`);}args 的类型是 Result,而 Result 是 Array 的子类。遍历它拿到的是解码后的值本身 —— 一个 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 之后:
import { JsonRpcProvider, formatEther, Interface } from 'ethers';
const provider = new JsonRpcProvider('https://mainnet.infura.io/v3/<your-project-id>');自己跑一个 geth,还是用 Infura、Alchemy 这类托管节点,对上层代码没有区别。区别在别处:托管节点按请求计费,也按请求限流,而下面每一次 getBlock、getTransaction 都是一次独立的 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 行那个分支必须写。一笔交易的收据里通常混着好几个合约发出的日志,一个只装了 Transfer 的 Interface 遇到别的事件就返回 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 查的是节点数据库里的区块,而节点在重组之后并不会立刻把被废弃的那一支删掉,侧链上的块照样查得到。于是这段代码看起来在做安全检查,实际上一次都不会触发 —— 它是个只会给出否定答案的检测器。
重组的定义是「同一个高度上的区块被换掉了」。既然是「换掉了」,判据就只能来自你自己上一轮记下的东西:
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 还认 safe 和 finalized 两个区块标签,finalized 那个高度已经被三分之二以上的质押量投票确认,通常落后链头两个 epoch、约 12.8 分钟。所以索引程序真正省事的做法不是把重组处理得多漂亮,而是只把 finalized 之前的数据当成定局。
消费的是事件日志而不是区块的话,还有一条更短的路:JSON-RPC 的日志对象上带一个 removed 字段,某条日志因重组而作废时它是 true。这是日志层面判断重组的标准信号,不必自己维护哈希表。
这套写法的几笔账
每个数据点都是一次往返。 一个区块里上百笔交易,全要详情就是上百次 RPC 加最开始那一次。托管节点按请求计费也按请求限流,扫一段历史很容易先撞上 429,而不是先撞上带宽。想快就得自己攒批量请求,或者改用 getLogs 按事件过滤,一次拿回一整段区间。
getLogs 有区间上限。 各家节点服务对单次 eth_getLogs 的区块跨度和返回条数都设了限制,扫全历史必须自己切窗口、自己接住「这段太大了」的报错并二分重试。这段重试逻辑通常比解析代码本身还长。
ABI 得自己管。 解码依赖 ABI,而合约会升级,代理合约背后的实现会换。ABI 和链上字节码对不上的时候,parseLog 只是安静地返回 null,不会告诉你为什么 —— 这和「这条日志不属于这个合约」是同一个返回值。
重组之后的补偿逻辑比检测难。 找出分叉点只是第一步,把已经写进库的数据回滚到那个高度、并且保证这中间没有把脏数据发给下游,才是真正花时间的部分。这也是很多项目最后不自己扫链、改用 TheGraph 一: 架构解析里那类索引服务的原因。
这篇归在链上基础设施下。同一主题里最近的另外两篇是基于 MetaMask 理解钱包工作原理和 TheGraph 二:subgraph 四大关键定义数据。