- Escrow is the canonical Solana program pattern — every DEX, order book, and marketplace reuses the same primitives.
- A maker deposits token A into a PDA-owned vault and records how much token B they want. A taker completes the swap atomically, or the maker refunds at any time.
- The program demonstrates PDA-signed CPIs, the Token interface (works with both legacy SPL Token and Token-2022), atomic two-legged transfers, and returning rent when accounts close.
- Uses Anchor 1.0.0 (April 2026). All three canonical reference implementations —
solana-foundation/anchor/tests/escrow,solana-foundation/solana-bootcamp-2026/04-escrow, andsolana-developers/program-examples/tokens/escrow/anchor— converge on the patterns in this guide.
Why learn the escrow pattern
An escrow is a program that holds assets during a trade and releases them only when both sides have performed. Two parties swap tokens without trusting each other or a third party:- The maker opens an offer: “I will give X of token A for Y of token B”.
- The taker fulfills the offer: they send Y of token B to the maker, and the program releases X of token A to them — all in one atomic transaction.
- If no taker appears, the maker refunds and reclaims their tokens.
- AMMs (Raydium, Orca) — a pool is an escrow with two token vaults and a pricing curve.
- Order books (Phoenix, OpenBook) — each resting order is a small escrow.
- NFT marketplaces (Magic Eden, Tensor) — a listing is an escrow of one NFT for N of a fungible token.
- Limit orders (Jupiter, Kamino) — a trigger-based escrow that fills when price crosses a threshold.
This guide assumes you have read Solana: Anchor development and Solana: Program derived addresses and cross-program invocations. Concepts like
#[derive(Accounts)], PDA derivation, and basic CPIs are not re-explained here.Prerequisites
Install the tooling if you have not done so already. This guide targets Anchor 1.0.0.| Tool | Version | Install |
|---|---|---|
| Rust | 1.89.0+ | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| Solana CLI | 3.1.10+ | sh -c "\$(curl -sSfL https://release.anza.xyz/v3.1.10/install)" |
| Anchor CLI | 1.0.0 | cargo install avm --git https://github.com/solana-foundation/anchor --locked && avm install 1.0.0 && avm use 1.0.0 |
| Node.js | 22+ | nodejs.org |
| Yarn | 1.22+ | npm install -g yarn |
The design
Four accounts carry state across the escrow lifecycle:- Escrow (PDA) — holds swap metadata: who made it, which mints, how much token B they want. Derived from
["escrow", maker, seed]so a single maker can run many concurrent escrows with differentseedvalues. - Vault (ATA owned by the Escrow PDA) — holds the maker’s deposited token A. The vault is an associated token account whose authority is the Escrow PDA, so only the program (signing for the PDA) can move tokens out.
- Maker and taker ATAs — standard associated token accounts for mint A and mint B held by the two parties.
take and refund close the vault and the Escrow account, returning the rent lamports to the maker.
Project setup
Scaffold a new Anchor workspace:--template modular layout gives you separate files for instructions, state, and errors — the structure this guide uses. Replace the placeholder content with the files below.
Cargo.toml
Openprograms/anchor-escrow/Cargo.toml and set the dependencies:
Program ID
Generate a program keypair and write its public key intodeclare_id! and Anchor.toml:
State
Createprograms/anchor-escrow/src/state.rs:
take or refund time:
seed— a maker-chosenu64used as the PDA seed. Lets a single maker run many concurrent escrows with different seeds.maker— the wallet that created the offer. Used forhas_onechecks and to return rent.mint_a,mint_b— the two mints involved. Stored sotakeandrefundcan validate the mint accounts the caller passes in.receive— how much of mint B the taker must send to complete the swap.bump— the PDA bump seed, cached atmaketime so we do not re-derive on every CPI.
#[derive(InitSpace)] generates Escrow::INIT_SPACE — the byte size of all fields. The final space for the account is 8 + Escrow::INIT_SPACE (the 8 leading bytes are the Anchor discriminator).
Errors
Createprograms/anchor-escrow/src/error.rs:
#[error_code] block. Keep all variants here.
The make instruction
The maker deposits amount of mint A into a new vault and records how much of mint B they want in return.
Create programs/anchor-escrow/src/instructions/make.rs:
#[instruction(seed: u64)]— surfaces theseedargument so we can reference it in PDA seeds. Anchor validates at deserialization time that[b"escrow", maker, seed]derives to theescrowaccount the caller passed in.initonescrow— allocates and zero-fills the account, paid for by the maker, sized to8 + Escrow::INIT_SPACE.initonvault— creates the associated token account, paid for by the maker, with the Escrow PDA as authority. Anchor calls the Associated Token Program internally; no manual CPI needed.mint::token_program = token_program— asserts the mint is owned by the Token program that the caller passed. Prevents mixing SPL Token and Token-2022 mints under a single program account.associated_token::token_program = token_program— same invariant, applied to ATAs.
populate_escrow— writes the escrow state in oneset_innercall. Usingset_inneris cleaner than five individual field assignments and generates fewer compute units.deposit_tokens— CPIs into the Token program withtransfer_checked, which validates the mint decimals against the transfer amount. The authority is the maker (a real signer on the transaction), so no PDA signing is needed yet.
The token interface
Notice the types:Interface<'info, TokenInterface>, InterfaceAccount<'info, Mint>, InterfaceAccount<'info, TokenAccount>. These come from anchor_spl::token_interface and work with either SPL Token or Token-2022 mints. The concrete Program<'info, Token> and Account<'info, TokenAccount> from anchor_spl::token only work with legacy SPL Token.
For new programs, default to the interface types. The cost is zero — runtime dispatch is resolved by the token_program account the caller passes. The benefit is that your program correctly types Token-2022 accounts and accepts both legacy and Token-2022 mints. Type compatibility is not runtime compatibility, though: extensions that require extra CPI logic — transfer hooks (need remaining_accounts containing the hook program plus its validation accounts), confidential transfers (encrypted amounts), and several others — still need explicit handling. See the Token-2022 extensions section below for the full list and the hazards that apply to an escrow.
The take instruction
The taker atomically:
- Sends
escrow.receiveof mint B from their ATA to the maker’s ATA. - Receives the entire vault balance of mint A.
- Triggers the program to close the vault and the Escrow account, with rent going to the maker.
programs/anchor-escrow/src/instructions/take.rs:
has_one prevents wrong-account substitution
The has_one = maker, has_one = mint_a, has_one = mint_b constraints tell Anchor: “the account named maker passed in the instruction must equal escrow.maker”. Same for the two mints. Without these checks, a malicious taker could pass in any mint_a account and the program would happily process the swap — meaning the taker could drain the vault using a different (valueless) mint account and still pretend the constraints match.
The has_one binding makes the escrow state the authoritative record and the passed-in accounts merely views onto it. The PDA derivation already binds maker to the escrow (the seeds include maker.key()), so the has_one = maker is technically redundant for that account — but it costs almost nothing and makes the security property explicit.
close = maker reclaims rent
When close = maker is set on an #[account(mut, ...)], Anchor:
- Zero-fills the account data after the instruction returns.
- Sets the account’s lamport balance to zero and transfers the lamports to
maker. - Assigns the account to the System program so it cannot be reopened with the same address.
close, the rent lamports would stay locked forever.
Who pays for maker_ata_b
The init_if_needed constraint on maker_ata_b lists payer = taker. The taker funds the maker’s receive ATA if it does not already exist — which makes sense economically, because the taker is the one who wants the swap to complete. If the ATA already exists (maker had previously received mint B), init_if_needed does nothing and the taker pays no extra rent. The associated_token::authority = maker constraint binds the new ATA to the maker as its sole token-account authority, so the taker cannot substitute an account they control.
Signing CPIs as a PDA
The most distinctive pattern in this program is the PDA-signed transfer from the vault back to the taker:CpiContext::new_with_signer tells the runtime: “the authority on this CPI is a PDA, and here are the seeds used to derive it”. The Solana runtime regenerates the PDA from the seeds, verifies it matches the authority account, and grants the CPI the signer privilege as if the PDA had signed the outer transaction.
Two details to notice:
- The seed list must end with the bump byte. The bump is the last seed Anchor pushes through when deriving the canonical PDA.
- The program can sign as a PDA only if the PDA was derived from its own program ID. The runtime rejects PDA signing attempts from the wrong program.
close_account CPI — the vault’s authority is the Escrow PDA, so the program signs as the PDA to close the vault and sweep its rent lamports back to the maker.
Order of operations
The handler runstransfer_to_maker before withdraw_and_close_vault. Vault safety does not depend on this ordering — Solana transactions are atomic end-to-end, so any failed sub-call anywhere in the instruction rolls back every prior state change, including the vault release. The ordering is a code-clarity and cost-efficiency convention: fail fast on the cheap external check (does the taker actually hold enough mint B?) before doing the more expensive PDA-signed vault sweep. If you reversed the order the program would still be safe, just less readable and slightly more wasteful when the taker cannot pay.
The refund instruction
If no taker shows up, the maker can cancel the escrow and reclaim their token A.
Create programs/anchor-escrow/src/instructions/refund.rs:
Refund is Take with half the work:
- Only the maker signs — no taker, so no second token transfer.
- No mint B involved. The vault sweeps back to the maker’s
mint_aATA (re-created withinit_if_neededif the maker closed it aftermake). - The escrow
has_one = makerconstraint ensures only the original maker can refund. Combined with themaker: Signerrequirement, nobody else can trigger this instruction.
has_one = mint_b. refund does not touch mint B, so the constraint is unnecessary.
Instruction module and lib.rs
Create programs/anchor-escrow/src/instructions/mod.rs:
handler symbols — keep the globs anyway, because Anchor’s #[program] macro requires them to generate its client-side account helper modules.
Replace programs/anchor-escrow/src/lib.rs with the entry point:
Building and IDL
target/deploy/anchor_escrow.so and emits the IDL at target/idl/anchor_escrow.json. If the build fails with a declare_id! mismatch, run anchor keys sync and rebuild.
A TypeScript test for the full lifecycle
Createtests/anchor-escrow.ts. This test spins up make → take → refund against the Anchor provider (which points at Surfpool or solana-test-validator depending on your Anchor.toml):
Deploying to devnet and mainnet
Switch to devnet:If
anchor program deploy returns a 503 from the default devnet RPC, add --with-compute-unit-price 1 — the built-in fee-bump routes the upload through a higher-priority lane. This is a known workaround for deployment rate-limits. See Anchor issue #4255.Production considerations
Partial fills
The program as written requires the taker to fulfill the escrow in full —take always transfers the entire vault. Extending to partial fills requires tracking a remaining balance in the Escrow struct and computing a proportional amount of mint B per call. Most production order-book programs (OpenBook, Phoenix) instead prefer many small escrows over a single partially-fillable one, because the accounting is simpler and the program cannot end up holding a dust amount that no rational taker will fill.
Price staleness
An escrow is a fixed offer: the maker locks in a price atmake time and lives with whatever the market does until a taker arrives or they refund. For long-lived offers this is a real risk — a token whose market price doubles will get filled immediately, probably at a loss to the maker. Production protocols mitigate with:
- Expiry timestamps — add an
expires_at: i64field;takefails afterClock::get()?.unix_timestamp > expires_at. - Oracle-pegged takers — require the taker to provide a Pyth or Switchboard price update and enforce a bound. This turns the escrow into a slippage-protected swap.
- Short refund latency — keep the maker able to cancel cheaply and frequently, so they can rebalance.
Front-running and the taker set
The program as written is permissionless: anyone holding mint B and enough SOL for fees may calltake. For simple bilateral OTC swaps this is benign — the maker gets their desired mint B regardless of who the taker is. But if you extend the Escrow state to gate take on a specific taker: Pubkey, the make transaction becomes a front-running target: a searcher watching the mempool can construct their own take against the just-created escrow. Gating by the taker’s pubkey is not sufficient on its own; you need either a commit-reveal flow, a signed authorization blob from the maker, or an off-chain private relay.
Economic attacks on the vault
The Escrow PDA owns the vault, and the vault holds real tokens. Always check:- No
closewithoutmut— you cannot close an account that is not marked mutable. has_oneon every storedPubkey— missinghas_oneon a stored mint or authority is the most common escrow exploit. The PDA derivation only covers seeds, not stored fields.associated_token::authority = escrow— if the vault authority is set to anything other than the Escrow PDA, the program loses control.mint::token_programandassociated_token::token_program— always thread thetoken_programaccount through every constraint. Mixing SPL Token and Token-2022 accounts under a single program instance is a common source of subtle state bugs.
Token-2022 extensions
The program usesTokenInterface, so it accepts Token-2022 mints. Several extensions are quietly hostile to the escrow pattern and require explicit handling before you accept arbitrary user-supplied mints:
- Transfer fees — the mint deducts a fee from each transfer, so the vault ends up holding less than the
amountthe maker thought they deposited.takereadsself.vault.amountand withdraws whatever the vault actually holds — which is fine from a correctness standpoint but changes what the taker receives. Either document the behavior or reject transfer-fee mints atmaketime. - Default account state = Frozen — the mint ships with all new accounts frozen until the freeze authority thaws them. Your
makeinstruction creates the vault ATA successfully, but the very nexttransfer_checkedfails because the vault is frozen. Becausemakeis a single instruction, the failed deposit rolls back the vault initialization atomically — the maker’smint_aATA is untouched and nothing is locked. The failure is still a bad user experience, so reject frozen-by-default mints atmaketime to surface the error cleanly. - Withheld transfer fees on the vault — if the vault accrues withheld fees (from Token-2022 mints that redirect a fraction of every transfer to the mint),
close_accountrejects the close. The program must callharvest_withheld_tokens_to_minton the vault before the close, or the vault cannot be cleaned up and the escrow cannot be finalized. - CPI guard — a token account with CPI guard enabled rejects outgoing transfers made via a cross-program invocation. The vault is set up and manipulated entirely through CPIs, so enabling CPI guard on the vault (directly or through a propagated extension) freezes the escrow mid-flight.
- Close account authority — this Token-2022 extension lets the mint designate a separate “closer” per token account, overriding the account’s own authority. A vault whose mint propagates a non-PDA close authority to new ATAs cannot be closed by the program’s PDA-signed
close_accountCPI; the close returnsMissingRequiredSignatureand the vault becomes unclosable. - Memo transfer on the maker’s ATA — if the maker enables the
MemoTransferextension on theirmaker_ata_b, every incoming transfer must carry a memo instruction. The taker’stransfer_checkedintakehas no memo attached, so the maker’s extension choice bricks the swap. Common on stablecoins and regulated tokens. Note that this is a token-account extension, not a mint extension, so it is invisible to mint TLV inspection — and the maker can enable it any time beforetakeis called. Mitigations: have the client checkRequiredMemoTransfersonmaker_ata_bbefore submittingtake, or document that escrows into memo-required ATAs are out of scope. - Transfer hooks — fire a CPI into a third-party program on every transfer. Your
transfer_checkedcall now passes through arbitrary code and requires extraremaining_accounts(the hook program plus its validation accounts) that our fixedTransferCheckedcontext does not provide. This also opens an indirect-reentrancy surface: the hook program can CPI back into your escrow during the middle oftake, before the vault close has finalized. Whitelist trusted hook programs or reject mints with transfer hooks entirely; an arbitrary-hook-accepting escrow is not safe without dedicated reentrancy guards. - Confidential transfers — amounts are encrypted. The vault balance readout in
takeandrefundwould need an auditor key. - Non-transferable — a non-transferable Token-2022 mint cannot be swapped; your program refuses any transfer and the maker cannot refund. Reject these at
maketime. - Permanent delegate — the mint authority can unilaterally move tokens out of any account, including your vault. Reject mints with this extension if you are holding user funds.
Common gotchas
init_if_needed without constraints masks bugs
init_if_needed in take for taker_ata_a and maker_ata_b is convenient — the caller does not need to pre-create destination ATAs. But if you forget the associated_token::mint and associated_token::authority constraints, Anchor will happily initialize a fresh, empty ATA with whatever seeds the caller provides. Always pair init_if_needed with full mint/authority/token-program constraints so Anchor validates the account whether it exists or not.
Seed-ordering mistakes
The PDA seeds are positional.["escrow", maker, seed] is not the same PDA as ["escrow", seed, maker]. Every reference to the seed list — in the Make.seeds constraint, in signer_seeds for the take and refund CPIs, and on the client side — must list the seeds in the same order. A mismatch manifests as ConstraintSeeds errors at runtime; the compiler cannot catch it.
Re-using the same seed
If a maker callsmake with the same seed twice (without a refund in between), the second call fails with AccountAlreadyInitialized — the PDA is already taken. For UX, either:
- Increment the seed client-side each time (e.g., take the current unix timestamp as the seed).
- Let the client derive the PDA up front and fail fast if the account exists.
close only clears lamports at instruction end
Anchor’s close = destination runs after the handler returns. You cannot read from or transfer from an account in the same instruction after it has been closed — because at instruction-runtime it is still “alive” as far as the handler’s account data is concerned. The vault close and the final token transfer must happen before the handler returns, and the close semantic takes effect during the account-writeback phase of the runtime.
BN serialization of u64 seeds
On the client,seed.toArrayLike(Buffer, "le", 8) produces the 8-byte little-endian encoding that matches seed.to_le_bytes() in Rust. Getting either the endianness or the length wrong (e.g., "be" or length 4) yields a different PDA. Use the same helper in every derivation path.
Mint decimals must be consistent
transfer_checked fails if the decimals you pass do not match the mint account. If you read the mint once and cache decimals on the escrow state to save a mint fetch on take, make sure nothing mutates the mint’s decimals between make and take — Token-2022 extensions that modify mint metadata can surprise you here.
Further reading
- Solana: Anchor development — project setup, constraints, deployment, testing
- Solana: Program derived addresses and cross-program invocations — PDA derivation and CPI mechanics
- Solana: Token Extensions (Token-2022) — extension semantics your escrow may need to handle
- Anchor 1.0.0 release notes — breaking changes from pre-1.0 Anchor
solana-foundation/anchortests/escrow— canonical integration test inside the Anchor monoreposolana-foundation/solana-bootcamp-202604-escrow— maker/take/refund with LiteSVM testssolana-developers/program-examplestokens/escrow— parallel native Rust and Anchor implementationssolana-program/escrow— an alternative production-grade escrow program maintained by the Solana Program Library. It uses a different architecture (TLV-encoded state with pluggable Timelock, Hook, Arbiter, and Block-Token-Extensions modules) and is admin-controlled rather than peer-to-peer; consider it when you need those lifecycle hooks or an arbitrator role. The Anchor maker/taker pattern in this guide is simpler and more suitable when two parties swap directly without governance.