摘要:用 ethers.js v6 在 Sepolia 测试网只读调用 Circle 官方测试版 USDC 合约:Human-Readable ABI、Contract 读 decimals 与 balanceOf、手动编码 calldata 走 eth_call,再用 Etherscan「Read as Proxy」核对选择器,附 BAD_DATA 与地址校验和两个常见坑。
上一篇 ethers.js 连测试网:Provider 与读余额第一步 只读了 ETH 余额,用到的是节点自带的 eth_getBalance。但链上大部分资产和数据都在合约里:USDC 余额、预言机价格、NFT 归属……读它们需要三样东西——合约地址(address)、接口描述(ABI)、只读调用(call)。本文用 ethers.js v6 在 Sepolia 测试网读 Circle 官方测试版 USDC 合约,全部只读:不需要私钥、不发交易、不花 Gas,代码在本机真实跑通。
准备:环境与要读的合约
环境沿用上一篇:Node.js 20、npm install ethers(本文运行版本 6.17.0),RPC 用公共节点 https://ethereum-sepolia-rpc.publicnode.com。合约地址一定从官方渠道拿,不要从群聊或搜索结果里抄。Circle 在 Circle Docs · USDC contract addresses 的 Testnet 表里列出了 Ethereum Sepolia 的 USDC 地址,并且醒目提示:测试网代币没有任何金融价值、不由真实美元支持。

ABI 是什么:告诉 ethers 怎么编码、怎么解码
节点只认字节。你想调 balanceOf(地址),得有人把「函数名 + 参数」编码成一串十六进制 calldata,再把节点返回的 32 字节结果解码回数字——这套规则就是 ABI(Application Binary Interface)。ethers 官方文档 Application Binary Interfaces 写得很清楚:calldata 的前 4 字节是方法选择器(method selector),即规范化函数签名的 keccak256 哈希前 4 字节;后面的参数都按 32 字节对齐拼接;成功调用的返回值同样按 32 字节编码。

文档也提到 ABI 从哪来:标准合约(如 ERC-20)可直接用标准 ABI;在 Etherscan 验证过源码的合约可以从浏览器取 ABI。ethers 还支持 Human-Readable ABI——直接写 Solidity 风格的函数签名字符串,只写你用到的几个函数即可,不必贴整份 JSON。
第一段代码:Contract 读 name / decimals / balanceOf
新建 read_contract.mjs。读取用 new Contract(address, abi, provider):第三个参数传 Provider(而不是 Signer)时,合约对象只能做只读调用,这正是我们要的安全边界。
import { JsonRpcProvider, Contract, formatUnits, Interface } from "ethers";
const RPC = "https://ethereum-sepolia-rpc.publicnode.com";
// Circle 官方文档列出的 Sepolia 测试网 USDC 合约
const USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const HOLDER = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // 任意公开地址
// Human-Readable ABI:只写要用到的函数签名
const abi = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
];
const provider = new JsonRpcProvider(RPC);
const usdc = new Contract(USDC, abi, provider);
const code = await provider.getCode(USDC);
console.log("code size (bytes):", (code.length - 2) / 2);
const [name, symbol, decimals, supply, bal] = await Promise.all([
usdc.name(), usdc.symbol(), usdc.decimals(), usdc.totalSupply(), usdc.balanceOf(HOLDER),
]);
console.log("name:", name);
console.log("symbol:", symbol);
console.log("decimals:", decimals.toString());
console.log("totalSupply raw:", supply.toString());
console.log("totalSupply:", formatUnits(supply, decimals));
console.log("balanceOf raw:", bal.toString());
console.log("balanceOf:", formatUnits(bal, decimals), symbol);
// 底层其实就是一次 eth_call:selector + 编码后的参数
const iface = new Interface(abi);
const data = iface.encodeFunctionData("balanceOf", [HOLDER]);
console.log("calldata:", data);
const rawRet = await provider.call({ to: USDC, data });
console.log("eth_call return:", rawRet);
console.log("decoded:", iface.decodeFunctionResult("balanceOf", rawRet)[0].toString());
几个要点:
view函数在 ethers 里会自动走eth_call:节点在本地执行一遍合约代码并返回结果,不上链、不需要签名、不花 Gas。- 返回的
uint256是 JavaScript 的bigint;人类可读金额要用formatUnits(值, decimals)换算。USDC 的 decimals 是 6,不是 ETH 的 18——直接套formatEther会差 1012 倍。 - 最后一段手动用
Interface编码 calldata、调provider.call、再解码,等于把usdc.balanceOf()背后发生的事拆开给你看。
第二段代码:两个常见坑
新建 pitfall.mjs,故意犯两个新手最常见的错:把普通钱包地址当合约读、地址大小写被改乱。
import { JsonRpcProvider, Contract, getAddress } from "ethers";
const provider = new JsonRpcProvider("https://ethereum-sepolia-rpc.publicnode.com");
const EOA = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // 普通地址,不是合约
const abi = ["function symbol() view returns (string)"];
console.log("getCode(EOA):", await provider.getCode(EOA));
try {
await new Contract(EOA, abi, provider).symbol();
} catch (e) {
console.log("error.code:", e.code);
console.log("error.shortMessage:", e.shortMessage);
}
try {
getAddress("0x1C7d4b196cb0c7b01d743fbc6116a902379c7238"); // 大小写被改乱
} catch (e) {
console.log("bad checksum ->", e.shortMessage);
}
console.log("checksummed:", getAddress("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"));
本机运行结果与逐行解读
两个脚本在本机依次运行,下图为真实终端输出(数值为 2026-10-05 运行时的 Sepolia 链上状态,会变化):

| 输出 | 含义 | 怎么核对 |
|---|---|---|
| code size: 1798 | 该地址有合约字节码;体积很小,因为它是代理合约 | 下一节 Etherscan 显示 Source Code (Proxy) |
| decimals: 6 | 金额要除以 106 | Etherscan 代币页「WITH 6 DECIMALS」 |
| totalSupply | 原始值 ÷ 106 才是展示值 | 与 Etherscan「Max Total Supply」一致 |
| calldata: 0x70a08231… | 前 4 字节 0x70a08231 是 balanceOf(address) 的选择器,后 32 字节是左补零的地址 |
Read as Proxy 列表里 balanceOf 后括号的 ID |
| eth_call return: 0x…501f5277 | 32 字节大端整数,0x501f5277 = 1344230007 | decoded 一行;÷106 = 1344.230007 |
| getCode(EOA): 0x | 普通地址没有代码 | 对 0x 调合约函数必然解码失败 |
| BAD_DATA / could not decode result data | 节点返回空 0x,ethers 无法按 ABI 解码 | 先查地址是不是合约、是不是对的链 |
| bad address checksum | 混合大小写地址校验和不符,ethers 直接拒绝 | 用 getAddress() 统一成 EIP-55 校验格式 |
遇到 BAD_DATA 最常见的三个原因:地址填成了钱包地址;合约在 A 链、RPC 连的是 B 链(例如把主网 USDC 地址拿到 Sepolia 上读);ABI 里的函数合约根本没有。排查顺序永远是:getNetwork() 看 chainId → getCode() 看有没有代码 → 再对 ABI。
在 Etherscan 上逐项核对
打开 Sepolia Etherscan · USDC 代币页:标签显示 ERC-20、Source Code (Proxy),旁边是 Implementation 地址;Max Total Supply 与脚本算出的 totalSupply 一致;右侧 Other Info 写着「WITH 6 DECIMALS」。

再切到合约页的 Contract → Read as Proxy。每个只读函数后面括号里就是它的 4 字节选择器:第 8 项 balanceOf (0x70a08231) 正是我们 calldata 的开头,第 11 项 decimals (0x313ce567)、第 16 项 name (0x06fdde03) 也都能对上。

为什么要「as Proxy」?代理合约自己几乎没有业务代码,调用进来后用 delegatecall 转给实现合约(Implementation)执行,存储仍在代理地址上。所以:地址用代理地址,ABI 用实现合约的接口。在 ethers 里你照样对代理地址 new Contract(),只要 ABI 里的函数实现合约有就行。列表里还能看到 isBlacklisted、paused 这类函数,说明发行方保留了冻结与暂停能力——读合约之前顺手看一眼函数清单,是了解一个代币权限结构最快的办法。
自查清单
- 合约地址只从官方文档或已验证的浏览器页面获取,用
getAddress()规范化。 - 先确认 chainId 与合约所在网络一致,再
getCode()确认是合约。 - 只读场景 Contract 第三个参数传 Provider,不要为了读数据去加载私钥。
- 金额一律
formatUnits(值, decimals),decimals 从合约读,不要写死 18。 - 代理合约:地址用代理,ABI 用实现;浏览器看「Read as Proxy」。
下一步会进入写操作(Signer 发测试网交易、等确认),那时才需要测试网 ETH 和测试私钥。到主网真正交互时,Gas 要用 ETH 支付,可在 币安 或 欧易 准备少量 ETH;日常钱包可以用 欧易 Web3 钱包(创建时可填推荐码 CHENGXULVTU)。开发与测试请始终用独立的测试账户。
相关阅读
- ethers.js 连测试网:Provider 与读余额第一步
- 预言机是什么:Chainlink 喂价在浏览器怎么核对
- BNB Chain 怎么运作:PoSA 验证者、出块与 BEP-20 链上核对
- 以太坊 Gas 费怎么算:Base Fee、Priority Fee 与 EIP-1559 字段对照
说明:文中代码只连 Sepolia 测试网、只做只读调用;供应量、余额为 2026-10-05 运行与截图时的测试网数据,会持续变化,测试网 USDC 没有任何价值。接口以 ethers v6 官方文档为准。本文只讲技术方法,不构成任何投资建议。
