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.
This is how our Transfer Fee Collector works. You connect the authority wallet and get an exact quote: a platform fee that starts at 0.05 SOL, plus the network fees for the harvest transactions. You approve one payment, the service harvests every account from its own wallet in batches of 26, and you sign one withdraw at the end.
Read-only scan of a public Token-2022 mint: 1,713 accounts hold fees, which takes 66 harvest transactions. The quote is shown before any payment.
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 (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.