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¶
- Atomic Swaps: Exchange Token A for Token B without trust
- Conditional Payments: Release funds when conditions are verified
- Vesting Schedules: Time-locked token releases
- 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:
- Escrow PDA: Stores escrow metadata
-
Seeds:
["escrow", maker, escrow_id] -
Vault PDA: Holds the tokens
- Seeds:
["vault", escrow] - 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:
5. Reentrancy Protection¶
Solana's single-threaded execution prevents traditional reentrancy, but follow these patterns:
- Update state before making CPIs
- Use the
closeconstraint 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:
- Create a new Anchor project
- Implement the escrow program step by step
- Write tests for each instruction
- 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¶
- SPL Token Documentation
- Anchor SPL Reference
- Paulx Escrow Tutorial (Classic reference)