"Get the balance" is the first thing most apps do and the last thing most developers think hard about. But balance reads are quietly one of the most common sources of wrong numbers in production — off-by-a-million display bugs, ledgers that drift from the explorer, "missing" funds that were never missing. The RPC calls are simple; the traps are in the assumptions around them. Here's how to read balances correctly, and the five ways it goes wrong.
Native balance vs token balance are two different calls
The first thing to get straight: an account's native coin balance and its ERC-20 token balances come from completely different places.
Native balance (ETH, or the chain's gas token) is a first-class part of account state. You read it with eth_getBalance:
// viem
const wei = await client.getBalance({ address }); // eth_getBalance
It returns a value in the smallest unit — wei (10^18 per ETH). No contract involved.
Token balance (USDC, WBTC, any ERC-20) is not account state — it's a number stored inside the token contract. You read it with an eth_call to that contract's balanceOf:
// viem
const bal = await client.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: "balanceOf",
args: [account],
}); // eth_call under the hood
Mixing these up — calling eth_getBalance and expecting a USDC number — is the most basic error, but the ones below are subtler.
Trap #1: assuming 18 decimals
Wei has 18 decimals; tokens do not have to. USDC and USDT use 6, WBTC uses 8, plenty of tokens use 18, and a few use odd values. If you hard-code value / 1e18, a user's 1,000 USDC renders as 0.000000000001.
Always read the token's own decimals() and format against that:
const [raw, decimals] = await Promise.all([
client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account] }),
client.readContract({ address: token, abi: erc20Abi, functionName: "decimals" }),
]);
const human = formatUnits(raw, decimals); // correct regardless of token
Cache decimals per token (it never changes) so you're not fetching it on every read. This is the same "raw units vs human value" split that bites people in gas estimation — the chain deals in integers; the decimals live off to the side.
Trap #2: reconstructing balances by summing Transfer events
This is the big one for indexers. It's tempting to compute a balance as sum of Transfer events in − sum of Transfer events out. For plain tokens it works — until it doesn't, because several things change a balance without a matching transfer:
-
Rebasing tokens grow (or shrink) balances by protocol action, emitting no
Transfer. On Blast, native yield rebases ETH and USDB balances directly — sum-the-transfers under-counts every yield-bearing account. Staked-ETH derivatives and other elastic-supply tokens behave the same way. -
Fee-on-transfer tokens deduct a fee in-transit, so the amount received ≠ the amount in the
Transfervalue (see Trap #3). - Non-standard mints/burns or storage manipulations can move balances in ways your event filter doesn't model.
The robust rule: for current balances, snapshot the source of truth — call balanceOf (or eth_getBalance) at the block you care about — rather than deriving it from event history. Use events to know when to refresh and to track movements, but treat the on-chain read as authoritative. If you keep a running ledger, reconcile it against real reads periodically so accrued yield and fees don't let it drift. (This is also why reorg-safe indexing keys on block hash and re-reads on rollback — see handling chain reorgs.)
Trap #3: fee-on-transfer tokens
A subset of tokens take a cut on every transfer. If Alice sends 100 and the token skims 2%, Bob's balance rises by 98 while the Transfer log may show 100 (or the fee shows as a second transfer). Any logic that assumes "amount sent == amount received" — payment confirmation, swap accounting, escrow — can be wrong. When correctness matters, compare balanceOf before and after the transaction rather than trusting the transfer amount. It's a niche case, but it's exactly the kind that causes "the numbers don't add up" bugs no one can reproduce.
Trap #4: "latest" vs a specific block
By default both eth_getBalance and an eth_call to balanceOf read at the latest block. Two consequences:
- Balances are a moving target. Two reads a few seconds apart can differ. For anything that must be internally consistent — a portfolio total, an accounting snapshot — pin every read to the same block number, not "latest," so all values are as-of the same moment.
-
Historical reads need archive. Asking for a balance at an old block (
eth_getBalance(addr, 15000000)orbalanceOfwith a past block tag) requires an archive node; a full/pruned node will return a "missing trie node" error for state that far back. The mechanics of reading old state are covered in eth_call at a past block.
// pin to a block for a consistent snapshot
const block = await client.getBlockNumber();
const ethBal = await client.getBalance({ address, blockNumber: block });
const usdcBal = await client.readContract({
address: usdc, abi: erc20Abi, functionName: "balanceOf", args: [address], blockNumber: block,
});
Reading many balances at once
A portfolio view needs dozens or hundreds of balanceOf calls. Firing them as individual requests is slow and rate-limit-hungry. Batch them:
-
Multicall3 bundles many
balanceOfreads into a singleeth_call, returning all results together and pinned to one block — perfect for consistent snapshots. See the Multicall3 cheat sheet. - viem exposes this directly as
client.multicall({ contracts: [...] }).
const balances = await client.multicall({
contracts: tokens.map((token) => ({
address: token, abi: erc20Abi, functionName: "balanceOf", args: [account],
})),
}); // one round-trip, one block, N balances
The same reads in ethers and web3.py
// ethers v6
const ethBal = await provider.getBalance(address); // native, wei
const erc20 = new ethers.Contract(token, erc20Abi, provider);
const raw = await erc20.balanceOf(address);
const dec = await erc20.decimals();
const human = ethers.formatUnits(raw, dec);
# web3.py
eth_bal = w3.eth.get_balance(account) # native, wei
erc20 = w3.eth.contract(address=token, abi=ERC20_ABI)
raw = erc20.functions.balanceOf(account).call()
dec = erc20.functions.decimals().call()
human = raw / (10 ** dec)
The short version
Native balance (eth_getBalance) and token balance (balanceOf via eth_call) are different calls — don't conflate them, and remember native is in wei. Then avoid the four traps: read each token's decimals() instead of assuming 18; snapshot balanceOf/eth_getBalance for current balances instead of summing Transfer events (rebasing and fee-on-transfer tokens make event-summing wrong); compare before/after balances when fee-on-transfer correctness matters; and pin reads to a single block for consistent snapshots, using an archive node for historical ones. For many balances, batch with Multicall3 so everything lands in one round-trip at one block. Get those right and your numbers will finally match the explorer.
Need a reliable endpoint to read balances across chains? A flat-rate Ethereum RPC endpoint — plus 75+ other chains under one key — gives you eth_getBalance, eth_call, and Multicall over HTTP and WebSocket. Grab a free key and point your stack at:
https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
Originally published on the SwiftNodes blog. SwiftNodes provides flat-rate multi-chain RPC endpoints — HTTP + WebSocket, 75+ chains, no per-request metering. Grab a free key.
Top comments (0)