Chapter 2: The Escrow
Congrats! You just shipped your first Anchor program; from here on, things only get smoother as we rinse and repeat the patterns you've just learned, layering in new tricks along the way.
Our next stop is the Escrow; arguably the most common building block on Solana.
Look closely at an AMM, an orderbook, or an NFT marketplace: under the hood, each relies on an escrow pattern. The program acts as a neutral third party, holding assets from two participants until a set of conditions is met, releasing those assets to complete an exchange of value.
Before we dive into the code and introduce a few new Anchor features that we haven't seen during the Vault lesson, we need a rock-solid mental model of accounts.
For the first time, this program will mix several account types, so it's worth taking a moment to see how they work together.
Account on Solana
On Solana, every piece of state lives in an account; picture the ledger as one giant table where each row shares the same base schema:
pub struct Account {
/// lamports in the account
pub lamports: u64,
/// data held in this account
#[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
pub data: Vec<u8>,
/// the program that owns this account and can mutate its lamports or data.
pub owner: Pubkey,
/// `true` if the account is a program; `false` if it merely belongs to one
pub executable: bool,
/// the epoch at which this account will next owe rent (currently deprecated and is set to `0`)
pub rent_epoch: Epoch,
}
All accounts on Solana share the same base layout. What sets them apart is:
- The owner - The program that has exclusive rights to modify the data and lamports of the account.
- The data - Used by the owner program to distinguish between different account types.
When we talk about Token Program Accounts, what we mean is an account where the owner
is the Token Program. Unlike a System Account whose data field is empty, a Token Program Account can be either a Mint or a Token account. We use discriminators to distinguish between them.
Just as the Token Program can own accounts, so can any other program. The Escrow
record we are going to create in this chapter is another example of a Program Account. In this case, it will belong to our own program.
Token Program
The Token Program, part of the Solana Program Library (SPL), is the built-in toolkit for minting and moving any asset that isn't native SOL. It has instructions to create tokens, mint new supply, transfer balances, burn, freeze, and more.
This program owns two key account types:
- Mint Account: stores the metadata for one specific token: supply, decimals, mint authority, freeze authority, and so on.
- Token Account: holds a balance of that mint for a particular owner. Only the owner can reduce the balance (transfer, burn, etc.), but anyone can send tokens to the account, increasing its balance
In our Escrow program we'll create both kinds of accounts and show how the vault PDA safely moves balances between them.
Program Accounts
We've already covered Anchor's core macros in the previous unit; now we'll put one of its most powerful macros to work. In this section we'll use #[account]
to define a custom data structure and store it inside an account that our program owns.
#[account]
In the vault example, our PDA is an empty System Program account with a deterministic address. When a PDA is owned by our program, it unlocks its second superpower: the ability to write arbitrary data to it. Anchor makes this process seamless.
An important feature of the #[account]
macro (which is also relevant to instructions) is the use of discriminators. These are unique identifiers that help Anchor distinguish between different accounts or instructions within your program. Let's explore this concept before looking at the Escrow
struct.
Discriminators
Anchor needs a quick way to tell one account from another. We do this with either a Default Discriminator, or a Custom Discriminator.
Default Discriminators
By default, Anchor creates an 8-byte discriminator; a tiny prefix stored at the start of every serialized struct that is generated using sha256("account:<StructName>")[0..8]
for accounts, or sha256("global:<instruction_name>")[0..8]
for instructions. The seeds of each are in PascalCase and snake_case accordingly. You can see how Anchor calculates default discriminators for both Accounts
and Instructions
below:
When Anchor reads the data of an account or instruction with a default discriminator, it peeks at the first 8 bytes, matches them to a table of known discriminators, and then deserializes into the correct Rust struct, or routes to the correct instruction handler. The hash-based approach gives a high certainty that each type in your program has a unique identifier to tell them apart.
Custom Discriminators
Starting with Anchor v0.31.0
you can now override the auto-generated hash and give an account or instruction its own discriminator directly in the #[account]
or #[instruction]
macro.
#[account(discriminator = 1)] // single-byte
pub struct Escrow { … }
#[instruction(discriminator = [1, 2, 3, 4])] // four-byte array
pub fn make(…) { … }
Note: Discriminators must be unique. If two instructions or accounts share the same prefix, the runtime won't be able to tell them apart and Anchor's IDL generator will throw an error if it spots a collision. Additionally, using [1]
as discriminator prevents you from using [1, 2, …]
as these discriminators also start with 1
. Accounts also cannot use [0]
as a discriminator, as there is no way to differentiate between a newly initialized account with null data and a zero byte discriminator.
Now that we've understood accounts and discriminators, let's apply this knowledge to create an Escrow program. For our Escrow
program we'll tag both our Accounts
and Instructions
with a custom single-byte discriminator.
Our accounts will include:
Escrow
account: 0x01
Our instructions will include:
make
instruction: 0x00take
instruction: 0x01refund
instruction:` 0x02
With the discriminators sorted, let's declare the data that we want to live inside the account:
use anchor_lang::prelude::*;
#[derive(InitSpace)]
#[account(discriminator = 1)]
pub struct Escrow {
pub seed: u64,
pub maker: Pubkey,
pub mint_a: Pubkey,
pub mint_b: Pubkey,
pub receive: u64,
pub bump: u8,
}
Creating a struct is very easy, The only constraint that we have with Anchor is that the account's data is capped at 10,240 bytes (10 KiB). If we need more, we're going to need to use zero_copy
and chunked writes; an advanced topic for another day.
Anchor also needs to know the byte size of the struct to allocate the correct amount of space on-chain. The InitSpace
derive macro takes care of this. When you derive InitSpace
, it implements the Space
trait for your struct, calculating and storing its size in an INIT_SPACE
constant.
The total space in bytes needed for the account is the sum of INIT_SPACE
(size of all the fields combined) and the discriminator size (DISCRIMINATOR.len()
).
Solana accounts require a rent deposit in lamports, which depends on the size of the account. Knowing the size helps us calculate how many lamports we need to deposit to make the account open.
Here's how we're going to initiate the account in our Account
struct:
#[account(
init,
payer = maker,
space = Escrow::INIT_SPACE + Escrow::DISCRIMINATOR.len(),
seeds = [b"escrow", maker.key().as_ref(), seed.to_le_bytes().as_ref()],
bump,
)]
pub escrow: Account<'info, Escrow>,
Here are some of the fields used in the #[account]
macro, beyond the seeds
and bump
fields that we have already covered, and what they do:
- init: tells Anchor to create the account.
- payer: which signer funds the rent (here, the maker).
- space: how many bytes to allocate. This is where the rent calculation magic happens as well.
Once we create the account, we can access its data like this:
#[account(
seeds = [b"escrow", maker.key().as_ref(), escrow.seed.to_le_bytes().as_ref()],
bump = escrow.bump,
)]
pub escrow: Account<'info, Escrow>
This structure is very similar to the PDAs we saw in previous lessons, with the main difference being that we're telling Anchor that the account should match the Escrow
struct and should be deserialized for use accordingly.
Lastly, since we deposited lamports as rent in the account, we should close the account and retrieve the lamports once this account is not needed anymore.
#[account(
mut,
close = maker,
seeds = [b"escrow", maker.key().as_ref(), escrow.seed.to_le_bytes().as_ref()],
bump = escrow.bump,
)]
pub escrow: Account<'info, Escrow>,
close
transfers whatever lamports remain in the account back to maker, then zeros out the data.
Anchor SPL
Natively, the core Anchor crate only bundles CPI helpers for the System Program. If you want the same hand-holding for SPL tokens you pull in the anchor_spl
crate.
anchor_spl
adds:
- Helper builders for every instruction in both the SPL Token and Token-2022 programs.
- Type wrappers that make it painless to verify and deserialize Mint and Token accounts.
Let's look at how the Mint
and Token
accounts are structured:
#[account(
mint::authority = <target_account>,
mint::decimals = <expr>,
mint::token_program = <target_account>
)]
pub mint: Account<'info, Mint>,
#[account(
mut,
associated_token::mint = <target_account>,
associated_token::authority = <target_account>,
associated_token::token_program = <target_account>
)]
pub maker_ata_a: Account<'info, TokenAccount>,
Account<'info, Mint>
and Account<'info, TokenAccount>
tell Anchor to:
- confirm the account really is a Mint or Token account;
- deserialize its data so you can read fields directly;
- enforce any extra constraints you specify (
authority
,decimals
,mint
,token_program
, etc.).
These token-related accounts follow the same init
pattern used earlier. Since Anchor knows their fixed byte size, we don't need to specify a space
value, only the payer funding the account.
Anchor also offers init_if_needed
macro: it checks whether the token account already exists and, if not, creates it. That shortcut isn't safe for every account type, but it's perfectly suited to token accounts, so we'll rely on it here.
As mentioned, anchor_spl
creates helpers for both the Token and Token2022 programs, with the latter introducing Token Extensions. The main challenge is that even though these accounts achieve similar goals and have comparable structures, they can't be deserialized and checked the same way since they're owned by two different programs.
We could create more "advanced" logic to handle these different account types, but fortunately Anchor supports this scenario through InterfaceAccounts:
use anchor_spl::token_interface::{Mint, TokenAccount};
#[account(
mint::authority = <target_account>,
mint::decimals = <expr>
mint::token_program = <target_account>
)]
pub mint: InterfaceAccounts<'info, Mint>,
#[account(
mut,
associated_token::mint = <target_account>,
associated_token::authority = <target_account>,
associated_token::token_program = <target_account>
)]
pub maker_ata_a: InterfaceAccounts<'info, TokenAccount>,
The key difference here is that we're using InterfaceAccounts
instead of Account
. This allows our program to work with both Token and Token2022 accounts without needing to handle the differences in their deserialization logic. The interface provides a common way to interact with both types of accounts while maintaining type safety and proper validation.
This approach is particularly useful when you want your program to be compatible with both token standards, as it eliminates the need to write separate logic for each program. The interface handles all the complexity of dealing with different account structures behind the scenes.
Account constraints
So far we've leaned on built-in arguments and custom error enums to catch mistakes, but Anchor lets you bolt on extra checks right in the account macro.
Here are some examples:
#[account(
seeds = [b"escrow", maker.key().as_ref(), escrow.seed.to_le_bytes().as_ref()],
bump = escrow.bump,
has_one = maker @ EscrowError::InvalidMaker,
)]
pub escrow: Account<'info, Escrow>,
The has_one
constraint tells Anchor: "this field on the account struct must equal this account's key." If the keys differ, Anchor throws EscrowError::InvalidMaker
before your handler runs.
#[account(
seeds = [b"escrow", maker.key().as_ref(), escrow.seed.to_le_bytes().as_ref()],
bump = escrow.bump,
constraint = maker.key() == escrow.maker @ EscrowError::InvalidMaker,
)]
pub escrow: Account<'info, Escrow>,
When the has_one
constraint doesn't fit, such as when the field name differs, you can write a custom constraint with any boolean expression. If the expression evaluates to false, Anchor raises the specified error.
These inline constraints keep validation next to the account definition and save you from scattering require!()
calls throughout your instruction logic.