Skip to content
docs

Balances

Every account reads its own balances: getBalance() for the network’s gas coin and getTokenBalance(token) for a token contract. The wallet decides which tokens to read, formats the amounts and adds prices. This page builds that in a few small functions.

Snippets come from examples/balances, which our CI typechecks and tests. The live run reads real balances on Base.

Terminal window
npm install @anywalletkit/kit @anywalletkit/wallet-evm @anywalletkit/networks @anywalletkit/swap2-assets
import { bigintToUnits, type IWalletAccountReadOnly } from '@anywalletkit/core'
import { Wallet } from '@anywalletkit/kit'
import { builtinNetwork } from '@anywalletkit/networks'
import { ccChain } from '@anywalletkit/swap2-assets'
import { WalletManagerEvm, type EvmProvider } from '@anywalletkit/wallet-evm'
/** One registration per EVM network, each with its own RPC list. */
export function balancesWallet(seedPhrase: string, rpcs: Record<number, EvmProvider[]>): Wallet {
const wallet = new Wallet(seedPhrase)
for (const [id, provider] of Object.entries(rpcs)) {
wallet.registerWallet(`eip155:${id}`, WalletManagerEvm, { provider, chainId: Number(id) })
}
return wallet
}

Register each EVM network on its own chain id so it gets its own RPC list. The same seed gives the same address on every EVM network, but the balances are separate. To take the RPC lists from the user’s settings, see Networks.

A chain has no call that lists the tokens an address holds. The wallet keeps a list.

/** A token the wallet reads a balance for. `token: null` is the network's gas coin. */
export interface ListedToken {
chain: string
token: string | null
symbol: string
decimals: number
/** `catalog`: shipped with the SDK; `user`: the user added it by contract address. */
source: 'catalog' | 'user'
}
/** The gas coin from the network table, the catalog's tokens, then the tokens the user added. */
export function tokenList(chain: string, userTokens: ListedToken[] = []): ListedToken[] {
const network = builtinNetwork(chain)
const native: ListedToken[] = network ? [{ chain, token: null, symbol: network.symbol, decimals: network.decimals, source: 'catalog' }] : []
const catalog: ListedToken[] = (ccChain(chain)?.assets ?? [])
.filter((a) => a.contract) // the native entry is already in from the network table
.map((a) => ({ chain, token: a.contract!, symbol: a.symbol, decimals: a.decimals, source: 'catalog' }))
const known = new Set(catalog.map((t) => t.token!.toLowerCase()))
const own = userTokens.filter((t) => t.chain === chain && t.token && !known.has(t.token.toLowerCase()))
return [...native, ...catalog, ...own]
}
  • The gas coin comes from the network table in networks: its symbol and decimals per network. POL on Polygon, MNT on Mantle. Never hardcode “ETH”.
  • The catalog in swap2-assets lists the native coin and the major tokens per chain, with contract addresses and decimals. It is a short curated list, not a full token list.
  • The user’s tokens are the ones they added by contract address. Keep them in your own storage, mark them as user-added, and drop duplicates of catalog tokens. Custom tokens does this with @anywalletkit/tokens: it reads the token from the chain and warns about look-alikes before it is saved.

To find tokens outside the list, use a holdings indexer. Balances and prices lists providers per chain. Show what it finds behind a “hidden” filter: most unknown tokens are spam.

export interface BalanceRow extends ListedToken {
/** Base units. `null` when this read failed; never show a failed read as 0. */
amount: bigint | null
error?: string
}
/** Every listed balance of one account. One failing token does not hide the others. */
export async function readBalances(account: IWalletAccountReadOnly, tokens: ListedToken[]): Promise<BalanceRow[]> {
const reads = await Promise.allSettled(tokens.map((t) => (t.token ? account.getTokenBalance(t.token) : account.getBalance())))
return tokens.map((t, i) => {
const r = reads[i]!
return r.status === 'fulfilled' ? { ...t, amount: r.value } : { ...t, amount: null, error: String((r.reason as Error)?.message ?? r.reason) }
})
}
  • Amounts are bigint in base units. 1 USDC is 1_000_000n, 1 ETH is 10n ** 18n.
  • A failed read is not zero. One token contract can revert or one RPC can time out. Keep the row with its error and show “couldn’t load”, never 0. A wrong zero makes users think their funds are gone.
  • Chains without tokens. Bitcoin and Zcash accounts throw on getTokenBalance. Read only getBalance() there. Solana, TON, Tron and NEAR accounts read tokens the same way as EVM: an SPL mint, a jetton master, a TRC-20 contract or a NEP-141 account.
/** Exact decimal string from base units, cut to `maxFraction` digits (rounded down). No Number on the way. */
export function formatAmount(amount: bigint, decimals: number, maxFraction = 6): string {
const negative = amount < 0n
const abs = negative ? -amount : amount
const base = 10n ** BigInt(decimals)
const whole = (abs / base).toLocaleString('en-US')
const fraction = (abs % base).toString().padStart(decimals, '0').slice(0, maxFraction).replace(/0+$/, '')
return `${negative ? '-' : ''}${whole}${fraction ? '.' + fraction : ''}`
}

Format from the bigint itself. Number(amount) loses precision above 2⁵³, and some Hermes builds return wrong values above 2⁶³, so an 18-decimal balance can show a wrong number. When you need a Number for math (a USD value, a chart), use bigintToUnits(amount, decimals) from core. It is exact for display and pricing. Never use it for an amount you sign.

/** Balances on every chain at once. A chain whose RPC is down comes back with its error, not as zero. */
export async function portfolio(wallet: Wallet, chains: string[], userTokens: ListedToken[] = []) {
const results = await Promise.allSettled(chains.map(async (chain) => readBalances(await wallet.getAccount(chain), tokenList(chain, userTokens))))
return chains.map((chain, i) => {
const r = results[i]!
return r.status === 'fulfilled' ? { chain, rows: r.value } : { chain, rows: [] as BalanceRow[], error: String((r.reason as Error)?.message ?? r.reason) }
})
}

Read the chains in parallel. A chain whose RPC is down comes back with its rows marked as failed, and the rest of the screen still loads.

When to refresh:

  • when the screen opens and when the app returns to the foreground;
  • after the user sends, once the receipt arrives. Until then, show your own pending row: a balance read right after a broadcast may not include it yet;
  • on pull to refresh. Avoid fast timers: each refresh is one call per token per chain, and public RPCs rate-limit quickly.

The SDK has no price source. You bring a price API and call it through your server, so its key stays off the device. The function below takes it as a parameter.

/** Your price API, behind your server: USD per whole token, keyed by `chain/token` (token = contract, or `native`). */
export type PriceSource = (keys: string[]) => Promise<Record<string, number>>
export const priceKey = (t: ListedToken) => `${t.chain}/${t.token ? t.token.toLowerCase() : 'native'}`
/** USD value per row and a total. A token with no price is `null` and stays out of the total. */
export async function valued(rows: BalanceRow[], prices: PriceSource) {
const quotes = await prices(rows.filter((r) => r.amount !== null && r.amount > 0n).map(priceKey))
const out = rows.map((r) => {
const price = quotes[priceKey(r)]
const usd = r.amount !== null && price !== undefined ? bigintToUnits(r.amount, r.decimals) * price : null
return { ...r, usd }
})
return { rows: out, totalUsd: out.reduce((sum, r) => sum + (r.usd ?? 0), 0), unpriced: out.filter((r) => r.usd === null && (r.amount ?? 0n) > 0n).length }
}
  • Look up by contract address, never by symbol. Symbols collide, and fake tokens copy real ones.
  • No price is null, not $0. Show “no price” and leave the token out of the total.
  • Price only what the user holds. Zero balances never reach the API.
  • Cache prices for a minute or more and show when a price is stale.

Providers to compare are listed on Balances and prices.