7. SPL Token Operations
Learn how to create and manage SPL tokens (fungible tokens) on Solana.
Source: instructions/token.rs
SPL Token Basics
SPL tokens are Solana’s standard for fungible tokens. Key concepts:
- Mint: The token definition (like an ERC-20 contract)
- Token Account: Holds tokens for a specific owner
- Associated Token Account (ATA): Deterministic token account address
- Authority: Can be a PDA (for program control) or wallet
Create Mint with PDA Authority
use anchor_spl::token_interface::{Mint, TokenInterface};
use crate::constants::*;
#[derive(Accounts)]
pub struct CreateMint<'info> {
#[account(mut)]
pub signer: Signer<'info>,
#[account(
init,
payer = signer,
mint::decimals = 6, // Token decimals (like USDC)
mint::authority = mint_authority, // PDA as mint authority
mint::token_program = token_program,
seeds = [b"mint"],
bump
)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(
seeds = [SEED_MINT_AUTHORITY],
bump
)]
/// CHECK: PDA used as mint authority
pub mint_authority: UncheckedAccount<'info>,
pub token_program: Interface<'info, TokenInterface>,
pub system_program: Program<'info, System>,
}
pub fn create_mint_handler(ctx: Context<CreateMint>) -> Result<()> {
msg!("Mint created with PDA authority");
Ok(())
}
Why PDA as Authority?
✅ Program control - Only your program can mint/burn
✅ No private key - Cannot be stolen or lost
✅ Deterministic - Same address every time
✅ Auditable - All actions are on-chain
Mint Tokens
use anchor_spl::token_interface::{mint_to, MintTo, TokenAccount};
#[derive(Accounts)]
pub struct MintTokens<'info> {
#[account(mut)]
pub signer: Signer<'info>,
#[account(
init_if_needed,
payer = signer,
associated_token::mint = mint,
associated_token::authority = signer,
associated_token::token_program = token_program,
)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(
seeds = [SEED_MINT_AUTHORITY],
bump
)]
/// CHECK: PDA mint authority
pub mint_authority: UncheckedAccount<'info>,
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}
pub fn mint_tokens_handler(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
// Create signer seeds for PDA
let signer_seeds: &[&[&[u8]]] = &[&[
SEED_MINT_AUTHORITY,
&[ctx.bumps.mint_authority]
]];
let cpi_accounts = MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.mint_authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_context = CpiContext::new(cpi_program, cpi_accounts)
.with_signer(signer_seeds);
mint_to(cpi_context, amount)?;
msg!("Minted {} tokens to {}", amount, ctx.accounts.token_account.key());
Ok(())
}
Client-Side (TypeScript)
import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { BN } from "@coral-xyz/anchor";
const [mintPda] = PublicKey.findProgramAddressSync(
[Buffer.from("mint")],
program.programId
);
const [mintAuthority] = PublicKey.findProgramAddressSync(
[Buffer.from("mint_authority")],
program.programId
);
const userTokenAccount = await getAssociatedTokenAddress(
mintPda,
user.publicKey
);
await program.methods
.mintTokens(new BN(1_000_000)) // 1 token with 6 decimals
.accounts({
signer: user.publicKey,
tokenAccount: userTokenAccount,
mint: mintPda,
mintAuthority: mintAuthority,
tokenProgram: TOKEN_PROGRAM_ID,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
systemProgram: SystemProgram.programId,
})
.signers([user])
.rpc();
Transfer Tokens
use anchor_spl::token_interface::{transfer_checked, TransferChecked};
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub from_account: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub to_account: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
pub fn transfer_tokens_handler(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
let cpi_accounts = TransferChecked {
from: ctx.accounts.from_account.to_account_info(),
to: ctx.accounts.to_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
// transfer_checked validates decimals (safer than transfer)
transfer_checked(
cpi_ctx,
amount,
ctx.accounts.mint.decimals
)?;
msg!("Transferred {} tokens", amount);
Ok(())
}
Why transfer_checked?
✅ Validates decimals - Prevents precision errors
✅ Validates mint - Ensures both accounts use same token
✅ Safer - Recommended by Solana
Burn Tokens
use anchor_spl::token_interface::{burn, Burn};
#[derive(Accounts)]
pub struct BurnTokens<'info> {
#[account(mut)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub mint: InterfaceAccount<'info, Mint>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
pub fn burn_tokens_handler(ctx: Context<BurnTokens>, amount: u64) -> Result<()> {
let cpi_accounts = Burn {
mint: ctx.accounts.mint.to_account_info(),
from: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
burn(cpi_ctx, amount)?;
msg!("Burned {} tokens", amount);
Ok(())
}
Delegate Approval
Allow another account to spend tokens on your behalf:
use anchor_spl::token_interface::{approve, Approve};
#[derive(Accounts)]
pub struct ApproveDelegate<'info> {
#[account(mut)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
/// CHECK: Delegate can be any account
pub delegate: AccountInfo<'info>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
pub fn approve_delegate_handler(ctx: Context<ApproveDelegate>, amount: u64) -> Result<()> {
let cpi_accounts = Approve {
to: ctx.accounts.token_account.to_account_info(),
delegate: ctx.accounts.delegate.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
approve(cpi_ctx, amount)?;
msg!("Approved {} tokens for delegate", amount);
Ok(())
}
Revoke Approval
use anchor_spl::token_interface::{revoke, Revoke};
pub fn revoke_delegate_handler(ctx: Context<RevokeDelegate>) -> Result<()> {
let cpi_accounts = Revoke {
source: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts
);
revoke(cpi_ctx)?;
msg!("Delegate approval revoked");
Ok(())
}
Freeze/Thaw Token Account
Prevent transfers (requires freeze authority):
use anchor_spl::token_interface::{freeze_account, FreezeAccount};
#[derive(Accounts)]
pub struct FreezeTokenAccount<'info> {
#[account(mut)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(
seeds = [SEED_MINT_AUTHORITY],
bump
)]
/// CHECK: Freeze authority PDA
pub freeze_authority: UncheckedAccount<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
pub fn freeze_token_account_handler(ctx: Context<FreezeTokenAccount>) -> Result<()> {
let signer_seeds: &[&[&[u8]]] = &[&[
SEED_MINT_AUTHORITY,
&[ctx.bumps.freeze_authority]
]];
let cpi_accounts = FreezeAccount {
account: ctx.accounts.token_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
authority: ctx.accounts.freeze_authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts)
.with_signer(signer_seeds);
freeze_account(cpi_ctx)?;
msg!("Token account frozen");
Ok(())
}
Thaw (Unfreeze)
use anchor_spl::token_interface::{thaw_account, ThawAccount};
pub fn thaw_token_account_handler(ctx: Context<ThawTokenAccount>) -> Result<()> {
let signer_seeds: &[&[&[u8]]] = &[&[
SEED_MINT_AUTHORITY,
&[ctx.bumps.freeze_authority]
]];
let cpi_accounts = ThawAccount {
account: ctx.accounts.token_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
authority: ctx.accounts.freeze_authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts
).with_signer(signer_seeds);
thaw_account(cpi_ctx)?;
msg!("Token account thawed");
Ok(())
}
Close Token Account (Reclaim Rent)
use anchor_spl::token_interface::{close_account, CloseAccount};
#[derive(Accounts)]
pub struct CloseTokenAccount<'info> {
#[account(mut)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub destination: SystemAccount<'info>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
pub fn close_token_account_handler(ctx: Context<CloseTokenAccount>) -> Result<()> {
// Ensure account is empty
require_eq!(
ctx.accounts.token_account.amount,
0,
ErrorCode::TokenAccountNotEmpty
);
let cpi_accounts = CloseAccount {
account: ctx.accounts.token_account.to_account_info(),
destination: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts
);
close_account(cpi_ctx)?;
msg!("Token account closed, rent reclaimed");
Ok(())
}
Token Extensions (Token-2022)
Token-2022 supports extensions like:
- Transfer fees
- Transfer hooks
- Confidential transfers
- Interest-bearing tokens
use anchor_spl::token_2022::Token2022;
pub token_program: Program<'info, Token2022>,
Complete Example: Token Swap
pub fn swap_tokens_handler(
ctx: Context<SwapTokens>,
amount_in: u64,
minimum_amount_out: u64
) -> Result<()> {
// 1. Transfer input tokens from user
transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.user_token_in.to_account_info(),
to: ctx.accounts.pool_token_in.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
mint: ctx.accounts.mint_in.to_account_info(),
},
),
amount_in,
ctx.accounts.mint_in.decimals,
)?;
// 2. Calculate output amount (simplified)
let amount_out = calculate_swap_output(amount_in, minimum_amount_out)?;
// 3. Transfer output tokens to user (PDA signs)
let signer_seeds: &[&[&[u8]]] = &[&[
SEED_POOL_AUTHORITY,
&[ctx.bumps.pool_authority]
]];
transfer_checked(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.pool_token_out.to_account_info(),
to: ctx.accounts.user_token_out.to_account_info(),
authority: ctx.accounts.pool_authority.to_account_info(),
mint: ctx.accounts.mint_out.to_account_info(),
},
signer_seeds,
),
amount_out,
ctx.accounts.mint_out.decimals,
)?;
msg!("Swapped {} for {}", amount_in, amount_out);
Ok(())
}
Best Practices
✅ Use transfer_checked instead of transfer
✅ Use PDA as mint authority for program control
✅ Store bump seeds to avoid recomputation
✅ Validate token accounts match expected mint
✅ Check balances before transfers
✅ Use init_if_needed carefully - security risk
❌ Don’t hardcode decimals - read from mint
❌ Don’t skip validation - verify all accounts
❌ Don’t forget signer seeds for PDA operations
Next: Cross-Program Invocation (CPI) →