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:
- a maker creates the escrow terms
- the maker funds a vault token account controlled by the program
- the taker completes the escrow and receives the tokens
- 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.
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:
makertells the program who created the escrowtakertells the program who is allowed to complete itmintties the escrow to one token mintamountrecords the agreed token quantitystatusprevents 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:
- moving tokens into program-controlled custody
- advancing the state machine from
InitializedtoFunded
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::FundedIf 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 = mintand
associated_token::authority = taker
associated_token::mint = mintThis 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:
- maker initializes and funds successfully
- wrong taker cannot complete
- correct taker completes successfully
- second completion attempt fails
- maker cancel path works only in allowed states
- 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
Why does this escrow design need both an escrow state account and a vault token account?
What should be true before the program lets the taker complete the escrow?