ERC-20 代币余额并不存储在钱包中,而是存在于代币合约内部,作为从地址到 uint256 的映射。读取它需要对代币合约的 balanceOf(address) 函数发起 eth_call,其 calldata 由 4 字节函数选择器和 32 字节填充参数构成。同样的模式可用于读取 decimals()、symbol()、name() 和 totalSupply()。本指南解释了 ABI 编码,提供了可运行的 Node.js 示例,并针对余额为零、合约地址错误等常见故障提供了排查手册。
为什么 ERC-20 余额存在于代币合约中
ERC-20 代币余额不是钱包的属性。钱包是外部拥有账户(EOA)或合约,不持有任何代币特定状态。相反,代币合约维护一个从地址到 uint256 的映射,如 EIP-20 所定义。当你调用 balanceOf(holder) 时,合约会查找该映射并返回值。
这种设计意味着读取代币余额需要对代币合约发起 eth_call,而不是查询钱包。钱包地址只是函数的参数。元数据如 decimals、symbol、name 和 totalSupply 也是如此:它们都由代币合约存储和返回。
由于余额是合约状态,它可能在不同区块之间发生变化。你传递给 eth_call 的区块标签决定了使用哪个状态。如果省略区块标签,节点通常使用最新区块,但此行为有文档说明且因提供商而异。当你需要可复现的结果时,请始终指定区块标签。
- 代币余额存储在代币合约的存储中,而不是钱包上。
- balanceOf(address) 从合约的映射中返回 uint256。
- 元数据函数(decimals、symbol、name、totalSupply)也是合约读取。
- 区块标签控制使用哪个状态快照。
代币读取的 eth_call 请求结构
eth_call 方法针对节点状态执行只读消息调用并返回返回数据。它不创建交易,不消耗你账户的 gas,也不持久化任何内容。请求对象有一个 to 字段用于代币合约地址,一个 data 字段用于 ABI 编码的 calldata。可选的区块参数可以作为第二个参数包含。
根据 以太坊 JSON-RPC 规范,eth_call 接受一个交易对象和一个区块号或标签。对于纯读取,交易对象不应包含 from 字段,尽管某些提供商接受它。data 字段是十六进制编码的 calldata。
一个最小请求如下所示:{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0xTokenContract","data":"0x70a08231..."},"latest"],"id":1}。响应包含一个 result 字段,其中是十六进制编码的返回数据。
const response = await fetch('https://api.onfinality.io/eth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_call',
params: [
{
to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum
data: '0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
},
'latest'
],
id: 1
})
});
const json = await response.json();
console.log(json.result); // 0x... (32-byte hex)ABI 编码:函数选择器和填充参数
ERC-20 读取的 calldata 由两部分构成:4 字节函数选择器和 ABI 编码的参数。选择器是函数签名(如 balanceOf(address))的 keccak256 哈希的前 4 个字节。对于 balanceOf(address),选择器是 0x70a08231。对于 decimals(),是 0x313ce567。对于 symbol(),是 0x95d89b41。对于 name(),是 0x06fdde03。对于 totalSupply(),是 0x18160ddd。
每个参数左填充到 32 字节。地址是 20 字节,因此用 12 个前导零字节填充。例如,地址 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 变为 0x000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045。完整的 calldata 是选择器与填充参数的拼接。
Solidity ABI 规范定义了这种编码。你可以手动构造,也可以使用 ethers.js、web3.js 或 viem 等库。库更不易出错,但理解编码有助于调试原始响应。
- balanceOf(address) 选择器:0x70a08231
- decimals() 选择器:0x313ce567
- symbol() 选择器:0x95d89b41
- name() 选择器:0x06fdde03
- totalSupply() 选择器:0x18160ddd
解码返回数据:uint256 和动态字符串
eth_call 的返回数据是十六进制编码的。对于 balanceOf、decimals 和 totalSupply,返回的是单个 32 字节字,可以解码为 uint256。对于 decimals,值是一个存储在 32 字节字中的 uint8,因此你可以读取最后一个字节或将整个字解析为整数。对于 balanceOf 和 totalSupply,整个 32 字节字就是整数值。
对于 symbol 和 name,返回的是动态 ABI 编码字符串。第一个 32 字节字是指向字符串数据的偏移量,该偏移量处的下一个 32 字节字是长度,随后的字节是 UTF-8 字符串。手动解码需要读取偏移量,然后读取长度,再切片字符串字节。
一个常见错误是将 symbol 或 name 的整个返回值当作 uint256 处理。那会得到一个巨大的数字,而不是字符串。始终检查函数签名并相应解码。
function decodeUint256(hex) {
return BigInt(hex);
}
function decodeString(hex) {
const data = hex.slice(2); // remove 0x
const offset = parseInt(data.slice(0, 64), 16) * 2;
const length = parseInt(data.slice(offset, offset + 64), 16) * 2;
const stringHex = data.slice(offset + 64, offset + 64 + length);
return Buffer.from(stringHex, 'hex').toString('utf8');
}
// Example usage:
// const rawBalance = '0x00000000000000000000000000000000000000000000000000000000000f4240';
// console.log(decodeUint256(rawBalance)); // 1000000n
// const rawSymbol = '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000045553444300000000000000000000000000000000000000000000000000000000';
// console.log(decodeString(rawSymbol)); // 'USDC'读取 decimals 并转换原始余额
decimals() 函数返回代币使用的小数位数。大多数代币使用 18,但这并不保证。例如,USDC 使用 6。要将原始余额转换为人类可读的金额,请将原始余额除以 10^decimals。对于有 6 位小数的代币,原始余额 1000000 等于 1.0 个代币。
在显示余额之前,始终调用 decimals()。假设像 USDC 这样的代币有 18 位小数,会显示偏差 10^12 倍的余额。转换是简单的除法,但必须使用正确的 decimals 值。
如果 decimals() 回滚或返回意外值,合约可能未完全实现 ERC-20 标准。某些代币返回固定值或完全省略该函数。在这种情况下,你可能需要回退到已知值或优雅地处理错误。
- decimals() 返回 uint8,通常为 18,但不总是。
- 人类可读余额 = 原始余额 / 10^decimals。
- USDC 使用 6 位小数;DAI 使用 18 位。
- 在格式化之前始终获取 decimals。
可运行的 Node.js 示例:余额、decimals、symbol 和转换
以下 Node.js 脚本使用基于 fetch 的原始 JSON-RPC 读取持有者的余额、代币 decimals 和代币 symbol。然后它将原始余额转换为人类可读的金额,并解码 symbol 的动态字符串返回值。将 RPC URL、代币合约和持有者地址替换为你自己的值。
此示例使用 OnFinality 以太坊端点作为占位符。你可以使用任何以太坊 RPC 端点,包括你自己的节点或提供商。该脚本演示了完整流程:编码 calldata、发送 eth_call、解码返回数据并格式化结果。
const RPC_URL = 'https://api.onfinality.io/eth';
const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC
const HOLDER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
async function rpc(method, params) {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 })
});
const json = await res.json();
if (json.error) throw new Error(json.error.message);
return json.result;
}
function encodeBalanceOf(address) {
const selector = '70a08231';
const padded = address.toLowerCase().replace('0x', '').padStart(64, '0');
return '0x' + selector + padded;
}
function decodeUint256(hex) {
return BigInt(hex);
}
function decodeString(hex) {
const data = hex.slice(2);
const offset = parseInt(data.slice(0, 64), 16) * 2;
const length = parseInt(data.slice(offset, offset + 64), 16) * 2;
const stringHex = data.slice(offset + 64, offset + 64 + length);
return Buffer.from(stringHex, 'hex').toString('utf8');
}
async function main() {
const balanceHex = await rpc('eth_call', [
{ to: TOKEN, data: encodeBalanceOf(HOLDER) },
'latest'
]);
const rawBalance = decodeUint256(balanceHex);
const decimalsHex = await rpc('eth_call', [
{ to: TOKEN, data: '0x313ce567' },
'latest'
]);
const decimals = Number(decodeUint256(decimalsHex));
const symbolHex = await rpc('eth_call', [
{ to: TOKEN, data: '0x95d89b41' },
'latest'
]);
const symbol = decodeString(symbolHex);
const human = Number(rawBalance) / 10 ** decimals;
console.log(`Token: ${symbol}`);
console.log(`Raw balance: ${rawBalance}`);
console.log(`Decimals: ${decimals}`);
console.log(`Human-readable balance: ${human}`);
}
main().catch(console.error);针对你自己端点的可复现结果表
要验证你的 RPC 端点的行为,请用你自己测量的值填写下表。使用已知的代币合约和已知的持有者地址。记录链 ID、原始 balanceOf 返回值、decimals 值、人类可读余额、symbol 和总供应量。在不同的区块标签下重复测量,以观察区块标签如何影响结果。
此表是一个模板。不要依赖本文中预填的数字;请针对你自己的端点进行测量。目标是确认你的端点返回一致的数据,并且你的解码逻辑正确。
- 代币合约:[你的代币地址]
- 链 ID:[你的链 ID]
- 原始 balanceOf:[十六进制或十进制]
- Decimals:[整数]
- 人类可读余额:[原始 / 10^decimals]
- Symbol:[字符串]
- 总供应量:[原始 totalSupply]
常见故障:balanceOf 返回零
balanceOf 调用返回零是最常见的问题。它通常意味着以下三种情况之一:代币合约地址错误、链 ID 错误,或者你正在读取一个不直接实现 balanceOf 的代理地址。如果合约地址错误,eth_call 可能返回 0x 或回滚。如果链错误,该地址可能在该链上不存在。
要诊断,首先使用 eth_getCode 验证合约在你查询的链上存在。非空字节码结果确认合约已部署。然后调用 symbol() 或 name() 确认合约是 ERC-20 代币。如果 symbol() 回滚,合约可能不符合 ERC-20 标准,或者可能是需要不同接口的代理。
对于代理合约,实现地址存储在特定的存储槽中。你可以使用 eth_getStorageAt 读取该槽,然后调用实现。然而,许多代理透明地转发调用,因此 balanceOf 可能仍然有效。如果无效,请检查代理的 ABI 或文档。
- 代币合约地址错误:使用 eth_getCode 验证。
- 链 ID 错误:确保代币在你查询的链上存在。
- 代理地址:检查代理是否转发调用或需要实现地址。
- 非标准代币:某些代币未按预期实现 balanceOf。
eth_call 代币读取的局限性与权衡
代币合约各不相同。有些是代理,有些返回非标准类型,而 rebasing 或 fee-on-transfer 代币使 balanceOf 成为移动目标。Rebasing 代币在没有转账的情况下改变余额,因此你读取的值可能与用户的预期不符。Fee-on-transfer 代币在转账时扣除费用,因此接收者的余额可能少于发送金额。
区块标签影响结果。如果你查询 'latest',余额反映你的端点已知的最新区块状态。如果你查询特定区块号,余额反映该历史状态。为了可复现的结果,请始终指定区块号。
eth_call 反映你查询的端点的状态。不同的提供商可能处于不同的区块高度,或者有不同的状态修剪策略。如果你需要跨提供商的一致结果,请比较区块号并使用相同的区块标签。
- 代理和非标准代币可能破坏简单读取。
- Rebasing 和 fee-on-transfer 代币使余额动态变化。
- 区块标签决定状态快照。
- 端点状态可能因提供商而异。
eth_call 代币读取的故障排查清单
当 eth_call 代币读取失败时,请按清单逐一排查。首先,确认 RPC 端点可达,并且对简单方法如 eth_blockNumber 返回有效响应。其次,验证代币合约地址和链 ID。第三,检查 calldata 是否正确编码:选择器必须匹配函数签名,参数必须 32 字节填充。
如果调用回滚,请使用 解码以太坊回滚原因和自定义错误 中的技术解码回滚原因。如果返回数据为空,合约可能未实现该函数。如果返回数据意外,请检查 ABI 解码逻辑。
对于存储级调试,你可以使用 eth_getStorageAt 和 EVM 存储布局 检查余额的原始存储槽。这很高级,但当 balanceOf 意外返回零时很有用。此外,使用 eth_getCode:读取合约字节码 验证合约字节码,确保它是合约而不是 EOA。
- 使用 eth_blockNumber 检查 RPC 端点。
- 验证代币合约和链 ID。
- 确认 calldata 编码:选择器和填充。
- 如果调用失败,解码回滚原因。
- 检查存储槽以进行高级调试。
- 使用 eth_getCode 验证合约字节码。
下一步:将代币读取集成到你的应用中
一旦你能可靠地读取 ERC-20 余额和元数据,就可以将这些调用集成到你的应用中。使用 ethers.js 或 viem 等库来处理 ABI 编码和解码,但保留原始 JSON-RPC 示例用于调试。缓存 decimals 和 symbol 值,因为它们很少变化,但始终使用区块标签获取最新余额。
对于生产环境,考虑使用具有可靠正常运行时间和一致状态的专用 RPC 提供商。OnFinality 提供 以太坊 RPC 节点 和 API 服务,可以支持你的代币读取工作负载。查看 RPC 定价 以选择适合你请求量的计划。
要深入了解,请探索 eth_call 状态覆盖模拟 以进行假设状态读取,以及 以太坊 RPC 节点指南 了解节点操作最佳实践。OnFinality Learn 中心 有更多关于以太坊 JSON-RPC 方法的指南。
- 使用库进行编码,但理解原始 calldata。
- 缓存元数据;使用区块标签获取余额。
- 选择具有一致状态的 RPC 提供商。
- 探索状态覆盖以进行模拟。
- 阅读以太坊 RPC 节点指南获取操作技巧。