ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ethers.js实战:从零到一读取智能合约链上数据

ethers.js实战:从零到一读取智能合约链上数据 写合约相关代码快三年了每次有朋友问我怎么和链上数据打交道我第一个推荐的还是 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合约实例、InterfaceABI 编解码。读取合约信息本质上是走 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_URLhttps://eth.llamarpc.com CHAIN_ID1公共 RPC 节点我后面会详细说怎么挑这里先随便填一个能用的。1.3 Provider 的选择Infura、Alchemy、公共节点还是本地节点读取合约信息第一步永远是拿到一个 Provider。你把 Provider 理解为“链上数据的入口”就行。它替你封装了 JSON-RPC 请求ethers.js 内部已经处理好了eth_blockNumber、eth_getBalance、eth_call这些底层方法。Provider 的常见来源有Provider 类型优点缺点适用场景本地 Geth / Anvil自主可控免费速度快需要同步全节点或启动开发链本地开发调试hardhat 环境Infura稳定免费额度足够开发注册麻烦限速生产级 DAppAlchemy开发体验好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 当成“接口文档”这个类比没错但不够到位。ABIApplication 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。获取途径通常是项目仓库里编译产出文件artifacts/contracts/XXX.sol/XXX.json中的abi字段在 Etherscan 的 “Contract” 页面找到 “ABI” 标签复制或者用接口直接抓取自己写一个极简 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你可以离线 parseconst 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 份额并计算出对应可赎回的两种代币数量。步骤拆开就是拿到 LP Token 合约地址和 ABI读取钱包的 LP 余额读取池子总的 LP 供应量读取池子内两种代币的储备量按占比换算可赎回代币数核心代码如下完整展示了读取多个合约、多种返回值的组合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?modulecontractactiongetabiaddress合约地址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 (methodbalanceOf(address), args...)新手第一反应是 RPC 节点坏了其实九成情况是合约函数本身 revert 了。原因可能是参数地址格式不对、调用的函数在当前链上不存在、合约已经自毁或者被暂停。排查步骤我按顺序列一下用区块浏览器手工调一次同样的方法看是否也报错核对 ABI 中的函数名和参数类型是否和合约源码一致确认合约地址是否在当前链上部署过切换到对应区块浏览器的合约页看代码检查传入参数是否为合法地址——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 官方文档你会发现自己已经能很自然地理解文档在讲什么了。
返回列表