Transfer Fee Extension

Summary #

  • The Token Extension Program's transfer fee extension allows fees to be withheld on every transfer. These fees are held on the recipient's account, and can only be redeemed from the withdrawWithheldAuthority authority
  • Withheld tokens can be withdrawn directly from the recipient accounts or can be harvested back to the mint and then withdrawn
  • Transfers with mints using the transfer fee extension need to use the transferCheckedWithFee instruction

Overview #

Suppose you're a Solana game developer and you're making a large open world multiplayer role playing game. You'll have a currency in this game that all the players will earn and trade with. To make the economy in the game circular, you may want to charge a small transfer fee every time this currency changes hands, you'd call this the developer tax. This can be accomplished with the transfer fee extension. The neat part is this will work on every transfer, in-game and out!

The Token Extension Program's transfer fee extension enables you to configure a transfer fee on a mint such that fees are assessed at the protocol level. On every transfer, some amount of that mint is withheld on the recipient account which cannot be used by the recipient. At any point after the transfer, the withdraw authority can claim these withheld tokens.

The transfer fee extension is customizable and updatable. Here are the inputs that we'll delve into a bit later:

  • Fee basis points: This is the fee assessed on every transfer. For example, if 1000 tokens with 50 basis points are transferred, it will yield 5 tokens.
  • Maximum fee: The cap on transfer fees. With a maximum fee of 5000 tokens, a transfer of 10,000,000,000,000 tokens will only yield 5000 tokens.
  • Transfer fee authority: The entity that can modify the fees.
  • Withdraw withheld authority: The entity that can move tokens withheld on the mint or token accounts.

Calculating fee basis points #

Before we go into the extension, here's a quick intro to "fee basis points".

A basis point is a unit of measurement used in finance to describe the percentage change in the value or rate of a financial instrument. One basis point is equivalent to 0.01% or 0.0001 in decimal form.

To get the fee we must calculate it as follows:

Fee = (token_amount * fee_basis_points) / 10000

The constant 10,000 is used to convert the fee basis point percentage to the equivalent amount.

Configuring a mint with a transfer fee #

Initializing a mint with the transfer fee extension involves three instructions:

  • SystemProgram.createAccount
  • createInitializeTransferFeeConfigInstruction
  • createInitializeMintInstruction

The first instruction SystemProgram.createAccount allocates space on the blockchain for the mint account. This instruction accomplishes three things:

  • Allocates space
  • Transfers lamports for rent
  • Assigns to it's owning program

As with all Token Extension Program's mints, we need to calculate the space and lamports needed for the mint. We can get these by calling getMintLen and getMinimumBalanceForRentExemption

const extensions = [ExtensionType.TransferFeeConfig];
const mintLength = getMintLen(extensions);
 
const mintLamports =
  await connection.getMinimumBalanceForRentExemption(mintLength);
 
const createAccountInstruction = SystemProgram.createAccount({
  fromPubkey: payer.publicKey,
  newAccountPubkey: mintKeypair.publicKey,
  space: mintLength,
  lamports: mintLamports,
  programId: TOKEN_2022_PROGRAM_ID,
});

The second instruction createInitializeTransferFeeConfigInstruction initializes the transfer fee extension.

It takes the following parameters:

  • mint: Token mint account
  • transferFeeConfigAuthority: Optional authority that can update the fees
  • withdrawWithheldAuthority: Optional authority that can withdraw fees
  • transferFeeBasisPoints: Amount of transfer collected as fees, expressed as basis points of the transfer amount
  • maximumFee: Maximum fee assessed on transfers
  • programId: SPL Token program account
const initializeTransferFeeConfigInstruction =
  createInitializeTransferFeeConfigInstruction(
    mintKeypair.publicKey,
    payer.publicKey,
    payer.publicKey,
    feeBasisPoints,
    maxFee,
    TOKEN_2022_PROGRAM_ID,
  );

The third instruction createInitializeMintInstruction initializes the mint.

const initializeMintInstruction = createInitializeMintInstruction(
  mintKeypair.publicKey,
  decimals,
  payer.publicKey,
  null,
  TOKEN_2022_PROGRAM_ID,
);

Lastly, you need to add all of these instructions to a transaction and send it off the the blockchain.

const mintTransaction = new Transaction().add(
  createAccountInstruction,
  initializeTransferFeeConfigInstruction,
  initializeMintInstruction,
);
 
const signature = await sendAndConfirmTransaction(
  connection,
  mintTransaction,
  [payer, mintKeypair],
  { commitment: "finalized" },
);

Transferring mint with transfer fees #

There are a couple of notes when transferring tokens with the transfer fee extension.

First, the recipient is the one who "pays" for the fee. If I send 100 tokens with basis points of 50 (5%), the recipient will receive 95 tokens (five withheld)

Second, the fee is calculated not by the tokens sent, but the smallest unit of said token. In Solana programming, we always specify amounts to be transferred, minted or burned in their smallest unit. To send one SOL to someone, we actually send 1 * 10 ^ 9 lamports. Another way to look at it is if you wanted to send one US dollar, you're actually sending 100 pennies. Let's make this dollar a token with a 50 basis points (5%) transfer fee. Sending one dollar, would result in a five cent fee. Now let's say we have a max fee of 10 cents, this will always be the highest fee, even if we send $10,000.

The calculation can be summed up like this:

const transferAmount = BigInt(tokensToSend * 10 ** decimals);
const basisPointFee =
  (transferAmount * BigInt(feeBasisPoints)) / BigInt(10_000);
const fee = basisPointFee > maxFee ? maxFee : basisPointFee;

Third and final, there are two ways to transfer tokens with the transfer fee extension: transfer_checked or transfer_checked_with_fee. The regular transfer function lacks the necessary logic to handle fees.

You have the choice of which function to use for transferring:

  • transfer_checked_with_fee: You have to calculate and provide the correct fees
  • transfer_checked: This will calculate the fees for you
/**
 * Transfer tokens from one account to another, asserting the token mint and decimals
 *
 * @param connection     Connection to use
 * @param payer          Payer of the transaction fees
 * @param source         Source account
 * @param mint           Mint for the account
 * @param destination    Destination account
 * @param owner          Owner of the source account
 * @param amount         Number of tokens to transfer
 * @param decimals       Number of decimals in transfer amount
 * @param multiSigners   Signing accounts if `owner` is a multisig
 * @param confirmOptions Options for confirming the transaction
 * @param programId      SPL Token program account
 *
 * @return Signature of the confirmed transaction
 */
 
const secondTransferAmount = BigInt(1 * 10 ** decimals);
const secondTransferSignature = await transferChecked(
  connection,
  payer,
  sourceAccount,
  mint,
  destinationAccount,
  sourceKeypair,
  secondTransferAmount,
  decimals, // Can also be gotten by getting the mint account details with `getMint(...)`
  [],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);

Collecting fees #

There are two ways to "collect fees" from the withheld portion of the token accounts.

  1. The withdrawWithheldAuthority can withdraw directly from the withheld portion of a user's token account into any "token vault"
  2. We can "harvest" the withheld tokens and store them within the mint account itself, which can be withdrawn at any point from the withdrawWithheldAuthority

But first, why have these two options?

Simply put, directly withdrawing is a permissioned function, meaning only the withdrawWithheldAuthority can call it. Whereas harvesting is permissionless, where anyone can call the harvest function consolidating all of the fees into the mint itself.

But why not just directly transfer the tokens to the fee collector on each transfer?

Two reasons: one, where the mint creator wants the fees to end up may change. Two, this would create a bottleneck.

Say you have a very popular token with transfer fee enabled and your fee vault is the recipient of the fees. If thousands of people are trying to transact the token simultaneously, they'll all have to update your fee vault's balance - your fee vault has to be "writable". While it's true Solana can execute in parallel, it cannot execute in parallel if the same accounts are being written to at the same time. So, these thousands of people would have to wait in line, slowing down the transfer drastically. This is solved by setting aside the withheld transfer fees within the recipient's account - this way, only the sender and receiver's accounts are writable. Then the withdrawWithheldAuthority can withdraw to the fee vault anytime after.

Directly withdrawing fees #

In the first case, If we want to withdraw all withheld transfer fees from all token accounts directly we can do the following:

  1. Grab all token accounts associated with the mint using getProgramAccounts
  2. Add all token accounts with some withheld tokens to a list
  3. Call the withdrawWithheldTokensFromAccounts function (the authority needs to be a signer)
// grabs all of the token accounts for a given mint
const accounts = await connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {
  commitment: "finalized",
  filters: [
    {
      memcmp: {
        offset: 0,
        bytes: mint.toString(),
      },
    },
  ],
});
 
const accountsToWithdrawFrom = [];
for (const accountInfo of accounts) {
  const unpackedAccount = unpackAccount(
    accountInfo.pubkey,
    accountInfo.account,
    TOKEN_2022_PROGRAM_ID,
  );
 
  // If there is withheld tokens add it to our list
  const transferFeeAmount = getTransferFeeAmount(unpackedAccount);
  if (
    transferFeeAmount != null &&
    transferFeeAmount.withheldAmount > BigInt(0)
  ) {
    accountsToWithdrawFrom.push(accountInfo.pubkey);
  }
}
 
/**
 * Withdraw withheld tokens from accounts
 *
 * @param connection     Connection to use
 * @param payer          Payer of the transaction fees
 * @param mint           The token mint
 * @param destination    The destination account
 * @param authority      The mint's withdraw withheld tokens authority
 * @param multiSigners   Signing accounts if `owner` is a multisig
 * @param sources        Source accounts from which to withdraw withheld fees
 * @param confirmOptions Options for confirming the transaction
 * @param programId      SPL Token program account
 *
 * @return Signature of the confirmed transaction
 */
await withdrawWithheldTokensFromAccounts(
  connection,
  payer,
  mint,
  feeVaultAccount,
  authority,
  [],
  accountsToWithdrawFrom,
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);

Harvesting fees #

The second approach we call "harvesting" - this is a permissionless function meaning anyone can call it. This approach is great for "cranking" the harvest instruction with tools like clockwork. The difference is when we harvest, the withheld tokens get stored in the mint itself. Then the withdrawWithheldAuthority can withdraw the tokens from the mint at any point.

To harvest:

  1. gather all of the accounts you want to harvest from (same flow as above)
  2. call harvestWithheldTokensToMint
  3. To withdraw from the mint, call withdrawWithheldTokensFromMint
/**
 * Harvest withheld tokens from accounts to the mint
 *
 * @param connection     Connection to use
 * @param payer          Payer of the transaction fees
 * @param mint           The token mint
 * @param sources        Source accounts from which to withdraw withheld fees
 * @param confirmOptions Options for confirming the transaction
 * @param programId      SPL Token program account
 *
 * @return Signature of the confirmed transaction
 */
await harvestWithheldTokensToMint(
  connection,
  payer,
  mint,
  accountsToHarvestFrom,
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);
 
/**
 * Withdraw withheld tokens from mint
 *
 * @param connection     Connection to use
 * @param payer          Payer of the transaction fees
 * @param mint           The token mint
 * @param destination    The destination account
 * @param authority      The mint's withdraw withheld tokens authority
 * @param multiSigners   Signing accounts if `owner` is a multisig
 * @param confirmOptions Options for confirming the transaction
 * @param programId      SPL Token program account
 *
 * @return Signature of the confirmed transaction
 */
await withdrawWithheldTokensFromMint(
  connection,
  payer,
  mint,
  feeVaultAccount,
  authority,
  [],
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);

Updating fees #

As of right now there is no way to set the transfer fee post creation with the JS library. However you can from the CLI assuming the result of solana config wallet is the transferFeeConfigAuthority:

solana address
## The result of ^ needs to be the `transferFeeConfigAuthority`
spl-token set-transfer-fee <MINT_ID> <FEE_IN_BASIS_POINTS> <MAX_FEE>

Updating authorities #

If you'd like to change the transferFeeConfigAuthority or the withdrawWithheldAuthority you can with the setAuthority function. Just pass in the correct accounts and the authorityType, which in these cases are: TransferFeeConfig and WithheldWithdraw, respectively.

/**
 * Assign a new authority to the account
 *
 * @param connection       Connection to use
 * @param payer            Payer of the transaction fees
 * @param account          Address of the account
 * @param currentAuthority Current authority of the specified type
 * @param authorityType    Type of authority to set
 * @param newAuthority     New authority of the account
 * @param multiSigners     Signing accounts if `currentAuthority` is a multisig
 * @param confirmOptions   Options for confirming the transaction
 * @param programId        SPL Token program account
 *
 * @return Signature of the confirmed transaction
 */
 
await setAuthority(
  connection,
  payer,
  mint,
  currentAuthority,
  AuthorityType.TransferFeeConfig, // or AuthorityType.WithheldWithdraw
  newAuthority,
  [],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);

Lab #

In this lab, we are going to create a transfer fee configured mint. We'll use a fee vault to hold the transfer fees, and we'll collect the fees using both the direct and the harvesting methods.

1. Getting started #

To get started, create an empty directory named transfer-fee and navigate to it. We'll be initializing a brand new project. Run npm init and follow through the prompts.

Next, we'll need to add our dependencies. Run the following to install the required packages:

npm i @solana-developers/helpers @solana/spl-token @solana/web3.js esrun dotenv typescript

Create a directory named src. In this directory, create a file named index.ts. This is where we will run checks against the rules of this extension. Paste the following code in index.ts:

import { Connection, Keypair } from "@solana/web3.js";
import { initializeKeypair } from "@solana-developers/helpers";
import { transferCheckedWithFee } from "@solana/spl-token";
 
/**
 * Create a connection and initialize a keypair if one doesn't already exists.
 * If a keypair exists, airdrop a SOL token if needed.
 */
const connection = new Connection("http://127.0.0.1:8899");
const payer = await initializeKeypair(connection);
 
console.log(`public key: ${payer.publicKey.toBase58()}`);
 
const mintKeypair = Keypair.generate();
const mint = mintKeypair.publicKey;
console.log("\nmint public key: " + mintKeypair.publicKey.toBase58() + "\n\n");
 
// CREATE MINT WITH TRANSFER FEE
 
// CREATE FEE VAULT ACCOUNT
 
// CREATE A SOURCE ACCOUNT AND MINT TOKEN
 
// CREATE DESTINATION ACCOUNT
 
// TRANSFER TOKENS
 
// FETCH ACCOUNTS WITH WITHHELD TOKENS
 
// WITHDRAW WITHHELD TOKENS
 
// VERIFY UPDATED FEE VAULT BALANCE
 
// HARVEST WITHHELD TOKENS TO MINT
 
// WITHDRAW HARVESTED TOKENS
 
// VERIFY UPDATED FEE VAULT BALANCE

index.ts has a main function that creates a connection to the specified validator node and calls initializeKeypair. This main function is where we'll be writing our script.

Go ahead and run the script. You should see the mint public key logged to your terminal.

esrun src/index.ts

If you run into an error in initializeKeypair with airdropping, follow the next step.

2. Run validator node #

For the sake of this guide, we'll be running our own validator node.

In a separate terminal, run the following command: solana-test-validator. This will run the node and also log out some keys and values. The value we need to retrieve and use in our connection is the JSON RPC URL, which in this case is http://127.0.0.1:8899. We then use that in the connection to specify to use the local RPC URL.

const connection = new Connection("http://127.0.0.1:8899", "confirmed");

Alternatively, if you’d like to use testnet or devnet, import the clusterApiUrl from @solana/web3.js and pass it to the connection as such:

const connection = new Connection(clusterApiUrl("devnet"), "confirmed");

If you decide to use devnet, and have issues with airdropping SOL. Feel free to add the keypairPath parameter to initializeKeypair. You can get this from running solana config get in your terminal. And then go to faucet.solana.com and airdrop some sol to your address. You can get your address from running solana address in your terminal.

3. Create a mint with transfer fee #

Let's create a function createMintWithTransferFee in a new file src/create-mint.ts.

To create a mint with the transfer fee extension, we need three instructions: SystemProgram.createAccount, createInitializeTransferFeeConfigInstruction and createInitializeMintInstruction.

We'll also want the our new createMintWithTransferFee function to have following arguments:

  • connection : The connection object
  • payer : Payer for the transaction
  • mintKeypair : Keypair for the new mint
  • decimals : Mint decimals
  • feeBasisPoints : Fee basis points for the transfer fee
  • maxFee : Maximum fee points for the transfer fee
import {
  sendAndConfirmTransaction,
  Connection,
  Keypair,
  SystemProgram,
  Transaction,
  TransactionSignature,
} from "@solana/web3.js";
 
import {
  ExtensionType,
  createInitializeMintInstruction,
  getMintLen,
  TOKEN_2022_PROGRAM_ID,
  createInitializeTransferFeeConfigInstruction,
} from "@solana/spl-token";
 
export async function createMintWithTransferFee(
  connection: Connection,
  payer: Keypair,
  mintKeypair: Keypair,
  decimals: number,
  feeBasisPoints: number,
  maxFee: bigint,
): Promise<TransactionSignature> {
  const extensions = [ExtensionType.TransferFeeConfig];
  const mintLength = getMintLen(extensions);
 
  const mintLamports =
    await connection.getMinimumBalanceForRentExemption(mintLength);
 
  console.log("Creating a transaction with transfer fee instruction...");
  const mintTransaction = new Transaction().add(
    SystemProgram.createAccount({
      fromPubkey: payer.publicKey,
      newAccountPubkey: mintKeypair.publicKey,
      space: mintLength,
      lamports: mintLamports,
      programId: TOKEN_2022_PROGRAM_ID,
    }),
    createInitializeTransferFeeConfigInstruction(
      mintKeypair.publicKey,
      payer.publicKey,
      payer.publicKey,
      feeBasisPoints,
      maxFee,
      TOKEN_2022_PROGRAM_ID,
    ),
    createInitializeMintInstruction(
      mintKeypair.publicKey,
      decimals,
      payer.publicKey,
      null,
      TOKEN_2022_PROGRAM_ID,
    ),
  );
 
  console.log("Sending transaction...");
  const signature = await sendAndConfirmTransaction(
    connection,
    mintTransaction,
    [payer, mintKeypair],
    { commitment: "finalized" },
  );
  console.log("Transaction sent");
 
  return signature;
}

Now let's import and call our new function in src/index.ts. We'll create a mint that has nine decimal points, 1000 fee basis points (10%), and a max fee of 5000.

// CREATE MINT WITH TRANSFER FEE
const decimals = 9;
const feeBasisPoints = 1000;
const maxFee = BigInt(5000);
 
await createMintWithTransferFee(
  connection,
  payer,
  mintKeypair,
  decimals,
  feeBasisPoints,
  maxFee,
);

Run the script to make sure it's working so far.

esrun src/index.ts

4. Create a fee vault account #

Before we transfer any tokens and accrue transfer fees, let's create a "fee vault" that will be the final recipient of all transfer fees.

For simplicity, let's make the fee vault the associated token account (ATA) of our payer.

// CREATE FEE VAULT ACCOUNT
console.log("\nCreating a fee vault account...");
 
const feeVaultAccount = await createAssociatedTokenAccount(
  connection,
  payer,
  mintKeypair.publicKey,
  payer.publicKey,
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);
 
const initialBalance = (
  await connection.getTokenAccountBalance(feeVaultAccount, "finalized")
).value.amount;
 
console.log("Current fee vault balance: " + initialBalance + "\n\n");

Let's run the script again, we should have a zero balance.

esrun src/index.ts

5. Create two token accounts and mint to one #

Let's now create two test token accounts we'll call the source and destination accounts. Then let's mint some tokens to the source.

We can do this by calling createAccount and mintTo.

We'll mint 10 full tokens.

// CREATE TEST ACCOUNTS AND MINT TOKENS
console.log("Creating source account...");
 
const sourceKeypair = Keypair.generate();
const sourceAccount = await createAccount(
  connection,
  payer,
  mint,
  sourceKeypair.publicKey,
  undefined,
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);
 
console.log("Creating destination account...");
 
const destinationKeypair = Keypair.generate();
const destinationAccount = await createAccount(
  connection,
  payer,
  mint,
  destinationKeypair.publicKey,
  undefined,
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);
 
console.log("Minting 10 tokens to source...\n\n");
 
const amountToMint = 10 * 10 ** decimals;
 
await mintTo(
  connection,
  payer,
  mint,
  sourceAccount,
  payer,
  amountToMint,
  [payer],
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);

If you'd like, run the script to check that everything is working:

esrun src/index.ts

6. Transfer one token #

Now, let's transfer 1 token from our sourceAccount to our destinationAccount and see what happens.

To transfer a token with the transfer fee extension enabled, we have to call transferCheckedWithFee. This requires us to decide how much we want to send, and to calculate the correct fee associated.

To do this, we can do a little math:

First, to send one full token is actually sending 1 * (10 ^ decimals) tokens. In Solana programming, we always specify amounts to be transferred, minted or burned in their smallest unit. To send one SOL to someone, we actually send 1 * 10 ^ 9 lamports. Another way to look at it is if you wanted to send one US dollar, you're actually sending 100 pennies.

Now, we can take the resulting amount: 1 * (10 ^ decimals) and calculate the fee using the basis points. We can do this by taking the transferAmount multiplying it by the feeBasisPoints and dividing by 10_000 (the definition of a fee basis point).

Lastly, we need to check if the fee is more than the max fee, if it is, then we call transferCheckedWithFee with our max fee.

const transferAmount = BigInt(1 * 10 ** decimals);
const basisPointFee =
  (transferAmount * BigInt(feeBasisPoints)) / BigInt(10_000);
const fee = basisPointFee > maxFee ? maxFee : basisPointFee;

With all of this information, take a second, what do you think the final balances and withheld amounts for this transaction will be?

Now, let's transfer one of our tokens and print out the resulting balances:

// TRANSFER TOKENS
console.log("Transferring with fee transaction...");
 
const transferAmount = BigInt(1 * 10 ** decimals);
const fee = (transferAmount * BigInt(feeBasisPoints)) / BigInt(10_000);
 
const transferSignature = await transferCheckedWithFee(
  connection,
  payer,
  sourceAccount,
  mint,
  destinationAccount,
  sourceKeypair.publicKey,
  transferAmount,
  decimals,
  fee,
  [sourceKeypair],
  { commitment: "finalized" },
  TOKEN_2022_PROGRAM_ID,
);
 
const sourceAccountAfterTransfer = await getAccount(
  connection,
  sourceAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const destinationAccountAfterTransfer = await getAccount(
  connection,
  destinationAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAmountAfterTransfer = getTransferFeeAmount(
  destinationAccountAfterTransfer,
);
 
console.log(`Source Token Balance: ${sourceAccountAfterTransfer.amount}`);
console.log(
  `Destination Token Balance: ${destinationAccountAfterTransfer.amount}`,
);
console.log(
  `Withheld Transfer Fees: ${withheldAmountAfterTransfer?.withheldAmount}\n`,
);

Go ahead and run the script:

esrun src/index.ts

You should get the following:

Transferring with fee transaction...
Source Token Balance: 9000000000
Destination Token Balance: 999995000
Withheld Transfer Fees: 5000

A little breakdown:

Our fee basis points are 1000, meaning 10% of the amount transferred should be used as a fee. In this case 10% of 1,000,000,000 is 100,000,000, which is way bigger than our 5000 max fee. So that's why we see 5000 withheld. Additionally, note that the receiver is the one who "pays" for the transfer fee.

Info

From now on, to calculate fees, you may want to use the calculateFee helper function. We did it manually for demonstration purposes. The following is one way to accomplish this:

const transferAmount = BigInt(1 * 10 ** decimals);
const mintAccount = await getMint(
  connection,
  mint,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
const transferFeeAmount = getTransferFeeConfig(mintAccount);
const fee = calculateFee(
  transferFeeAmount?.newerTransferFee!,
  secondTransferAmount,
);

7. Withdrawing fees #

There are two ways in which we can collect fees from the recipient's account into the fee vault. The first one is withdrawing the withheld fees directly from the recipient's account itself to the fee vault account using withdrawWithheldTokensFromAccounts. The second approach is "harvesting" the fees from the recipient's account to the mint with harvestWithheldTokensToMint and then withdrawing it from the mint to the fee vault account with withdrawWithheldTokensFromMint.

7.1 Withdraw fees directly from the recipient accounts #

First, let's withdraw the fees directly. We can accomplish this by calling withdrawWithheldTokensFromAccounts. This is a permissioned function, meaning only the withdrawWithheldAuthority can sign for it.

The withdrawWithheldTokensFromAccounts function takes the following parameters:

  • connection: The connection to use
  • payer: The payer keypair of the transaction fees
  • mint: The token mint
  • destination: The destination account - in our case, the fee vault
  • authority: The mint's withdraw withheld tokens authority - in our case, the payer
  • multiSigners: Signing accounts if owner is a multisig
  • sources: Source accounts from which to withdraw withheld fees
  • confirmOptions: Options for confirming the transaction
  • programId: SPL Token program account - in our case TOKEN_2022_PROGRAM_ID

Now, let's directly withdraw the fees from the destination account and check the resulting balances:

// DIRECTLY WITHDRAW
await withdrawWithheldTokensFromAccounts(
  connection,
  payer,
  mint,
  feeVaultAccount,
  payer.publicKey,
  [],
  [destinationAccount],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAccountAfterWithdraw = await getAccount(
  connection,
  destinationAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAmountAfterWithdraw = getTransferFeeAmount(
  withheldAccountAfterWithdraw,
);
 
const feeVaultAfterWithdraw = await getAccount(
  connection,
  feeVaultAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
console.log(
  `Withheld amount after withdraw: ${withheldAmountAfterWithdraw?.withheldAmount}`,
);
console.log(
  `Fee vault balance after withdraw: ${feeVaultAfterWithdraw.amount}\n`,
);

Go ahead and run the script:

esrun src/index.ts

You should get the following:

Withheld amount after withdraw: 0
Fee vault balance after withdraw: 5000
Info

The withdrawWithheldTokensFromAccounts can also be used to collect all fees from all token accounts, if you fetch them all first. Something like the following would work:

const accounts = await connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {
  commitment: "finalized",
  filters: [
    {
      memcmp: {
        offset: 0,
        bytes: mint.toString(),
      },
    },
  ],
});
 
const accountsToWithdrawFrom = [];
for (const accountInfo of accounts) {
  const unpackedAccount = unpackAccount(
    accountInfo.pubkey,
    accountInfo.account,
    TOKEN_2022_PROGRAM_ID,
  );
 
  const transferFeeAmount = getTransferFeeAmount(unpackedAccount);
  if (
    transferFeeAmount != null &&
    transferFeeAmount.withheldAmount > BigInt(0)
  ) {
    accountsToWithdrawFrom.push(accountInfo.pubkey);
  }
}
 
await withdrawWithheldTokensFromAccounts(
  connection,
  payer,
  mint,
  feeVaultAccount,
  payer.publicKey,
  [],
  accountsToWithdrawFrom,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);

7.2 Harvest and then withdraw #

Let's look at the second option to retrieving the withheld fees: "harvesting". The difference here is that instead of withdrawing the fees directly, we "harvest" them back to the mint itself using harvestWithheldTokensToMint. This is a permissionless function, meaning anyone can call it. This is useful if you use something like clockwork to automate these harvesting functions.

After the fees are harvested to the mint account, we can call withdrawWithheldTokensFromMint to transfer these tokens into our fee vault. This function is permissioned and we need the withdrawWithheldAuthority to sign for it.

To do this, we need to transfer some more tokens to accrue more fees. This time, we're going to take a shortcut and use the transferChecked function instead. This will automatically calculate our fees for us. Then we'll print out the balances to see where we are at:

// TRANSFER TOKENS PT2
console.log("Transferring with fee transaction pt2...");
 
const secondTransferAmount = BigInt(1 * 10 ** decimals);
const secondTransferSignature = await transferChecked(
  connection,
  payer,
  sourceAccount,
  mint,
  destinationAccount,
  sourceKeypair,
  secondTransferAmount,
  decimals, // Can also be gotten by getting the mint account details with `getMint(...)`
  [],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const sourceAccountAfterSecondTransfer = await getAccount(
  connection,
  sourceAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const destinationAccountAfterSecondTransfer = await getAccount(
  connection,
  destinationAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAmountAfterSecondTransfer = getTransferFeeAmount(
  destinationAccountAfterTransfer,
);
 
console.log(`Source Token Balance: ${sourceAccountAfterSecondTransfer.amount}`);
console.log(
  `Destination Token Balance: ${destinationAccountAfterSecondTransfer.amount}`,
);
console.log(
  `Withheld Transfer Fees: ${withheldAmountAfterSecondTransfer?.withheldAmount}\n`,
);

Now, let's harvest the tokens back to the mint account. We will do this using the harvestWithheldTokensToMint function. This function takes the following parameters:

  • connection: Connection to use
  • payer: Payer of the transaction fees
  • mint: The token mint
  • sources: Source accounts from which to withdraw withheld fees
  • confirmOptions: Options for confirming the transaction
  • programId: SPL Token program account

Then we'll check the resulting balances. However, since the withheld amount will now be stored in the mint, we have to fetch the mint account with getMint and then read the transfer fee extension data on it by calling getTransferFeeConfig:

// HARVEST WITHHELD TOKENS TO MINT
await harvestWithheldTokensToMint(
  connection,
  payer,
  mint,
  [destinationAccount],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAccountAfterHarvest = await getAccount(
  connection,
  destinationAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const withheldAmountAfterHarvest = getTransferFeeAmount(
  withheldAccountAfterHarvest,
);
 
const mintAccountAfterHarvest = await getMint(
  connection,
  mint,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const mintTransferFeeConfigAfterHarvest = getTransferFeeConfig(
  mintAccountAfterHarvest,
);
 
console.log(
  `Withheld amount after harvest: ${withheldAmountAfterHarvest?.withheldAmount}`,
);
console.log(
  `Mint withheld amount after harvest: ${mintTransferFeeConfigAfterHarvest?.withheldAmount}\n`,
);

Lastly, let's withdraw these fees from the mint itself using the withdrawWithheldTokensFromMint function. This function takes the following parameters:

  • connection: Connection to use
  • payer: Payer of the transaction fees
  • mint: The token mint
  • destination: The destination account
  • authority: The mint's withdraw withheld tokens authority
  • multiSigners: Signing accounts if owner is a multisig
  • confirmOptions: Options for confirming the transaction
  • programId: SPL Token program account

After that, let's check the balances:

// WITHDRAW HARVESTED TOKENS
await withdrawWithheldTokensFromMint(
  connection,
  payer,
  mint,
  feeVaultAccount,
  payer,
  [],
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const mintAccountAfterSecondWithdraw = await getMint(
  connection,
  mint,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
const mintTransferFeeConfigAfterSecondWithdraw = getTransferFeeConfig(
  mintAccountAfterSecondWithdraw,
);
 
const feeVaultAfterSecondWithdraw = await getAccount(
  connection,
  feeVaultAccount,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);
 
console.log(
  `Mint withheld balance after second withdraw: ${mintTransferFeeConfigAfterSecondWithdraw?.withheldAmount}`,
);
console.log(
  `Fee Vault balance after second withdraw: ${feeVaultAfterSecondWithdraw.amount}`,
);

Now, let's run it.

esrun src/index.ts

You should see the balances after every step of the way.

That's it! We have successfully created a mint with a transfer fee. If you get stuck at any point, you can find the working code in the solution branch of this repository.

Challenge #

Create a transfer fee enabled mint and transfer some tokens with different decimals, fee transfer points and max fees.