Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides12 min read

Reading ERC-20 Token Balances and Metadata over eth_call

A practical guide to reading ERC-20 balances, decimals, symbol, and totalSupply with eth_call, including ABI encoding, decoding, and troubleshooting.

TL;DR

An ERC-20 token balance is not stored on the wallet; it lives inside the token contract as a mapping from address to uint256. Reading it requires an eth_call to the token contract's balanceOf(address) function, with calldata built from the 4-byte function selector and 32-byte-padded arguments. The same pattern reads decimals(), symbol(), name(), and totalSupply(). This guide explains the ABI encoding, shows runnable Node.js examples, and provides a troubleshooting playbook for common failures such as zero balances and wrong contract addresses.

Why ERC-20 Balances Live in the Token Contract

An ERC-20 token balance is not a property of a wallet. The wallet is an externally owned account (EOA) or contract that holds no token-specific state. Instead, the token contract maintains a mapping from address to uint256, as defined in EIP-20. When you call balanceOf(holder), the contract looks up that mapping and returns the value.

This design means that reading a token balance requires an eth_call to the token contract, not a query about the wallet. The wallet's address is only an argument to the function. The same applies to metadata such as decimals, symbol, name, and totalSupply: they are all stored and returned by the token contract.

Because the balance is contract state, it can change between blocks. The block tag you pass to eth_call determines which state is used. If you omit the block tag, the node typically uses the latest block, but this behavior is documented and varies by provider. Always specify a block tag when you need reproducible results.

  • Token balances are stored in the token contract's storage, not on the wallet.
  • balanceOf(address) returns a uint256 from the contract's mapping.
  • Metadata functions (decimals, symbol, name, totalSupply) are also contract reads.
  • The block tag controls which state snapshot is used.

The eth_call Request Shape for Token Reads

The eth_call method executes a read-only message call against the node's state and returns the return data. It does not create a transaction, does not consume gas from your account, and persists nothing. The request object has a to field for the token contract address and a data field for the ABI-encoded calldata. An optional block parameter can be included as the second argument.

According to the Ethereum JSON-RPC specification, eth_call takes a transaction object and a block number or tag. The transaction object must not include a from field for a pure read, though some providers accept it. The data field is the hex-encoded calldata.

A minimal request looks like this: {"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0xTokenContract","data":"0x70a08231..."},"latest"],"id":1}. The response contains a result field with the hex-encoded return data.

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 Encoding: Function Selector and Padded Arguments

The calldata for an ERC-20 read is built from two parts: a 4-byte function selector and the ABI-encoded arguments. The selector is the first 4 bytes of keccak256 of the function signature, such as balanceOf(address). For balanceOf(address), the selector is 0x70a08231. For decimals(), it is 0x313ce567. For symbol(), it is 0x95d89b41. For name(), it is 0x06fdde03. For totalSupply(), it is 0x18160ddd.

Each argument is left-padded to 32 bytes. An address is 20 bytes, so it is padded with 12 leading zero bytes. For example, the address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 becomes 0x000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045. The full calldata is the selector concatenated with the padded argument.

The Solidity ABI specification defines this encoding. You can construct it by hand or use a library like ethers.js, web3.js, or viem. Libraries are less error-prone, but understanding the encoding helps when debugging raw responses.

  • balanceOf(address) selector: 0x70a08231
  • decimals() selector: 0x313ce567
  • symbol() selector: 0x95d89b41
  • name() selector: 0x06fdde03
  • totalSupply() selector: 0x18160ddd

Decoding Return Data: uint256 and Dynamic Strings

The return data from eth_call is hex-encoded. For balanceOf, decimals, and totalSupply, the return is a single 32-byte word that can be decoded as a uint256. For decimals, the value is a uint8 stored in a 32-byte word, so you read the last byte or parse the whole word as an integer. For balanceOf and totalSupply, the full 32-byte word is the integer value.

For symbol and name, the return is a dynamic ABI-encoded string. The first 32-byte word is an offset to the string data, the next 32-byte word at that offset is the length, and the following bytes are the UTF-8 string. Decoding this manually requires reading the offset, then the length, then slicing the string bytes.

A common mistake is to treat the entire return as a uint256 for symbol or name. That yields a huge number, not a string. Always check the function signature and decode accordingly.

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'

Reading decimals and Converting Raw Balances

The decimals() function returns the number of decimal places the token uses. Most tokens use 18, but this is not guaranteed. USDC uses 6, for example. To convert a raw balance to a human-readable amount, divide the raw balance by 10^decimals. For a token with 6 decimals, a raw balance of 1000000 equals 1.0 token.

Always call decimals() before displaying a balance. Assuming 18 decimals for a token like USDC will show a balance that is off by a factor of 10^12. The conversion is a simple division, but it must use the correct decimals value.

If decimals() reverts or returns an unexpected value, the contract may not fully implement the ERC-20 standard. Some tokens return a fixed value or omit the function entirely. In such cases, you may need to fall back to a known value or handle the error gracefully.

  • decimals() returns uint8, typically 18 but not always.
  • Human-readable balance = raw balance / 10^decimals.
  • USDC uses 6 decimals; DAI uses 18.
  • Always fetch decimals before formatting.

Runnable Node.js Example: Balance, Decimals, Symbol, and Conversion

The following Node.js script uses raw JSON-RPC over fetch to read a holder's balance, the token decimals, and the token symbol. It then converts the raw balance to a human-readable amount and decodes the dynamic string return for symbol. Replace the RPC URL, token contract, and holder address with your own values.

This example uses the OnFinality Ethereum endpoint as a placeholder. You can use any Ethereum RPC endpoint, including your own node or a provider. The script demonstrates the full flow: encode calldata, send eth_call, decode return data, and format the result.

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);

Reproducible Results Table for Your Own Endpoint

To verify the behavior of your RPC endpoint, fill in the following table with values you measure yourself. Use a known token contract and a known holder address. Record the chain ID, the raw balanceOf return, the decimals value, the human-readable balance, the symbol, and the total supply. Repeat the measurement at different block tags to see how the block tag affects the result.

This table is a template. Do not rely on pre-filled numbers from this article; measure against your own endpoint. The goal is to confirm that your endpoint returns consistent data and that your decoding logic is correct.

  • Token contract: [your token address]
  • Chain ID: [your chain ID]
  • Raw balanceOf: [hex or decimal]
  • Decimals: [integer]
  • Human-readable balance: [raw / 10^decimals]
  • Symbol: [string]
  • Total supply: [raw totalSupply]

Common Failure: balanceOf Returns Zero

A balanceOf call returning zero is the most common issue. It usually means one of three things: the token contract address is wrong, the chain ID is wrong, or you are reading a proxy address that does not implement balanceOf directly. If the contract address is wrong, eth_call may return 0x or revert. If the chain is wrong, the address may not exist on that chain.

To diagnose, first verify the contract exists on the chain you are querying using eth_getCode. A non-empty bytecode result confirms a contract is deployed. Then call symbol() or name() to confirm the contract is an ERC-20 token. If symbol() reverts, the contract may not be ERC-20 compliant or may be a proxy that requires a different interface.

For proxy contracts, the implementation address is stored in a specific storage slot. You can read that slot with eth_getStorageAt and then call the implementation. However, many proxies forward calls transparently, so balanceOf may still work. If it does not, check the proxy's ABI or documentation.

  • Wrong token contract address: verify with eth_getCode.
  • Wrong chain ID: ensure the token exists on the chain you query.
  • Proxy address: check if the proxy forwards calls or requires implementation address.
  • Non-standard token: some tokens do not implement balanceOf as expected.

Limitations and Tradeoffs of eth_call Token Reads

Token contracts vary. Some are proxies, some return non-standard types, and rebasing or fee-on-transfer tokens make balanceOf a moving target. A rebasing token changes balances without transfers, so the value you read may not match the user's expectation. Fee-on-transfer tokens deduct a fee on transfer, so the recipient's balance may be less than the sent amount.

The block tag affects the result. If you query 'latest', the balance reflects the state at the latest block known to your endpoint. If you query a specific block number, the balance reflects that historical state. For reproducible results, always specify a block number.

eth_call reflects the state of the endpoint you query. Different providers may be at different block heights or may have different state pruning policies. If you need consistent results across providers, compare block numbers and use the same block tag.

  • Proxies and non-standard tokens can break simple reads.
  • Rebasing and fee-on-transfer tokens make balances dynamic.
  • Block tag determines the state snapshot.
  • Endpoint state may vary by provider.

Troubleshooting Checklist for eth_call Token Reads

When an eth_call token read fails, work through a checklist. First, confirm the RPC endpoint is reachable and returns a valid response for a simple method like eth_blockNumber. Second, verify the token contract address and chain ID. Third, check that the calldata is correctly encoded: the selector must match the function signature, and arguments must be 32-byte padded.

If the call reverts, decode the revert reason using the techniques in Decoding Ethereum revert reasons and custom errors. If the return data is empty, the contract may not implement the function. If the return data is unexpected, check the ABI decoding logic.

For storage-level debugging, you can inspect the raw storage slot for a balance using eth_getStorageAt and EVM storage layout. This is advanced but useful when balanceOf returns zero unexpectedly. Also, verify the contract bytecode with eth_getCode: Reading contract bytecode to ensure it is a contract, not an EOA.

  • Check RPC endpoint with eth_blockNumber.
  • Verify token contract and chain ID.
  • Confirm calldata encoding: selector and padding.
  • Decode revert reasons if the call fails.
  • Inspect storage slots for advanced debugging.
  • Verify contract bytecode with eth_getCode.

Next Steps: Integrating Token Reads into Your Application

Once you can read ERC-20 balances and metadata reliably, integrate these calls into your application. Use a library like ethers.js or viem to handle ABI encoding and decoding, but keep the raw JSON-RPC examples for debugging. Cache decimals and symbol values, as they rarely change, but always fetch balances fresh with a block tag.

For production, consider using a dedicated RPC provider with reliable uptime and consistent state. OnFinality offers Ethereum RPC nodes and an API service that can support your token read workloads. Review RPC pricing to choose a plan that fits your request volume.

To go deeper, explore eth_call state override simulation for hypothetical state reads, and the Ethereum RPC node guide for node operation best practices. The OnFinality Learn hub has more guides on Ethereum JSON-RPC methods.

  • Use libraries for encoding but understand raw calldata.
  • Cache metadata; fetch balances with a block tag.
  • Choose an RPC provider with consistent state.
  • Explore state overrides for simulation.
  • Read the Ethereum RPC node guide for operational tips.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started