Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 12 分钟

通过 eth_call 读取 ERC-20 代币余额与元数据

一份实用指南,介绍如何使用 eth_call 读取 ERC-20 余额、decimals、symbol 和 totalSupply,包括 ABI 编码、解码以及故障排查。

TL;DR

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 节点指南获取操作技巧。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始