Skip to main content

How to Build an Escrow Program on Solana with Anchor

Updated on
Nov 09, 2026

19 min read

Overview​

When two people swap assets, whoever sends first can be left with nothing. Traditional finance solves this with an escrow agent: a trusted third party that holds both sides' assets until both have paid, then hands them over and keeps a fee.

A Solana escrow program replaces that agent. It is an onchain program that holds one side of a token swap in a vault only the program controls, and releases those tokens when the other side pays. The swap is trustless, because neither person has to trust the other or a third party. This guide walks through one written with Anchor.

It works like this:

  • The maker, who creates the offer, puts their tokens in a vault controlled by the program.
  • Anyone can take the offer. The taker sends the maker the tokens the maker asked for, and receives the tokens in the vault in exchange. The take is atomic, so both transfers happen in one transaction or neither does.
  • Nobody runs the program for profit. There is no operator, no fee, and no private key that can move the vault's tokens.

An escrow is often the first onchain program (sometimes called a "smart contract") that Solana developers learn, because it teaches the parts most Solana finance programs are built from:

  • A vault only the program controls
  • The deal's terms stored in an account
  • A way for everyone to get their tokens out at every stage
  • Amounts counted in minor units

This guide follows the Anchor 1 version of the program, in the anchor-v1 directory of finance/escrow in Quicknode's Solana program examples repository. The same directory also has Anchor 2 (anchor), Quasar (quasar), and native Rust versions of the program, plus a Kani model-checking crate (kani-proofs).

TL;DR
  • An escrow program lets two people swap tokens without trusting each other or paying a third party
  • The maker's tokens wait in a vault controlled by the program, and the taker pays the maker directly
  • Anchor rejects a transaction that passes the wrong accounts before any token moves
  • The taker's transaction includes the terms they saw, so it fails if the maker swaps in a worse offer before it lands
  • Build the program with Anchor, test it with LiteSVM, then deploy it to Solana devnet through a Quicknode endpoint

What You Will Do​

  • Follow a token swap between a maker and a taker from start to finish, account by account
  • See the checks that stop a taker from passing the wrong accounts
  • See how the taker's terms stop a maker from switching the offer before the take lands
  • Build the program, run its LiteSVM tests, and deploy it to Solana devnet through a Quicknode endpoint

What You Will Need​

See the Whole Escrow Flow Step by Step​

Press Play to run the animation, step through it with Next and Back, and use Reset to start over.

Alice offers 250 USDC for 1 TSLAx and her USDC moves into a vault controlled by the offer PDA, then either she cancels and gets it back, or Bob pays her 1 TSLAx and receives the vault's 250 USDC.

The rest of this guide explains each step: which accounts are created and closed, which tokens move, and which checks stop each attack.

Why Does a Token Swap Need an Escrow?​

A token swap needs an escrow because whichever side sends first takes all the risk. If the other side keeps the tokens and never pays, the first sender has no way to get them back. An escrow holds one side's tokens until the other side pays, so neither side can walk away with both.

Alice has 1 SOL and 1,000 USDC. She wants a Tesla share and will pay 250 USDC for it (an example price). Bob has 1,000 USDC and 1 TSLAx, a token that tracks one Tesla share, and is happy to sell his TSLAx at that price.

Alice holds 1,000 USDC and wants 1 TSLAx; Bob holds 1,000 USDC and 1 TSLAx and is willing to sell the TSLAx for 250 USDC

If Alice sends Bob her USDC first, Bob can keep it and never send the TSLAx. An escrow solves this by holding one side's tokens until the other side pays.

In traditional finance, a trusted third party does this. Alice sends her funds to the third party, which holds them until Bob sends his, then passes each side on to its new owner and keeps a fee.

A traditional escrow agent receives both sides' assets, takes a fee, and passes the rest on to each party

On Solana, a program replaces the third party. The program holds Alice's tokens in a vault, and only the program's code can move them out. Nobody operates this program: whoever deploys it earns nothing, takes no fee, and has no private key that can move the vault's tokens. Each side receives everything the other side offered. Each side still pays Solana's transaction fee for the transactions they send, a base fee of 5,000 lamports per signature at the time of writing, however large the trade.

The escrow program holds Alice's USDC in a vault and swaps it for Bob's TSLAx, with no third party and no fee

A Maker Offers and a Taker Accepts​

This guide and the code use the same two roles:

  • Alice is the maker. She creates the offer: 250 USDC for 1 TSLAx.
  • Bob is the taker. He accepts it.

The program counts amounts in minor units, the whole numbers a token account stores. USDC has six decimals, so Alice's 250 USDC is 250,000,000 minor units. TSLAx has eight decimals, so the one Tesla share she wants is 100,000,000 minor units. In a USDC account, that same number means 100 USDC, so a program has to know which token an amount is in before the amount means anything.

How Does a Solana Escrow Program Work?​

The program has three instruction handlers. make_offer() creates an offer that says what the maker wants in return. The offer stays open until someone runs take_offer() or the maker runs cancel_offer(). Because the maker can always cancel, their tokens never get stuck in an offer nobody takes.

The offer's state machine: make_offer() opens it, and either take_offer() or cancel_offer() closes it

The Maker Makes an Offer and Funds the Vault​

Alice calls make_offer() with three arguments: an id she picks, token_a_offered_amount (250,000,000 minor units of USDC), and token_b_wanted_amount (100,000,000 minor units of TSLAx).

make_offer() creates the offer account and the vault, creates Alice's TSLAx account, and moves 250 USDC from Alice into the vault

This transaction creates two accounts for the offer, plus a TSLAx account for Alice if she does not have one.

The offer account records the deal:

FieldPurpose
idThe number Alice picked, so one maker can have many offers open at once
makerAlice's address, so the program knows who may cancel and who gets paid
token_mint_a and token_mint_bThe two tokens in the deal
token_b_wanted_amountHow much of token B the maker wants, in minor units
bumpThe bump seed from deriving the offer's address, saved so later handlers can sign as the offer PDA without searching for it again

From programs/escrow/src/state/offer.rs:

programs/escrow/src/state/offer.rs
#[account]
#[derive(InitSpace)]
pub struct Offer {
pub id: u64,
pub maker: Pubkey,
pub token_mint_a: Pubkey,
pub token_mint_b: Pubkey,
pub token_b_wanted_amount: u64,
pub bump: u8,
}

The offer account lives at a PDA derived from the seeds "offer", the maker's address, and the ID. Because the maker's address is one of the seeds, two makers can use the same ID without getting the same address.

The vault holds Alice's USDC. It is an ordinary token account whose authority is the offer PDA. A PDA has no private key, and only the program that derived it can sign for it. So neither Alice nor the program's author can move the vault's tokens: only the program's code can. The vault is the offer's associated token account for token A, so its address is derived from the offer's address, the token program, and the USDC mint.

The transaction also passes the accounts any token transfer needs: the two mint accounts, Alice's token account for each mint, the System Program, the token program, and the Associated Token Program. The escrow works with either the Classic Token Program (SPL Token) or the Token Extensions Program (Token-2022), as long as all the token accounts in one instruction use the same one. Bob's accounts are faded in the figure because this transaction does not touch them.

Before the handler code runs, Anchor checks the account constraints in MakeOfferAccountConstraints:

  • Alice must sign, and her account must be writable because she pays for the new accounts.
  • Both mint accounts must belong to the token program the transaction passes.
  • maker_token_account_a must be Alice's associated token account for token A, the account her USDC comes from.
  • The offer account is created at the PDA derived from "offer", Alice's address, and the ID, with room for its fields plus the discriminator that marks its type. If an account already exists there, the whole transaction fails.
  • The vault is created as the offer's associated token account for token A.

From programs/escrow/src/instructions/make_offer.rs:

programs/escrow/src/instructions/make_offer.rs
#[account(
init,
payer = maker,
space = Offer::DISCRIMINATOR.len() + Offer::INIT_SPACE,
seeds = [b"offer", maker.key().as_ref(), id.to_le_bytes().as_ref()],
bump
)]
pub offer: Account<'info, Offer>,

#[account(
init,
payer = maker,
associated_token::mint = token_mint_a,
associated_token::authority = offer,
associated_token::token_program = token_program
)]
pub vault: InterfaceAccount<'info, TokenAccount>,

maker_token_account_b is Alice's account for the token she wants: the TSLAx account Bob will pay into. If it does not exist, make_offer() creates it and Alice pays the rent. That way Bob never has to, and take_offer() only checks that the account is there.

Once Anchor has checked the accounts, the handler does three things:

  • handle_validate_offer() rejects an offer with zero tokens on either side with ZeroAmount.
  • handle_send_offered_tokens_to_vault() calls the shared transfer_tokens() helper to move 250,000,000 minor units from maker_token_account_a into the vault, signed by Alice.
  • handle_save_offer() writes the offer's fields, including the bump, into the offer account.

The Taker Pays the Maker and Receives the Vault's Tokens​

Bob calls take_offer() and passes the offer account he wants to take. Anchor checks every other account Bob passes against what the offer account recorded. Bob also passes the terms he saw: minimum_token_a_out, the least USDC he will accept (250,000,000 minor units), and maximum_token_b_in, the most TSLAx he will pay (100,000,000 minor units).

take_offer() moves 1 TSLAx from Bob to Alice, moves the vault&#39;s 250 USDC to Bob, and closes the vault and offer accounts

Alice is not part of this transaction. She does not need to be online, because she already moved her USDC into the vault when she signed make_offer().

The constraints in TakeOfferAccountConstraints tie the offer account to the other accounts Bob passes. has_one checks that the maker, token_mint_a, and token_mint_b Bob passes match the addresses stored in the offer, and the seeds re-derive the offer's address from the maker and the stored ID. close = maker closes the offer account at the end of the instruction and returns its rent to Alice.

From programs/escrow/src/instructions/take_offer.rs:

programs/escrow/src/instructions/take_offer.rs
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump
)]
offer: Account<'info, Offer>,

The handler then does three things.

handle_check_offer_terms() fails with OfferTermsChanged unless the vault holds at least minimum_token_a_out and the offer wants no more than maximum_token_b_in. It runs before any token moves. From programs/escrow/src/instructions/take_offer.rs:

programs/escrow/src/instructions/take_offer.rs
pub fn handle_check_offer_terms(
context: &Context<TakeOfferAccountConstraints>,
minimum_token_a_out: u64,
maximum_token_b_in: u64,
) -> Result<()> {
require!(
context.accounts.vault.amount >= minimum_token_a_out
&& context.accounts.offer.token_b_wanted_amount <= maximum_token_b_in,
EscrowError::OfferTermsChanged
);
Ok(())
}

handle_send_wanted_tokens_to_maker() moves 100,000,000 minor units of TSLAx directly from taker_token_account_b to maker_token_account_b, signed by Bob. The program never holds token B.

handle_withdraw_and_close_vault() moves the vault's USDC to taker_token_account_a, the USDC account Bob already has. If Bob had never held USDC, he would have no USDC account for the vault's tokens to go to. The take still works: take_offer() creates the account in the same transaction, Bob pays its rent, and he keeps the account afterward. Because the offer PDA controls the vault, the program signs this transfer with the offer's seeds: "offer", the maker's address, the ID, and the saved bump.

Here is the signing step, from programs/escrow/src/instructions/take_offer.rs:

programs/escrow/src/instructions/take_offer.rs
let maker_key = context.accounts.maker.key();
let id_bytes = context.accounts.offer.id.to_le_bytes();
let bump = [context.accounts.offer.bump];
let offer_seeds: &[&[u8]] = &[b"offer", maker_key.as_ref(), id_bytes.as_ref(), &bump];

transfer_tokens(
&context.accounts.vault,
&context.accounts.taker_token_account_a,
&context.accounts.vault.amount,
&context.accounts.token_mint_a,
&context.accounts.offer.to_account_info(),
&context.accounts.token_program,
Some(offer_seeds),
)?;

Then close_token_account(), the other shared helper, closes the vault and returns its rent to Alice, and the close = maker constraint closes the offer account and returns its rent to Alice too.

Both sides now have what the other offered: Alice has 750 USDC and 1 TSLAx, and Bob has 1,250 USDC and no TSLAx.

The Maker Cancels an Untaken Offer and Gets the Tokens Back​

If nobody takes the offer, Alice needs a way to get her USDC back, or it would stay in the vault forever. cancel_offer() returns it.

cancel_offer() returns the vault&#39;s 250 USDC to Alice and closes the vault and offer accounts, refunding both rent deposits to her

Only the maker can cancel. The constraints in CancelOfferAccountConstraints re-derive the offer's address from the signer's address, so if Bob signs, the address does not match and Anchor fails with ConstraintSeeds, which test_cancel_offer_rejects_non_maker checks. A has_one = maker check also compares the signer with the offer's stored maker.

From programs/escrow/src/instructions/cancel_offer.rs:

programs/escrow/src/instructions/cancel_offer.rs
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump,
)]
pub offer: Account<'info, Offer>,

cancel_offer() works like the second half of take_offer(). Its handler, handle_cancel_offer(), signs with the offer's seeds to move everything in the vault back to maker_token_account_a, then closes the vault and the offer account, returning both rent deposits to Alice.

An All-or-Nothing Offer Needs No Division or Rounding​

The offer is all-or-nothing, so there are no partial fills and no division, multiplication, or rounding. Token B moves in exactly the amount the offer stores. Token A moves as the vault's whole balance: the offered amount, plus anything someone else has sent to the vault. Every transfer goes through the shared transfer_tokens() helper, which calls the token program's transfer_checked(). That instruction takes the mint's decimals along with the amount, and the token program refuses the transfer if they do not match the mint.

The only other lamport math is rent. Whoever creates an account pays its rent deposit and gets it back when the account closes (see What is Rent on Solana and How to Calculate it).

Alice pays rent for the offer account and the vault, and for her TSLAx account if it did not exist. If her TSLAx account already existed, she gets back every lamport of rent she paid, and the tests check this to the lamport.

Bob pays rent only for his USDC account, if he did not already have one, and he keeps that account afterward. Neither side ends up with the other's rent.

Five Invariants Every Escrow Handler Keeps​

These hold before and after every handler:

  • Alice's offered tokens are always in exactly one place: her token account, the vault, or (after the take) Bob's token account.
  • From the end of make_offer() until a take or cancel, the vault holds at least token_a_offered_amount. Someone could send extra tokens straight to the vault, but whatever it holds goes to the taker on a take, or back to the maker on a cancel. Both handlers empty the vault before closing it.
  • The offer account never changes after handle_save_offer() writes it. No handler updates it.
  • Only the taker's signature can move token B, and only the offer PDA's signature can move the vault's tokens.
  • The maker gets back the rent for the offer account and the vault, whichever way the offer closes.

How the Escrow Program Rejects Attacks​

Each attack below is something a taker or maker could try by passing different accounts or arguments. Each one fails before any token moves.

A Reused Offer ID Fails at Account Creation​

The offer account's address comes from the maker and the ID. While an offer is open, a second make_offer() with the same ID fails when it tries to create the account.

A Switched Offer Fails the Taker's Terms Check​

Once an offer closes, its ID can be used again, so a maker could try to change the terms under a taker. Bob sends take_offer() for the 250 USDC offer. Before it lands, Alice cancels and makes a new offer with the same ID, at the same address, offering 1 USDC for the same 1 TSLAx. Bob's take finds the new offer and fails with OfferTermsChanged, because the vault holds less than his minimum_token_a_out.

test_take_offer_rejects_switched_offer runs this attack. test_take_offer_rejects_switched_offer_wanting_more_token_b runs it with a new offer of 250 USDC for 2 TSLAx, more than Bob's maximum_token_b_in.

A Wrong Account Fails Its Address Check​

The best-known escrow attack is a taker passing the wrong account and hoping the program does not check. Here are three versions, and what stops each:

  • The taker's own account as the maker's payment account. Bob passes his own TSLAx account as maker_token_account_b, so the 1 TSLAx he pays would go back to him. That account must be Alice's associated token account for token B, whose address is derived from Alice's address, the token program, and the TSLAx mint. Bob's account has a different address, so Anchor rejects the transaction.
  • Another offer's vault. Alice has a second offer open, with 500 USDC in its vault. Bob passes the first offer, 250 USDC for 1 TSLAx, together with the second offer's vault, hoping to get 500 USDC for his 1 TSLAx. The vault must be the first offer's associated token account for token A, so the second vault is rejected. Even without that check, the token program would refuse the transfer, because the first offer's PDA is not the second vault's authority.
  • A different mint. Bob creates a worthless token of his own, mints himself 100,000,000 minor units of it, and passes its mint as token_mint_b and his account of it as taker_token_account_b. The has_one = token_mint_b check compares that mint with the token_mint_b stored in the offer, and rejects it.

A constraint blocks each one:

  • Associated token account addresses tie each token account to its owner and mint.
  • has_one checks tie the maker and mints to what the offer stored.
  • The seeds and bump tie the offer account to the program.

For more on how Anchor expresses these checks, see How to Use Account Constraints in Your Solana Anchor Program.

A Cancel by Anyone but the Maker Fails​

cancel_offer() derives the offer's address from the signer, so when Bob signs, the address does not match and Anchor fails with ConstraintSeeds.

A Zero-Amount or Same-Token Offer Fails​

An offer with zero tokens on one side gives someone something for nothing, and trading a token for itself is not a swap.

  • make_offer() rejects an offer of zero tokens on either side with ZeroAmount. test_make_offer_rejects_zero_offered_amount and test_make_offer_rejects_zero_wanted_amount check both sides.
  • An offer of a token for itself never reaches the handler. With one mint, Alice's token A and token B accounts are the same account, and Anchor will not accept the same writable account twice. It fails with ConstraintDuplicateMutableAccount, which test_make_offer_rejects_same_mint checks.

The Tests Cover Each Flow and the Main Attacks​

The tests are in programs/escrow/tests/test_escrow.rs. They are Rust integration tests that run in LiteSVM, a Solana virtual machine that runs inside the test process, and they set up the wallets, mints, and token accounts each test needs. Every piece of program state is created by calling the program's own instruction handlers, not by injecting hand-built account data, so a test cannot pass using an account the program could never create.

  • test_make_offer calls make_offer() and checks that the vault holds the offered amount and that the offer account's ID and maker match what Alice sent.
  • test_take_offer runs a full swap and checks both tokens on both sides: Bob gains 250 USDC and pays his 1 TSLAx, and Alice gains 1 TSLAx and has 750 USDC left. It then checks that the vault and offer accounts are gone, and that Alice's lamports are back to what they were before make_offer(), so no rent went to the wrong person.
  • test_take_offer_rejects_switched_offer and test_take_offer_rejects_switched_offer_wanting_more_token_b cancel and remake the offer after Bob builds his take but before he sends it. Each checks that the take fails with OfferTermsChanged, Bob still has his 1,000 USDC and 1 TSLAx, Alice received no TSLAx, and the new offer's USDC is still in the vault.
  • test_cancel_offer checks that Alice's token A balance goes back to what it was before the offer and that she gets all her rent back.
  • test_cancel_offer_rejects_non_maker has Bob try to cancel Alice's offer and checks that the transaction fails with ConstraintSeeds.

The kani-proofs crate next to anchor-v1 uses the Kani model checker to check the escrow's invariants for every input in each proof's declared ranges, in proofs such as proof_take_offer_conserves_value, proof_take_offer_honors_taker_terms, and proof_cancel_offer_returns_all_to_maker. The crate models the native version's transfer and close arithmetic as pure functions.

Build, Test, and Deploy the Anchor Escrow Program​

Clone the examples repository and move into the Anchor 1 version of the escrow:

git clone https://github.com/quicknode/solana-program-examples
cd solana-program-examples/finance/escrow/anchor-v1

Anchor 1 will not build if the program ID in declare_id! does not match the program keypair in target/deploy/. A fresh clone has no keypair there, so sync the IDs first, then build. The repository's CI runs the same two commands in this order:

anchor keys sync
anchor build

anchor build writes the compiled program to target/deploy/escrow.so, which the tests load. Run the tests in programs/escrow/tests/test_escrow.rs with Cargo:

cargo test

Anchor.toml sets test = "cargo test" under [scripts], so anchor test runs the same tests. LiteSVM runs inside the test process, so you do not need a local validator or a network connection. A passing run lists every test_ function in the file as ok, including the tests that check each attack fails.

Deploy to Devnet Through Quicknode​

Point the Solana CLI at your Quicknode devnet endpoint. Copy the HTTP Provider URL from your endpoint's page in the Quicknode dashboard:

solana config set --url <your Quicknode devnet URL>

Check that your CLI wallet, ~/.config/solana/id.json (the wallet Anchor.toml names), has enough devnet SOL for the program's rent deposit, then deploy, passing Anchor the same URL:

solana balance
anchor deploy --provider.cluster <your Quicknode devnet URL>

anchor deploy deploys target/deploy/escrow.so under the program ID that anchor keys sync wrote into declare_id!, and uploads the program's IDL by default. Your copy of the program has its own program ID, so use that ID wherever a client or indexer needs the escrow program's address.

Use Quicknode with the Escrow Program​

  • List open offers with getProgramAccounts. Every open offer is a 121-byte Offer account owned by the escrow program (an 8-byte discriminator plus the fields), with maker starting at byte 16. A getProgramAccounts call with a dataSize filter of 121 returns every open offer. Add a memcmp filter at offset 16 to get one maker's offers.
  • Keep an offer board up to date with Solana gRPC. make_offer() creates an offer account, and take_offer() and cancel_offer() close it, so an indexer subscribed to the escrow program's accounts sees each offer open and close as it happens. Monitor Solana Programs with Solana gRPC (Rust) shows how to subscribe to one program's activity.
  • Tell makers when their offer is taken. A maker does not sign take_offer(), so the only way they learn their offer was taken is by reading the chain. How to Stream Solana Program Data filters a program's transactions with Streams and sends them to a webhook, which can notify Alice when a transaction closes her offer account.
  • Land a take when other takers want the same offer. Only the first take_offer() for an offer succeeds, because it closes the offer account and later takes find no account at that address. A taker competing for a good offer can set a priority fee using the Priority Fee API. Understand Solana Priority Fees explains how the fee affects which transaction lands first.

Wrapping Up​

You followed one swap from Alice's offer to Bob's take. The vault holds the maker's tokens under the offer PDA's control, the offer account records what the maker wants, and the account constraints and the taker's terms stop the wrong-account and switched-offer attacks the tests try. You also built the program, ran its LiteSVM tests, and deployed your own copy to devnet through Quicknode. A good next change is an expiry slot that lets anyone send an abandoned offer's tokens back to its maker. That adds a new way to close an open offer, so start with a test proving the vault's tokens and both rent deposits still go back to the maker on that path.

Next Steps​

Frequently Asked Questions​

How does an escrow program work on Solana?

The maker calls make_offer, which moves their tokens into a vault controlled by the offer PDA and records what they want in return in an offer account. A taker calls take_offer, which sends the maker the tokens they asked for and gives the taker the vault's tokens, all in one transaction. If nobody takes the offer, the maker calls cancel_offer to get their tokens back.

Who can move the tokens in a Solana escrow vault?

Only the escrow program. The vault is controlled by the offer PDA, which has no private key. Only the program that derived the PDA can sign for it, using its seeds: offer, the maker's address, the offer ID, and the stored bump.

How does the escrow stop a maker from changing the offer before it is taken?

The taker passes minimum_token_a_out and maximum_token_b_in, the terms they saw. Before any token moves, take_offer fails with OfferTermsChanged if the vault holds less than the minimum or the offer wants more than the maximum. So if the maker cancels and remakes the offer on worse terms, the take fails.

Who pays the rent for the escrow accounts, and who gets it back?

The maker pays the rent for the offer account and the vault, plus their own token B account if it does not exist yet. Both take_offer and cancel_offer close the vault and the offer account and return both rent deposits to the maker. The taker pays rent only for their own token A account if they never held that token.

How do I run the escrow program's tests?

Clone quicknode/solana-program-examples, change into finance/escrow/anchor-v1, run anchor keys sync and anchor build, then run cargo test. The tests in programs/escrow/tests/test_escrow.rs run in LiteSVM, so they need no local validator.

Which token programs does the escrow program accept?

Both. The program uses Anchor's token interface types, so it accepts either the Classic Token Program or the Token Extensions Program, as long as every token account in one instruction uses the same token program.