With any form of transaction, there's often a desire to collect or apply a fee. Similar to a small service charge every time you transfer money at a bank or the way royalties or taxes are collected for particular transfers.
The TransferFee
extension allows you to configure a transfer fee directly on
the Mint Account, enabling fees to be collected at a protocol level. Every time
tokens are transferred, the fee is set aside in the recipient's Token Account.
This fee is untouchable by the recipient and can only be accessed by the
Withdraw Authority.
The design of pooling transfer fees at the recipient account is meant to maximize parallelization of transactions. Otherwise, one configured fee recipient account would be write-locked between parallel transfers, decreasing throughput of the protocol.
In this guide, we'll walk through an example of creating a mint with the
TransferFee
extension enabled using Solana Playground. Here is the
final script.
The Transfer Fee extension can ONLY take a fee from its same Token Mint. (e.g.
if you created TokenA
, all transfer fees via the Transfer Fee extension will
be in TokenA
). If you wish to achieve a similar transfer fee in a token other
that itself, use the Transfer Hook extension.
Getting Started
Start by opening this Solana Playground link with the following starter code.
// Clientconsole.log("My address:", pg.wallet.publicKey.toString());const balance = await pg.connection.getBalance(pg.wallet.publicKey);console.log(`My balance: ${balance / web3.LAMPORTS_PER_SOL} SOL`);
If it is your first time using Solana Playground, you'll first need to create a Playground Wallet and fund the wallet with devnet SOL.
If you do not have a Playground wallet, you may see a type error within the
editor on all declarations of pg.wallet.publicKey
. This type error will clear
after you create a Playground wallet.
To get devnet SOL, run the solana airdrop
command in the Playground's
terminal, or visit this devnet faucet.
solana airdrop 5
Once you've created and funded the Playground wallet, click the "Run" button to run the starter code.
Add Dependencies
Let's start by setting up our script. We'll be using the @solana/web3.js
and
@solana/spl-token
libraries.
Replace the starter code with the following:
import {Connection,Keypair,SystemProgram,Transaction,clusterApiUrl,sendAndConfirmTransaction,} from "@solana/web3.js";import {ExtensionType,TOKEN_2022_PROGRAM_ID,createAccount,createInitializeMintInstruction,createInitializeTransferFeeConfigInstruction,getMintLen,getTransferFeeAmount,harvestWithheldTokensToMint,mintTo,transferCheckedWithFee,unpackAccount,withdrawWithheldTokensFromAccounts,withdrawWithheldTokensFromMint,} from "@solana/spl-token";// Connection to devnet clusterconst connection = new Connection(clusterApiUrl("devnet"), "confirmed");// Playground walletconst payer = pg.wallet.keypair;// Transaction signature returned from sent transactionlet transactionSignature: string;
Mint Setup
First, let's define the properties of the Mint Account we'll be creating in the following step.
// Generate new keypair for Mint Accountconst mintKeypair = Keypair.generate();// Address for Mint Accountconst mint = mintKeypair.publicKey;// Decimals for Mint Accountconst decimals = 2;// Authority that can mint new tokensconst mintAuthority = pg.wallet.publicKey;// Authority that can modify transfer feesconst transferFeeConfigAuthority = pg.wallet.keypair;// Authority that can move tokens withheld on mint or token accountsconst withdrawWithheldAuthority = pg.wallet.keypair;// Fee basis points for transfers (100 = 1%)const feeBasisPoints = 100;// Maximum fee for transfers in token base unitsconst maxFee = BigInt(100);
Next, let's determine the size of the new Mint Account and calculate the minimum lamports needed for rent exemption.
// Size of Mint Account with extensionsconst mintLen = getMintLen([ExtensionType.TransferFeeConfig]);// Minimum lamports required for Mint Accountconst lamports = await connection.getMinimumBalanceForRentExemption(mintLen);
With Token Extensions, the size of the Mint Account will vary based on the extensions enabled.
Build Instructions
Next, let's build the set of instructions to:
- Create a new account
- Initialize the
TransferFee
extension - Initialize the remaining Mint Account data
First, build the instruction to invoke the System Program to create an account and assign ownership to the Token Extensions Program.
// Instruction to invoke System Program to create new accountconst createAccountInstruction = SystemProgram.createAccount({fromPubkey: payer.publicKey, // Account that will transfer lamports to created accountnewAccountPubkey: mint, // Address of the account to createspace: mintLen, // Amount of bytes to allocate to the created accountlamports, // Amount of lamports transferred to created accountprogramId: TOKEN_2022_PROGRAM_ID, // Program assigned as owner of created account});
Next, build the instruction to initialize the TransferFee
extension for the
Mint Account.
// Instruction to initialize TransferFeeConfig Extensionconst initializeTransferFeeConfig =createInitializeTransferFeeConfigInstruction(mint, // Mint Account addresstransferFeeConfigAuthority.publicKey, // Authority to update feeswithdrawWithheldAuthority.publicKey, // Authority to withdraw feesfeeBasisPoints, // Basis points for transfer fee calculationmaxFee, // Maximum fee per transferTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Lastly, build the instruction to initialize the rest of the Mint Account data. This is the same as with the original Token Program.
// Instruction to initialize Mint Account dataconst initializeMintInstruction = createInitializeMintInstruction(mint, // Mint Account Addressdecimals, // Decimals of MintmintAuthority, // Designated Mint Authoritynull, // Optional Freeze AuthorityTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Send Transaction
Finally, we add the instructions to a new transaction and send it to the
network. This will create a mint account with the TransferFee
extension.
// Add instructions to new transactionconst transaction = new Transaction().add(createAccountInstruction,initializeTransferFeeConfig,initializeMintInstruction,);// Send transactiontransactionSignature = await sendAndConfirmTransaction(connection,transaction,[payer, mintKeypair], // Signers);console.log("\nCreate Mint Account:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Run the script by clicking the Run
button. You can then inspect the
transactions on the SolanaFM.
Create Token Accounts
Next, let's set up two Token Accounts to demonstrate the functionality of the
TransferFee
extension.
First, create a sourceTokenAccount
owned by the Playground wallet.
// Create Token Account for Playground walletconst sourceTokenAccount = await createAccount(connection,payer, // Payer to create Token Accountmint, // Mint Account addresspayer.publicKey, // Token Account ownerundefined, // Optional keypair, default to Associated Token Accountundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Next, generate a random keypair and use it as the owner of a
destinationTokenAccount
.
// Random keypair to use as owner of Token Accountconst randomKeypair = new Keypair();// Create Token Account for random keypairconst destinationTokenAccount = await createAccount(connection,payer, // Payer to create Token Accountmint, // Mint Account addressrandomKeypair.publicKey, // Token Account ownerundefined, // Optional keypair, default to Associated Token Accountundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Lastly, mint 2000 tokens to the sourceTokenAccount
to fund it.
// Mint tokens to sourceTokenAccounttransactionSignature = await mintTo(connection,payer, // Transaction fee payermint, // Mint Account addresssourceTokenAccount, // Mint tomintAuthority, // Mint Authority address2000_00, // Amountundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nMint Tokens:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Transfer Tokens
Next, let's try to transfer tokens from the sourceTokenAccount
to the
destinationTokenAccount
. The transfer fee will automatically be deducted from
the transfer amount and remain in the destinationTokenAccount
account.
To transfer tokens, we have to use the either the transferChecked
or
transferCheckedWithFee
instructions.
In this example, we'll use transferCheckedWithFee
. The transfer only succeeds
if the correct transfer fee amount is passed into the instruction.
// Transfer amountconst transferAmount = BigInt(1000_00);// Calculate transfer feeconst fee = (transferAmount * BigInt(feeBasisPoints)) / BigInt(10_000);// Determine fee chargedconst feeCharged = fee > maxFee ? maxFee : fee;// Transfer tokens with feetransactionSignature = await transferCheckedWithFee(connection,payer, // Transaction fee payersourceTokenAccount, // Source Token Accountmint, // Mint Account addressdestinationTokenAccount, // Destination Token Accountpayer.publicKey, // Owner of Source AccounttransferAmount, // Amount to transferdecimals, // Mint Account decimalsfeeCharged, // Transfer feeundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nTransfer Tokens:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Withdraw Fee from Token Accounts
When tokens are transferred, transfer fees automatically accumulate in the recipient Token Accounts. The Withdraw Authority can freely withdraw these withheld tokens from each Token Account of the Mint.
To find the Token Accounts that have accumulated fees, we need to fetch all Token Accounts for the mint and then filter for ones which have withheld tokens.
First, we fetch all Token Accounts for the Mint Account.
// Retrieve all Token Accounts for the Mint Accountconst allAccounts = await connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {commitment: "confirmed",filters: [{memcmp: {offset: 0,bytes: mint.toString(), // Mint Account address},},],});
Next, we filter for Token Accounts that hold transfer fees.
// List of Token Accounts to withdraw fees fromconst accountsToWithdrawFrom = [];for (const accountInfo of allAccounts) {const account = unpackAccount(accountInfo.pubkey, // Token Account addressaccountInfo.account, // Token Account dataTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);// Extract transfer fee data from each accountconst transferFeeAmount = getTransferFeeAmount(account);// Check if fees are available to be withdrawnif (transferFeeAmount !== null && transferFeeAmount.withheldAmount > 0) {accountsToWithdrawFrom.push(accountInfo.pubkey); // Add account to withdrawal list}}
Finally, we use the withdrawWithheldAuthority
instruction to withdraw the fees
from the Token Accounts to a specified destination Token Account.
// Withdraw withheld tokens from Token AccountstransactionSignature = await withdrawWithheldTokensFromAccounts(connection,payer, // Transaction fee payermint, // Mint Account addressdestinationTokenAccount, // Destination account for fee withdrawalwithdrawWithheldAuthority, // Authority for fee withdrawalundefined, // Additional signersaccountsToWithdrawFrom, // Token Accounts to withdrawal fromundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nWithdraw Fee From Token Accounts:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Run the script by clicking the Run
button. You can then inspect the
transaction on the SolanaFM.
Harvest Fee to Mint Account
Token Accounts holding any tokens, including withheld ones, cannot be closed. However, a user may want to close a Token Account with withheld transfer fees.
Users can permissionlessly clear out Token Accounts of withheld tokens using the
harvestWithheldTokensToMint
instruction. This transfers the fees accumulated
on the Token Account directly to the Mint Account.
Let's first send another transfer so the destinationTokenAccount
has withheld
transfer fees.
// Transfer tokens with feetransactionSignature = await transferCheckedWithFee(connection,payer, // Transaction fee payersourceTokenAccount, // Source Token Accountmint, // Mint Account addressdestinationTokenAccount, // Destination Token Accountpayer.publicKey, // Owner of Source AccounttransferAmount, // Amount to transferdecimals, // Mint Account decimalsfeeCharged, // Transfer feeundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nTransfer Tokens:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Next, we'll "harvest" the fees from the destinationTokenAccount
. Note that
this can be done by anyone and not just the owner of the Token Account.
// Harvest withheld fees from Token Accounts to Mint AccounttransactionSignature = await harvestWithheldTokensToMint(connection,payer, // Transaction fee payermint, // Mint Account address[destinationTokenAccount], // Source Token Accounts for fee harvestingundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nHarvest Fee To Mint Account:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Withdraw Fee from Mint Account
Tokens "harvested" to the Mint Account can then be withdrawn at any time by the Withdraw Authority to a specified Token Account.
// Withdraw fees from Mint AccounttransactionSignature = await withdrawWithheldTokensFromMint(connection,payer, // Transaction fee payermint, // Mint Account addressdestinationTokenAccount, // Destination account for fee withdrawalwithdrawWithheldAuthority, // Withdraw Withheld Authorityundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nWithdraw Fee from Mint Account:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Run the script by clicking the Run
button. You can then inspect the
transaction on the SolanaFM.
Conclusion
The TransferFee
extension enables token creators to enforce fees on each
transfer without requiring extra instructions or specialized programs. This
approach ensures that fees are collected in the same currency as the transferred
tokens, simplifying the transaction process.