Token Escrow Tutorial
This tutorial walks through the examples/escrow_program step by step. The program implements a trustless token exchange between two parties using a PDA-owned vault account.
How the escrow works
- Make – the maker deposits token A into a PDA-owned vault and records the desired amount of token B in an escrow state account.
- Take – the taker sends token B to the maker, the vault releases token A to the taker, and the escrow is closed with rent returned to the maker.
No party needs to trust the other. The program enforces the exchange atomically: either both transfers happen or neither does.
Project setup
The escrow program enables the token feature for SPL token helpers:
[dependencies]
pina = { workspace = true, features = ["logs", "token", "derive"] }
[dev-dependencies]
mollusk-svm = { workspace = true }
The token feature unlocks CPI wrappers for SPL Token, Token-2022, and Associated Token Account operations.
Program ID and discriminators
#![allow(unused)]
fn main() {
use pina::*;
declare_id!("4ibrEMW5F6hKnkW4jVedswYv6H6VtwPN6ar6dvXDN1nT");
#[discriminator]
pub enum EscrowInstruction {
Make = 1,
Take = 2,
}
#[discriminator]
pub enum EscrowAccount {
EscrowState = 1,
}
}
Two discriminator enums serve different purposes. EscrowInstruction tags instruction data so the entrypoint can dispatch to the right handler. EscrowAccount tags on-chain account data so the program can verify it is reading the correct account type.
Custom errors
The #[error] macro converts an enum into a set of ProgramError::Custom error codes:
#![allow(unused)]
fn main() {
#[error]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscrowError {
OfferKeyMismatch = 0,
TokenAccountMismatch = 1,
EmptyOffer = 2,
}
}
Each variant’s numeric value becomes the custom error code. You can return these from any processor via Err(EscrowError::OfferKeyMismatch.into()).
Wire values are part of the program ABI: add a new variant with the next value instead of reusing an existing code, and give every variant a doc comment, because the generated IDL uses it as the message that clients and explorers display. Make returns EmptyOffer before any CPI when either side of the offer is zero, so a fat-fingered amount cannot hand token A over for nothing.
Escrow state account
The #[account] macro defines the on-chain state layout:
#![allow(unused)]
fn main() {
#[account(discriminator = EscrowAccount)]
pub struct EscrowState {
pub maker: Address,
pub mint_a: Address,
pub mint_b: Address,
pub amount_a: u64,
pub amount_b: u64,
pub seed: u64,
pub bump: u8,
}
}
The macro auto-injects a discriminator field as the first byte, set to EscrowAccount::EscrowState, and derives PinaPod’s native-schema machinery. EscrowStateZc is the generated storage view. Its integer fields are alignment-one little-endian wrappers, and account loaders return that validated view without copying.
The seed and bump fields are stored so that PDA derivation can be verified on subsequent instructions without re-computing it.
Instruction data
#![allow(unused)]
fn main() {
#[instruction(discriminator = EscrowInstruction::Make)]
pub struct MakeInstruction {
pub seed: u64,
pub amount_a: u64,
pub amount_b: u64,
pub bump: u8,
}
#[instruction(discriminator = EscrowInstruction::Take)]
pub struct TakeInstruction {}
}
MakeInstruction carries all the parameters needed to set up the escrow. TakeInstruction has no payload beyond its discriminator byte – the taker just needs to invoke the instruction with the right accounts.
PDA seeds
The escrow PDA is derived from a prefix, the maker’s address, and a user-chosen seed. Pina’s #[pda] attribute declares the typed seeds once on the account struct:
#![allow(unused)]
fn main() {
#[account(discriminator = EscrowAccount)]
#[pda(seeds = [SEED_PREFIX, maker: Address, seed: u64], bump = bump)]
pub struct EscrowState {
pub maker: Address,
// ...
pub seed: PodU64,
pub bump: u8,
}
/// Seed prefix for escrow PDAs.
const SEED_PREFIX: &[u8] = b"escrow";
}
The attribute generates:
EscrowState::seeds(maker, seed)– a typed seeds struct withas_slices()(without bump) andwith_bump(bump)(with bump).EscrowState::try_find_pda(...)/find_pda(...)– canonical PDA derivation.EscrowState::assert_seeds(account, ...)– verifies an existing account against the storedbumpfield, avoiding a canonical bump search on-chain.
Supported seed types are Address, u8, u16, u32, u64, [u8; N], and const &[u8] references.
Make: accounts and validation
#![allow(unused)]
fn main() {
#[derive(Accounts, Debug)]
pub struct MakeAccounts<'a> {
pub maker: &'a AccountView,
pub mint_a: &'a AccountView,
pub mint_b: &'a AccountView,
pub maker_ata_a: &'a AccountView,
pub escrow: &'a mut AccountView,
pub vault: &'a AccountView,
pub system_program: &'a AccountView,
pub token_program: &'a AccountView,
}
}
Accounts are listed in the order clients must provide them. The #[derive(Accounts)] macro maps each positional AccountView from the mutable entrypoint slice into its named field.
The processor validates every account before performing any mutation:
#![allow(unused)]
fn main() {
const SPL_PROGRAM_IDS: [Address; 2] = [token::ID, token_2022::ID];
impl<'a> ProcessAccountInfos<'a> for MakeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let args = MakeInstruction::try_from_bytes(data)?;
let maker_address = *self.maker.address();
let escrow_seeds = EscrowState::seeds(&maker_address, u64::from(args.seed));
// Validate all accounts before mutating anything.
self.token_program.assert_addresses(&SPL_PROGRAM_IDS)?;
let token_program = *self.token_program.address();
self.maker.assert_signer()?;
drop(
self.mint_a
.as_token_mint_for_program(&token_program)?
.assert_no_extensions()?,
);
drop(
self.mint_b
.as_token_mint_for_program(&token_program)?
.assert_no_extensions()?,
);
drop(self.maker_ata_a.as_associated_token_account(
self.maker.address(),
self.mint_a.address(),
&token_program,
)?);
self.escrow.assert_empty()?.assert_writable()?;
self.vault.assert_empty()?.assert_writable()?;
// ... create accounts and transfer tokens ...
Ok(())
}
}
}
Key validation patterns:
assert_addresseschecks that the token program is either SPL Token or Token-2022.assert_signerensures the maker signed the transaction.as_token_mint_for_programaccepts only a canonical token program, checks the mint owner, and parses its concrete layout.as_associated_token_accountchecks the canonical token-program owner, derived address, stored current authority, and stored mint. The escrow separately owns any required state, delegate, close-authority, and Token-2022 extension policy.assert_emptyandassert_writablevalidate the initialization state and runtime permissions. The creation builder validates the canonical PDA itself.assert_emptyandassert_writablecover the vault, and theCreateCPI that follows binds the address: the associated token program derives the same[wallet, token_program, mint]seeds and rejects a mismatch withInvalidSeedsbefore it creates anything. Keep an explicitassert_associated_token_addressonly on validation-only paths that never reach an ATA instruction.
Validation methods return the same reference type they receive, so mutable chains stay mutable all the way to as_account_mut().
Make: creating the escrow
After validation the processor creates the PDA account and initializes its state:
#![allow(unused)]
fn main() {
CreateProgramAccountWithBump {
account: self.escrow,
payer: self.maker,
owner: &ID,
seeds: &escrow_seeds.as_slices(),
bump: args.bump,
}
.invoke_with::<EscrowState>(|escrow| {
escrow.maker = *self.maker.address();
escrow.mint_a = *self.mint_a.address();
escrow.mint_b = *self.mint_b.address();
escrow.amount_a.set(0);
escrow.amount_b = args.amount_b;
escrow.seed = args.seed;
escrow.bump = args.bump;
Ok(())
})?;
}
CreateProgramAccountWithBump::invoke_with first derives the canonical PDA and rejects args.bump if it differs. It then issues a CreateAccount CPI, allocates EscrowState::SIZE bytes, writes the discriminator, runs the initializer, and validates the completed state. Do not add separate assert_canonical_bump or assert_seeds_with_bump calls before this builder. Plain invoke is for account types whose fields may all remain zero after the discriminator is written.
Make: token operations via CPI
With the escrow account created, the program creates the vault ATA and transfers tokens:
#![allow(unused)]
fn main() {
associated_token_account::instructions::Create {
account: self.vault,
funding_account: self.maker,
wallet: self.escrow,
mint: self.mint_a,
system_program: self.system_program,
token_program: self.token_program,
}
.invoke()?;
let token_program = *self.token_program.address();
let decimals = self
.mint_a
.as_token_mint_for_program(&token_program)?
.decimals();
drop(
self.mint_b
.as_token_mint_for_program(&token_program)?,
);
drop(self.maker_ata_a.as_associated_token_account(
self.maker.address(),
self.mint_a.address(),
&token_program,
)?);
token::instructions::TransferChecked::new(
self.maker_ata_a,
self.mint_a,
self.vault,
self.maker,
args.amount_a.into(),
decimals,
)
.invoke_with_program(&token_program)?;
}
Pina’s token feature provides typed CPI instruction builders. Construct the instruction with new() and invoke it through the validated token program account. The shared loader validates either the fixed SPL Token layout or a complete Token-2022 extension layout according to the selected program.
The vault is an ATA owned by the escrow PDA. This means only the escrow program (signing with the PDA seeds) can later release the tokens.
Take: completing the exchange
The Take instruction performs two token transfers and cleans up:
- Transfer token B from taker to maker (authorized by the taker’s signature).
- Transfer token A from vault to taker (authorized by the escrow PDA via
invoke_signed). - Close the vault account and return rent to the maker.
- Zero and close the escrow state account.
#![allow(unused)]
fn main() {
impl<'a> ProcessAccountInfos<'a> for TakeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let _ = TakeInstruction::try_from_bytes(data)?;
// ... validation omitted for brevity ...
let (maker, seed, bump, amount_b) = {
let escrow = self.escrow.as_account::<EscrowState>(&ID)?;
(escrow.maker, escrow.seed, escrow.bump, escrow.amount_b)
};
let token_program = *self.token_program.address();
let decimals_a = self
.mint_a
.as_token_mint_for_program(&token_program)?
.decimals();
let decimals_b = self
.mint_b
.as_token_mint_for_program(&token_program)?
.decimals();
// Verify the escrow is the PDA for the maker and seed, using the
// stored bump field (avoids re-deriving the canonical bump on-chain).
EscrowState::assert_seeds(self.escrow, &maker, u64::from(seed), &ID)?;
// Transfer token B: taker -> maker
token::instructions::TransferChecked::new(
self.taker_ata_b,
self.mint_b,
self.maker_ata_b,
self.taker,
u64::from(amount_b),
decimals_b,
)
.invoke_with_program(&token_program)?;
// Transfer token A: vault -> taker (PDA-signed)
let escrow_seeds = EscrowState::seeds(&maker, u64::from(seed)).with_bump(bump);
let escrow_signer = escrow_seeds.to_signer();
let signers = [escrow_signer.as_signer()];
let vault_amount = self
.vault
.as_associated_token_account(
self.escrow.address(),
self.mint_a.address(),
&token_program,
)?
.amount();
token::instructions::TransferChecked::new(
self.vault,
self.mint_a,
self.taker_ata_a,
self.escrow,
vault_amount,
decimals_a,
)
.invoke_signed_with_program(&signers, &token_program)?;
// Close vault and escrow
token::instructions::CloseAccount::new(self.vault, self.maker, self.escrow)
.invoke_signed_with_program(&signers, &token_program)?;
// Clear the raw backing bytes while closing.
self.escrow.close_account_zeroed(&ID, self.maker)
}
}
}
The PDA signer is constructed from the same seeds used to derive the escrow address. invoke_signed_with_program passes these seeds to the selected SPL Token program so the runtime can verify the PDA signature.
close_account_zeroed verifies that ID owns the escrow, zeroes its data, transfers the remaining lamports to the maker, and closes the account. The non-zeroing close_with_recipient fails the require_zeroed_before_close lint unless the whole data buffer is cleared first with self.escrow.try_borrow_mut()?.fill(0);.
Entrypoint
The entrypoint ties everything together with a simple match:
#![allow(unused)]
fn main() {
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use pina::*;
use super::*;
nostd_entrypoint!(process_instruction);
#[inline(always)]
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let instruction: EscrowInstruction = parse_instruction(program_id, &ID, data)?;
match instruction {
EscrowInstruction::Make => {
MakeAccounts::try_from((program_id, accounts))?.process(data)
}
EscrowInstruction::Take => {
TakeAccounts::try_from((program_id, accounts))?.process(data)
}
}
}
}
}
Testing
Unit tests verify discriminator stability, seed construction, and program ID validation:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instruction_discriminators_are_stable() {
assert_eq!(EscrowInstruction::Make as u8, 1);
assert_eq!(EscrowInstruction::Take as u8, 2);
}
#[test]
fn seeds_build_expected_seed_arrays() {
let maker = Address::new_from_array([3u8; 32]);
let seed = PodU64::from(42);
let bump = 7u8;
let seeds = EscrowState::seeds(&maker, u64::from(seed));
assert_eq!(seeds.as_slices().len(), 3);
let seeds_with_bump = seeds.with_bump(bump);
assert_eq!(seeds_with_bump.as_slices().len(), 4);
}
#[test]
fn parse_instruction_rejects_program_id_mismatch() {
let wrong_program_id: Address = [9u8; 32].into();
let data = [EscrowInstruction::Make as u8];
let result = parse_instruction::<EscrowInstruction>(&wrong_program_id, &ID, &data);
assert!(matches!(result, Err(ProgramError::IncorrectProgramId)));
}
}
}
For full integration tests, use mollusk-svm to simulate transactions with real token accounts and verify the entire Make/Take flow end-to-end.
Key takeaways
- PDA vaults hold tokens on behalf of the program. Only the program can sign for them using
invoke_signed. - Validation-first – check every account before performing any mutation.
- Typed CPI builders in the
tokenfeature eliminate raw account-meta boilerplate. - Zero-copy state with
#[account]avoids serialization overhead. - Feature-gated entrypoints let the same crate serve as both an on-chain program and a testable library.