A Beginner’s Guide to Escrows on Solana

dvrvsimi
11 min readJan 28, 2025

· What is an Escrow?
· Core Concepts
Accounts
Ownership vs Authority
Program Derived Addresses (PDAs)
Solana Program Library (SPL)
Rent
· The Flow
· Prerequisite
· Implementation
Scaffold
· Writing Instructions
Initialize
Exchange
· The handler() function
· Other Implementations
· Next Steps
· Conclusion

Imagine you’re trading Pokémon cards online with a stranger. You have a rare Charizard and want to trade it for their Blastoise. The problem? You can’t trust them to send their card after you send yours. This is where an escrow comes in — it’s like having a trusted third-party hold onto your Charizard until the other person shows up with the Blastoise.

On Solana, an escrow program is this trusted friend, but instead of being a person, it’s a smart contract that:

  • Holds onto Token A (your Charizard)
  • Waits for Token B (their Blastoise)
  • Automatically completes the swap when both are present

What is an Escrow?

An escrow program on Solana is essentially a secure way to facilitate trades between two parties, where the program acts as a trusted intermediary.

Core Concepts

We’ll discuss core Solana concepts needed to better understand escrows:

Accounts

Every piece of data on Solana lives in an account. Accounts are not the “space” but the “files” occupying the space and these files come in types such as:

  • System accounts (user wallets, stores lamports)
  • Program accounts (smart contracts)
  • Data accounts (program data)
  • Token accounts (store tokens)

Ownership vs Authority

Ownership: talks about which program can modify account data. Only the owner program can change account data, e.g: token accounts are owned by token programs.

Authority: Who can initiate actions with the account. it’s like having permission to use a credit card.

Program Derived Addresses (PDAs)

They are special accounts that programs can control. Because they are deterministically created and have no private key, users can only invoke an action and hope the program was written to permit such action. We would utilize this concept to store the escrow state and control the temporary token account.

Solana Program Library (SPL)

Think of SPLs like the standard library in Rust, but specifically for Solana, the SPL Token Program provides standard ways to create new tokens, transfer tokens and manage token accounts

For our escrow program, we’ll be heavily using the SPL Token Program since we’re dealing with token trades. When someone wants to put tokens into escrow, we’ll interact with this program to handle the token transfers.

Rent

Every account (file) takes up space and we need to pay “rent” to use the space. It’s like paying for cloud storage but on the blockchain. You can pay rent in two ways:

  • Pay-as-you-go: Pay small amounts periodically. It adopts a subscription-based model so if you don’t pay, data gets deleted (“deactivated”).
  • Rent-exemption: Pay a larger amount upfront. It’s like buying a storage unit outright to make your account become “rent-exempt”. You can get this SOL back if you decided close the account.

The Flow

Alice wants to trade Token A for Bob’s Token B
Alice initializes the escrow by:

  • Creating an escrow account to store trade info
    Transferring her Token A into a temporary holding account

Bob sees the trade and can complete it by:

  • Sending his Token B to Alice
    Receiving Token A from the holding account.

Prerequisite

This guide assumes that you know a few about programming on Solana, here’s a comprehensive guide to help you get started.

Implementation

There is a widely known escrow implementation in native Rust that is fondly referred to as the “Solana bible”, it’s great for low level applications that require custom tweaks but for the purpose of this article, we will stick to using Anchor. Let’s write some code!

Scaffold

Run anchor init escrow in your preferred location to set up the program scaffold, you can add the --no-git flag to prevent initialization of an empty repository within your folder. Change directory into escrow to see the generated scaffold.

Writing Instructions

The escrow needs to be able to do at least 2 things- initialize a swap and execute the initialized swap according to a set of predefined conditions, cd to programs/escrow/src and run mkdir instructions. In this newly created folder, we will create 2 separate files: initialize.rs and exchange.rs. Paste the snippets below in their respective files

Initialize

// programs/escrow/src/instructions/initialize.rs
use anchor_lang::prelude::*;
use anchor_spl::token::{ Token, TokenAccount};
use crate::state::*;

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

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

pub token_to_receive_account: Account<'info, TokenAccount>,

#[account(
init,
payer = initializer,
space = Escrow::LEN,
seeds = [
ESCROW_SEED,
initializer.key().as_ref(),
temp_token_account.key().as_ref()
]
,
bump
)]
pub escrow: Account<'info, Escrow>,

/// CHECK: PDA owned by the program
#[account(
seeds = [ESCROW_PDA_SEED]
,
bump
)]
pub escrow_pda: AccountInfo<'info>,

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

pub fn handler(
ctx: Context<Initialize>,
amount: u64,
) -> Result<()> {
// Set escrow details
let escrow = &mut ctx.accounts.escrow;
escrow.initializer = ctx.accounts.initializer.key();
escrow.temp_token_account = ctx.accounts.temp_token_account.key();
escrow.initializer_token_receive = ctx.accounts.token_to_receive_account.key();
escrow.expected_amount = amount;

// Transfer authority of temp token account to PDA
let cpi_accounts = token::SetAuthority {
current_authority: ctx.accounts.initializer.to_account_info(),
account_to_transfer: ctx.accounts.temp_token_account.to_account_info(),
};

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

token::set_authority(
cpi_ctx,
token::AuthorityType::AccountOwner,
Some(ctx.accounts.escrow_pda.key()),
)?;

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

Exchange

// programs/escrow/src/instructions/exchange.rs
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount};
use crate::state::*;
use crate::constants::constants::{ESCROW_SEED, ESCROW_PDA_SEED};
use crate::errors::EscrowError; // also these

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

#[account(
mut,
constraint = taker_token_account.owner == taker.key()
)]
pub taker_token_account: Account<'info, TokenAccount>,

#[account(
mut,
seeds = [
ESCROW_SEED,
escrow.initializer.as_ref(),
escrow.temp_token_account.as_ref()
],
bump,
constraint = escrow.temp_token_account == temp_token_account.key(),
constraint = escrow.initializer_token_receive == initializer_receive_account.key()
)]
pub escrow: Account<'info, Escrow>,

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

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

/// CHECK: PDA owned by the program
#[account(
seeds = [ESCROW_PDA_SEED],
bump
)]
pub escrow_pda: AccountInfo<'info>,

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

pub fn handler(
ctx: Context<Exchange>,
amount: u64,
) -> Result<()> {
// 1. Amount Verification
if amount != ctx.accounts.escrow.expected_amount {
return Err(EscrowError::AmountMismatch.into());
}

// 2. First Transfer: Taker -> Initializer
let transfer_to_initializer_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
token::Transfer {
from: ctx.accounts.taker_token_account.to_account_info(),
to: ctx.accounts.initializer_receive_account.to_account_info(),
authority: ctx.accounts.taker.to_account_info(),
},
);

token::transfer(
transfer_to_initializer_ctx,
ctx.accounts.escrow.expected_amount
)?;

// 3. Second Transfer: Setup PDA Signing
let seeds = &[
ESCROW_PDA_SEED,
&[*ctx.bumps.get("escrow_pda").unwrap()]
];
let signer = &[&seeds[..]];

// 4. Second Transfer: Temp Account -> Taker
let transfer_to_taker_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token::Transfer {
from: ctx.accounts.temp_token_account.to_account_info(),
to: ctx.accounts.taker_token_account.to_account_info(),
authority: ctx.accounts.escrow_pda.to_account_info(),
},
signer
);

token::transfer(transfer_to_taker_ctx, amount)?;

// 5. Cleanup: Close Temp Token Account
let close_temp_token_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token::CloseAccount {
account: ctx.accounts.temp_token_account.to_account_info(),
destination: ctx.accounts.taker.to_account_info(),
authority: ctx.accounts.escrow_pda.to_account_info(),
},
signer
);

token::close_account(close_temp_token_ctx)?;

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

Notice 2 distinct sections in each snippet, the structs: Initialize and Exchange , they define what accounts are needed in their respective instructions and the handlers: handler in both cases. We will discuss one case because almost the same logic applies to the other:

Account 1 — The Initializer:

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

The initializer the person starting the escrow (Alice)

  • mut means this account can be modified
  • Signer means this person (Alice) must sign the transaction

Account 2 — Temporary Token Account:

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

This is where the tokens being escrowed will be held. Think of it like a temporary vault for the tokens.

Account 3 — Token Receive Account:
pub token_to_receive_account: Account<’info, TokenAccount>,

This is where the initializer will receive their tokens when the trade happens. It’s not marked as mut because we’re just storing its address for later

Account 4 — The Escrow Account:

#[account(
init,
payer = initializer,
space = Escrow::LEN
seeds = [
ESCROW_SEED,
initializer.key().as_ref(),
temp_token_account.key().as_ref()
],
bump
)]
pub escrow: Account<'info, Escrow>,

This creates a new account to store escrow information

  • init means we’re creating this account
  • payer = initializer means the Alice pays for this account creation
  • space = Escrow::LEN allocates the right amount of space
  • seeds uses a bunch of strings to ensure that the PDA is reproducible
  • bump is part of the PDA derivation to ensure we get a valid program address

Account 5 — The Escrow PDA:

/// CHECK: PDA owned by the program
#[account(
seeds = [ESCROW_PDA_SEED],
bump
)]
pub escrow_pda: AccountInfo<'info>,
  • It uses AccountInfo instead of Account because it’s not storing any data — it’s just used as a signing authority
  • The /// CHECK: PDA owned by the program comment above it tells Anchor this account doesn’t need the usual validation.
  • We use a simpler seed pattern because this PDA just needs to be a program-owned authority that can sign for token transfers

System Accounts:

pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,

These are required Solana programs needed for:

  • Token operations (token_program)
  • Account creation (system_program)
  • Rent exemption (rent)

The handler() function

This function exists in both instructions but the context and logic implemented in each is different.

pub fn handler(
ctx: Context<Initialize>,
amount: u64,
) -> Result<()> {

This is the actual logic that runs during initialization. It takes a context (containing all our accounts) and the amount expected.

  1. Setting Up the Escrow:
let escrow = &mut ctx.accounts.escrow;
escrow.initializer = ctx.accounts.initializer.key();
escrow.temp_token_account = ctx.accounts.temp_token_account.key();
escrow.initializer_token_receive = ctx.accounts.token_to_receive_account.key();
escrow.expected_amount = amount;

We store important information in the escrow account:

  • Who started it? (initializer)
  • Where are the escrowed tokens stored? (temp_token_account)
  • Where should the exchanged tokens be sent? (initializer_token_receive)
  • How many tokens do they want in return? (expected_amount)

2. Preparing to Transfer Authority:

let cpi_accounts = token::SetAuthority {
current_authority: ctx.accounts.initializer.to_account_info(),
account_to_transfer: ctx.accounts.temp_token_account.to_account_info(),
};

Changes token account authority with SetAuthority

  • Current owner is the initializer
  • Account being transferred is the temp_token_account

3. Creating CPI (Cross-Program Invocation) Context:

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

The CpiContext is like preparing an envelope for sending an instruction to another program (in this case, the Token program).

4. Executing Authority Transfer:

token::set_authority(
cpi_ctx, // The context we created with program and accounts in 3
AuthorityType::AccountOwner, // Type of authority we're changing
Some(ctx.accounts.escrow_pda.key()), // New authority's public key
)?;

Calls token program to change authority, changes the account owner authority and sets new authority to our escrow PDA.

For Exchange, it is where the actual trade happens in the escrow. Let’s see how:

  1. First, the amount check:
let escrow = &ctx.accounts.escrow;

if amount != escrow.expected_amount {
return Err(EscrowError::AmountMismatch.into());
}

Think of this like checking if someone is paying the correct price. If Alice wants 50 tokens and Bob tries to pay 40, the trade fails.

2. First Transfer — From Taker to Initializer:

let transfer_to_initializer_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
token::Transfer {
from: ctx.accounts.taker_token_account.to_account_info(),
to: ctx.accounts.initializer_receive_account.to_account_info(),
authority: ctx.accounts.taker.to_account_info(),
},
);
token::transfer(transfer_to_initializer_ctx, escrow.expected_amount)?;

Bob (the taker) handing over their tokens to Alice (initializer):

from: Bob’s token account
to: Alice’s receiving account
authority: Bob, since it’s their tokens being sent
The CpiContext is like a secure envelope for this transfer instruction

4. Second Transfer — From Escrow to Taker:

let transfer_to_taker_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
token::Transfer {
from: ctx.accounts.temp_token_account.to_account_info(),
to: ctx.accounts.taker_token_account.to_account_info(),
authority: ctx.accounts.escrow.to_account_info(),
},
);
token::transfer(transfer_to_taker_ctx, amount)?;

The escrow vault releases Alice’s tokens to Bob:

from: The temporary escrow holding account
to: Bob’s token account
authority: The escrow account itself, since it controls the temporary account.

5. Cleanup: Close Temp Token Account

let close_temp_token_ctx = CpiContext::new_with_signer(
// The program we're calling (Token Program)
ctx.accounts.token_program.to_account_info(),

// The accounts needed for closing
token::CloseAccount {
// The account we want to close
account: ctx.accounts.temp_token_account.to_account_info(),

// Where to send the rent lamports
destination: ctx.accounts.taker.to_account_info(),

// Who has permission to close the account (our PDA)
authority: ctx.accounts.escrow_pda.to_account_info(),
},

// PDA signer seeds we created earlier
signer
);

Other Implementations

It is a good practice to break your code down into smaller sections, readability is easier. Let’s add more files to our programs folder, your directory should look like this:

program file structure

mod.rs helps Rust understand how code is organized in directories, controls what parts of your code are visible to other parts, and can simplify imports for users of your code. Without them, you’d have to:

  • Declare modules in each parent file
  • Handle visibility in multiple places
  • Have less organized code structure
// mod.rs
pub mod initialize;
pub mod exchange;

// Re-export the account structs
pub use initialize::Initialize;
pub use exchange::Exchange;

constant.rs :

// constants.rs
pub mod constants {
pub const ESCROW_SEED: &[u8] = b"escrow";
pub const ESCROW_PDA_SEED: &[u8] = b"escrow-pda";
}

state.rs : handles the escrow state and space alloc

#[account]  // Anchor attribute for account structs
pub struct Escrow {
pub initializer: Pubkey, // 32 bytes
pub temp_token_account: Pubkey, // 32 bytes
pub initializer_token_receive: Pubkey,// 32 bytes
pub expected_amount: u64, // 8 bytes
}
impl Escrow {
// 8 bytes for account discriminator
// + 32 bytes (Pubkey) * 3
// + 8 bytes (u64)
pub const LEN: usize = 8 + 32 + 32 + 32 + 8;
}

errors.rs:

use anchor_lang::prelude::*;

#[error_code]
pub enum EscrowError {
#[msg("Amount expected by initializer is not equal to the amount proposed by taker")]
AmountMismatch,
#[msg("Invalid token account owner")]
InvalidOwner,
}

Finally, thelib.rs will be:

use anchor_lang::prelude::*;

pub mod state;
pub mod errors;
pub mod constants;
pub mod instructions;

use crate::instructions::{Initialize, Exchange};

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

#[program]
mod escrow {
use super::*;

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

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

Next Steps

After establishing the basic escrow implementation, you can enhance it for production readiness. The next priorities should be security, testing, and user experience.

You can start by implementing comprehensive error handling. Replace generic errors with specific, actionable error types that help users understand what went wrong. For example, add custom errors for invalid token mints, insufficient balances, and authorization failures.

Writing thorough tests is crucial. Create unit tests for individual components and integration tests that simulate complete trade flows. Test edge cases like attempting trades with wrong token amounts or unauthorized accounts. Include failure cases to ensure errors are handled gracefully.

Enhance the security by adding token validation. Verify token mints match expected values and implement checks for frozen or invalid token accounts. Consider adding time-based constraints to prevent stale trades from executing.

Remember that escrow programs manage user assets, so prioritize security and reliability over adding new features. Each enhancement should be thoroughly tested before deployment.

Conclusion

Building an escrow program is a fundamental way to understand how secure trading works on Solana. Through this implementation, we’ve explored how programs can act as trustworthy intermediaries, managing token transfers and ensuring both parties fulfill their commitments before completing a trade. The escrow program demonstrates key Solana concepts like PDAs, CPIs, and account management, all while providing real-world utility.

Remember, while our implementation focuses on a basic token swap, the same concepts can be extended to handle more complex scenarios like multi-party trades, time-locked exchanges, or conditional releases. The power of programmable escrow lies in its ability to enforce trust through code, making it a cornerstone of decentralized finance.

Tschüss!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

dvrvsimi
dvrvsimi

Written by dvrvsimi

bme | 🦀 | ml/ai | tw | web3

No responses yet

Write a response