learn.sol
Anchor Programs • Escrow Mechanism Project
Lesson 10 of 12

Escrow with a PDA Vault

Build one guided escrow project in Anchor using PDA state, a vault authority PDA, token CPI, and explicit state transitions that cannot be bypassed.

Escrow is the first project in this section where your program has to do four hard things at once:

  • hold state over time
  • control token movement with a PDA
  • enforce who is allowed to act next
  • stop repeated or invalid state transitions

That is why escrow matters.

If token minting taught you program-controlled authority, escrow teaches you program-controlled flows.

What You Are Building

You will build one simple token escrow with this lifecycle:

  1. a maker creates the escrow terms
  2. the maker funds a vault token account controlled by the program
  3. the taker completes the escrow and receives the tokens
  4. or the maker cancels before completion and receives the tokens back

That is enough.

Do not add deadlines, partial fills, fees, or multi-party settlement in the first version.

The point of this project is to make one state machine completely clear.

The Core Idea

The escrow has two separate responsibilities:

  • state tracking in your own escrow account
  • token custody through a vault controlled by a PDA

Those are different jobs.

That is why the project needs both:

  • an escrow state PDA
  • a vault authority PDA
  • a vault token account owned by that vault authority PDA

The token program still controls token balances.

Your Anchor program controls when those balances are allowed to move.

Escrow PDATap to reveal
The program-owned state account that stores the deal terms and the current stage of the escrow.
Escrow PDA
Vault Authority PDATap to reveal
The PDA that is allowed to authorize token movement out of the escrow vault.
Vault Authority PDA
Vault Token AccountTap to reveal
The token account that actually holds the escrowed assets while the program controls the next valid move.
Vault Token Account
State TransitionTap to reveal
A controlled move such as `Initialized -> Funded -> Completed` that prevents replay and invalid actions.
State Transition

Step 1: Define The Escrow State Clearly

Start with the smallest useful state machine.

#[account]
#[derive(InitSpace)]
pub struct Escrow {
    pub maker: Pubkey,
    pub taker: Pubkey,
    pub mint: Pubkey,
    pub amount: u64,
    pub status: EscrowStatus,
}

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

Why each field exists:

  • maker tells the program who created the escrow
  • taker tells the program who is allowed to complete it
  • mint ties the escrow to one token mint
  • amount records the agreed token quantity
  • status prevents invalid transitions and repeated execution

That is enough for the first version.

Step 2: Choose Deterministic PDA Rules

This project needs two PDA formulas.

The escrow PDA

Use one PDA for the business state itself.

A clean rule is:

seeds = [b"escrow", maker.key().as_ref(), escrow_id.as_ref()]

The exact third seed can vary.

It can be a nonce, an ID, or another stable unique value.

The important part is the design principle:

  • namespace the account
  • tie it to the maker
  • make the final seed stable enough to derive again later

The vault authority PDA

Use a second PDA for token authority.

A clean rule is:

seeds = [b"vault-authority", escrow.key().as_ref()]

This is a good pattern because the vault authority is tied directly to one escrow record.

That makes the authority model easier to reason about.

Step 3: Create The Escrow State

The first instruction writes the escrow terms, but does not move tokens yet.

use anchor_spl::token_interface::Mint;

#[derive(Accounts)]
#[instruction(escrow_id: [u8; 8])]
pub struct InitializeEscrow<'info> {
    #[account(mut)]
    pub maker: Signer<'info>,

    pub taker: UncheckedAccount<'info>,
    pub mint: InterfaceAccount<'info, Mint>,

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

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

Then the handler stores the deal terms.

pub fn initialize_escrow(
    ctx: Context<InitializeEscrow>,
    _escrow_id: [u8; 8],
    amount: u64,
) -> Result<()> {
    let escrow = &mut ctx.accounts.escrow;
    escrow.maker = ctx.accounts.maker.key();
    escrow.taker = ctx.accounts.taker.key();
    escrow.mint = ctx.accounts.mint.key();
    escrow.amount = amount;
    escrow.status = EscrowStatus::Initialized;
    Ok(())
}

The important part here is not that the handler is long.

The important part is that it is short.

The instruction is only defining the agreement.

It is not trying to define the agreement and move funds and settle the trade all at once.

The chosen mint should be part of that agreement immediately.

If the asset is not fixed during initialization, the escrow terms are still incomplete.

Step 4: Define The Vault Flow

Now you need a token account that actually holds the escrowed tokens.

The clean beginner design is:

  • derive a vault authority PDA from the escrow PDA
  • create an ATA for that vault authority and the chosen mint
  • move maker tokens into that ATA during the funding step

That keeps the authority model consistent with the lessons you already learned.

Step 5: Fund The Escrow

This is the instruction where the maker deposits tokens into the vault.

use anchor_spl::{
    associated_token::AssociatedToken,
    token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked},
};

#[derive(Accounts)]
pub struct FundEscrow<'info> {
    #[account(mut)]
    pub maker: Signer<'info>,

    #[account(
        mut,
        has_one = maker,
        has_one = mint,
        constraint = escrow.status == EscrowStatus::Initialized @ EscrowError::InvalidState,
    )]
    pub escrow: Account<'info, Escrow>,

    pub mint: InterfaceAccount<'info, Mint>,

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

    #[account(
        seeds = [b"vault-authority", escrow.key().as_ref()],
        bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        init_if_needed,
        payer = maker,
        associated_token::mint = mint,
        associated_token::authority = vault_authority,
        associated_token::token_program = token_program,
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,

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

Now the handler can move the tokens.

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

    let cpi_accounts = TransferChecked {
        mint: ctx.accounts.mint.to_account_info(),
        from: ctx.accounts.maker_token_account.to_account_info(),
        to: ctx.accounts.vault_token_account.to_account_info(),
        authority: ctx.accounts.maker.to_account_info(),
    };

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

    token_interface::transfer_checked(cpi_context, escrow.amount, ctx.accounts.mint.decimals)?;
    escrow.status = EscrowStatus::Funded;
    Ok(())
}

What This Funding Step Is Really Doing

It is doing two jobs:

  1. moving tokens into program-controlled custody
  2. advancing the state machine from Initialized to Funded

That second job matters as much as the transfer.

If you move tokens and forget the state transition, your escrow model is already weak.

Step 6: Complete The Escrow

Now the taker should be able to receive the tokens, but only if the escrow is actually funded.

#[derive(Accounts)]
pub struct CompleteEscrow<'info> {
    #[account(mut)]
    pub taker: Signer<'info>,

    #[account(
        mut,
        constraint = escrow.status == EscrowStatus::Funded @ EscrowError::InvalidState,
        constraint = escrow.taker == taker.key() @ EscrowError::Unauthorized,
        has_one = mint,
    )]
    pub escrow: Account<'info, Escrow>,

    pub mint: InterfaceAccount<'info, Mint>,

    #[account(
        seeds = [b"vault-authority", escrow.key().as_ref()],
        bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = vault_authority,
        associated_token::token_program = token_program,
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = taker,
        associated_token::token_program = token_program,
    )]
    pub taker_token_account: InterfaceAccount<'info, TokenAccount>,

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

And the handler uses a PDA-signed token CPI.

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

    let signer_seeds: &[&[&[u8]]] = &[&[
        b"vault-authority",
        escrow.key().as_ref(),
        &[ctx.bumps.vault_authority],
    ]];

    let cpi_accounts = TransferChecked {
        mint: ctx.accounts.mint.to_account_info(),
        from: ctx.accounts.vault_token_account.to_account_info(),
        to: ctx.accounts.taker_token_account.to_account_info(),
        authority: ctx.accounts.vault_authority.to_account_info(),
    };

    let cpi_context = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
    )
    .with_signer(signer_seeds);

    token_interface::transfer_checked(cpi_context, escrow.amount, ctx.accounts.mint.decimals)?;

    escrow.status = EscrowStatus::Completed;
    Ok(())
}

Read The Completion Flow In The Right Order

First, confirm the state

constraint = escrow.status == EscrowStatus::Funded

If the escrow is not funded, completion must fail.

Then confirm the actor

constraint = escrow.taker == taker.key()

The program decides who the taker is.

The client does not get to redefine that at call time.

Then confirm the exact token accounts

associated_token::authority = vault_authority
associated_token::mint = mint

and

associated_token::authority = taker
associated_token::mint = mint

This is important.

You do not want the completion path to accept arbitrary token accounts and hope the token CPI rejects bad input later.

The account context should already describe the exact custody model.

Then let the PDA sign for the vault authority

.with_signer(signer_seeds)

This is the same PDA-signing pattern from the CPI lesson.

Now it is being used for real custody movement.

Then update the state machine

escrow.status = EscrowStatus::Completed;

If you forget this line, the escrow can be completed again.

That is the kind of bug this project is supposed to teach you to avoid.

Step 7: Add Cancellation Before Completion

A minimal escrow also needs a cancellation path.

The simplest rule is:

  • only the maker can cancel
  • cancellation is allowed only while the escrow is funded or initialized, depending on your design

A clean beginner rule is to allow cancellation only before completion.

The same pattern applies:

  • validate actor
  • validate current state
  • move tokens back with a PDA-signed CPI if needed
  • mark the escrow Cancelled

Step 8: Add Explicit Errors

A state-machine project should fail clearly.

#[error_code]
pub enum EscrowError {
    #[msg("Escrow is not in the required state")]
    InvalidState,

    #[msg("Caller is not authorized for this action")]
    Unauthorized,

    #[msg("Token mint does not match the escrow configuration")]
    InvalidMint,
}

Specific errors make your tests and reviews much stronger.

The Real Lesson In This Project

Escrow is not hard because token CPI is hard.

Escrow is hard because state transitions and authority rules must stay correct across time.

That is the real engineering problem.

The project works only if all of these remain aligned:

  • escrow state PDA
  • vault authority PDA
  • vault token account
  • current status
  • expected actor
  • expected mint

If one of those drifts, the design gets unsafe quickly.

Minimum Test Matrix

Do not ship this project with one happy-path test.

The minimum useful tests are:

  1. maker initializes and funds successfully
  2. wrong taker cannot complete
  3. correct taker completes successfully
  4. second completion attempt fails
  5. maker cancel path works only in allowed states
  6. wrong mint or wrong token account relationships fail

That is the real proof that the state machine is working.

Common Escrow Mistakes

Mistake 1: using client-side booleans instead of on-chain status

Escrow state belongs on-chain.

The client can display state, but the program must enforce it.

Mistake 2: forgetting to tie the vault authority PDA to one escrow

A vague authority model creates review problems and safety problems.

Tie the vault authority to the escrow directly.

Mistake 3: moving tokens without advancing the state machine

If the transfer succeeds but the status stays stale, the program logic is already inconsistent.

Mistake 4: relying on signer checks without stored authority checks

For serious state transitions, the business rule must be explicit in account data and constraints.

One Good Extension

If the core version works, add exactly one improvement:

  • an expiry timestamp that lets the maker cancel after timeout

That is a good extension because it deepens the same state-machine logic without exploding the scope.

What Comes Next

After this project, the next lesson should move from “working flows” to “cleaner and safer program structure.”

That is where ergonomics and maintainability start to matter more.

Quick Check

Quick Check
Single answer

Why does this escrow design need both an escrow state account and a vault token account?

Quick Check
Single answer

What should be true before the program lets the taker complete the escrow?

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px