Transfers & Delegates
SPL transfers move token balances between token accounts of the same mint. Delegates let a third party transfer up to an approved amount on the owner's behalf - the pattern behind DEX routers and escrow programs.
Recipe
spl-token transfer <MINT> <AMOUNT> <RECIPIENT> --fund-recipient
spl-token approve <MINT> <AMOUNT> <DELEGATE_PUBKEY>use anchor_spl::token::{self, Transfer, Approve};
token::transfer(cpi_ctx, amount)?;
token::approve(cpi_ctx, amount)?;When to reach for this:
- User-to-user payments in fungible tokens
- Approving a swap program to pull tokens
- Building escrow that moves funds on settlement
- Revoking stale DeFi approvals
Working Example
# Alice sends 2.5 tokens (decimals=6 -> 2500000 raw)
spl-token transfer "$MINT" 2500000 <BOB_WALLET> --fund-recipient
# Alice approves router to spend up to 1_000_000 raw units
spl-token approve "$MINT" 1000000 <ROUTER_VAULT_PUBKEY>use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer, Approve, Revoke};
#[derive(Accounts)]
pub struct TransferPayment<'info> {
#[account(mut)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn transfer_payment(ctx: Context<TransferPayment>, amount: u64) -> Result<()> {
require_keys_eq!(ctx.accounts.from.mint, ctx.accounts.to.mint, ErrorCode::MintMismatch);
let cpi = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
);
token::transfer(cpi, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct ApproveRouter<'info> {
#[account(mut)]
pub owner_token: Account<'info, TokenAccount>,
pub owner: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn approve_router(ctx: Context<ApproveRouter>, delegate: Pubkey, amount: u64) -> Result<()> {
let cpi = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Approve {
to: ctx.accounts.owner_token.to_account_info(),
delegate: ctx.accounts.token_program.to_account_info(), // replace with delegate account in production
authority: ctx.accounts.owner.to_account_info(),
},
);
token::approve(cpi, amount)?;
Ok(())
}What this demonstrates:
- Source and destination must share the same mint
- Owner signs transfer; delegate can sign after
approve - Revoke sets delegated amount back to zero
Deep Dive
How It Works
- Transfer debits
source.amountand creditsdestination.amountatomically - Delegate fields on token account:
delegate: Option<Pubkey>,delegated_amount: u64 - Multisig owners supported via SPL multisig accounts (legacy pattern)
- Frozen accounts reject transfers when freeze authority has frozen them
Delegate Lifecycle
| Action | Effect |
|---|---|
approve | Sets delegate + delegated_amount ceiling |
transfer by delegate | Reduces delegated_amount and balance |
revoke | Clears delegate |
TypeScript Notes
import { createSolanaRpc, address } from "@solana/kit";
const rpc = createSolanaRpc("https://api.devnet.solana.com");
// Build transfer instructions via @solana-program/token helpers
// Always derive recipient ATA; fund if missingGotchas
- Mint mismatch - transfer between different mints fails. Fix: validate
from.mint == to.minton-chain and in clients. - Insufficient delegated amount - delegate transfer exceeds approval. Fix: re-approve or split across transactions.
- Stale unlimited approvals - security risk in DeFi. Fix: approve exact spend; revoke after swap.
- Transfer to non-ATA address - works but confuses users. Fix: derive and display ATA addresses.
- Frozen source account - all transfers blocked. Fix: check
statefield before UX promises.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
transfer_checked | On-chain programs need decimals guard | Simple CLI transfers |
| Escrow PDA custody | Atomic swap/settlement | Simple wallet send |
| Token-2022 transfer hook | Policy on every move | Plain fungible payments |
FAQs
What is a delegate?
A pubkey authorized to transfer up to delegated_amount from your token account without owning the account.
Can a delegate transfer more than approved?
No. SPL program enforces delegated_amount ceiling.
How do I revoke an approval?
spl-token revoke <MINT> or token::revoke CPI from owner.
Does approve cost SOL?
Small transaction fee only - no rent change on token account.
Can programs be delegates?
Yes. Router program PDAs or vaults commonly hold delegate role.
What is transfer_checked?
Includes expected decimals in instruction data - programs use it to prevent decimal confusion attacks.
Can I transfer partial balances?
Yes. Any amount up to source balance (or delegated ceiling for delegates).
Do transfers change total supply?
No. Supply unchanged - only redistributes existing tokens.
What if recipient has no ATA?
Transaction fails unless you add create-ATA instruction or --fund-recipient.
Are delegates per-mint?
Delegates are per token account (per wallet+mint ATA), not global per wallet.
Can owner still transfer after approve?
Yes. Owner retains full control; delegate has additional limited authority.
How do wallets show approvals?
Read token account delegate fields via RPC jsonParsed encoding.
Related
- Associated Token Accounts (ATAs) - recipient addresses
- Authorities - freeze blocking transfers
- SPL Token in Programs - CPI security
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, anchor-lang 0.32.1, Rust 1.91.1, @solana/kit 7.0.0, Surfpool 0.12.0, and LiteSVM 0.6.x.