Escrow Contract
An escrow holds tokens until predefined conditions are met. This implementation supports time-locked escrows with optional third-party arbitration.
Program Design
Text
┌─────────────────────────────────────────────────────────────────┐
│ Escrow Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Create Escrow │
│ Depositor → [Tokens] → Escrow Vault │
│ │
│ 2. Complete (Success) │
│ Escrow Vault → [Tokens] → Recipient │
│ │
│ 3. Cancel (Timeout/Dispute) │
│ Escrow Vault → [Tokens] → Depositor │
│ │
└─────────────────────────────────────────────────────────────────┘
Complete Implementation
Rust
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer, Mint};
use anchor_spl::associated_token::AssociatedToken;
declare_id!("Escrow11111111111111111111111111111111111");
#[program]
pub mod escrow {
use super::*;
/// Create a new escrow with deposited tokens
pub fn create_escrow(
ctx: Context<CreateEscrow>,
amount: u64,
unlock_time: i64,
allow_early_release: bool,
) -> Result<()> {
require!(amount > 0, EscrowError::InvalidAmount);
require!(
unlock_time > Clock::get()?.unix_timestamp,
EscrowError::InvalidUnlockTime
);
let escrow = &mut ctx.accounts.escrow;
escrow.depositor = ctx.accounts.depositor.key();
escrow.recipient = ctx.accounts.recipient.key();
escrow.arbiter = ctx.accounts.arbiter.key();
escrow.mint = ctx.accounts.mint.key();
escrow.amount = amount;
escrow.unlock_time = unlock_time;
escrow.allow_early_release = allow_early_release;
escrow.state = EscrowState::Active;
escrow.bump = ctx.bumps.escrow;
escrow.vault_bump = ctx.bumps.vault;
// Transfer tokens to escrow vault
let cpi_accounts = Transfer {
from: ctx.accounts.depositor_token.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.depositor.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
token::transfer(cpi_ctx, amount)?;
emit!(EscrowCreated {
escrow: ctx.accounts.escrow.key(),
depositor: escrow.depositor,
recipient: escrow.recipient,
arbiter: escrow.arbiter,
amount,
unlock_time,
});
Ok(())
}
/// Release tokens to recipient (after unlock time or by arbiter)
pub fn release(ctx: Context<Release>) -> Result<()> {
let escrow = &ctx.accounts.escrow;
require!(escrow.state == EscrowState::Active, EscrowError::EscrowNotActive);
let clock = Clock::get()?;
let is_arbiter = ctx.accounts.authority.key() == escrow.arbiter;
let is_depositor = ctx.accounts.authority.key() == escrow.depositor;
let is_unlocked = clock.unix_timestamp >= escrow.unlock_time;
// Verify release conditions
require!(
is_unlocked || (is_arbiter && escrow.allow_early_release) || is_depositor,
EscrowError::Unauthorized
);
// Transfer tokens to recipient
let seeds = &[
b"escrow",
escrow.depositor.as_ref(),
escrow.recipient.as_ref(),
&escrow.unlock_time.to_le_bytes(),
&[escrow.bump],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.recipient_token.to_account_info(),
authority: ctx.accounts.escrow.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds,
);
token::transfer(cpi_ctx, escrow.amount)?;
// Update state
let escrow = &mut ctx.accounts.escrow;
escrow.state = EscrowState::Released;
emit!(EscrowReleased {
escrow: ctx.accounts.escrow.key(),
recipient: escrow.recipient,
amount: escrow.amount,
released_by: ctx.accounts.authority.key(),
});
Ok(())
}
/// Cancel escrow and return tokens to depositor
pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
let escrow = &ctx.accounts.escrow;
require!(escrow.state == EscrowState::Active, EscrowError::EscrowNotActive);
let is_arbiter = ctx.accounts.authority.key() == escrow.arbiter;
let is_depositor = ctx.accounts.authority.key() == escrow.depositor;
let clock = Clock::get()?;
let is_expired = clock.unix_timestamp >= escrow.unlock_time + GRACE_PERIOD;
// Only arbiter or depositor (after grace period) can cancel
require!(
is_arbiter || (is_depositor && is_expired),
EscrowError::Unauthorized
);
// Return tokens to depositor
let seeds = &[
b"escrow",
escrow.depositor.as_ref(),
escrow.recipient.as_ref(),
&escrow.unlock_time.to_le_bytes(),
&[escrow.bump],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.depositor_token.to_account_info(),
authority: ctx.accounts.escrow.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds,
);
token::transfer(cpi_ctx, escrow.amount)?;
// Update state
let escrow = &mut ctx.accounts.escrow;
escrow.state = EscrowState::Cancelled;
emit!(EscrowCancelled {
escrow: ctx.accounts.escrow.key(),
depositor: escrow.depositor,
amount: escrow.amount,
cancelled_by: ctx.accounts.authority.key(),
});
Ok(())
}
/// Close escrow account and reclaim rent (after release/cancel)
pub fn close_escrow(ctx: Context<CloseEscrow>) -> Result<()> {
let escrow = &ctx.accounts.escrow;
require!(
escrow.state == EscrowState::Released || escrow.state == EscrowState::Cancelled,
EscrowError::EscrowStillActive
);
// Account closed via constraint, rent goes to depositor
Ok(())
}
}
// Constants
const GRACE_PERIOD: i64 = 7 * 24 * 60 * 60; // 7 days
// Accounts
#[derive(Accounts)]
#[instruction(amount: u64, unlock_time: i64)]
pub struct CreateEscrow<'info> {
#[account(
init,
payer = depositor,
space = 8 + Escrow::INIT_SPACE,
seeds = [
b"escrow",
depositor.key().as_ref(),
recipient.key().as_ref(),
&unlock_time.to_le_bytes()
],
bump
)]
pub escrow: Account<'info, Escrow>,
#[account(
init,
payer = depositor,
token::mint = mint,
token::authority = escrow,
seeds = [b"vault", escrow.key().as_ref()],
bump
)]
pub vault: Account<'info, TokenAccount>,
pub mint: Account<'info, Mint>,
#[account(mut)]
pub depositor: Signer<'info>,
#[account(
mut,
token::mint = mint,
token::authority = depositor
)]
pub depositor_token: Account<'info, TokenAccount>,
/// CHECK: Recipient doesn't need to sign for creation
pub recipient: UncheckedAccount<'info>,
/// CHECK: Arbiter doesn't need to sign for creation
pub arbiter: UncheckedAccount<'info>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct Release<'info> {
#[account(
mut,
seeds = [
b"escrow",
escrow.depositor.as_ref(),
escrow.recipient.as_ref(),
&escrow.unlock_time.to_le_bytes()
],
bump = escrow.bump,
has_one = recipient,
)]
pub escrow: Account<'info, Escrow>,
#[account(
mut,
seeds = [b"vault", escrow.key().as_ref()],
bump = escrow.vault_bump,
)]
pub vault: Account<'info, TokenAccount>,
/// CHECK: Verified via has_one
pub recipient: UncheckedAccount<'info>,
#[account(
mut,
token::mint = escrow.mint,
)]
pub recipient_token: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct Cancel<'info> {
#[account(
mut,
seeds = [
b"escrow",
escrow.depositor.as_ref(),
escrow.recipient.as_ref(),
&escrow.unlock_time.to_le_bytes()
],
bump = escrow.bump,
)]
pub escrow: Account<'info, Escrow>,
#[account(
mut,
seeds = [b"vault", escrow.key().as_ref()],
bump = escrow.vault_bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(
mut,
token::mint = escrow.mint,
token::authority = escrow.depositor,
)]
pub depositor_token: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct CloseEscrow<'info> {
#[account(
mut,
close = depositor,
seeds = [
b"escrow",
escrow.depositor.as_ref(),
escrow.recipient.as_ref(),
&escrow.unlock_time.to_le_bytes()
],
bump = escrow.bump,
has_one = depositor,
)]
pub escrow: Account<'info, Escrow>,
#[account(mut)]
pub depositor: Signer<'info>,
}
// State
#[account]
#[derive(InitSpace)]
pub struct Escrow {
pub depositor: Pubkey,
pub recipient: Pubkey,
pub arbiter: Pubkey,
pub mint: Pubkey,
pub amount: u64,
pub unlock_time: i64,
pub allow_early_release: bool,
pub state: EscrowState,
pub bump: u8,
pub vault_bump: u8,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)]
pub enum EscrowState {
Active,
Released,
Cancelled,
}
// Events
#[event]
pub struct EscrowCreated {
pub escrow: Pubkey,
pub depositor: Pubkey,
pub recipient: Pubkey,
pub arbiter: Pubkey,
pub amount: u64,
pub unlock_time: i64,
}
#[event]
pub struct EscrowReleased {
pub escrow: Pubkey,
pub recipient: Pubkey,
pub amount: u64,
pub released_by: Pubkey,
}
#[event]
pub struct EscrowCancelled {
pub escrow: Pubkey,
pub depositor: Pubkey,
pub amount: u64,
pub cancelled_by: Pubkey,
}
// Errors
#[error_code]
pub enum EscrowError {
#[msg("Invalid amount: must be greater than zero")]
InvalidAmount,
#[msg("Invalid unlock time: must be in the future")]
InvalidUnlockTime,
#[msg("Escrow is not active")]
EscrowNotActive,
#[msg("Escrow is still active")]
EscrowStillActive,
#[msg("Unauthorized")]
Unauthorized,
}
TypeScript Client
TypeScript
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Escrow } from "../target/types/escrow";
import * as token from "@solana/spl-token";
export class EscrowClient {
constructor(
private program: Program<Escrow>,
private provider: anchor.AnchorProvider,
) {}
async createEscrow(
recipient: anchor.web3.PublicKey,
arbiter: anchor.web3.PublicKey,
mint: anchor.web3.PublicKey,
amount: anchor.BN,
unlockTime: anchor.BN,
allowEarlyRelease: boolean,
): Promise<anchor.web3.PublicKey> {
const depositor = this.provider.wallet.publicKey;
const [escrowPda] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("escrow"),
depositor.toBuffer(),
recipient.toBuffer(),
unlockTime.toArrayLike(Buffer, "le", 8),
],
this.program.programId,
);
const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), escrowPda.toBuffer()],
this.program.programId,
);
const depositorToken = await token.getAssociatedTokenAddress(
mint,
depositor,
);
await this.program.methods
.createEscrow(amount, unlockTime, allowEarlyRelease)
.accounts({
escrow: escrowPda,
vault: vaultPda,
mint,
depositor,
depositorToken,
recipient,
arbiter,
tokenProgram: token.TOKEN_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.rpc();
return escrowPda;
}
async release(escrow: anchor.web3.PublicKey): Promise<void> {
const escrowData = await this.program.account.escrow.fetch(escrow);
const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), escrow.toBuffer()],
this.program.programId,
);
const recipientToken = await token.getOrCreateAssociatedTokenAccount(
this.provider.connection,
(this.provider.wallet as anchor.Wallet).payer,
escrowData.mint,
escrowData.recipient,
);
await this.program.methods
.release()
.accounts({
escrow,
vault: vaultPda,
recipient: escrowData.recipient,
recipientToken: recipientToken.address,
authority: this.provider.wallet.publicKey,
tokenProgram: token.TOKEN_PROGRAM_ID,
})
.rpc();
}
async cancel(escrow: anchor.web3.PublicKey): Promise<void> {
const escrowData = await this.program.account.escrow.fetch(escrow);
const [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), escrow.toBuffer()],
this.program.programId,
);
const depositorToken = await token.getAssociatedTokenAddress(
escrowData.mint,
escrowData.depositor,
);
await this.program.methods
.cancel()
.accounts({
escrow,
vault: vaultPda,
depositorToken,
authority: this.provider.wallet.publicKey,
tokenProgram: token.TOKEN_PROGRAM_ID,
})
.rpc();
}
async getEscrow(escrow: anchor.web3.PublicKey) {
return this.program.account.escrow.fetch(escrow);
}
async findEscrowsByDepositor(depositor: anchor.web3.PublicKey) {
return this.program.account.escrow.all([
{
memcmp: {
offset: 8, // After discriminator
bytes: depositor.toBase58(),
},
},
]);
}
}
Security Considerations
| Risk | Mitigation |
|---|---|
| Token theft | PDA-owned vault, seed validation |
| Unauthorized release | Time lock + authority checks |
| Stuck funds | Grace period + arbiter cancel |
| Front-running | No vulnerable state transitions |
| Replay attacks | Unique PDA per escrow |
Next: Staking Program - Token staking with rewards.