Disclosure: we build Jumpbit, which ships a no-code tool for this. This post is the mechanics and the code, so you can script it yourself or judge the tool.
TL;DR: Token-2022 withholds each transfer fee in the receiving token account. To collect, scan every token account of the mint, harvest the withheld amounts into the mint in batches (permissionless), then withdraw from the mint with the withdraw withheld authority.
The lifecycle
- A transfer of N tokens credits the recipient with N minus the fee. The fee is stored as
withheldAmounton the recipient's token account. - Harvest moves withheld amounts from token accounts to the mint, which is your token's own on-chain account and belongs to no tool or service. Anyone can submit it.
- Withdraw moves the mint's withheld total to a destination token account. The mint's
withdrawWithheldAuthoritymust sign. - If that authority was revoked, the fees can never be withdrawn.
Step 1: find every account holding fees
The withheld amount lives in a Token-2022 account extension, so you cannot get it from a plain balance query. Filter the program's accounts by mint and by account type, then read the extension:
import {
AccountType,
getTransferFeeAmount,
TOKEN_2022_PROGRAM_ID,
unpackAccount,
} from '@solana/spl-token';
import { Connection, PublicKey } from '@solana/web3.js';
import bs58 from 'bs58';
const ACCOUNT_TYPE_OFFSET = 165;
export const findWithheldAccounts = async (connection: Connection, mint: PublicKey) => {
const accounts = await connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {
commitment: 'confirmed',
filters: [
{ memcmp: { offset: 0, bytes: mint.toBase58() } },
{ memcmp: { offset: ACCOUNT_TYPE_OFFSET, bytes: bs58.encode([AccountType.Account]) } },
],
});
return accounts
.map(({ pubkey, account }) => {
const parsed = unpackAccount(pubkey, account, TOKEN_2022_PROGRAM_ID);
return { address: pubkey, withheld: getTransferFeeAmount(parsed)?.withheldAmount ?? 0n };
})
.filter(entry => entry.withheld > 0n);
};
Offset 0 is the mint field of a token account. Offset 165 is where the account type byte sits for Token-2022 accounts, so the second filter excludes mints and other account types. On a token with many holders this call is heavy, so run it against a dedicated RPC with a generous timeout and cache the result.
Step 2: harvest in batches
A transaction can only carry a limited number of accounts. We measured a v0 transaction with two compute-budget instructions and one fee-payer signature: 26 accounts serialize to 1,116 bytes, each extra account adds 33 bytes, and 29 accounts (1,215 bytes) is the most that fits under the 1,232-byte limit. We use 26 to leave headroom. The harvest instruction has no signer of its own, so it needs only a fee payer:
import { createHarvestWithheldTokensToMintInstruction, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
const HARVEST_BATCH = 26;
const chunk = <T>(items: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
items.slice(index * size, index * size + size),
);
export const harvestAll = async (
connection: Connection,
payer: Keypair,
mint: PublicKey,
sources: PublicKey[],
) => {
for (const batch of chunk(sources, HARVEST_BATCH)) {
const transaction = new Transaction().add(
createHarvestWithheldTokensToMintInstruction(mint, batch, TOKEN_2022_PROGRAM_ID),
);
await sendAndConfirmTransaction(connection, transaction, [payer]);
}
};
A production version also needs priority fees, retries for expired blockhashes, and bookkeeping for which batches confirmed. Harvested fees wait on the mint until the authority withdraws them.
Step 3: withdraw from the mint
Create the destination token account if it does not exist, then withdraw with the authority:
import {
createAssociatedTokenAccountIdempotentInstruction,
createWithdrawWithheldTokensFromMintInstruction,
getAssociatedTokenAddressSync,
TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';
import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
export const withdrawFromMint = async (
connection: Connection,
authority: Keypair,
mint: PublicKey,
) => {
const destination = getAssociatedTokenAddressSync(
mint,
authority.publicKey,
false,
TOKEN_2022_PROGRAM_ID,
);
const transaction = new Transaction().add(
createAssociatedTokenAccountIdempotentInstruction(
authority.publicKey,
destination,
authority.publicKey,
mint,
TOKEN_2022_PROGRAM_ID,
),
createWithdrawWithheldTokensFromMintInstruction(
mint,
destination,
authority.publicKey,
[],
TOKEN_2022_PROGRAM_ID,
),
);
return sendAndConfirmTransaction(connection, transaction, [authority]);
};
You can also withdraw straight from token accounts with createWithdrawWithheldTokensFromAccountsInstruction, which skips the harvest step but needs the authority to sign every batch.
Why pool vaults matter
Pool vaults are token accounts of your mint. Every swap that touches the pool leaves a fee on its vault, so they can hold a large share. They are harvested like any other account, with no signature from the pool.
Who pays for the harvest?
Harvest does not need the withdraw authority, only a fee payer, so the many-transaction part can be done by anyone. Only the withdraw needs the authority's signature.
That split is what makes it possible for a tool or service to do the heavy lifting without ever holding your tokens or your authority. It pays the transaction fees and moves the withheld amounts into the mint, and you sign the final withdraw yourself.
Gotchas
- Do not revoke the withdraw authority before collecting. After that the fees are locked.
- A token account cannot be closed while it holds withheld fees. The close instruction fails until the withheld amount is zero, so harvest it first.
- Large holder sets need multiple runs or long runs. Track which accounts are done and rescan at the end.
- Test on devnet first. Create a transfer-fee mint, move some tokens, and run the whole flow before touching mainnet.
If you do not want to maintain this, the no-code version is here: https://jumpbit.io/en/solana/token-manager-tools/solana-transfer-fee-collector
Docs: Solana transfer fees


Top comments (1)
Official Platform Update
Security protocols have been updated for all developer accounts.