Read Solana wallet token balances without stale snapshots

Sum token accounts by mint using exact integers, keep a slot watermark, and prevent a slower Solana RPC response from replacing a newer wallet balance.

To read a Solana wallet’s balance for one token mint, fetch all its token accounts for that mint, sum their raw integer amounts, and keep the response’s context slot. Use the last accepted slot as a lower bound on later reads. That prevents an older RPC response from overwriting newer data without promising a historical read at an exact slot.

Sum the wallet’s token accounts for one mint

A wallet public key can own multiple token accounts for the same mint. Reading only its associated token account can undercount the balance, and getBalance reads native SOL in lamports rather than SPL tokens. Use Solana’s getTokenAccountsByOwner method with the wallet address and a mint filter.

Use the parsed tokenAmount.amount string and its decimals. Sum with BigInt, then format the total for display. Floating-point values can lose precision, particularly when you are reconciling raw balances for large supplies. Keep both the integer string and decimals if this data will be consumed by another service.

For a first run, select the matching cluster from OnFinality’s Solana mainnet RPC page or Solana Devnet RPC page. Use a wallet and mint pair you know exists on that cluster. A familiar mint address or a successful HTTP response does not prove the endpoint is on the intended cluster; use getGenesisHash to check the cluster when configuring an endpoint or failover.

Read and record the snapshot’s context slot

Save this as read-balance.mjs and run it with Node.js 18 or later. Set RPC_URL, OWNER, and MINT. On the next run, set MIN_SLOT to the context slot from the last accepted response for the same wallet, mint, cluster, and commitment.

JavaScript · Node.js
const { RPC_URL, OWNER, MINT, MIN_SLOT } = process.env;
if (!RPC_URL || !OWNER || !MINT) throw new Error("Set RPC_URL, OWNER, and MINT");
const floor = MIN_SLOT === undefined ? undefined : Number(MIN_SLOT);
if (floor !== undefined && (!/^\d+$/.test(MIN_SLOT) || !Number.isSafeInteger(floor))) {
  throw new Error("MIN_SLOT must be a nonnegative integer");
}

let res;
try {
  res = await fetch(RPC_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    signal: AbortSignal.timeout(10_000),
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "getTokenAccountsByOwner",
      params: [OWNER, { mint: MINT }, {
        encoding: "jsonParsed", commitment: "finalized", minContextSlot: floor,
      }],
    }),
  });
} catch {
  throw new Error("Solana RPC request failed or timed out");
}
if (!res.ok) throw new Error("RPC HTTP " + res.status);
const payload = await res.json();
if (payload.error) throw new Error("RPC error " + payload.error.code);
const { context, value } = payload.result;
if (!Number.isSafeInteger(context.slot) || context.slot < (floor ?? 0)) {
  throw new Error("Stale or missing context slot");
}

let rawAmount = 0n;
let decimals = null;
for (const { account } of value) {
  const info = account.data.parsed.info;
  if (info.mint !== MINT || info.owner !== OWNER) throw new Error("Unexpected account");
  const amount = info.tokenAmount;
  if (decimals !== null && decimals !== amount.decimals) throw new Error("Decimals differ");
  decimals = amount.decimals;
  rawAmount += BigInt(amount.amount);
}
console.log({ slot: context.slot, rawAmount: rawAmount.toString(), decimals, accounts: value.length });

An empty array produces a raw amount of zero and no decimals. If you need to render that zero with the mint’s precision, obtain decimals separately from trusted mint metadata. A response from a different cluster, a failed request, or an unexpected parse result should not be converted into a zero balance.

Reject late responses when writing the cache

minContextSlot sets a lower bound for where the node may evaluate the request. It does not request account state at that exact slot. If the node has not reached the floor, wait with bounded backoff or retry against an endpoint on the same cluster with the same commitment. Keep the last good snapshot visible and mark it stale if the refresh fails.

Two refreshes can overlap. If A starts first and returns slot 100 after B has already stored slot 102, reject A even if it satisfied the floor at the time it started. Implement the comparison at write time with an atomic update, and keep separate watermarks for different commitments. Using finalized favors stability; it will usually lag the freshest view.

Practice with two synthetic updates, slots 102 and 100, applied in that order. The slot 102 value must survive. Then repeat with two real reads, passing the first response’s slot to the second; the new slot must meet the floor or the endpoint must return an error. The two exercises verify different guarantees: cache ordering and provider freshness.

Label portfolio views assembled from multiple reads

The example covers one mint. A portfolio that reads native SOL, multiple mints, or token programs separately can receive a different context slot for each result. Keep those slots with the data and report the observed window. Raising every request’s floor can narrow staleness, but it does not make the calls atomic.

For a portfolio-wide scan, plan coverage for both the original Token Program and Token-2022. A program-ID filter reads one program at a time. If a method or parsed encoding is unavailable on your endpoint, inspect the actual JSON-RPC error and verify provider support before treating the wallet as empty.

For endpoint setup, use OnFinality RPC Assistant’s Solana endpoint guide. For the tradeoff between recency and stability, see OnFinality Learn’s guide to Solana commitment levels.