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

RiskMitigation
Token theftPDA-owned vault, seed validation
Unauthorized releaseTime lock + authority checks
Stuck fundsGrace period + arbiter cancel
Front-runningNo vulnerable state transitions
Replay attacksUnique PDA per escrow

Next: Staking Program - Token staking with rewards.