Querying Solana Token Balances Without Precision Bugs

Why uiAmount is unsafe for decisions, how to fetch balances for one wallet or many, and the SOL-versus-wrapped-SOL distinction that breaks bots.

BoltTx Team··8 min read
solanatoken-balancespl-tokenprecisionrpctrading-bot

Reading a token balance looks like a solved problem. The RPC returns a number, you use the number.

★The number it returns most conveniently is the one you should not make decisions with.★

The Field That Costs You

getTokenAccountBalance returns three representations of the same value:

const { value } = await connection.getTokenAccountBalance(tokenAccount);

value.amount;        // ★"1234567890123" — string, exact★
value.decimals;      // 9
value.uiAmount;      // ★1234.567890123 — number, lossy★
value.uiAmountString // "1234.567890123" — string, display-safe

uiAmount is a JavaScript number, which cannot exactly represent integers above 2^53.★ A token with 9 decimals crosses that threshold at roughly nine million units — entirely normal for a meme coin balance.

// ★Wrong: comparison on a lossy float.★
if (value.uiAmount >= threshold) { sell(); }

// ★Correct: comparison in raw units.★
const raw = BigInt(value.amount);
const thresholdRaw = BigInt(Math.floor(threshold * 10 ** value.decimals));
if (raw >= thresholdRaw) { sell(); }

Use amount for every decision, uiAmountString for every display, and uiAmount for neither. The failure only appears on large balances, which is why it survives testing and shows up in production.

Fetching for a Whole Wallet

One call returns every token account a wallet owns:

const { value } = await connection.getParsedTokenAccountsByOwner(
  wallet,
  { programId: TOKEN_PROGRAM_ID },
);

for (const { pubkey, account } of value) {
  const info = account.data.parsed.info;
  console.log(info.mint, info.tokenAmount.amount);   // ★string★
}

★Two things surprise people here.★

Empty accounts are included. A wallet that has traded many tokens accumulates accounts with a zero balance, each still holding rent. Filter them out for display, and consider closing them to recover the rent.

Token-2022 is a different program. Accounts under the newer token program are not returned by a query scoped to TOKEN_PROGRAM_ID. If a balance is missing and the mint looks unusual, query TOKEN_2022_PROGRAM_ID as well.

If your balance reads are correct and the problem is transactions landing, a free BoltTx key is one line to test the submission path.

Reading Many Accounts at Once

Fetching balances in a loop is the most common way a bot hits its rate limit:

// ★Do not do this.★
for (const acct of accounts) {
  await connection.getTokenAccountBalance(acct);
}

// ★One request, up to 100 accounts.★
const infos = await connection.getMultipleAccountsInfo(accounts);

const balances = infos.map((info) =>
  info ? info.data.readBigUInt64LE(64) : 0n     // ★amount at offset 64★
);

getMultipleAccountsInfo takes up to 100 addresses per call.★ For a bot tracking positions across many tokens, this is the difference between one request and a hundred — and the parsing is a single readBigUInt64LE at a known offset.

A null entry means the account does not exist, which for a token account means the wallet has never held that token. That is not an error, and it is not the same as a zero balance — a zero balance means an account exists and is empty.

SOL Is Not a Token Account

The distinction that breaks bots at the worst moment:

// ★Native SOL — lamports on the wallet itself.★
const lamports = await connection.getBalance(wallet);

// ★Wrapped SOL — a token account like any other.★
const wsol = await getAssociatedTokenAddress(NATIVE_MINT, wallet);
const { value } = await connection.getTokenAccountBalance(wsol);

★These are different balances and they do not move together.★ Wrapping SOL creates a token account and transfers lamports into it; unwrapping closes the account and returns them.

Two practical consequences:

Checking native SOL before a swap that spends wrapped SOL tells you nothing about whether the swap can succeed.

★Native SOL is not fully spendable.★ The rent-exempt minimum must remain, and transaction fees come out of the same balance — so "I have SOL" and "I can spend this much SOL" are different statements.

const rentExempt = await connection.getMinimumBalanceForRentExemption(0);
const spendable = lamports - rentExempt - feeBuffer;

This is why a wallet showing a balance can still fail with insufficient funds.

Balance Reads Are Point-in-Time

A balance is true for the slot it was read at, and a bot acting on it is acting on the past.

// ★Read at the commitment that matches the decision.★
const { value } = await connection.getTokenAccountBalance(acct, "confirmed");

processed is fastest and can be rolled back — fine for deciding what to attempt, wrong for anything you record. The real protection is not a stricter commitment but an on-chain check: put the minimum output in the instruction so the program enforces it, rather than relying on a balance you read a moment earlier.

★A balance read is an input to your decision, not a guarantee about execution.★

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★Every slot between reading a balance and landing a transaction is time for that balance to change.★ Landing faster narrows the window; correct on-chain checks make the window survivable.

Where BoltTx Fits

We handle submission, not balance queries. Whatever you use for reads stays as it is.

Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands. Because the endpoints are independent, heavy balance polling cannot exhaust the path your transactions go out on.

You sign locally. We never hold funds, never sign, and never modify transaction contents. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.

Get a free API key. No monthly fee:

const sender = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

FAQ

How do I get a token balance on Solana? getTokenAccountBalance on the token account address, then use the amount string rather than uiAmount. For a whole wallet, getParsedTokenAccountsByOwner returns every account at once.

Why should I not use uiAmount? It is a JavaScript number and cannot exactly represent integers above 2^53. With 9 decimals that threshold is around nine million tokens, so large balances silently lose precision.

What is the difference between amount and uiAmount? amount is the exact raw value as a string, in the smallest unit. uiAmount is that value divided by the decimals as a float, convenient for display and unsafe for comparisons.

How do I compare token balances safely? Convert amount to a bigint and compare in raw units. Scale your threshold up by the decimals rather than scaling the balance down, so no precision is lost before the decision.

How do I fetch many token balances at once? getMultipleAccountsInfo accepts up to 100 addresses per call. Read the amount with readBigUInt64LE at offset 64, which is where a token account stores it.

Why is a token missing from getParsedTokenAccountsByOwner? Most likely it is a Token-2022 mint, which lives under a different program. Query TOKEN_2022_PROGRAM_ID as well, since a query scoped to the original token program will not return it.

What does a null account mean when fetching balances? The account does not exist, meaning the wallet has never held that token. That is different from a zero balance, which means an account exists and is empty.

Is wrapped SOL the same as my SOL balance? No. Native SOL is lamports on the wallet, while wrapped SOL is an ordinary token account. They do not move together, and checking one tells you nothing about the other.

Why do I get insufficient funds when I have SOL? Because the rent-exempt minimum must remain in the account and fees come out of the same balance. Compute spendable as your balance minus rent exemption minus a fee buffer.

How do I close empty token accounts? With a close instruction per account, which returns the rent to the owner. A bot trading many tokens accumulates these continuously, so a periodic cleanup recovers real capital.

Which commitment should I use for balance reads? confirmed for decisions you act on. processed is faster but can be rolled back, which makes it unsuitable for anything you record off chain.

Can a balance change between reading it and my transaction landing? Yes, and it routinely does. The protection is an on-chain check inside your instruction, not a stricter read commitment before it.

How do I convert a raw amount to a human-readable value? Divide by ten to the power of decimals, and only for display. Keep comparisons and arithmetic in raw units so precision is preserved through the decision.

Where is the amount stored in a token account? At byte offset 64, as a little-endian u64. That is why readBigUInt64LE(64) decodes it directly from a raw account fetch.

Does getTokenAccountBalance work on a wallet address? No. It expects a token account address, not the wallet. Derive the associated token account first, or use getParsedTokenAccountsByOwner to query by wallet.

Why does a token account exist with a zero balance? Because it was created for a token that has since been fully sold. The account stays until closed, holding rent that is recoverable at any time.

Back to all posts