使用 ethers.js 查询任意时间点 ETH 与 ERC-20 余额的完整指南

·

在区块链数据审计、历史对账与个人资产快照等场景里,开发者常常需要回溯某地址在特定时刻到底有多少 ETHERC-20 代币ethers.js 以其简洁的 API 成为首选工具,本文将通过实战代码带你一步步完成查询,并配合大量注释、常见疑问与扩展示例,让整套流程即学即用。

前置条件


快速跑通核心代码

核心逻辑分为三步:创建 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 的 getBalanceContract.balanceOf 支持三种输入:

Node 本身无法把 Unix 时间戳反向查块,需要依赖公开索引服务或自建索引。可选思路:

  1. 访问 Etherscan API,block.getblockreward&timestamp=xxx 获取最近区块。
  2. 本地缓存 “高 → 时间戳” 映射表,使用二分搜索快速逼近。
  3. 使用开源工具 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/experimentalFallbackProvider 做负载均衡。

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 还是做链上审计,都能把开发周期从小时缩短到分钟。祝你编码愉快!