An account on Solana is a byte array. The program that owns it knows what those bytes mean; your client does not, unless you tell it.
★Most parsing bugs are not crashes. They are numbers that look plausible and are wrong.★
Start With What the RPC Already Parses
Before writing a decoder, check whether one exists. For anything using a standard program, it does:
const info = await connection.getParsedAccountInfo(tokenAccount);
const parsed = info.value?.data;
if (parsed && "parsed" in parsed) {
console.log(parsed.parsed.info.owner);
console.log(parsed.parsed.info.tokenAmount.amount); // ★string★
}
★getParsedAccountInfo handles SPL token accounts, mints, stake accounts, and nonce accounts natively.★ For those, hand-rolling a decoder adds risk for no benefit.
It does not help for custom programs, which is where the rest of this applies.
The Token Account Layout
Worth knowing by heart, because it appears in nearly every filter and decoder:
offset 0 mint 32 bytes
offset 32 ★owner★ 32 bytes
offset 64 ★amount★ 8 bytes (u64, little-endian)
offset 72 delegate option 36 bytes
offset 108 state 1 byte
offset 109 isNative option 12 bytes
offset 121 delegatedAmount 8 bytes
offset 129 closeAuthority 36 bytes
★total 165 bytes★
const data = info.value!.data as Buffer;
const mint = new PublicKey(data.subarray(0, 32));
const owner = new PublicKey(data.subarray(32, 64));
const amount = data.readBigUInt64LE(64); // ★BigInt, not number★
★readBigUInt64LE, never readUInt32LE on the low half.★ A u64 can hold values far beyond what a JavaScript number represents exactly, and reading only 4 bytes gives a plausible wrong answer for any large balance.
If your parsing is right and the problem is transactions landing, a free BoltTx key is one line to test the submission path.
The Anchor Discriminator
Every Anchor account starts with 8 bytes identifying the account type — the first 8 bytes of a SHA-256 hash of account:<Name>.
// ★Skip it before reading any field.★
const body = data.subarray(8);
const authority = new PublicKey(body.subarray(0, 32));
Two things follow, and both matter:
Every offset shifts by 8. A field at byte 0 of the struct is at byte 8 of the account. ★This is the single most common cause of a memcmp filter that matches nothing and a decoder that returns garbage.★
You can use it to identify types. Filtering on the discriminator selects exactly one account type from a program:
const disc = createHash("sha256")
.update("account:PoolState")
.digest()
.subarray(0, 8);
const accounts = await connection.getProgramAccounts(PROGRAM_ID, {
filters: [{ memcmp: { offset: 0, bytes: bs58.encode(disc) } }],
});
Use the IDL Instead of Offsets
Hardcoded offsets are correct until the program is upgraded, and then they are silently wrong.
import { BorshAccountsCoder } from "@coral-xyz/anchor";
const coder = new BorshAccountsCoder(idl);
const decoded = coder.decode("PoolState", data); // ★layout comes from the IDL★
★A program upgrade that inserts a field shifts every offset after it.★ Your hardcoded decoder keeps running, keeps returning numbers, and every one of them is now reading the wrong bytes. A discriminator check would catch a type mismatch; a field insertion within the same type produces no signal at all.
The IDL-based decoder fails loudly instead, because the layout it uses came from the program rather than from your memory of it.
Variable-Length Fields
Borsh encodes dynamic data with a length prefix, which means offsets after such a field are not fixed:
u32 length (4 bytes)
then that many bytes
let cursor = 8; // past the discriminator
const nameLen = data.readUInt32LE(cursor);
cursor += 4;
const name = data.subarray(cursor, cursor + nameLen).toString("utf8");
cursor += nameLen; // ★next field starts here★
★Once a struct contains a string or a vector, you must walk it sequentially.★ You cannot compute a later field's offset in advance, and you cannot memcmp filter on anything after it.
Option<T> is similar: one byte for presence, then the value if present. An Option<Pubkey> is 1 + 32 = 33 bytes when present and 1 byte when not, unless the layout pads it — the token account's 36-byte delegate field is a padded form.
Reading Numbers Safely
// ★Correct.★
const amount: bigint = data.readBigUInt64LE(64);
// ★Loses precision above 2^53.★
const wrong = Number(data.readBigUInt64LE(64));
Keep amounts as bigint through any comparison or arithmetic, and convert to a display value only at the edge:
const ui = Number(amount) / 10 ** decimals; // display only
★Do the comparison in raw units, not in the display value.★ A threshold check performed on a float that already lost precision is a bug that surfaces only on large balances — which are the ones worth getting right.
Debugging a Decoder
console.log("length:", data.length);
console.log("owner:", info.value?.owner.toBase58());
console.log("first 16:", data.subarray(0, 16).toString("hex"));
★owner and length together identify the account type faster than anything else.★ Wrong owner means you fetched the wrong account. Unexpected length means it is not the type you assumed — and a length of exactly 165 is a token account regardless of what you thought you were reading.
Verify a decoder against an account whose values you can confirm in an explorer before trusting it on accounts you cannot check.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★Parsing is what you do with state you read; landing is what happens to state you write.★ A decoder that reads a pool correctly still needs the resulting transaction to reach a block producer.
Where BoltTx Fits
We handle submission, not indexing or decoding. Whatever you use to read accounts 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.
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 parse Solana account data?
For standard programs use getParsedAccountInfo, which decodes token accounts, mints, stake and nonce accounts natively. For custom programs, decode with the program's IDL rather than hardcoded offsets.
What is the Anchor discriminator? Eight bytes at the start of every Anchor account, derived from a hash of the account name. It identifies the type and shifts every field offset by 8 relative to the struct.
Why does my decoder return garbage? Most often a missing discriminator skip, so every field is read 8 bytes early. Check the account length and owner first — they identify the type faster than inspecting the data.
How do I read a u64 amount in JavaScript?
readBigUInt64LE at the field offset, keeping it as a bigint. Converting to number loses precision above 2^53, which silently corrupts large balances.
What is the SPL token account layout? Mint at 0, owner at 32, amount at 64 as a little-endian u64, then delegate, state, and the remaining fields, totalling 165 bytes. That size is itself a reliable type check.
Why do my offsets break after a program upgrade? Because inserting a field shifts everything after it. A hardcoded decoder keeps running and returns wrong values, which is why decoding from the IDL is safer than memorised offsets.
How does Borsh encode strings and vectors? With a four-byte little-endian length prefix followed by the data. Any field after a variable-length one has no fixed offset, so you must walk the buffer sequentially.
Can I memcmp filter on a field after a string? No. Its position depends on the string's length, so there is no fixed offset to filter against. Only fields before the first variable-length field can be filtered this way.
How is Option encoded in Borsh? One byte indicating presence, then the value if present. Some layouts pad the field to a fixed width instead, which is why a token account's delegate occupies 36 bytes rather than 33.
How do I identify what type an account is? Check the owner and the data length first, then the Anchor discriminator if the program uses Anchor. A length of exactly 165 with the token program as owner is a token account.
Should I use getParsedAccountInfo or decode myself? Use the parsed version for standard programs — it is maintained and correct. Decode yourself only for custom programs, and prefer the IDL over manual offsets there.
Why is my token balance wrong?
Likely a precision loss from converting a u64 to a JavaScript number, or reading only four bytes of the eight-byte amount. Keep it as a bigint until display.
How do I convert a raw amount to a display value? Divide by ten to the power of the mint's decimals, and only for display. Perform comparisons and arithmetic in raw units so precision is not lost before the decision.
How do I verify a decoder is correct? Test it against an account whose values you can confirm independently in an explorer. A decoder that produces plausible numbers is not evidence that the offsets are right.
What does the account owner field tell me? Which program controls the account, which determines how the data should be interpreted. An unexpected owner means you fetched the wrong account rather than that the decoder is broken.
Can I parse an account without the IDL? Yes, if you know the layout, but you take on the risk that a program upgrade shifts it. With the IDL, the layout comes from the program rather than from your assumption about it.
Related Reading
- Solana getProgramAccounts Guide
- Solana Token Balance Queries
- Solana Associated Token Account
- Solana Account Rent Exemption
- Solana Transaction Landing