Skip to content

Module 5: DApp 1 - Token & Escrow System

Learning Objectives

By the end of this module, you will:

  • Understand the SPL Token Program architecture
  • Create and manage token mints with Anchor
  • Build a complete escrow system with PDAs
  • Implement cross-program invocations (CPIs)
  • Apply security best practices for token handling

Prerequisites

Complete Module 4: Anchor Framework before starting this module.


Part A: SPL Token Program

The SPL (Solana Program Library) Token Program is Solana's standard for fungible and non-fungible tokens. Unlike Ethereum's ERC-20 where each token is a separate contract, Solana uses a single program that manages all tokens through different account configurations.

Token Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    SPL Token Program                         │
│                  (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA)│
└─────────────────────────────────────────────────────────────┘
              ┌───────────────┼───────────────┐
              │               │               │
              ▼               ▼               ▼
        ┌──────────┐   ┌──────────┐   ┌──────────┐
        │   Mint   │   │  Token   │   │  Token   │
        │ Account  │   │ Account  │   │ Account  │
        │          │   │ (User A) │   │ (User B) │
        │ supply   │   │ amount   │   │ amount   │
        │ decimals │   │ mint     │   │ mint     │
        │ authority│   │ owner    │   │ owner    │
        └──────────┘   └──────────┘   └──────────┘

Key Account Types

1. Mint Account

The mint account defines a token type. It stores:

Field Type Description
mint_authority Option<Pubkey> Can mint new tokens (None = fixed supply)
supply u64 Total tokens in circulation
decimals u8 Decimal places (e.g., 9 for SOL-like, 6 for USDC-like)
is_initialized bool Whether the mint is active
freeze_authority Option<Pubkey> Can freeze token accounts
// Mint account structure (from spl-token)
pub struct Mint {
    pub mint_authority: COption<Pubkey>,
    pub supply: u64,
    pub decimals: u8,
    pub is_initialized: bool,
    pub freeze_authority: COption<Pubkey>,
}

2. Token Account

Token accounts hold a specific token for a specific owner:

Field Type Description
mint Pubkey Which token type this account holds
owner Pubkey Who controls these tokens
amount u64 Token balance
delegate Option<Pubkey> Approved spender
state AccountState Initialized/Frozen/Uninitialized
delegated_amount u64 Amount approved for delegate
close_authority Option<Pubkey> Can close this account

3. Associated Token Account (ATA)

An ATA is a deterministically derived token account. Given a wallet and mint, there's exactly one ATA address:

// ATA derivation
seeds = [
    wallet_address,
    token_program_id,
    mint_address,
]
program_id = associated_token_program_id

Why use ATAs?

  • Predictable addresses: No need to query for a user's token account
  • Standard: All wallets and dApps use the same derivation
  • Convenience: Can send tokens to a wallet even if they don't have the token account yet

Creating Tokens with Anchor

Initialize a Mint

use anchor_lang::prelude::*;
use anchor_spl::token::{Mint, Token};

#[derive(Accounts)]
pub struct CreateToken<'info> {
    #[account(
        init,
        payer = payer,
        mint::decimals = 9,
        mint::authority = payer,
        mint::freeze_authority = payer,
    )]
    pub mint: Account<'info, Mint>,

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

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

pub fn create_token(ctx: Context<CreateToken>) -> Result<()> {
    msg!("Token mint created: {}", ctx.accounts.mint.key());
    Ok(())
}

Initialize a Token Account

use anchor_spl::token::{TokenAccount, Token, Mint};
use anchor_spl::associated_token::AssociatedToken;

#[derive(Accounts)]
pub struct CreateTokenAccount<'info> {
    #[account(
        init,
        payer = payer,
        associated_token::mint = mint,
        associated_token::authority = owner,
    )]
    pub token_account: Account<'info, TokenAccount>,

    pub mint: Account<'info, Mint>,

    /// CHECK: The owner of the token account
    pub owner: UncheckedAccount<'info>,

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

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

Minting Tokens

Minting creates new tokens and adds them to the supply:

use anchor_spl::token::{self, MintTo};

#[derive(Accounts)]
pub struct MintTokens<'info> {
    #[account(mut)]
    pub mint: Account<'info, Mint>,

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

    /// Must be the mint authority
    pub authority: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
    let cpi_accounts = MintTo {
        mint: ctx.accounts.mint.to_account_info(),
        to: 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);

    token::mint_to(cpi_ctx, amount)?;

    msg!("Minted {} tokens", amount);
    Ok(())
}

Transferring Tokens

Basic Transfer

use anchor_spl::token::{self, Transfer};

#[derive(Accounts)]
pub struct TransferTokens<'info> {
    #[account(mut)]
    pub from: Account<'info, TokenAccount>,

    #[account(mut)]
    pub to: Account<'info, TokenAccount>,

    /// Must be the owner of `from` account
    pub authority: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.from.to_account_info(),
        to: ctx.accounts.to.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);

    token::transfer(cpi_ctx, amount)?;

    Ok(())
}

Transfer with PDA Authority

When a program controls tokens (like an escrow vault), use CpiContext::new_with_signer:

pub fn transfer_from_vault(ctx: Context<TransferFromVault>, amount: u64) -> Result<()> {
    let escrow_key = ctx.accounts.escrow.key();

    // Seeds used to derive the vault PDA
    let seeds = &[
        b"vault",
        escrow_key.as_ref(),
        &[ctx.accounts.escrow.vault_bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.recipient.to_account_info(),
        authority: ctx.accounts.vault.to_account_info(), // Vault is its own authority
    };

    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new_with_signer(
        cpi_program,
        cpi_accounts,
        signer_seeds,
    );

    token::transfer(cpi_ctx, amount)?;

    Ok(())
}

Burning Tokens

Burning removes tokens from circulation:

use anchor_spl::token::{self, Burn};

#[derive(Accounts)]
pub struct BurnTokens<'info> {
    #[account(mut)]
    pub mint: Account<'info, Mint>,

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

    pub authority: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn burn_tokens(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);

    token::burn(cpi_ctx, amount)?;

    msg!("Burned {} tokens", amount);
    Ok(())
}

Closing Token Accounts

When a token account is empty, you can close it to recover rent:

use anchor_spl::token::{self, CloseAccount};

#[derive(Accounts)]
pub struct CloseTokenAccount<'info> {
    #[account(mut)]
    pub token_account: Account<'info, TokenAccount>,

    /// Receives the rent
    #[account(mut)]
    pub destination: SystemAccount<'info>,

    pub authority: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn close_token_account(ctx: Context<CloseTokenAccount>) -> Result<()> {
    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_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);

    token::close_account(cpi_ctx)?;

    Ok(())
}

Part B: Escrow Contract

An escrow is a financial arrangement where a third party holds assets until conditions are met. On Solana, the "third party" is a program with deterministic rules.

Use Cases

  1. Atomic Swaps: Exchange Token A for Token B without trust
  2. Conditional Payments: Release funds when conditions are verified
  3. Vesting Schedules: Time-locked token releases
  4. Crowdfunding: Collect funds, refund if goal not met

Escrow State Machine

┌──────────┐     fund()      ┌──────────┐
│ Created  │ ───────────────▶│  Funded  │
└──────────┘                 └──────────┘
     │                            │
     │ cancel()                   │ exchange()  │ cancel()
     ▼                            ▼             ▼
┌──────────┐                 ┌──────────┐  ┌──────────┐
│Cancelled │                 │Completed │  │Cancelled │
└──────────┘                 └──────────┘  └──────────┘

State Account Design

use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct Escrow {
    /// The party initiating the escrow
    pub maker: Pubkey,

    /// The counterparty (can be anyone if Pubkey::default())
    pub taker: Pubkey,

    /// Token the maker is offering
    pub mint_a: Pubkey,

    /// Token the maker wants in return
    pub mint_b: Pubkey,

    /// Amount of token A being offered
    pub amount_a: u64,

    /// Amount of token B required
    pub amount_b: u64,

    /// Bump for the escrow PDA
    pub escrow_bump: u8,

    /// Bump for the vault PDA
    pub vault_bump: u8,

    /// Current state of the escrow
    pub state: EscrowState,

    /// Unique identifier for this escrow
    pub escrow_id: u64,
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq, InitSpace)]
pub enum EscrowState {
    Created,
    Funded,
    Completed,
    Cancelled,
}

impl Default for EscrowState {
    fn default() -> Self {
        EscrowState::Created
    }
}

PDA Design

We use two PDAs:

  1. Escrow PDA: Stores escrow metadata
  2. Seeds: ["escrow", maker, escrow_id]

  3. Vault PDA: Holds the tokens

  4. Seeds: ["vault", escrow]
  5. The vault is a token account owned by the program
// Escrow PDA derivation
let (escrow_pda, escrow_bump) = Pubkey::find_program_address(
    &[
        b"escrow",
        maker.as_ref(),
        &escrow_id.to_le_bytes(),
    ],
    &program_id,
);

// Vault PDA derivation (token account)
let (vault_pda, vault_bump) = Pubkey::find_program_address(
    &[
        b"vault",
        escrow_pda.as_ref(),
    ],
    &program_id,
);

Program Implementation

Program Entry Point

use anchor_lang::prelude::*;

declare_id!("EscR1234567890abcdefghijkmnopqrstuvwxyz");

pub mod state;
pub mod instructions;
pub mod error;
pub mod events;

use instructions::*;

#[program]
pub mod token_escrow {
    use super::*;

    pub fn initialize(
        ctx: Context<Initialize>,
        escrow_id: u64,
        amount_a: u64,
        amount_b: u64,
    ) -> Result<()> {
        instructions::initialize::handler(ctx, escrow_id, amount_a, amount_b)
    }

    pub fn fund(ctx: Context<Fund>) -> Result<()> {
        instructions::fund::handler(ctx)
    }

    pub fn exchange(ctx: Context<Exchange>) -> Result<()> {
        instructions::exchange::handler(ctx)
    }

    pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
        instructions::cancel::handler(ctx)
    }
}

Initialize Instruction

use anchor_lang::prelude::*;
use anchor_spl::token::{Mint, Token, TokenAccount};
use anchor_spl::associated_token::AssociatedToken;

use crate::state::{Escrow, EscrowState};
use crate::events::EscrowCreated;

#[derive(Accounts)]
#[instruction(escrow_id: u64)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = maker,
        space = 8 + Escrow::INIT_SPACE,
        seeds = [b"escrow", maker.key().as_ref(), &escrow_id.to_le_bytes()],
        bump,
    )]
    pub escrow: Account<'info, Escrow>,

    #[account(
        init,
        payer = maker,
        seeds = [b"vault", escrow.key().as_ref()],
        bump,
        token::mint = mint_a,
        token::authority = escrow,
    )]
    pub vault: Account<'info, TokenAccount>,

    pub mint_a: Account<'info, Mint>,
    pub mint_b: Account<'info, Mint>,

    /// The maker's token account for mint_a
    #[account(
        mut,
        constraint = maker_ata_a.mint == mint_a.key(),
        constraint = maker_ata_a.owner == maker.key(),
    )]
    pub maker_ata_a: Account<'info, TokenAccount>,

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

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

pub fn handler(
    ctx: Context<Initialize>,
    escrow_id: u64,
    amount_a: u64,
    amount_b: u64,
) -> Result<()> {
    let escrow = &mut ctx.accounts.escrow;

    escrow.maker = ctx.accounts.maker.key();
    escrow.taker = Pubkey::default(); // Anyone can take
    escrow.mint_a = ctx.accounts.mint_a.key();
    escrow.mint_b = ctx.accounts.mint_b.key();
    escrow.amount_a = amount_a;
    escrow.amount_b = amount_b;
    escrow.escrow_bump = ctx.bumps.escrow;
    escrow.vault_bump = ctx.bumps.vault;
    escrow.state = EscrowState::Created;
    escrow.escrow_id = escrow_id;

    emit!(EscrowCreated {
        escrow: escrow.key(),
        maker: escrow.maker,
        mint_a: escrow.mint_a,
        mint_b: escrow.mint_b,
        amount_a,
        amount_b,
    });

    msg!("Escrow initialized: {}", escrow.key());
    Ok(())
}

Fund Instruction

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};

use crate::state::{Escrow, EscrowState};
use crate::error::EscrowError;
use crate::events::EscrowFunded;

#[derive(Accounts)]
pub struct Fund<'info> {
    #[account(
        mut,
        seeds = [b"escrow", escrow.maker.as_ref(), &escrow.escrow_id.to_le_bytes()],
        bump = escrow.escrow_bump,
        constraint = escrow.state == EscrowState::Created @ EscrowError::InvalidState,
    )]
    pub escrow: Account<'info, Escrow>,

    #[account(
        mut,
        seeds = [b"vault", escrow.key().as_ref()],
        bump = escrow.vault_bump,
    )]
    pub vault: Account<'info, TokenAccount>,

    #[account(
        mut,
        constraint = maker_ata_a.mint == escrow.mint_a,
        constraint = maker_ata_a.owner == maker.key(),
        constraint = maker_ata_a.amount >= escrow.amount_a @ EscrowError::InsufficientFunds,
    )]
    pub maker_ata_a: Account<'info, TokenAccount>,

    #[account(
        constraint = maker.key() == escrow.maker @ EscrowError::Unauthorized,
    )]
    pub maker: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn handler(ctx: Context<Fund>) -> Result<()> {
    let escrow = &mut ctx.accounts.escrow;

    // Transfer tokens from maker to vault
    let cpi_accounts = Transfer {
        from: ctx.accounts.maker_ata_a.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
        authority: ctx.accounts.maker.to_account_info(),
    };

    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);

    token::transfer(cpi_ctx, escrow.amount_a)?;

    // Update state
    escrow.state = EscrowState::Funded;

    emit!(EscrowFunded {
        escrow: escrow.key(),
        amount: escrow.amount_a,
    });

    msg!("Escrow funded with {} tokens", escrow.amount_a);
    Ok(())
}

Exchange Instruction

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use anchor_spl::associated_token::AssociatedToken;

use crate::state::{Escrow, EscrowState};
use crate::error::EscrowError;
use crate::events::EscrowCompleted;

#[derive(Accounts)]
pub struct Exchange<'info> {
    #[account(
        mut,
        seeds = [b"escrow", escrow.maker.as_ref(), &escrow.escrow_id.to_le_bytes()],
        bump = escrow.escrow_bump,
        constraint = escrow.state == EscrowState::Funded @ EscrowError::InvalidState,
        close = maker,
    )]
    pub escrow: Account<'info, Escrow>,

    #[account(
        mut,
        seeds = [b"vault", escrow.key().as_ref()],
        bump = escrow.vault_bump,
    )]
    pub vault: Account<'info, TokenAccount>,

    /// Taker's token account for mint_b (what they're paying)
    #[account(
        mut,
        constraint = taker_ata_b.mint == escrow.mint_b,
        constraint = taker_ata_b.owner == taker.key(),
        constraint = taker_ata_b.amount >= escrow.amount_b @ EscrowError::InsufficientFunds,
    )]
    pub taker_ata_b: Account<'info, TokenAccount>,

    /// Taker's token account for mint_a (what they're receiving)
    #[account(
        init_if_needed,
        payer = taker,
        associated_token::mint = mint_a,
        associated_token::authority = taker,
    )]
    pub taker_ata_a: Account<'info, TokenAccount>,

    /// Maker's token account for mint_b (what they're receiving)
    #[account(
        init_if_needed,
        payer = taker,
        associated_token::mint = mint_b,
        associated_token::authority = maker,
    )]
    pub maker_ata_b: Account<'info, TokenAccount>,

    /// CHECK: Maker receives rent and tokens
    #[account(mut)]
    pub maker: UncheckedAccount<'info>,

    pub mint_a: Account<'info, token::Mint>,
    pub mint_b: Account<'info, token::Mint>,

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

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

pub fn handler(ctx: Context<Exchange>) -> Result<()> {
    let escrow = &ctx.accounts.escrow;

    // Verify taker if specified
    if escrow.taker != Pubkey::default() {
        require!(
            ctx.accounts.taker.key() == escrow.taker,
            EscrowError::Unauthorized
        );
    }

    // Transfer token B from taker to maker
    let cpi_accounts = Transfer {
        from: ctx.accounts.taker_ata_b.to_account_info(),
        to: ctx.accounts.maker_ata_b.to_account_info(),
        authority: ctx.accounts.taker.to_account_info(),
    };

    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);

    token::transfer(cpi_ctx, escrow.amount_b)?;

    // Transfer token A from vault to taker
    let escrow_key = escrow.key();
    let seeds = &[
        b"escrow",
        escrow.maker.as_ref(),
        &escrow.escrow_id.to_le_bytes(),
        &[escrow.escrow_bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.taker_ata_a.to_account_info(),
        authority: ctx.accounts.escrow.to_account_info(),
    };

    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );

    token::transfer(cpi_ctx, escrow.amount_a)?;

    // Close vault and return rent to maker
    let cpi_accounts = token::CloseAccount {
        account: ctx.accounts.vault.to_account_info(),
        destination: ctx.accounts.maker.to_account_info(),
        authority: ctx.accounts.escrow.to_account_info(),
    };

    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );

    token::close_account(cpi_ctx)?;

    emit!(EscrowCompleted {
        escrow: escrow_key,
        maker: escrow.maker,
        taker: ctx.accounts.taker.key(),
        amount_a: escrow.amount_a,
        amount_b: escrow.amount_b,
    });

    msg!("Escrow completed successfully");
    Ok(())
}

Cancel Instruction

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};

use crate::state::{Escrow, EscrowState};
use crate::error::EscrowError;
use crate::events::EscrowCancelled;

#[derive(Accounts)]
pub struct Cancel<'info> {
    #[account(
        mut,
        seeds = [b"escrow", escrow.maker.as_ref(), &escrow.escrow_id.to_le_bytes()],
        bump = escrow.escrow_bump,
        constraint = escrow.state != EscrowState::Completed @ EscrowError::InvalidState,
        constraint = escrow.state != EscrowState::Cancelled @ EscrowError::InvalidState,
        close = maker,
    )]
    pub escrow: Account<'info, Escrow>,

    #[account(
        mut,
        seeds = [b"vault", escrow.key().as_ref()],
        bump = escrow.vault_bump,
    )]
    pub vault: Account<'info, TokenAccount>,

    #[account(
        mut,
        constraint = maker_ata_a.mint == escrow.mint_a,
        constraint = maker_ata_a.owner == maker.key(),
    )]
    pub maker_ata_a: Account<'info, TokenAccount>,

    #[account(
        mut,
        constraint = maker.key() == escrow.maker @ EscrowError::Unauthorized,
    )]
    pub maker: Signer<'info>,

    pub token_program: Program<'info, Token>,
}

pub fn handler(ctx: Context<Cancel>) -> Result<()> {
    let escrow = &ctx.accounts.escrow;

    // If funded, return tokens to maker
    if escrow.state == EscrowState::Funded {
        let seeds = &[
            b"escrow",
            escrow.maker.as_ref(),
            &escrow.escrow_id.to_le_bytes(),
            &[escrow.escrow_bump],
        ];
        let signer_seeds = &[&seeds[..]];

        // Transfer tokens back
        let cpi_accounts = Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.maker_ata_a.to_account_info(),
            authority: ctx.accounts.escrow.to_account_info(),
        };

        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            cpi_accounts,
            signer_seeds,
        );

        token::transfer(cpi_ctx, ctx.accounts.vault.amount)?;

        // Close vault
        let cpi_accounts = token::CloseAccount {
            account: ctx.accounts.vault.to_account_info(),
            destination: ctx.accounts.maker.to_account_info(),
            authority: ctx.accounts.escrow.to_account_info(),
        };

        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            cpi_accounts,
            signer_seeds,
        );

        token::close_account(cpi_ctx)?;
    }

    emit!(EscrowCancelled {
        escrow: escrow.key(),
        maker: escrow.maker,
    });

    msg!("Escrow cancelled");
    Ok(())
}

Error Definitions

use anchor_lang::prelude::*;

#[error_code]
pub enum EscrowError {
    #[msg("Invalid escrow state for this operation")]
    InvalidState,

    #[msg("Unauthorized: Only the maker can perform this action")]
    Unauthorized,

    #[msg("Insufficient funds in token account")]
    InsufficientFunds,

    #[msg("Invalid mint for this escrow")]
    InvalidMint,

    #[msg("Escrow has already been completed")]
    AlreadyCompleted,

    #[msg("Escrow has already been cancelled")]
    AlreadyCancelled,

    #[msg("Numerical overflow")]
    Overflow,
}

Event Definitions

use anchor_lang::prelude::*;

#[event]
pub struct EscrowCreated {
    pub escrow: Pubkey,
    pub maker: Pubkey,
    pub mint_a: Pubkey,
    pub mint_b: Pubkey,
    pub amount_a: u64,
    pub amount_b: u64,
}

#[event]
pub struct EscrowFunded {
    pub escrow: Pubkey,
    pub amount: u64,
}

#[event]
pub struct EscrowCompleted {
    pub escrow: Pubkey,
    pub maker: Pubkey,
    pub taker: Pubkey,
    pub amount_a: u64,
    pub amount_b: u64,
}

#[event]
pub struct EscrowCancelled {
    pub escrow: Pubkey,
    pub maker: Pubkey,
}

Security Considerations

1. Signer Verification

Always verify that the correct party is signing:

#[account(
    constraint = maker.key() == escrow.maker @ EscrowError::Unauthorized,
)]
pub maker: Signer<'info>,

2. State Checks

Prevent operations in invalid states:

#[account(
    constraint = escrow.state == EscrowState::Funded @ EscrowError::InvalidState,
)]
pub escrow: Account<'info, Escrow>,

3. Account Ownership

Verify token accounts belong to the right mints and owners:

#[account(
    constraint = maker_ata_a.mint == escrow.mint_a,
    constraint = maker_ata_a.owner == maker.key(),
)]
pub maker_ata_a: Account<'info, TokenAccount>,

4. Checked Math

Use checked arithmetic to prevent overflows:

let total = amount_a
    .checked_add(fee)
    .ok_or(EscrowError::Overflow)?;

5. Reentrancy Protection

Solana's single-threaded execution prevents traditional reentrancy, but follow these patterns:

  • Update state before making CPIs
  • Use the close constraint to prevent account resurrection

Testing the Escrow

TypeScript Test Suite

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { TokenEscrow } from "../target/types/token_escrow";
import {
  createMint,
  createAssociatedTokenAccount,
  mintTo,
  getAccount,
} from "@solana/spl-token";
import { expect } from "chai";

describe("token-escrow", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.TokenEscrow as Program<TokenEscrow>;

  let mintA: anchor.web3.PublicKey;
  let mintB: anchor.web3.PublicKey;
  let makerAtaA: anchor.web3.PublicKey;
  let makerAtaB: anchor.web3.PublicKey;
  let takerAtaA: anchor.web3.PublicKey;
  let takerAtaB: anchor.web3.PublicKey;

  const maker = anchor.web3.Keypair.generate();
  const taker = anchor.web3.Keypair.generate();

  const ESCROW_ID = new anchor.BN(1);
  const AMOUNT_A = new anchor.BN(1000000000); // 1 token with 9 decimals
  const AMOUNT_B = new anchor.BN(500000000);  // 0.5 tokens

  before(async () => {
    // Airdrop SOL to maker and taker
    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(
        maker.publicKey,
        2 * anchor.web3.LAMPORTS_PER_SOL
      )
    );

    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(
        taker.publicKey,
        2 * anchor.web3.LAMPORTS_PER_SOL
      )
    );

    // Create mints
    mintA = await createMint(
      provider.connection,
      maker,
      maker.publicKey,
      null,
      9
    );

    mintB = await createMint(
      provider.connection,
      taker,
      taker.publicKey,
      null,
      9
    );

    // Create token accounts
    makerAtaA = await createAssociatedTokenAccount(
      provider.connection,
      maker,
      mintA,
      maker.publicKey
    );

    takerAtaB = await createAssociatedTokenAccount(
      provider.connection,
      taker,
      mintB,
      taker.publicKey
    );

    // Mint tokens
    await mintTo(
      provider.connection,
      maker,
      mintA,
      makerAtaA,
      maker,
      AMOUNT_A.toNumber()
    );

    await mintTo(
      provider.connection,
      taker,
      mintB,
      takerAtaB,
      taker,
      AMOUNT_B.toNumber()
    );
  });

  it("Initializes escrow", async () => {
    const [escrowPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("escrow"),
        maker.publicKey.toBuffer(),
        ESCROW_ID.toArrayLike(Buffer, "le", 8),
      ],
      program.programId
    );

    const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("vault"), escrowPda.toBuffer()],
      program.programId
    );

    await program.methods
      .initialize(ESCROW_ID, AMOUNT_A, AMOUNT_B)
      .accounts({
        escrow: escrowPda,
        vault: vaultPda,
        mintA,
        mintB,
        makerAtaA,
        maker: maker.publicKey,
      })
      .signers([maker])
      .rpc();

    const escrow = await program.account.escrow.fetch(escrowPda);
    expect(escrow.maker.toString()).to.equal(maker.publicKey.toString());
    expect(escrow.amountA.toString()).to.equal(AMOUNT_A.toString());
    expect(escrow.amountB.toString()).to.equal(AMOUNT_B.toString());
  });

  it("Funds escrow", async () => {
    const [escrowPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("escrow"),
        maker.publicKey.toBuffer(),
        ESCROW_ID.toArrayLike(Buffer, "le", 8),
      ],
      program.programId
    );

    const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("vault"), escrowPda.toBuffer()],
      program.programId
    );

    await program.methods
      .fund()
      .accounts({
        escrow: escrowPda,
        vault: vaultPda,
        makerAtaA,
        maker: maker.publicKey,
      })
      .signers([maker])
      .rpc();

    const vault = await getAccount(provider.connection, vaultPda);
    expect(vault.amount.toString()).to.equal(AMOUNT_A.toString());
  });

  it("Completes exchange", async () => {
    const [escrowPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("escrow"),
        maker.publicKey.toBuffer(),
        ESCROW_ID.toArrayLike(Buffer, "le", 8),
      ],
      program.programId
    );

    const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("vault"), escrowPda.toBuffer()],
      program.programId
    );

    await program.methods
      .exchange()
      .accounts({
        escrow: escrowPda,
        vault: vaultPda,
        takerAtaB,
        mintA,
        mintB,
        maker: maker.publicKey,
        taker: taker.publicKey,
      })
      .signers([taker])
      .rpc();

    // Verify taker received token A
    const takerAtaAAddr = await anchor.utils.token.associatedAddress({
      mint: mintA,
      owner: taker.publicKey,
    });
    const takerTokenA = await getAccount(provider.connection, takerAtaAAddr);
    expect(takerTokenA.amount.toString()).to.equal(AMOUNT_A.toString());

    // Verify maker received token B
    const makerAtaBAddr = await anchor.utils.token.associatedAddress({
      mint: mintB,
      owner: maker.publicKey,
    });
    const makerTokenB = await getAccount(provider.connection, makerAtaBAddr);
    expect(makerTokenB.amount.toString()).to.equal(AMOUNT_B.toString());
  });
});

Interactive Exercise

Try It Yourself

Open Solana Playground and:

  1. Create a new Anchor project
  2. Implement the escrow program step by step
  3. Write tests for each instruction
  4. Deploy to devnet and test with real tokens

Challenge: Add Time Lock

Extend the escrow with a time lock feature:

#[account]
pub struct Escrow {
    // ... existing fields
    pub unlock_time: i64,  // Unix timestamp
}

// In exchange instruction, add check:
let clock = Clock::get()?;
require!(
    clock.unix_timestamp >= escrow.unlock_time,
    EscrowError::TimeLocked
);

Summary

In this module, you learned:

Topic Key Concepts
SPL Tokens Mint accounts, token accounts, ATAs
Token Operations Create, mint, transfer, burn, close
Escrow Design State machine, PDA vaults, CPI
Security Signer verification, state checks, checked math
Testing Anchor test framework, token setup

Next Steps

Continue to Module 6: NFT Marketplace to learn about Metaplex and NFT trading.


Additional Resources


Previous: Anchor Framework Next: NFT Marketplace