Send your fee-bearing token and harvest the withheld fees
Transfer your fee-bearing Token-2022 mint, watch the protocol withhold its fee on chain, then withdraw those fees back with a single CLI command.
Send your fee-bearing token and harvest the withheld fees
The Scenario
Yesterday you created a fee-bearing token on devnet. You configured the basis points, you set a maximum fee, and you watched Token-2022 bake the rule directly into the mint. The mint exists, but it has not done anything yet. A token that just sits there is not a token. It is a row in a database.
Today you put it in motion. In your Web2 life you would test a payment processor by sending a few cents through it and reading the receipts on the dashboard. Solana lets you do something similar without a dashboard, without an API key, and without a sandbox. You will spin up a fresh wallet, transfer some of your token to it, and then read the recipient’s account directly to see the fee that the protocol withheld for you. Then you will pull those withheld tokens back out using the withdraw authority you set yesterday.
By the end you will have observed the entire fee lifecycle on chain: transfer, withhold, withdraw. No middleware. No webhook. The mint did the work.
The Challenge
What you’ll need
- A terminal with the Solana CLI installed and configured for devnet
- The spl-token CLI (installed with
cargo install spl-token-cli) - The mint address from yesterday’s fee-bearing token
- Your default wallet keypair, already funded with devnet SOL
- A code editor or notes app to keep track of the addresses you generate
Steps
- Confirm you are still on devnet and that the CLI can see your wallet:
solana config set --url https://api.devnet.solana.com
solana address
solana balance
If your balance is low, top up with solana airdrop 2.
Note: The devnet airdrop can sometimes fail due to rate limiting. If this happens use the web faucet instead.
- Export the mint address from yesterday into a shell variable so the next commands stay readable. Replace the placeholder with your actual mint address:
export MINT=[PASTE_YOUR_MINT_ADDRESS_HERE]
- Mint a fresh batch of supply to your own wallet so you have something to send. The number is a UI amount, so this mints one million whole tokens:
spl-token mint $MINT 1000000
- Generate a brand new keypair to act as your recipient. This wallet is throwaway and lives only on your machine:
solana-keygen new --no-bip39-passphrase --outfile recipient.json
export RECIPIENT=$(solana address -k recipient.json)
echo "Recipient wallet: $RECIPIENT"
- Create the recipient’s associated token account for this mint up front. The recipient’s throwaway wallet has no SOL, so you pay the rent yourself with
--fee-payer. Creating the account explicitly means you can see exactly which address holds the tokens before any transfer happens:
spl-token create-account $MINT \
--owner $RECIPIENT \
--fee-payer ~/.config/solana/id.json
- Transfer 1000 tokens to the recipient. The
--expected-feeflag tells the runtime exactly how much fee you expect to be withheld and aborts the transfer if the math does not match. Yesterday’s mint charges 100 basis points (1 percent), so the fee on 1000 tokens is 10 tokens. Set the expected fee accordingly. If you used different basis points yesterday, recalculate the fee asamount * basisPoints / 10000. There is no--fund-recipientflag here on purpose: the CLI cannot create an account on the fly for a mint that charges a transfer fee, which is exactly why you created the recipient’s account explicitly in the previous step. The recipient wallet holds no SOL, so--allow-unfunded-recipientlets the transfer proceed to the account you already created:
spl-token transfer \
--expected-fee 10 \
$MINT 1000 $RECIPIENT \
--allow-unfunded-recipient
- Find the recipient’s token account address so you can inspect it:
spl-token accounts --owner $RECIPIENT --verbose
Copy the token account address from the output and save it:
export RECIPIENT_TA=[PASTE_RECIPIENT_TOKEN_ACCOUNT_HERE]
- Read the recipient’s token account directly on chain. Look for the
TransferFeeAmountextension and thewithheld_amountfield. That is the slice the protocol kept for you, sitting on the recipient’s account, untouchable by the recipient:
spl-token display $RECIPIENT_TA
- Find your own associated token account for this mint so you have somewhere to withdraw the fees back into. Scoping to
$MINTkeeps the output to just this token instead of every account your default wallet owns:
spl-token accounts $MINT --verbose
Save your token account address for this mint:
export MY_TA=[PASTE_YOUR_TOKEN_ACCOUNT_HERE]
- Withdraw the withheld fees from the recipient’s account into your own token account. This call uses the withdraw authority you set yesterday, which by default is your wallet:
spl-token withdraw-withheld-tokens $MY_TA $RECIPIENT_TA
- Confirm the loop closed. The recipient’s withheld amount should now be zero, and your own balance should reflect the 10 tokens you just reclaimed:
spl-token display $RECIPIENT_TA
spl-token balance $MINT
Run it
spl-token create-account $MINT --owner $RECIPIENT --fee-payer ~/.config/solana/id.json
spl-token transfer --expected-fee 10 $MINT 1000 $RECIPIENT --allow-unfunded-recipient
spl-token display $RECIPIENT_TA
spl-token withdraw-withheld-tokens $MY_TA $RECIPIENT_TA
What Just Happened
You just watched a fee land without writing a single line of fee-collection code. When you ran the transfer, the Token-2022 program looked at the mint, saw that it had a transfer fee configured, and split your 1000 tokens into two pieces. The recipient’s spendable balance went up by 990. The remaining 10 went into a separate field on the recipient’s own account called withheld_amount, where neither the recipient nor anyone else can touch it. That separation is the whole point. The fee is not a side effect of an external program; it is a property of the asset itself.
Then you used the withdraw authority to pull those withheld tokens out. In a Web2 system you would have built a cron job that scrapes a payments table, sums up the fees, and moves them to a treasury account. Here you ran one command and the protocol moved the bytes for you. The same thing happens whether the fee accumulates on one account or ten thousand. If you scale this token to a real holder base, you would harvest withheld fees from many accounts up to the mint first using spl-token withdraw-withheld-tokens --include-mint, then drain the mint in a single call. Same primitive, different scale.
The interesting part for you as a developer is what you did not do. You did not deploy a program. You did not write a payment processor. You did not configure a webhook. You configured a mint, and the runtime did the rest, every time, for every transfer, forever.
Resources
- Transfer Fees extension docs
- Token-2022 extension guide
- QuickNode guide to Token-2022 transfer fees
- spl-token CLI reference
Submission
Take a screenshot of your terminal showing the spl-token display output with the non-zero withheld_amount followed by the successful withdraw-withheld-tokens call. Submit it below!