Solana

Solana Beta is live. Try BoltRPC Solana endpoints free - start your trial now.

How to Read ERC-20 Token Data via RPC: balanceOf, Transfers, and Approvals

Read ERC-20 token balances, allowances, transfer history, and approvals directly via RPC using ethers.js. Covers Multicall3 batching, event indexing, and production patterns.

BoltRPC
BoltRPC Team
7 min read
How to Read ERC-20 Token Data via RPC: balanceOf, Transfers, and Approvals

How to Read ERC-20 Token Data via RPC: balanceOf, Transfers, and Approvals

Every DeFi app, portfolio tracker, and token analytics tool reads ERC-20 data constantly. Balances, allowances, transfer history, total supply: all of it lives on-chain and is readable via standard JSON-RPC calls.

This guide covers how to read ERC-20 token data efficiently: single reads, batched Multicall3 calls, and event-based transfer history.


The ERC-20 ABI

ERC-20 is a standard interface. You do not need a token-specific ABI for the core functions:

import { ethers } from 'ethers'

const ERC20_ABI = [
  'function name() view returns (string)',
  'function symbol() view returns (string)',
  'function decimals() view returns (uint8)',
  'function totalSupply() view returns (uint256)',
  'function balanceOf(address account) view returns (uint256)',
  'function allowance(address owner, address spender) view returns (uint256)',
  'function transfer(address to, uint256 amount) returns (bool)',
  'function approve(address spender, uint256 amount) returns (bool)',
  'function transferFrom(address from, address to, uint256 amount) returns (bool)',
  'event Transfer(address indexed from, address indexed to, uint256 value)',
  'event Approval(address indexed owner, address indexed spender, uint256 value)',
]

const provider = new ethers.JsonRpcProvider(
  'https://eu.endpoints.matrixed.link/rpc/ethereum?auth=YOUR_KEY'
)

Read Token Metadata

const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
const token = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider)

// Read all metadata in parallel
const [name, symbol, decimals, totalSupply] = await Promise.all([
  token.name(),
  token.symbol(),
  token.decimals(),
  token.totalSupply(),
])

console.log(`${name} (${symbol})`)
console.log(`Decimals: ${decimals}`)
console.log(`Total Supply: ${ethers.formatUnits(totalSupply, decimals)} ${symbol}`)

Read Token Balance

async function getTokenBalance(tokenAddress, walletAddress) {
  const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
  const decimals = await token.decimals()
  const balance = await token.balanceOf(walletAddress)

  return {
    raw: balance,                                    // BigInt in wei units
    formatted: ethers.formatUnits(balance, decimals), // Human-readable
    decimals,
  }
}

const { formatted, raw } = await getTokenBalance(USDC_ADDRESS, '0xYourWallet')
console.log(`USDC balance: ${formatted}`)

Read Allowance

An allowance is how much a spender (like a DEX router) is allowed to transfer on behalf of the owner. Always check allowance before a swap or deposit.

async function getTokenAllowance(tokenAddress, ownerAddress, spenderAddress) {
  const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
  const [decimals, allowance] = await Promise.all([
    token.decimals(),
    token.allowance(ownerAddress, spenderAddress),
  ])

  return {
    raw: allowance,
    formatted: ethers.formatUnits(allowance, decimals),
    isUnlimited: allowance === ethers.MaxUint256,
  }
}

// Check USDC allowance for Uniswap V3 router
const UNISWAP_V3_ROUTER = '0xE592427A0AEce92De3Edee1F18E0157C05861564'

const allowance = await getTokenAllowance(USDC_ADDRESS, '0xYourWallet', UNISWAP_V3_ROUTER)
console.log(`USDC allowance for Uniswap: ${allowance.formatted}`)
console.log(`Unlimited approval: ${allowance.isUnlimited}`)

Batch Multiple Token Reads with Multicall3

Reading balances for many tokens one by one is slow and expensive (one RPC call per read). Use Multicall3 to batch all reads into a single call:

const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'
const MULTICALL3_ABI = [
  'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[] returnData)',
]

const erc20Interface = new ethers.Interface(ERC20_ABI)
const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider)

async function getMultipleTokenBalances(walletAddress, tokenAddresses) {
  // Build calls — balanceOf + decimals for each token
  const calls = tokenAddresses.flatMap(tokenAddress => [
    {
      target: tokenAddress,
      allowFailure: true,
      callData: erc20Interface.encodeFunctionData('balanceOf', [walletAddress]),
    },
    {
      target: tokenAddress,
      allowFailure: true,
      callData: erc20Interface.encodeFunctionData('decimals'),
    },
  ])

  // One RPC call for all tokens
  const results = await multicall.aggregate3(calls)

  return tokenAddresses.map((address, i) => {
    const balanceResult = results[i * 2]
    const decimalsResult = results[i * 2 + 1]

    if (!balanceResult.success || !decimalsResult.success) {
      return { address, balance: null, error: true }
    }

    const balance = erc20Interface.decodeFunctionResult('balanceOf', balanceResult.returnData)[0]
    const decimals = erc20Interface.decodeFunctionResult('decimals', decimalsResult.returnData)[0]

    return {
      address,
      balance,
      decimals,
      formatted: ethers.formatUnits(balance, decimals),
    }
  })
}

// 10 token balances in 1 RPC call
const tokens = [USDC_ADDRESS, DAI_ADDRESS, WETH_ADDRESS, LINK_ADDRESS /* ... */]
const balances = await getMultipleTokenBalances('0xYourWallet', tokens)

for (const token of balances) {
  console.log(`${token.address}: ${token.formatted}`)
}

Read Transfer History with eth_getLogs

Transfer events are indexed on-chain. Use eth_getLogs to query them:

const TRANSFER_TOPIC = ethers.id('Transfer(address,address,uint256)')

async function getTransferHistory(tokenAddress, walletAddress, blockRange = 10000) {
  const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
  const decimals = await token.decimals()

  const currentBlock = await provider.getBlockNumber()
  const paddedAddress = ethers.zeroPadValue(walletAddress, 32)

  // Get transfers FROM the wallet
  const sentLogs = await provider.getLogs({
    address: tokenAddress,
    topics: [TRANSFER_TOPIC, paddedAddress],
    fromBlock: currentBlock - blockRange,
    toBlock: 'latest',
  })

  // Get transfers TO the wallet
  const receivedLogs = await provider.getLogs({
    address: tokenAddress,
    topics: [TRANSFER_TOPIC, null, paddedAddress],
    fromBlock: currentBlock - blockRange,
    toBlock: 'latest',
  })

  const parseLog = (log, direction) => {
    const parsed = token.interface.parseLog(log)
    return {
      direction,
      from: parsed.args.from,
      to: parsed.args.to,
      amount: ethers.formatUnits(parsed.args.value, decimals),
      txHash: log.transactionHash,
      blockNumber: log.blockNumber,
    }
  }

  return [
    ...sentLogs.map(log => parseLog(log, 'sent')),
    ...receivedLogs.map(log => parseLog(log, 'received')),
  ].sort((a, b) => b.blockNumber - a.blockNumber)
}

const history = await getTransferHistory(USDC_ADDRESS, '0xYourWallet')
for (const tx of history.slice(0, 10)) {
  console.log(`[${tx.direction}] ${tx.amount} USDC — TX: ${tx.txHash}`)
}

Real-Time Transfer Monitoring

Use WebSocket subscriptions to watch transfers as they happen:

const wsProvider = new ethers.WebSocketProvider(
  'wss://eu.endpoints.matrixed.link/ws/ethereum?auth=YOUR_KEY'
)

const token = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider)

// Watch all USDC transfers
token.on('Transfer', async (from, to, value, event) => {
  const decimals = 6 // USDC has 6 decimals
  const amount = ethers.formatUnits(value, decimals)
  console.log(`USDC Transfer: ${from} → ${to}: ${amount} USDC`)
  console.log(`TX: ${event.log.transactionHash}`)
})

// Watch transfers TO a specific address only
const myAddress = '0xYourAddress'
const filter = token.filters.Transfer(null, myAddress)

token.on(filter, (from, to, value, event) => {
  const amount = ethers.formatUnits(value, 6)
  console.log(`Received ${amount} USDC from ${from}`)
})

Portfolio Snapshot

Combine token metadata + balances + prices for a wallet snapshot:

async function getPortfolioSnapshot(walletAddress, tokenList) {
  // Step 1: batch all balanceOf + decimals + symbol reads
  const calls = tokenList.flatMap(token => [
    { target: token.address, allowFailure: true, callData: erc20Interface.encodeFunctionData('balanceOf', [walletAddress]) },
    { target: token.address, allowFailure: true, callData: erc20Interface.encodeFunctionData('decimals') },
    { target: token.address, allowFailure: true, callData: erc20Interface.encodeFunctionData('symbol') },
  ])

  const results = await multicall.aggregate3(calls)

  return tokenList.map((token, i) => {
    const balanceResult = results[i * 3]
    const decimalsResult = results[i * 3 + 1]
    const symbolResult = results[i * 3 + 2]

    if (!balanceResult.success) return { ...token, balance: 0n, formatted: '0' }

    const balance = erc20Interface.decodeFunctionResult('balanceOf', balanceResult.returnData)[0]
    const decimals = erc20Interface.decodeFunctionResult('decimals', decimalsResult.returnData)[0]
    const symbol = erc20Interface.decodeFunctionResult('symbol', symbolResult.returnData)[0]

    return {
      address: token.address,
      symbol,
      decimals,
      balance,
      formatted: ethers.formatUnits(balance, decimals),
    }
  }).filter(t => t.balance > 0n) // Only non-zero balances
}

FAQ

Why does USDC show 6 decimals but most tokens show 18?

ERC-20 decimals are set by the token contract at deployment. Most tokens use 18 decimals (matching ETH’s wei denomination). USDC and USDT use 6 decimals. Always call decimals() before formatting amounts. Never assume 18.

What is the Transfer event topic hash?

keccak256('Transfer(address,address,uint256)') = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. ethers.js computes this automatically with ethers.id('Transfer(address,address,uint256)').

How do I detect unlimited approvals?

An unlimited approval sets allowance to 2^256 - 1 (the maximum uint256 value). In ethers.js, compare against ethers.MaxUint256. This is the pattern used by most DeFi protocols to avoid re-approving every transaction.

Can I read ERC-20 data on Polygon, Arbitrum, or other chains?

Yes. ERC-20 is the same standard on all EVM chains. Change the RPC endpoint to the target chain and use the token’s address on that chain. The ABI and call patterns are identical.

How do I track ERC-20 transfers for an indexer?

Use the chunked eth_getLogs pattern with fromBlock and toBlock to catch up on historical data, then switch to a WebSocket subscription for new transfers. Store by transactionHash + logIndex as the unique key to handle deduplication across reorgs. See the eth_getLogs production pipeline guide for the full pattern.


BoltRPC provides the eth_call throughput and WebSocket access your token data pipeline needs: Ethereum, Arbitrum, Base, Polygon and 17 other EVM networks.

Start your free 2-week trial: trial.boltrpc.io

Related: Building a Production eth_getLogs Pipeline | Optimizing RPC Calls for DeFi

Frequently asked questions

Ready to build with high-performance RPC?

Start your free trial today. No credit card required. Access 20+ networks instantly.

Disclaimer: The content in this article is for informational purposes only and does not constitute financial, legal, or technical advice. Code examples and configurations are provided as-is. Always verify information with official documentation and test thoroughly in your own environment before deploying to production.

Continue reading