写合约相关代码快三年了,每次有朋友问我怎么和链上数据打交道,我第一个推荐的还是 ethers.js。这个库在以太坊生态里的地位,基本相当于 jQuery 在早期前端的地位——不是说你不能用原生 fetch,但 ethers.js 把底层 RPC 通信、ABI 编解码、签名验签这些繁琐细节全封装好了,你要做的只是声明“我想读哪个合约的哪个字段”,然后拿数据就行。咱们这文章就直接拿“读取合约信息”这个场景做例子,从零开始把环境、合约实例化、调用只读方法、处理返回值到排查典型报错,一条龙讲透。

读完这篇文章,你应该能做到:给一个任意已部署合约的地址和 ABI,用 ethers.js 在十分钟内写出一段能稳定读取状态的脚本。适合刚入门链上开发的前端工程师,也适合想捣鼓 DeFi 数据但没系统学过 web3 的开发者。我不太讲理论大框架,重点全放在可复现的代码和真实踩过的坑上。

1. 准备工作:为什么选 ethers.js,以及项目怎么初始化

1.1 从 web3.js 到 ethers.js 的选型逻辑

老一代开发者往往习惯 web3.js,前几年市面上大多数教程也是基于它。我最初也在 web3.js 上花过时间,但后来在一个多签钱包项目里被它折磨过几次,比如返回值格式不统一、BigNumber 处理要自己引 BN.js、TypeScript 类型支持基本靠手写声明。换到 ethers.js 之后,最直观的感受就是“类型安全”和“输出可靠”。

ethers.js 的设计哲学是把 Ethereum 的核心概念直接映射成对象:Provider(区块链连接)、Signer(钱包签名者)、Contract(合约实例)、Interface(ABI 编解码)。读取合约信息,本质上是走 Provider 层面的 eth_call,不消耗 gas,也不会改变链上状态。这点新手必须分清:能调用“读函数”不代表你有权限“写合约”,你只是向节点请求了一次计算结果。

对比两者的选型,我的经验是:

  • 项目需要大量合约交互和事件监听,优先 ethers.js
  • 项目需要多链支持且已有 web3.js 历史代码,可以继续沿用
  • 项目对打包体积敏感,ethers.js 支持按需引入,压缩后体积更可控

尤其现在 ethers.js v6 发布之后,API 比 v5 更干净,Error 信息也更友好。新项目我基本无脑选 v6,旧的 v5 项目才保留兼容。

1.2 初始化项目和安装依赖

先建一个空目录,初始化 npm 项目,然后安装 ethers.js。我用 Linux 或 macOS 终端,Windows 用户用 PowerShell 也没区别,命令一致。

mkdir read-contract-demo
cd read-contract-demo
npm init -y
npm install ethers

安装完成后确认版本:

node -e "const { version } = require('ethers'); console.log(version);"

如果输出 6.x.x ,说明装的是 v6。注意 v6 和 v5 在 API 上有破坏性变化,比如 ethers.getDefaultProvider 变成 ethers.getDefaultProvider 仍然可用,但 Provider 类拆分更细致了,后面我会在代码里直接用 v6 语法。

除了 ethers 本体,我建议顺手装一个 dotenv ,用于管理 RPC 地址和私钥等敏感配置:

npm install dotenv

在项目根目录创建 .env 文件:

RPC_URL=https://eth.llamarpc.com
CHAIN_ID=1

公共 RPC 节点我后面会详细说怎么挑,这里先随便填一个能用的。

1.3 Provider 的选择:Infura、Alchemy、公共节点还是本地节点

读取合约信息,第一步永远是拿到一个 Provider。你把 Provider 理解为“链上数据的入口”就行。它替你封装了 JSON-RPC 请求,ethers.js 内部已经处理好了 eth_blockNumber 、 eth_getBalance 、 eth_call 这些底层方法。

Provider 的常见来源有:

Provider 类型 优点 缺点 适用场景
本地 Geth / Anvil 自主可控,免费,速度快 需要同步全节点或启动开发链 本地开发调试,hardhat 环境
Infura 稳定,免费额度足够开发 注册麻烦,限速 生产级 DApp
Alchemy 开发体验好,WebSocket 稳定 免费额度有每日上限 需要历史日志查询的项目
公共 RPC(如 llamarpc、publicnode) 零配置即用 稳定性差,容易限流 脚本临时调试

我个人推荐在脚本调试阶段直接用一个公共 RPC,如果遇到频繁 429 限流,再切换 Infura 或 Alchemy。生产环境则一定要用自有密钥的可信节点服务,并且配置多节点降级。

初始化 Provider 的代码很简单:

const { ethers } = require("ethers");
require("dotenv").config();

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL, Number(process.env.CHAIN_ID));

注意 v6 里 JsonRpcProvider 的第二个参数要传 number 类型的 chainId,如果不传也能用,但某些网络切换场景下会触发额外的 eth_chainId 请求,影响速度。显式传一个能减少一次网络开销。

2. 核心细节:ABI、合约地址和 Contract 实例化

2.1 ABI 到底是什么,为什么读取离不开它

很多刚接触合约的人会把 ABI 当成“接口文档”,这个类比没错,但不够到位。ABI(Application Binary Interface)就是合约函数的“标准说明书”,它用 JSON 数组描述每个函数的名称、输入参数类型、输出参数类型、状态可变性和事件格式。

举个例子,ERC20 的 balanceOf 函数在 ABI 里长这样:

{
  "type": "function",
  "name": "balanceOf",
  "stateMutability": "view",
  "inputs": [{ "name": "account", "type": "address" }],
  "outputs": [{ "name": "", "type": "uint256" }]
}

ethers.js 拿到这段描述后,就知道要把 balanceOf("0x...") 编码成 calldata 发给节点,再把返回的十六进制 0x0000... 解码成一个 JavaScript 可用的 BigInt。没有 ABI,你哪怕知道合约地址也读不了任何数据,因为节点本身不存储函数签名和返回值格式,它只负责执行和返回原始字节。

所以“读取合约信息”的第一步,永远是拿到目标合约的 ABI。获取途径通常是:

  1. 项目仓库里,编译产出文件 artifacts/contracts/XXX.sol/XXX.json 中的 abi 字段
  2. 在 Etherscan 的 “Contract” 页面,找到 “ABI” 标签复制,或者用接口直接抓取
  3. 自己写一个极简 ABI,只包含你要调用的那几个函数的签名

第三条“极简 ABI”是个特别好用的技巧。你不需要把整个合约上百个函数的 ABI 全堆上去,只要声明需要调用的函数即可。比如我只想读 USDC 的 decimals 和 balanceOf ,可以这么写:

const minimalAbi = [
  "function decimals() view returns (uint8)",
  "function balanceOf(address account) view returns (uint256)"
];

ethers.js 支持这种简写语法,会自动解析为标准 ABI。这在调试阶段能少粘很多 JSON,非常省事。但注意,如果合约使用了自定义错误(revert reason),极简 ABI 里也要补上对应接口才能解析出完整错误信息。

2.2 从“需要合约地址”这一步开始的实例化流程

拿到 ABI 后,剩下的实例化就是把地址、ABI、Provider 组成一个 Contract 对象。这里的核心是理解 读取用 Provider 连接,写入用 Signer 连接 。你只是读链上状态,不需要持私钥,更不需要签名,所以千万不要画蛇添足引入钱包私钥。

const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

const usdcContract = new ethers.Contract(
  USDC_ADDRESS,
  minimalAbi,
  provider
);

这段代码里,第三个参数传的是 provider ,所以这个 usdcContract 实例只能执行只读操作。如果有人在代码里用 new Contract(address, abi, signer) 然后只做读取,其实也没错,但白白浪费了签名器的初始化资源,而且一旦代码后续不小心调用了写函数,就可能触发意外的交易。

顺便提一句,如果你用 TypeScript,可以额外引入 @typechain/ethers-v6 这类工具生成类型安全的合约类,IDE 自动补全会特别舒服。脚本调试阶段嫌麻烦可以不搞,手动写注释也够用。

2.3 跨链和多环境治理:怎么保证 RPC 和链 ID 不打架

读取合约信息的时候最容易忽略的坑是“链没选对”。同一个地址在不同链上部署的合约完全不同,尤其是在多链项目里(比如 Polygon、Arbitrum、Base 都有各自的 USDC 合约地址)。所以 Provider 的 chainId 不能随便填。

我习惯写一个简单的配置表:

const CHAINS = {
  ethereum: { chainId: 1, rpc: process.env.ETH_RPC },
  polygon: { chainId: 137, rpc: process.env.POLYGON_RPC },
  arbitrum: { chainId: 42161, rpc: process.env.ARBITRUM_RPC },
};

function getProvider(networkName) {
  const config = CHAINS[networkName];
  if (!config) throw new Error(`Unsupported network: ${networkName}`);
  return new ethers.JsonRpcProvider(config.rpc, config.chainId);
}

这样调用脚本时只需要 getProvider("polygon") ,清晰且不容易传错。“为什么必须填 chainId”这一点,多数 RPC 节点是兼容多条链的(特别是公共节点),如果不显式指定,某些 RPC 会默认返回跟实际预期不符的链上数据,排查起来非常隐蔽。

3. 实操过程:读取函数调用、返回值处理和数据格式化

3.1 第一个读取函数:余额查询的完整代码

核心逻辑简单得让人意外。下面这段代码读取某个地址在 USDC 合约上的余额,并格式化为带小数的字符串:

const { ethers } = require("ethers");
require("dotenv").config();

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL, Number(process.env.CHAIN_ID));

const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const minimalAbi = [
  "function decimals() view returns (uint8)",
  "function balanceOf(address account) view returns (uint256)"
];

const usdc = new ethers.Contract(USDC_ADDRESS, minimalAbi, provider);

async function main() {
  const wallet = "0x28C6c54798a8e9E4FcBd7A1dC9F6c9Bd9e6B1B3"; // 代替某地址
  const balance = await usdc.balanceOf(wallet);
  const decimals = await usdc.decimals();

  console.log("raw balance:", balance.toString());
  console.log("formatted balance:", ethers.formatUnits(balance, decimals));
}

main().catch(console.error);

这段代码里有两个细节值得展开。第一, balanceOf 的返回值是 bigint 类型(ethers.js v6 直接用原生 BigInt,不再像 v5 那样返回自定义的 BigNumber),所以不能直接 balance + 100 ,编译器会报类型错误。第二, ethers.formatUnits(balance, decimals) 会把 wei 级别的最小单位转成人类可读的以太单位。比如 USDC 的 decimals 为 6,余额原始值 5000000 会被格式化成 “5.0”。

反过来,如果你想从带小数的数字出发去构造一个链上参数,就用 ethers.parseUnits("5.0", decimals) ,会得到 BigInt 类型的 5000000。这两个工具函数是处理链上数值的核心,几乎每个脚本都用得到。

3.2 返回值类型全解析:不只是 uint256,还有数组和结构体

合约读取函数的返回值未必永远是单个数字,常见类型组合五花八门。我把项目里真实遇到的类型整理了几类,并给出对应的处理姿势。

布尔型和字符串

// ABI: function paused() view returns (bool)
const paused = await contract.paused(); // true / false

// ABI: function name() view returns (string)
const tokenName = await contract.name(); // "USD Coin"

这类很简单,ethers.js 会自动映射成 JavaScript 的 boolean 和 string 。唯一需要注意的是返回 bytes32 的场景——比如某些合约的 symbol() 返回的是 bytes32 固定长度字节,需要手动解码成字符串:

const symbolBytes = await contract.symbol(); // Uint8Array 或 BytesLike
const symbol = ethers.toUtf8String(symbolBytes).replace(/\0/g, "");

数组和结构体(Tuple)

很多 DEX 路由合约的 getAmountsOut 会返回一个 uint256 数组:

// ABI: function getAmountsOut(uint amountIn, address[] path) view returns (uint[] memory amounts)
const amounts = await contract.getAmountsOut(
  ethers.parseEther("1"),
  [WETH_ADDRESS, USDC_ADDRESS]
);
// amounts 是 bigint 数组,逐个调用 amounts[i].toString()

结构体返回在较新的 Solidity 版本里很常见,比如 Uniswap V3 的 slot0 返回一个包含 7 个字段的结构体。ethers.js v6 会把结构体解析成类似对象的结果,既可以通过索引访问,也可以通过字段名访问:

const slot0 = await poolContract.slot0();
console.log(slot0.sqrtPriceX96.toString());
console.log(slot0[0].toString()); // 与上面等价

如果你的 ABI 声明里对结构体字段起了名字,直接按字段名取最直观;如果没起名字,只能按索引取。所以我强烈建议在写 minimal ABI 时,把 outputs 里的字段名写完整,能省不少事。

3.3 事件日志查询:读取历史交易里发生过的“事情”

如果说调用 view 函数是“读当前状态”,那查询 Event Log 就是“读历史变化”。很多链上指标统计、均值计算、用户行为分析都依赖历史日志。ethers.js 里最常用的方法是 queryFilter 。

下面是读取某个地址在 USDC 合约上最近 1000 个区块内的 Transfer 事件:

const transferEventAbi = [
  "event Transfer(address indexed from, address indexed to, uint256 value)"
];

const usdcInterface = new ethers.Interface(transferEventAbi);

const latestBlock = await provider.getBlockNumber();
const fromBlock = latestBlock - 1000;
const toBlock = latestBlock;

const logs = await provider.getLogs({
  address: USDC_ADDRESS,
  topics: [
    usdcInterface.getEventTopicByName("Transfer"),
    ethers.zeroPadValue(wallet.toLowerCase(), 32) // 只筛选 from = wallet
  ],
  fromBlock,
  toBlock
});

for (const log of logs) {
  const parsed = usdcInterface.parseLog(log);
  console.log(`from: ${parsed.args.from}, to: ${parsed.args.to}, value: ${ethers.formatUnits(parsed.args.value, 6)}`);
}

这段代码里有几个“如果只调 view 函数永远不会遇到”的坑。最典型的是 topics 里地址必须转成 32 字节左填充格式 ,你可以用 ethers.zeroPadValue 处理,或者直接用 ethers.id(address.toLowerCase()) 也可以(后者本质上也是取事件签名哈希)。另一个坑是 getLogs 的区块范围不能太大,公共 RPC 节点通常限制单次查询不超过 10000 个区块,否则会直接报 query returned more than 10000 results 。经验做法是分段查询,比如每次查 1000 个区块,然后拼接结果。

3.4 并发读取和批量查询:Promise.all 的优雅用法

现实场景中你往往不止读一个数据。拿一个 DeFi 仪表盘项目举例,可能要同时读十几个 token 的价格、余额、总供应量。如果一个个 await 下去,耗时等于所有请求串行相加,太浪费时间。解决办法是用 Promise.all 并发发出请求。

async function batchRead() {
  const [ethBalance, usdcBalance, daiBalance, currentBlock] = await Promise.all([
    provider.getBalance(wallet),
    usdc.balanceOf(wallet),
    dai.balanceOf(wallet),
    provider.getBlockNumber()
  ]);

  console.log({
    eth: ethers.formatEther(ethBalance),
    usdc: ethers.formatUnits(usdcBalance, 6),
    dai: ethers.formatUnits(daiBalance, 18),
    block: currentBlock
  });
}

需要注意的是,千万别无限并发。一个 RPC 节点如果同时收到几百个请求,很容易触发限流。我在脚本里通常会手动做一个简单的 chunk 控制,比如每批次最多 20 个请求,等一批完成再发下一批。ethers.js 本身没有内置并发限流器,所以这层控制得业务层自己做。

4. 进阶技巧:callStatic、存储槽读取和离线环境

4.1 callStatic:不改变链上状态的“模拟读取”

前面说过 view 函数天然只读,但有些函数并不是 view ,只是你想在“不发送交易”的情况下知道它执行完会返回什么。比如 Uniswap 的 swap 函数,它是非 view 的,模拟执行用 eth_call 也能拿到返回值。ethers.js 提供了统一方法: contract.functionName.staticCall(...args) 。

// 比如想模拟用 1 ETH 换出多少 USDC,但不想真正发起 swap
const amounts = await router.getAmountsOut.staticCall(
  ethers.parseEther("1"),
  [WETH_ADDRESS, USDC_ADDRESS]
);

注意, staticCall 的结果和实际交易执行的结果可能不同,因为在真实交易里状态会变化、gas 等条件也不同。它最合适的场景是:合约界面预览、前端按钮点击前的数据预估、或者调试某个非 view 函数的返回值。

另一个相关方法是 contract.functionName.estimateGas() ,用来估算执行交易需要的 gas。读取场景也能用到它,因为 RPC 节点执行 eth_estimateGas 时其实也会跑一遍完整调用逻辑,顺带能验证你的参数是否合理。

4.2 读取存储槽:绕过 ABI 直接看原始状态

有经验的开发者知道,区块链上所有合约状态变量都存放在一个个“存储槽”里,读取它们不需要 ABI,只需要知道槽位索引。ethers.js 暴露了 provider.getStorage 方法:

const slot0Value = await provider.getStorage(contractAddress, 0);

这个方法在两种场景下特别有用。一是 ABI 不完整,你只知道某个变量存在但接口文档缺失;二是想验证某些“代理合约”的实际实现地址(通常存在特定的槽位,比如 EIP-1967 中的 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc )。读取 EIP-1967 实现的代码可以这样写:

const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
const implementation = await provider.getStorage(proxyAddress, IMPLEMENTATION_SLOT);
console.log(ethers.getAddress(ethers.dataSlice(implementation, 12)));

注意 getStorage 返回的是 32 字节十六进制字符串,地址类型只占后 20 字节,所以要用 dataSlice(implementation, 12) 跳过前 12 字节。这个技巧在分析链上恶意合约、追踪升级逻辑时是必备武器。

4.3 离线环境下的读取:从 JSON-RPC 数据到“无节点模式”

如果目标只是解析链上数据,而且数据已经以原始 calldata 或日志形式拿到了,你甚至可以完全脱离 Provider,直接用 ethers.Interface 做编解码。比如你有一笔交易的回执,里面带 logs,你可以离线 parse:

const iface = new ethers.Interface(abi);
const decodedLog = iface.parseLog({
  topics: log.topics,
  data: log.data
});

这等于把 ethers.js 当成一个纯编解码库来用。我在写数据分析脚本时经常这么做:先把日志批量抓下来存到数据库,然后离线解析,完全不依赖节点在线状态。一个额外的好处是解析速度极快,一次能处理上百万条日志。

如果连原始 calldata 都有, iface.parseTransaction({ data }) 也能解析出调用了哪个函数以及参数是什么。这个能力对 audit 和链上行为分析非常有用,新手阶段可能用不上,但知道了总没坏处。

5. 实战复盘:一次真实项目的读取流程复现

5.1 需求场景和脚本骨架

我拿之前在做一个“DEX 监控面板”时的一段脚本当完整案例。需求很简单:给定一个钱包地址,读取它在某个 DEX 流动性池子里的 LP 份额,并计算出对应可赎回的两种代币数量。

步骤拆开就是:

  1. 拿到 LP Token 合约地址和 ABI
  2. 读取钱包的 LP 余额
  3. 读取池子总的 LP 供应量
  4. 读取池子内两种代币的储备量
  5. 按占比换算可赎回代币数

核心代码如下,完整展示了读取多个合约、多种返回值的组合:

const { ethers } = require("ethers");
require("dotenv").config();

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL, 1);

const PAIR_ADDRESS = "0x..."; // 真实的 LP 池子地址
const pairAbi = [
  "function balanceOf(address) view returns (uint256)",
  "function totalSupply() view returns (uint256)",
  "function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)",
  "function token0() view returns (address)",
  "function token1() view returns (address)"
];

const pair = new ethers.Contract(PAIR_ADDRESS, pairAbi, provider);

async function main() {
  const user = "0x...";

  const [userLpBalance, totalLpSupply, reserves, token0, token1] = await Promise.all([
    pair.balanceOf(user),
    pair.totalSupply(),
    pair.getReserves(),
    pair.token0(),
    pair.token1()
  ]);

  // 计算占比,全部用 BigInt 运算避免精度丢失
  const shareNumerator = userLpBalance * BigInt(10 ** 18); // 放大到 1e18
  const shareRatio = shareNumerator / totalLpSupply; // 相当于百分比的 1e18 倍

  const userToken0 = reserves.reserve0 * shareRatio / BigInt(10 ** 18);
  const userToken1 = reserves.reserve1 * shareRatio / BigInt(10 ** 18);

  console.log(`token0 (${token0}) 可赎回: ${ethers.formatUnits(userToken0, 18)}`);
  console.log(`token1 (${token1}) 可赎回: ${ethers.formatUnits(userToken1, 18)}`);
}

main().catch(console.error);

这段代码里有几个值得背下来的处理习惯。BigInt 除法会直接舍去小数,所以先放大再相除是避免精度误差的常规操作,缩放的倍数直接取 1e18,够覆盖绝大多数场景。 getReserves 返回的是结构体,直接按字段名取 reserve0 和 reserve1 ,非常方便。

5.2 从 Etherscan 自动抓取 ABI 的补充方案

有些老项目仓库里没有现成的 ABI 文件,但我又不想手工在 Etherscan 上复制一整个 JSON。这时可以用 Etherscan 的 API 直接抓取:

curl "https://api.etherscan.io/api?module=contract&action=getabi&address=合约地址&apikey=你的KEY"

返回结果的 result 字段就是一个标准 ABI JSON 字符串,在 Node 里 JSON.parse 就能用。这个办法在脚本里自动化批量拉取合约 ABI 时非常高效,比如我需要监控 20 个池子,拉 20 次接口存到本地文件即可。

不过注意 Etherscan API 普通免费 Key 有速率限制,大概每秒 5 次,批量拉取时最好加一个 200ms 的 sleep。其他链的区块浏览器也有对应接口,比如 PolygonScan、Arbiscan,格式大同小异,换一下域名就行。

6. 常见问题与排查技巧实录

6.1 “CALL_EXCEPTION” 报错:多半不是节点问题

读取合约遇到最多的报错是类似于:

Error: CALL_EXCEPTION: call revert exception (method="balanceOf(address)", args=...)

新手第一反应是 RPC 节点坏了,其实九成情况是合约函数本身 revert 了。原因可能是:参数地址格式不对、调用的函数在当前链上不存在、合约已经自毁或者被暂停。排查步骤我按顺序列一下:

  1. 用区块浏览器手工调一次同样的方法,看是否也报错
  2. 核对 ABI 中的函数名和参数类型是否和合约源码一致
  3. 确认合约地址是否在当前链上部署过(切换到对应区块浏览器的合约页看代码)
  4. 检查传入参数是否为合法地址—— ethers.isAddress(arg) 可以快速验证

这个报错还有一个常见变种: missing response 。它经常出现在公共 RPC 节点超时时,重试一次往往就成功了。所以我通常在脚本里加一个简单的重试函数:

async function withRetry(fn, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === retries - 1) throw e;
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}

6.2 BigInt 和精度问题:JS 里最隐蔽的坑

JavaScript 的 Number 类型最大安全整数是 2^53 - 1 ,而链上 uint256 动不动就超过这个范围。ethers.js v6 直接采用原生 BigInt 已经帮你规避了 99% 的坑,但真正致命的错误发生在“格式化之后再做运算”。

比如别人给你一个字符串 "1234.5678" ,你直接用 parseFloat 转成数字,再乘以另一个数字,精度可能瞬间丢失。有个血泪教训来自我早期写的一个价格计算脚本:把金额先 formatEther 成字符串,再 Number() 参与乘法,最后结果在小数点后几位跟链上实际情况对不上。正确答案是: 一切计算必须在 BigInt 阶段完成,格式化只发生在最终展示时 。这个原则怎么强调都不为过。

6.3 网络和链 ID 错配:读出来的数据“不对”怎么办

链 ID 错配的经典表现是“脚本没有报错,但读出的余额是 0”。比如你把 Ethereum 主网的合约地址和 ABI 填好,却用了一个连接 Polygon 的 RPC(有些公共 RPC 混合支持多条链),节点会直接告诉你这个合约地址上没有任何代码,执行 balanceOf 返回 0。

排查方法是打印 Provider 的当前网络信息:

const network = await provider.getNetwork();
console.log("chainId:", network.chainId);

如果 chainId 不是预期值,立刻去检查 .env 里的 RPC_URL。还有一个容易忽略的细节:某些 L2 的 RPC 服务商允许通过 URL 路径区分网络,比如 Alchemy 的 URL 里带了 -mainnet 或 -matic 后缀,一旦复制错,整个读取链路全错。

6.4 公共 RPC 限流和数据不一致问题

公共 RPC 节点免费,但限流策略非常严格,尤其是输出调用频繁、对实时性要求高的场景。常见报错是:

429 Too Many Requests

或者:

Project ID is required

第二个报错其实是 Infura 这类服务的提示,意思是必须带 API key。应对办法是准备多个 RPC 节点,做轮询或者故障切换。我常用一个小技巧:把三个公共 RPC 放进数组,每次请求随机取一个,失败就换下一个,能有效降低被限流的概率:

const RPC_ENDPOINTS = [
  process.env.RPC_URL_1,
  process.env.RPC_URL_2,
  process.env.RPC_URL_3
];

function getRandomProvider() {
  const rpc = RPC_ENDPOINTS[Math.floor(Math.random() * RPC_ENDPOINTS.length)];
  return new ethers.JsonRpcProvider(rpc, 1);
}

这种方式读出来的数据可能会有短暂的不一致,因为不同节点背后的区块同步状态略有差异。对于严格的资金安全类应用,还是应该用单一可信节点并开启 eth_getProof 等验证手段,但脚本层面随机节点完全够用。

7. 实际调试中的个人经验总结

写到这里,我还是想单独留一个板块说说使用习惯上的东西,这些不是从文档里能查到的。

ethers.js 的调试最有效的工具不是 console.log,而是把底层 RPC 请求打印出来。v6 里可以给 JsonRpcProvider 传入一个自定义 fetch 函数,把每次请求打到终端:

const provider = new ethers.JsonRpcProvider(url, chainId, {
  fetchOptions: {
    method: "POST",
    headers: { "Content-Type": "application/json" }
  }
});

但更好的方式是直接在代码里临时包一层 fetch,输出请求体和响应体。肉眼看到 eth_call 的完整参数,很多奇怪问题当场就明白了。

另外,我强烈建议在每个读取脚本里加一个“环境自检”段。开始读数据之前,先简单打印一下 Provider 连接到的区块高度和链 ID。这个习惯帮我无数次避免了在错误链上浪费半小时。自检代码也就三行:

const blockNumber = await provider.getBlockNumber();
const network = await provider.getNetwork();
console.log(`Connected to chain ${network.chainId}, block ${blockNumber}`);

最后分享一个小技巧:如果你在一个 Node 脚本里频繁读取同一个合约的同一个函数,可以缓存最近一次结果,设置一个 30 秒到 1 分钟的过期时间。链上数据变化没那么快,缓存能显著减少 RPC 请求量,尤其当你循环遍历几百个地址的时候,这个优化能把执行时间从几分钟压到几秒。我写过的一个地址扫描脚本直接提速 20 倍,做法就是只加了一个简单的 Map 缓存。这不算什么高级技术,但确实是我这几年实际项目里最能提升效率的土办法。

下次再有人问“ethers.js 读取合约信息难不难”,你可以直接告诉他:入口无非是 Provider、ABI、Contract 实例三板斧,真正的功夫全在对返回值类型和链上数据特性的理解上。把这篇文章里的例子跑通一遍,再去读 Ethers 官方文档,你会发现自己已经能很自然地理解文档在讲什么了。

更多推荐