在区块链数据审计、历史对账与个人资产快照等场景里,开发者常常需要回溯某地址在特定时刻到底有多少 ETH 或 ERC-20 代币。ethers.js 以其简洁的 API 成为首选工具,本文将通过实战代码带你一步步完成查询,并配合大量注释、常见疑问与扩展示例,让整套流程即学即用。
前置条件
- 已安装 Node.js(≥ 14.x)
- 已具备一条以太坊 主网 或公开测试网的 RPC 接入点
👉 获取免费且稳定的高速 RPC 端点 - 在本地项目中运行
npm install ethers完成库安装
快速跑通核心代码
核心逻辑分为三步:创建 Provider → 调用历史查询 → 合并结果。以下代码无需额外库,一行注释对应一行重要信息,可直接 node index.js 上手试跑。
const { ethers } = require('ethers');
// 1. 配置你的 HTTPS RPC 节点,支持任意兼容以太坊的 endpoint
const rpcUrl = 'https://mainnet.infura.io/v3/YOUR_PROJECT_ID';
// 2. 填入你想查询的地址、时间戳(Unix 秒),以及代币合约地址
const targetAddress = '0xYourAddressHere';
const targetTimestamp = 1690000000; // 具体时间戳
const tokenContractAddress = '0xa0b86a33e6v16...'; // 示例:USDT 合约
// 3. ABI 中只需声明 balanceOf 方法即可
const erc20ABI = [
'function balanceOf(address owner) view returns (uint256)'
];
// 创建 Provider
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
async function run() {
try {
console.log(`正在查询 ${new Date(targetTimestamp * 1000).toISOString()} 时的余额`);
// 查询 ETH 余额(历史状态)
const ethBalance = await provider.getBalance(targetAddress, targetTimestamp);
// 查询 ERC-20 余额(历史状态)
const token = new ethers.Contract(tokenContractAddress, erc20ABI, provider);
const tokenBalance = await token.balanceOf(targetAddress, { blockTag: targetTimestamp });
console.log(`ETH 余额: ${ethers.utils.formatEther(ethBalance)}`);
console.log(`Token 余额: ${ethers.utils.formatUnits(tokenBalance, 6)} USDT`);
} catch (err) {
console.error('查询失败:', err);
}
}
run();关键参数拆解:timestamp 如何映射到块高?
ethers.js 的 getBalance 与 Contract.balanceOf 支持三种输入:
- 数字 → 直接按区块号查询
- "latest" / "pending" / "earliest" → 语义区块
- 时间戳 → 需手动转为 最佳近似区块号
Node 本身无法把 Unix 时间戳反向查块,需要依赖公开索引服务或自建索引。可选思路:
- 访问 Etherscan API,
block.getblockreward×tamp=xxx获取最近区块。 - 本地缓存 “高 → 时间戳” 映射表,使用二分搜索快速逼近。
- 使用开源工具
ethereum-etl事先跑批生成时间戳索引文件。
代码增强:多代币 & 批量地址
当需要一次查 _n_ 种代币或 _m_ 个地址时,并行 Promise.all 是最佳实践,可压缩数秒到数毫秒。
const tokenList = [
{ address: '0xTokenA', abi: erc20ABI },
{ address: '0xTokenB', abi: erc20ABI }
];
const addressList = ['0xUser1', '0xUser2'];
async function batchQuery(blockTag) {
const promises = [];
for (const addr of addressList) {
// ETH
promises.push(provider.getBalance(addr, blockTag).then(b => ({ type: 'ETH', addr, balance: b })));
// TOKENS
for (const { address, abi } of tokenList) {
const contract = new ethers.Contract(address, abi, provider);
promises.push(
contract.balanceOf(addr, { blockTag }).then(b => ({ type: address, addr, balance: b }))
);
}
}
const results = await Promise.all(promises);
return results;
}调用 batchQuery(1690000000) 即可一次性拉回所有组合。
FAQ:你可能遇到的坑与诀窍
Q1:为什么返回的 ETH balance 为 0?
A:确认该地址在目标时间点确实有余额,并检查 RPC 是否支持 Archive 数据。免费节点通常只保留最近 128 个区块状态。
Q2:时间戳与区块时间不精确,误差怎么办?
A:链上时间戳是矿工设置的,波动通常在 ±20 秒内。若你有严格需求,可向前或向后多查 1~2 个区块再取平均值。
Q3:能否用相同脚本查询 BNB、Polygon、Arbitrum?
A:只需把 rpcUrl 换成对应链 HTTPS 端点,并修改相应的 代币合约地址与位数(decimals) 即可。
Q4:查询大批量数据会被限速吗?
A:会。建议在循环里加 await sleep(200),或使用 @ethersproject/experimental 的 FallbackProvider 做负载均衡。
Q5:如何知道代币位数 decimals?
A:在合约 ABI 中再声明 function decimals() view returns (uint8),然后 await token.decimals() 获取即可。
Q6:主网 gas 太贵,可否用 Goerli、Sepolia 测试?
A:完全可以。把 rpcUrl 换成测试网即可,但需要注意测试网水龙头有限额,且部分热门代币合约并未发行测试币。
性能与成本优化建议
| 策略 | 描述 | 适用场景 |
|---|---|---|
| Archive Node | 自建或购买 Archive 级节点 | 需要高频回溯历史 |
| 缓存本地缓存 | 先扫全链,再把时间戳-区块号写 JSON 文件 | 查询维度固定 |
| 事件回溯 | 解析 Transfer 事件,重建余额状态 | 复杂对账或审计 |
| 第三方 API | 使用 Etherscan API(account/tokenbalancehistory) | 少量查询且不想搭节点 |
完整示例项目结构
想一步到位?可把文件整理如下:
project/
├── index.js # 主脚本
├── config.js # 提取 RPC、地址、tokens 等配置
├── utils/
│ └── epoch2Block.js # 时间戳转最佳区块
└── cache/
└── ethBlocks.json # 本地缓存小结
借助 ethers.js 的极简 API,一条 HTTPS RPC 通道就能完成过去需要全节点才能实现的“历史余额”查询。牢记三件事:选对 Archive 节点、把时间戳映射到 块高、批量用 Promise.all 并行提速。吃透以上细节,无论你是写 Dune Dashboard 还是做链上审计,都能把开发周期从小时缩短到分钟。祝你编码愉快!