A user reports their balance is missing. You query the wallet, the token is not there, and the explorer shows it plainly.
★The token lives under Token-2022, and your query asked the classic token program.★
Two Programs, Not Two Versions
The name suggests an upgrade. It is not one.
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA ← ★classic SPL Token★
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb ← ★Token-2022★
★Two deployed programs, two addresses, both live at the same time.★ A mint belongs to exactly one of them, permanently, decided when it was created. Nothing migrates.
The consequence for every piece of code you write: the program ID is a parameter, not a constant.
The Query That Silently Misses Half
// ★Returns only classic tokens.★
const { value } = await connection.getParsedTokenAccountsByOwner(
wallet, { programId: TOKEN_PROGRAM_ID },
);
This is correct code with an incomplete result. ★No error, no warning — the Token-2022 balances simply are not in the array.★
// ★Query both, then merge.★
const [classic, t22] = await Promise.all([
connection.getParsedTokenAccountsByOwner(wallet, { programId: TOKEN_PROGRAM_ID }),
connection.getParsedTokenAccountsByOwner(wallet, { programId: TOKEN_2022_PROGRAM_ID }),
]);
const all = [...classic.value, ...t22.value];
Two calls, not one. There is no combined query, and no flag that returns both.
★This is the single most common Token-2022 bug, and it presents as "the user is wrong about their balance."★
Ask the Mint Which Program Owns It
Before building any instruction, one cheap read settles it:
const info = await connection.getAccountInfo(mintAddress);
const programId = info.owner; // ★the mint's owner IS the token program★
★The mint account's owner field is the authoritative answer.★ You do not need to guess, maintain a list, or infer it from the mint address — the chain tells you directly.
Cache it. A mint's owning program never changes, so this is one of the few values safe to keep for the process lifetime.
The ATA Derivation Changes
This is the failure that produces AccountNotFound on an account you are certain exists:
// ★The program ID is part of the derivation.★
const ata = await getAssociatedTokenAddress(
mint,
owner,
false,
programId, // ★classic and 2022 derive DIFFERENT addresses★
);
★Derive with the wrong program ID and you get a valid-looking address that does not exist.★ The error names the account, not your parameter, so it reads like the token account was never created.
Every instruction builder takes the same parameter:
createTransferInstruction(src, dst, owner, amount, [], programId);
createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint, programId);
createCloseAccountInstruction(account, dest, owner, [], programId);
Omitting it defaults to the classic program, which is why forgetting it fails silently on Token-2022 mints and works everywhere else.
If your program IDs are right and transactions still miss, a free BoltTx key is one line to test the submission path.
What Actually Differs Behaviourally
For a trading bot, most of Token-2022 is identical. The account layout matches for the first 165 bytes, amount is still a u64 at offset 64, and transfers behave the same way.
★What differs is the extensions — and only some of them change how you trade.★
| Extension | Does it affect trading? |
|---|---|
| ★Transfer fee★ | ★Yes — amount received is less than sent★ |
| ★Transfer hook★ | ★Yes — inserts a CPI, costs compute★ |
| ★Non-transferable★ | ★Yes — you cannot sell it★ |
| ★Default frozen★ | ★Yes — needs thawing before use★ |
| Interest-bearing | Display only, balance unchanged |
| Metadata pointer | No |
| Memo required | Adds an instruction |
★The starred four are the ones that break a bot that assumed classic behaviour.★ The rest are cosmetic from a trading perspective.
Extensions Change the Account Size
Classic token accounts are exactly 165 bytes. ★Token-2022 accounts with extensions are larger, and the size varies by which extensions are present.★
Two things follow:
A dataSize: 165 filter misses them. Any getProgramAccounts query using that filter silently excludes extended accounts.
Rent is higher. More bytes means more rent-exempt lamports, so creating a token account for an extended mint costs more than the classic 165-byte figure you may have hardcoded.
// ★Ask, do not assume.★
const rent = await connection.getMinimumBalanceForRentExemption(info.data.length);
A Practical Approach
The pattern that keeps this manageable is to resolve the program once, then thread it through:
async function resolveToken(connection, mint) {
const info = await connection.getAccountInfo(mint);
if (!info) throw new Error("mint not found");
const programId = info.owner;
const is2022 = programId.equals(TOKEN_2022_PROGRAM_ID);
return {
programId,
is2022,
// ★Only 2022 mints can carry extensions worth checking.★
extensions: is2022 ? await readExtensions(connection, mint) : [],
};
}
★Treat "which token program" as part of a token's identity, resolved at discovery time and carried alongside the mint address.★ Bots that store only the mint address end up re-deriving the program at each call site, and that is where one of them gets it wrong.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★A wrong program ID fails long before submission speed matters★ — either at instruction construction or as an immediate revert. It is a correctness problem, not a landing problem, and no submission path can help with it.
Where BoltTx Fits
We handle submission. Which token program your instructions reference is decided entirely in your code.
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. We never modify transaction contents, which includes never substituting program IDs.
You sign locally. We never hold funds and never sign. 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 connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
FAQ
What is the difference between Token-2022 and SPL Token? They are two separate deployed programs with different program IDs, both active. A mint belongs to exactly one of them, decided at creation, and nothing migrates between them.
Is Token-2022 an upgrade to SPL Token? No, despite the name. The classic token program continues to run unchanged, and Token-2022 exists alongside it with support for optional extensions.
Why is a token missing from getParsedTokenAccountsByOwner?
Because the query is scoped to one program ID. Token-2022 balances require a second call with TOKEN_2022_PROGRAM_ID, and there is no combined query that returns both.
How do I know which token program a mint uses?
Read the mint account's owner field. It is the token program that owns it, which is authoritative and requires no guessing or maintained list.
Why do I get AccountNotFound for a token account that exists? Likely an ATA derived with the wrong program ID. The program ID is part of the derivation, so classic and Token-2022 produce different addresses for the same wallet and mint.
Do I need to pass the program ID to instruction builders? Yes, for Token-2022. Omitting it defaults to the classic program, which is why the mistake works everywhere except on Token-2022 mints.
Is the account layout the same? For the first 165 bytes, yes — mint at 0, owner at 32, amount at 64. Token-2022 accounts with extensions are larger, with extension data appended after that.
Why does my dataSize 165 filter miss Token-2022 accounts?
Because extended accounts are larger than 165 bytes. A filter on that exact size silently excludes them, which is a common cause of incomplete getProgramAccounts results.
Which Token-2022 extensions affect trading? Transfer fee, transfer hook, non-transferable, and default frozen. The rest are largely cosmetic from a trading perspective, though memo-required adds an instruction.
Does Token-2022 cost more rent?
For accounts with extensions, yes, since they occupy more bytes. Query getMinimumBalanceForRentExemption with the actual data length rather than assuming 165 bytes.
Can a mint be migrated from SPL Token to Token-2022? No. The owning program is fixed at creation. A project wanting Token-2022 features must create a new mint and handle the transition itself.
Do DEXes support Token-2022 tokens? Support varies by protocol and by extension. Transfer hooks in particular require explicit handling, so verify with a small trade rather than assuming.
How do I display a user's full balance? Query both program IDs and merge the results. A single-program query is the most common reason a balance appears to be missing when the explorer shows it.
Should I cache which program owns a mint? Yes, indefinitely. The owning program never changes, making it one of the few values genuinely safe to cache for the lifetime of the process.
What happens if I use the classic program ID on a Token-2022 mint? The instruction fails, since the mint is not owned by the program you invoked. It is a correctness error caught immediately rather than an intermittent one.
How should a bot store token identity? The mint address together with its program ID, resolved once at discovery. Storing only the mint means re-deriving the program at every call site, and one of them will get it wrong.