Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides13 min read

Token-2022 Transfer Fees on Solana: Withheld Amounts and RPC Reads

A practical RPC-first guide to reading Token-2022 transfer fees, withheld amounts, and effective post-fee balances from Solana account and transaction data.

TL;DR

Token-2022 transfer fees are not paid to a recipient or a fee account at transfer time; the sender is debited the full amount, the recipient is credited amount minus fee, and the fee accumulates in the mint's withheld amount. The fee rate and maximum are defined per epoch in the TransferFeeConfig mint extension, so the applicable fee depends on the current epoch and the mint's configuration. To observe the effective post-fee amount of a completed transfer, read preTokenBalances and postTokenBalances from getTransaction meta rather than trusting the instruction amount. To read the accumulated withheld amount, decode the TransferFeeConfig extension from the mint account; getTokenAccountBalance on a wallet will not show it. This guide provides runnable Node.js examples and a results table for measuring these values against your own RPC endpoint.

TransferFeeConfig Mint Extension and Where Its Fields Live

The TransferFeeConfig extension is a mint extension defined by the Token-2022 program. According to the Solana Transfer Fees reference (https://solana.com/docs/references/token-extensions), it stores the transfer fee basis points, the maximum fee, the withdraw authority, and the withheld amount accumulator. These fields are part of the mint account data, not the token account data, so any client that wants to reason about fees must fetch and decode the mint.

The extension is appended to the base mint layout. The base mint account contains the standard fields such as mint authority, supply, decimals, and freeze authority. The TransferFeeConfig extension follows the base data and begins with a type discriminator and length prefix. The exact byte offsets depend on the Token-2022 program version and the presence of other extensions, so a robust decoder should parse the extension list rather than assume a fixed offset.

The withdraw authority is the only account allowed to withdraw accumulated withheld fees from the mint. The withheld amount is a running total of fees that have been withheld from transfers but not yet withdrawn. It is not a token account balance and does not appear in getTokenAccountBalance for any wallet.

The extension and its fields are defined by Solana itself in the Transfer Fees reference, and the mint-extension account layout is documented alongside the Token Extensions program documentation. The read methods used below — getTokenAccountBalance, getAccountInfo, and getTransaction — are specified in the Solana JSON-RPC API reference. Treat those as the source of truth; this article is the operational read procedure built on top of them.

  • TransferFeeConfig is a mint extension, not a token account extension.
  • Fields include transfer fee basis points, maximum fee, withdraw authority, and withheld amount.
  • The withheld amount accumulates on the mint and is withdrawable only by the withdraw authority.
  • Decoding should account for variable extension ordering and dataSize.

How a Token-2022 Transfer Moves Value and Withholds Fees

Under Token-2022, a transfer instruction debits the sender's token account by the full instruction amount. The recipient's token account is credited by the amount minus the calculated fee. The fee itself is not transferred to any account at that moment; instead, it is added to the mint's withheld amount. This behavior is documented in the Solana Transfer Fees reference and is a core difference from a simple SPL Token transfer.

Because the fee is withheld on the mint, the sum of all token account balances for a mint can be less than the mint's total supply. The difference is the withheld amount, which remains on the mint until the withdraw authority withdraws it. This means that a naive reconciliation of token accounts against supply will show a discrepancy equal to the accumulated withheld fees.

The fee calculation uses the transfer fee basis points and the maximum fee from the TransferFeeConfig extension. The basis points are applied to the transfer amount, and the result is capped at the maximum fee. The exact rounding and cap behavior are defined by the Token-2022 program and should be verified against the program source or the official documentation for the specific program version.

  • Sender is debited the full amount; recipient is credited amount minus fee.
  • The fee is added to the mint's withheld amount, not paid to an account.
  • Total token account balances may be less than mint supply by the withheld amount.
  • The fee is min(basis points applied to amount, maximum fee).

Epoch-Rate Windows Make Fee Calculations State-Dependent

The TransferFeeConfig extension defines fee rates in epoch-based windows. According to the Token-2022 documentation, the extension stores an older and a newer set of fee parameters, each with a basis points value, a maximum fee, and an epoch. The applicable rate for a transfer depends on the current epoch relative to those epoch values. This makes a fee calculation state-dependent: the same transfer amount can incur different fees depending on the current epoch and the mint's configured windows.

A client that wants to compute the fee for a candidate transfer must read the current epoch from the RPC node and then select the correct fee parameters from the mint's TransferFeeConfig. If the current epoch is greater than or equal to the newer epoch, the newer parameters apply; otherwise the older parameters apply. The exact comparison logic is defined by the Token-2022 program and should be confirmed against the official documentation.

Because the fee parameters can change at epoch boundaries, a fee computed at one time may not match a fee observed later. For historical transfers, the applicable fee parameters are those that were in effect at the time of the transfer, which may require reading historical mint state or relying on the transaction's own meta data.

  • TransferFeeConfig stores older and newer fee parameter sets with epochs.
  • The applicable rate depends on the current epoch relative to the stored epochs.
  • Fee calculations are state-dependent and can change at epoch boundaries.
  • Historical fee reconstruction may require historical mint state or transaction meta.

Reading Effective Post-Fee Amounts from getTransaction Meta

The instruction amount in a Token-2022 transfer is the pre-fee value. To observe what the recipient actually received, read the transaction meta from getTransaction. The meta contains preTokenBalances and postTokenBalances arrays, which list token account balances before and after the transaction. The difference between the post and pre balance for the recipient's token account is the effective post-fee amount.

This approach is more reliable than computing the fee from the instruction amount because it reflects the actual on-chain result, including any rounding, cap application, or program-specific behavior. It also avoids the need to decode the mint extension for every transfer, though decoding the mint is still necessary to understand the fee parameters and withheld amount.

When reading getTransaction, ensure the transaction is finalized or at least confirmed according to your required commitment level. The meta may be null for transactions that failed or are not yet available. For more on commitment levels, see Solana commitment levels and transaction confirmation.

  • Instruction amount is pre-fee; meta balances show the actual post-fee result.
  • preTokenBalances and postTokenBalances are keyed by account index and mint.
  • The recipient's post minus pre balance is the effective received amount.
  • Meta may be null for failed or unavailable transactions.

Reading the Accumulated Withheld Amount from the Mint Account

The withheld amount is stored in the TransferFeeConfig extension on the mint account. To read it, fetch the mint account with getAccountInfo and decode the extension. The withheld amount is a u64 that represents the total fees withheld but not yet withdrawn. It increases with each transfer that incurs a fee and decreases when the withdraw authority withdraws fees.

getTokenAccountBalance on a wallet will not show the withheld amount because it is not a token account balance. The withheld amount belongs to the mint, not to any user's token account. This is a common source of confusion when reconciling balances. For more on reading account data, see Reading Solana accounts: data, rent, and token accounts over RPC.

Decoding the mint account requires parsing the extension list. The base mint data is followed by a series of extensions, each with a type and length. The TransferFeeConfig extension has a specific type value and a known layout. A robust decoder should handle unexpected dataSize and unknown extensions gracefully.

  • Withheld amount is in the mint's TransferFeeConfig extension.
  • It is not visible via getTokenAccountBalance on a wallet.
  • It increases with fees and decreases on withdrawal.
  • Decoding must handle variable extension ordering and dataSize.

Runnable Node.js Example: Fetch Mint, Decode TransferFeeConfig, Compute Fee

The following Node.js example uses @solana/web3.js to fetch a mint account, decode the TransferFeeConfig extension, read the current epoch, and compute the fee for a candidate transfer amount. It assumes the mint has the TransferFeeConfig extension and that the extension is at a known offset for the program version in use. In practice, you should parse the extension list to find the correct offset.

The example uses getAccountInfo to fetch the mint, getEpochInfo to read the current epoch, and a simple decoder for the TransferFeeConfig fields. It prints the basis points, maximum fee, withheld amount, and the computed recipient amount for a given transfer amount.

const { Connection, PublicKey } = require('@solana/web3.js');

const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const MINT = new PublicKey(process.env.MINT || 'YourMintAddressHere');
const TRANSFER_AMOUNT = BigInt(process.env.TRANSFER_AMOUNT || '1000000');

async function main() {
  const connection = new Connection(RPC_URL, 'confirmed');
  const mintInfo = await connection.getAccountInfo(MINT);
  if (!mintInfo) throw new Error('Mint account not found');

  const data = mintInfo.data;
  // Base mint layout: 82 bytes for SPL Token; Token-2022 base is similar.
  // Extensions start after base data. This example assumes TransferFeeConfig
  // is the first extension and uses a simplified offset for demonstration.
  const baseLen = 82;
  const extType = data.readUInt16LE(baseLen);
  const extLen = data.readUInt16LE(baseLen + 2);
  if (extType !== 1) throw new Error('TransferFeeConfig extension not found at expected offset');

  const extData = data.slice(baseLen + 4, baseLen + 4 + extLen);
  // TransferFeeConfig layout (simplified):
  // withdrawAuthority (32), withheldAmount (8), olderEpoch (8), olderBps (2), olderMax (8),
  // newerEpoch (8), newerBps (2), newerMax (8)
  let offset = 0;
  const withdrawAuthority = new PublicKey(extData.slice(offset, offset + 32)); offset += 32;
  const withheldAmount = extData.readBigUInt64LE(offset); offset += 8;
  const olderEpoch = extData.readBigUInt64LE(offset); offset += 8;
  const olderBps = extData.readUInt16LE(offset); offset += 2;
  const olderMax = extData.readBigUInt64LE(offset); offset += 8;
  const newerEpoch = extData.readBigUInt64LE(offset); offset += 8;
  const newerBps = extData.readUInt16LE(offset); offset += 2;
  const newerMax = extData.readBigUInt64LE(offset); offset += 8;

  const epochInfo = await connection.getEpochInfo();
  const currentEpoch = BigInt(epochInfo.epoch);

  let bps, maxFee;
  if (currentEpoch >= newerEpoch) {
    bps = newerBps; maxFee = newerMax;
  } else {
    bps = olderBps; maxFee = olderMax;
  }

  const fee = (TRANSFER_AMOUNT * BigInt(bps)) / 10000n;
  const cappedFee = fee > maxFee ? maxFee : fee;
  const recipientAmount = TRANSFER_AMOUNT - cappedFee;

  console.log('Mint:', MINT.toBase58());
  console.log('Withdraw authority:', withdrawAuthority.toBase58());
  console.log('Withheld amount:', withheldAmount.toString());
  console.log('Current epoch:', currentEpoch.toString());
  console.log('Applicable bps:', bps);
  console.log('Applicable max fee:', maxFee.toString());
  console.log('Transfer amount:', TRANSFER_AMOUNT.toString());
  console.log('Computed fee:', cappedFee.toString());
  console.log('Recipient amount:', recipientAmount.toString());
}

main().catch(console.error);

Runnable Node.js Example: Read Post-Fee Amount from Transaction Meta

The following example fetches a transaction by signature and extracts the recipient's post-fee amount from preTokenBalances and postTokenBalances. It uses getTransaction with jsonParsed encoding to simplify the balance arrays. This is the most direct way to observe what a recipient actually received.

The example assumes the transaction is confirmed and that the meta is available. It prints the pre and post balances for each token account and computes the difference for the recipient. You can adapt it to filter by mint or owner.

const { Connection } = require('@solana/web3.js');

const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const SIGNATURE = process.env.SIGNATURE || 'YourTransactionSignatureHere';

async function main() {
  const connection = new Connection(RPC_URL, 'confirmed');
  const tx = await connection.getTransaction(SIGNATURE, {
    maxSupportedTransactionVersion: 0,
    commitment: 'confirmed'
  });
  if (!tx) throw new Error('Transaction not found');
  if (!tx.meta) throw new Error('Transaction meta is null');

  const pre = tx.meta.preTokenBalances || [];
  const post = tx.meta.postTokenBalances || [];

  console.log('Pre token balances:');
  for (const b of pre) {
    console.log('  accountIndex:', b.accountIndex, 'mint:', b.mint, 'amount:', b.uiTokenAmount.uiAmountString);
  }
  console.log('Post token balances:');
  for (const b of post) {
    console.log('  accountIndex:', b.accountIndex, 'mint:', b.mint, 'amount:', b.uiTokenAmount.uiAmountString);
  }

  // Compute difference for each account index present in both
  for (const p of post) {
    const preBal = pre.find(x => x.accountIndex === p.accountIndex);
    if (preBal) {
      const preAmt = BigInt(preBal.uiTokenAmount.amount);
      const postAmt = BigInt(p.uiTokenAmount.amount);
      const diff = postAmt - preAmt;
      console.log('Account index', p.accountIndex, 'delta:', diff.toString());
    }
  }
}

main().catch(console.error);

Results Table for Measuring Against Your Own Endpoint

Use the following table to record measurements from your own RPC endpoint. Fill in the mint address, the extension presence, the basis points, the maximum fee, the current withheld amount, the computed recipient amount, and the observed post balance from a transaction. This will help you verify that your decoding and fee calculation match on-chain behavior.

Run the first Node.js example to populate the mint-related columns, then run the second example on a known transfer to populate the observed post balance. Compare the computed recipient amount with the observed post balance minus pre balance. Discrepancies may indicate an incorrect extension offset, an epoch mismatch, or a different program version.

  • Mint address: ______________________________
  • TransferFeeConfig extension present (yes/no): ______________________________
  • Basis points (applicable): ______________________________
  • Maximum fee (applicable): ______________________________
  • Current withheld amount: ______________________________
  • Computed recipient amount for candidate transfer: ______________________________
  • Observed post balance minus pre balance for recipient: ______________________________
  • Notes on discrepancies: ______________________________

Failure Modes and Troubleshooting

A mint without the TransferFeeConfig extension will not have transfer fees. If you attempt to decode the extension and find an unexpected type or length, the mint likely does not have the extension. The phrase 'token extensions false' in some tooling means the extension is absent. In that case, transfers behave like standard SPL Token transfers with no withheld fee.

A transaction whose fee was not what a naive percentage predicted may have hit the maximum-fee cap. The fee is min(basis points applied to amount, maximum fee). If the computed percentage exceeds the maximum fee, the maximum fee applies. Always check the applicable maximum fee for the current epoch.

Account-data decode errors from an unexpected dataSize can occur if the mint has additional extensions or a different layout. The base mint data length and extension ordering can vary. A robust decoder should parse the extension list by reading the type and length of each extension until the end of the account data. For more on account data, see Reading Solana accounts: data, rent, and token accounts over RPC.

If getTransaction returns null meta, the transaction may be failed, not yet confirmed, or pruned. Use an appropriate commitment level and consider archival access for historical transactions. For more on transaction meta, see Decoding Solana transaction meta and inner instructions.

  • Missing extension: no transfer fee; 'token extensions false' indicates absence.
  • Maximum-fee cap can make the actual fee lower than a naive percentage.
  • Unexpected dataSize: parse extension list instead of fixed offsets.
  • Null meta: check commitment, transaction status, and archival availability.

Limitations and Tradeoffs of RPC-Based Fee Reads

Computing the fee from the instruction amount is wrong because the instruction amount is the pre-fee value. The actual fee is determined by the mint's TransferFeeConfig and the current epoch, and the recipient's balance change is the authoritative post-fee amount. Relying on the instruction amount can lead to incorrect accounting.

Fetching and decoding full mint data per read has a cost. Each read requires an RPC call to getAccountInfo and possibly getEpochInfo. For high-frequency applications, this can add latency and load. Caching mint data with a short TTL can help, but you must invalidate on epoch boundaries or when the mint's configuration changes.

Historical transfers may require archive access because getTransaction meta is not always available for old transactions on non-archival nodes. The availability of historical data varies by provider. For provider-specific behavior, consult your RPC provider's documentation. For more on RPC pricing and service levels, see RPC pricing and API service.

  • Instruction amount is pre-fee; do not use it as the received amount.
  • Full mint fetch and decode per read adds cost; cache with care.
  • Historical meta may require archive access; availability varies by provider.
  • Provider-specific limits and retention should be confirmed with your provider.

Next Steps for Integrating Token-2022 Fee Reads

To integrate Token-2022 fee reads into your application, start by decoding the mint's TransferFeeConfig extension and caching the applicable fee parameters per epoch. Then, for each transfer, read the transaction meta to confirm the actual post-fee amount. Use the results table to validate your implementation against your own RPC endpoint.

For broader Solana RPC coverage, see the Solana RPC endpoints (RPC Assistant) and the OnFinality Learn hub. If you need to query many mints or token accounts, consider getProgramAccounts filters and dataSlice pagination to reduce data transfer. For sending transactions with preflight checks, see Solana sendTransaction preflight error handling.

Finally, review the official Solana documentation for Transfer Fees and the Token-2022 program to confirm the latest extension layout and fee logic. The Solana JSON-RPC API reference for getTokenAccountBalance, getAccountInfo, and getTransaction is authoritative for the method contracts used here.

  • Decode and cache TransferFeeConfig per epoch.
  • Validate with transaction meta and the results table.
  • Use getProgramAccounts filters for bulk reads.
  • Confirm extension layout against official documentation.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started