Files
gh-tenequm-claude-plugins-s…/skills/solana-development/references/tokens-patterns.md
2025-11-30 09:01:25 +08:00

22 KiB

SPL Token Program - Common Patterns and Security

Common SPL Token patterns including escrow, staking, NFT creation, and account freezing. Comprehensive security considerations covering validation, authority checks, and defensive programming. Includes quick reference tables and security checklist.

For related topics, see:

Table of Contents

  1. Pattern 1: Token Escrow
  2. Pattern 2: Token Staking
  3. Pattern 3: NFT Creation
  4. Pattern 4: Freezing and Thawing Accounts
  5. Security Considerations
  6. Summary

Pattern 1: Token Escrow

Program holds tokens temporarily on behalf of users.

Using Anchor

use anchor_spl::token_interface::{self, TokenAccount, TokenInterface, Transfer};

#[derive(Accounts)]
pub struct InitializeEscrow<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + 8 + 1,
        seeds = [b"escrow", user.key().as_ref()],
        bump,
    )]
    pub escrow_state: Account<'info, EscrowState>,

    #[account(
        init,
        payer = user,
        token::mint = mint,
        token::authority = escrow_state,
        token::token_program = token_program,
    )]
    pub escrow_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(mut)]
    pub user_token_account: InterfaceAccount<'info, TokenAccount>,

    pub mint: InterfaceAccount<'info, Mint>,

    #[account(mut)]
    pub user: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct EscrowState {
    pub user: Pubkey,
    pub amount: u64,
    pub bump: u8,
}

pub fn initialize_escrow(ctx: Context<InitializeEscrow>, amount: u64) -> Result<()> {
    // Transfer tokens to escrow
    token_interface::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.user_token_account.to_account_info(),
                to: ctx.accounts.escrow_token_account.to_account_info(),
                authority: ctx.accounts.user.to_account_info(),
            },
        ),
        amount,
    )?;

    // Save state
    ctx.accounts.escrow_state.user = ctx.accounts.user.key();
    ctx.accounts.escrow_state.amount = amount;
    ctx.accounts.escrow_state.bump = ctx.bumps.escrow_state;

    Ok(())
}

#[derive(Accounts)]
pub struct ReleaseEscrow<'info> {
    #[account(
        mut,
        seeds = [b"escrow", escrow_state.user.as_ref()],
        bump = escrow_state.bump,
        has_one = user,
        close = user,
    )]
    pub escrow_state: Account<'info, EscrowState>,

    #[account(mut)]
    pub escrow_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(mut)]
    pub recipient_token_account: InterfaceAccount<'info, TokenAccount>,

    pub user: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
}

pub fn release_escrow(ctx: Context<ReleaseEscrow>) -> Result<()> {
    let seeds = &[
        b"escrow",
        ctx.accounts.user.key().as_ref(),
        &[ctx.accounts.escrow_state.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    token_interface::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.escrow_token_account.to_account_info(),
                to: ctx.accounts.recipient_token_account.to_account_info(),
                authority: ctx.accounts.escrow_state.to_account_info(),
            },
        ).with_signer(signer_seeds),
        ctx.accounts.escrow_state.amount,
    )?;

    Ok(())
}

Using Native Rust

use borsh::{BorshDeserialize, BorshSerialize};
use spl_token::instruction::transfer;

#[derive(BorshSerialize, BorshDeserialize)]
pub struct EscrowState {
    pub user: Pubkey,
    pub amount: u64,
    pub bump: u8,
}

pub fn initialize_escrow(
    program_id: &Pubkey,
    user: &AccountInfo,
    user_token_account: &AccountInfo,
    escrow_token_account: &AccountInfo,
    escrow_state: &AccountInfo,
    amount: u64,
    token_program: &AccountInfo,
) -> ProgramResult {
    // Transfer tokens to escrow
    invoke(
        &transfer(
            &spl_token::ID,
            user_token_account.key,
            escrow_token_account.key,
            user.key,
            &[],
            amount,
        )?,
        &[user_token_account.clone(), escrow_token_account.clone(), user.clone()],
    )?;

    // Save escrow state
    let (pda, bump) = Pubkey::find_program_address(&[b"escrow", user.key.as_ref()], program_id);
    let escrow = EscrowState {
        user: *user.key,
        amount,
        bump,
    };
    escrow.serialize(&mut &mut escrow_state.data.borrow_mut()[..])?;

    Ok(())
}

pub fn release_escrow(
    program_id: &Pubkey,
    escrow_state: &AccountInfo,
    escrow_token_account: &AccountInfo,
    recipient_token_account: &AccountInfo,
    escrow_pda: &AccountInfo,
    amount: u64,
    bump: u8,
    user: &Pubkey,
) -> ProgramResult {
    let signer_seeds: &[&[&[u8]]] = &[&[b"escrow", user.as_ref(), &[bump]]];

    invoke_signed(
        &transfer(
            &spl_token::ID,
            escrow_token_account.key,
            recipient_token_account.key,
            escrow_pda.key,
            &[],
            amount,
        )?,
        &[escrow_token_account.clone(), recipient_token_account.clone(), escrow_pda.clone()],
        signer_seeds,
    )?;

    Ok(())
}

Pattern 2: Token Staking

Users lock tokens to earn rewards.

Using Anchor

use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, Transfer};

#[derive(Accounts)]
pub struct StakeTokens<'info> {
    #[account(
        init_if_needed,
        payer = user,
        space = 8 + 32 + 8 + 8 + 1,
        seeds = [b"stake", user.key().as_ref()],
        bump,
    )]
    pub stake_account: Account<'info, StakeAccount>,

    #[account(mut)]
    pub user_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(
        mut,
        seeds = [b"vault"],
        bump,
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(mut)]
    pub user: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct StakeAccount {
    pub user: Pubkey,
    pub amount_staked: u64,
    pub stake_timestamp: i64,
    pub bump: u8,
}

pub fn stake_tokens(ctx: Context<StakeTokens>, amount: u64) -> Result<()> {
    // Transfer tokens to vault
    token_interface::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.user_token_account.to_account_info(),
                to: ctx.accounts.vault_token_account.to_account_info(),
                authority: ctx.accounts.user.to_account_info(),
            },
        ),
        amount,
    )?;

    // Update stake account
    let clock = Clock::get()?;
    ctx.accounts.stake_account.user = ctx.accounts.user.key();
    ctx.accounts.stake_account.amount_staked += amount;
    ctx.accounts.stake_account.stake_timestamp = clock.unix_timestamp;
    ctx.accounts.stake_account.bump = ctx.bumps.stake_account;

    Ok(())
}

#[derive(Accounts)]
pub struct UnstakeTokens<'info> {
    #[account(
        mut,
        seeds = [b"stake", user.key().as_ref()],
        bump = stake_account.bump,
        has_one = user,
    )]
    pub stake_account: Account<'info, StakeAccount>,

    #[account(mut)]
    pub user_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(
        mut,
        seeds = [b"vault"],
        bump,
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,

    /// CHECK: Vault authority PDA
    #[account(
        seeds = [b"vault-authority"],
        bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    pub user: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
}

pub fn unstake_tokens(ctx: Context<UnstakeTokens>, amount: u64) -> Result<()> {
    require!(
        ctx.accounts.stake_account.amount_staked >= amount,
        ErrorCode::InsufficientStake
    );

    let seeds = &[
        b"vault-authority",
        &[ctx.bumps.vault_authority],
    ];
    let signer_seeds = &[&seeds[..]];

    // Transfer tokens back to user
    token_interface::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault_token_account.to_account_info(),
                to: ctx.accounts.user_token_account.to_account_info(),
                authority: ctx.accounts.vault_authority.to_account_info(),
            },
        ).with_signer(signer_seeds),
        amount,
    )?;

    // Update stake account
    ctx.accounts.stake_account.amount_staked -= amount;

    Ok(())
}

Pattern 3: NFT Creation

Minting a non-fungible token (supply = 1, decimals = 0).

Using Anchor

use anchor_spl::token_interface::{self, Mint, MintTo, SetAuthority, TokenAccount, TokenInterface};
use anchor_spl::token_interface::spl_token_2022::instruction::AuthorityType;

#[derive(Accounts)]
pub struct CreateNFT<'info> {
    #[account(
        init,
        payer = payer,
        mint::decimals = 0,
        mint::authority = mint_authority,
        mint::token_program = token_program,
    )]
    pub mint: InterfaceAccount<'info, Mint>,

    #[account(
        init,
        payer = payer,
        associated_token::mint = mint,
        associated_token::authority = owner,
        associated_token::token_program = token_program,
    )]
    pub token_account: InterfaceAccount<'info, TokenAccount>,

    /// CHECK: Owner of the NFT
    pub owner: UncheckedAccount<'info>,

    pub mint_authority: Signer<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

pub fn create_nft(ctx: Context<CreateNFT>) -> Result<()> {
    // Mint exactly 1 token
    token_interface::mint_to(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.mint.to_account_info(),
                to: ctx.accounts.token_account.to_account_info(),
                authority: ctx.accounts.mint_authority.to_account_info(),
            },
        ),
        1,
    )?;

    // Remove mint authority to freeze supply
    token_interface::set_authority(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            SetAuthority {
                account_or_mint: ctx.accounts.mint.to_account_info(),
                current_authority: ctx.accounts.mint_authority.to_account_info(),
            },
        ),
        AuthorityType::MintTokens,
        None,
    )?;

    msg!("NFT created: {}", ctx.accounts.mint.key());
    Ok(())
}

Using Native Rust

use spl_token::instruction::{mint_to, set_authority, AuthorityType};

pub fn create_nft(
    mint: &AccountInfo,
    token_account: &AccountInfo,
    mint_authority: &AccountInfo,
    token_program: &AccountInfo,
) -> ProgramResult {
    // 1. Mint exactly 1 token
    invoke(
        &mint_to(
            &spl_token::ID,
            mint.key,
            token_account.key,
            mint_authority.key,
            &[],
            1,  // Exactly 1 token
        )?,
        &[mint.clone(), token_account.clone(), mint_authority.clone()],
    )?;

    // 2. Remove mint authority (make supply fixed)
    invoke(
        &set_authority(
            &spl_token::ID,
            mint.key,
            None,  // Set to None
            AuthorityType::MintTokens,
            mint_authority.key,
            &[],
        )?,
        &[mint.clone(), mint_authority.clone()],
    )?;

    Ok(())
}

Pattern 4: Freezing and Thawing Accounts

Using Anchor

use anchor_spl::token_interface::{self, FreezeAccount, Mint, ThawAccount, TokenAccount, TokenInterface};

#[derive(Accounts)]
pub struct FreezeTokenAccount<'info> {
    #[account(
        mint::freeze_authority = freeze_authority,
    )]
    pub mint: InterfaceAccount<'info, Mint>,

    #[account(mut)]
    pub token_account: InterfaceAccount<'info, TokenAccount>,

    pub freeze_authority: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
}

pub fn freeze_account(ctx: Context<FreezeTokenAccount>) -> Result<()> {
    token_interface::freeze_account(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            FreezeAccount {
                account: ctx.accounts.token_account.to_account_info(),
                mint: ctx.accounts.mint.to_account_info(),
                authority: ctx.accounts.freeze_authority.to_account_info(),
            },
        ),
    )?;
    Ok(())
}

pub fn thaw_account(ctx: Context<FreezeTokenAccount>) -> Result<()> {
    token_interface::thaw_account(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            ThawAccount {
                account: ctx.accounts.token_account.to_account_info(),
                mint: ctx.accounts.mint.to_account_info(),
                authority: ctx.accounts.freeze_authority.to_account_info(),
            },
        ),
    )?;
    Ok(())
}

Using Native Rust

use spl_token::instruction::{freeze_account, thaw_account};

pub fn freeze_token_account(
    token_account: &AccountInfo,
    mint: &AccountInfo,
    freeze_authority: &AccountInfo,
    token_program: &AccountInfo,
) -> ProgramResult {
    invoke(
        &freeze_account(
            token_program.key,
            token_account.key,
            mint.key,
            freeze_authority.key,
            &[],
        )?,
        &[
            token_account.clone(),
            mint.clone(),
            freeze_authority.clone(),
            token_program.clone(),
        ],
    )?;

    Ok(())
}

pub fn thaw_token_account(
    token_account: &AccountInfo,
    mint: &AccountInfo,
    freeze_authority: &AccountInfo,
    token_program: &AccountInfo,
) -> ProgramResult {
    invoke(
        &thaw_account(
            token_program.key,
            token_account.key,
            mint.key,
            freeze_authority.key,
            &[],
        )?,
        &[
            token_account.clone(),
            mint.clone(),
            freeze_authority.clone(),
            token_program.clone(),
        ],
    )?;

    Ok(())
}

Security Considerations

1. Always Validate Token Accounts

Anchor Approach

#[derive(Accounts)]
pub struct SafeTransfer<'info> {
    #[account(
        mut,
        constraint = source.mint == mint.key() @ ErrorCode::InvalidMint,
        constraint = source.owner == authority.key() @ ErrorCode::InvalidOwner,
    )]
    pub source: InterfaceAccount<'info, TokenAccount>,

    #[account(
        mut,
        constraint = destination.mint == mint.key() @ ErrorCode::InvalidMint,
    )]
    pub destination: InterfaceAccount<'info, TokenAccount>,

    pub mint: InterfaceAccount<'info, Mint>,

    pub authority: Signer<'info>,

    pub token_program: Interface<'info, TokenInterface>,
}

Native Rust Approach

// ❌ Dangerous - no validation
pub fn unsafe_transfer(
    source: &AccountInfo,
    destination: &AccountInfo,
    authority: &AccountInfo,
) -> ProgramResult {
    // No checks! Attacker can pass any accounts
    invoke(&transfer_instruction, &accounts)?;
    Ok(())
}

// ✅ Safe - validates everything
pub fn safe_transfer(
    source: &AccountInfo,
    destination: &AccountInfo,
    authority: &AccountInfo,
    expected_mint: &Pubkey,
) -> ProgramResult {
    // Validate source
    validate_token_account(source, authority.key, expected_mint)?;

    // Validate destination
    let dest_token = TokenAccount::unpack(&destination.data.borrow())?;
    if dest_token.mint != *expected_mint {
        return Err(ProgramError::InvalidAccountData);
    }

    invoke(&transfer_instruction, &accounts)?;
    Ok(())
}

2. Check Token Program ID

Anchor Approach

// Anchor automatically validates via Interface type
pub token_program: Interface<'info, TokenInterface>,

Native Rust Approach

pub fn validate_token_program(token_program: &AccountInfo) -> ProgramResult {
    if token_program.key != &spl_token::ID && token_program.key != &spl_token_2022::ID {
        msg!("Invalid Token Program");
        return Err(ProgramError::IncorrectProgramId);
    }
    Ok(())
}

3. Verify Mint Matches

Attack scenario: Attacker passes token account for wrong mint.

Anchor Approach

#[account(
    constraint = token_account.mint == expected_mint.key() @ ErrorCode::InvalidMint,
)]
pub token_account: InterfaceAccount<'info, TokenAccount>,

Native Rust Approach

// Always verify mint
let source_token = TokenAccount::unpack(&source.data.borrow())?;
let dest_token = TokenAccount::unpack(&dest.data.borrow())?;

if source_token.mint != dest_token.mint {
    msg!("Mint mismatch between source and destination");
    return Err(ProgramError::InvalidAccountData);
}

4. Authority Checks

Anchor Approach

#[account(
    constraint = token_account.owner == authority.key() @ ErrorCode::Unauthorized,
)]
pub token_account: InterfaceAccount<'info, TokenAccount>,

pub authority: Signer<'info>,  // Automatically validates is_signer

Native Rust Approach

// Verify authority matches token account owner
let token_account = TokenAccount::unpack(&token_account_info.data.borrow())?;

if token_account.owner != *authority.key {
    msg!("Authority doesn't own token account");
    return Err(ProgramError::IllegalOwner);
}

// Verify authority signed
if !authority.is_signer {
    msg!("Authority must sign");
    return Err(ProgramError::MissingRequiredSignature);
}

5. Account State Checks

Anchor Approach

use spl_token::state::AccountState;

pub fn check_not_frozen(ctx: Context<SomeContext>) -> Result<()> {
    let token_account = &ctx.accounts.token_account;

    require!(
        token_account.state == AccountState::Initialized,
        ErrorCode::AccountFrozen
    );

    Ok(())
}

Native Rust Approach

let token_account = TokenAccount::unpack(&token_account_info.data.borrow())?;

// Check not frozen
if token_account.state == spl_token::state::AccountState::Frozen {
    msg!("Token account is frozen");
    return Err(ProgramError::InvalidAccountData);
}

// Check initialized
if token_account.state == spl_token::state::AccountState::Uninitialized {
    msg!("Token account not initialized");
    return Err(ProgramError::UninitializedAccount);
}

6. Use TransferChecked Over Transfer

Why: transfer_checked validates the mint and decimals, preventing certain attack vectors.

Anchor Approach

// ✅ Preferred - validates mint and decimals
token_interface::transfer_checked(
    cpi_context,
    amount,
    decimals,
)?;

// ❌ Less secure - no mint/decimal validation
token_interface::transfer(
    cpi_context,
    amount,
)?;

Native Rust Approach

// ✅ Preferred
invoke(
    &transfer_checked(
        token_program.key,
        source.key,
        mint.key,
        destination.key,
        authority.key,
        &[],
        amount,
        decimals,
    )?,
    &accounts,
)?;

// ❌ Less secure
invoke(
    &transfer(
        token_program.key,
        source.key,
        destination.key,
        authority.key,
        &[],
        amount,
    )?,
    &accounts,
)?;

Summary

Key Takeaways

Anchor Advantages:

  • Automatic account validation through constraints
  • Cleaner, more concise code
  • Built-in safety checks
  • Type-safe account structures
  • Simplified CPI with CpiContext

Native Rust Advantages:

  • Full control over all operations
  • No framework overhead
  • Explicit validation (can be more transparent)
  • Useful for understanding low-level mechanics

Common Operations Quick Reference

Operation Anchor Module Native Rust Crate
Mint tokens token_interface::mint_to spl_token::instruction::mint_to
Transfer tokens token_interface::transfer spl_token::instruction::transfer
Transfer checked token_interface::transfer_checked spl_token::instruction::transfer_checked
Burn tokens token_interface::burn spl_token::instruction::burn
Create ATA associated_token constraint spl_associated_token_account
Close account token_interface::close_account spl_token::instruction::close_account
Freeze account token_interface::freeze_account spl_token::instruction::freeze_account

Security Checklist

  • Validate token program ID
  • Verify token account ownership
  • Check mint matches expected
  • Confirm authority is signer
  • Ensure account not frozen
  • Validate ATA derivation if applicable
  • Use transfer_checked instead of transfer
  • Validate account state (initialized/frozen)
  • Check sufficient balance before operations

Token Account Sizes

  • Mint account: 82 bytes
  • Token account: 165 bytes
  • Token-2022 with extensions: 82/165 + extension sizes

Token integration is fundamental for DeFi, NFT, and gaming programs on Solana. Whether using Anchor or native Rust, understanding both approaches provides the flexibility to choose the right tool for your use case.