Версії для прикладів у «Як побудувати escrow-програму на Anchor» перевірено 2 серпня 2026 року. Стабільна гілка Anchor v1 має релізи 1.0.x і орієнтується на Solana 3.x; Anchor v2 у документації позначений як alpha. Приклади для Anchor 0.29–0.32 залишаються лише відтворюваними прикладами для зафіксованого legacy-середовища: їх не слід переносити в новий проєкт без міграції залежностей і повторного тестування.

Ця інструкція проведе вас через створення повноцінної escrow-програми на Anchor від структури даних до робочого розгортання на Devnet. Ви отримаєте програму, де maker блокує токен A у vault, а taker обмінює на нього токен B — все без довіри між сторонами.

Середовище: Ubuntu 24.04 LTS (або WSL2), Rust 1.75+, Solana CLI 1.18.x, Anchor CLI 0.29.0, Node.js 18+.
Кластер: Devnet.
Передумови: завершений матеріал Як створити простий токен-контракт на Anchor, базове розуміння PDA та CPI на Solana.
Очікуваний результат: скомпільована escrow-програма, розгорнута на Devnet, з трьома інструкціями: create, take, cancel.

Що таке escrow

Посередницький контракт для безпечного обміну

Escrow — це смарт-контракт, що утримує актив однієї сторони як заставу до виконання умов угоди обома учасниками. На Solana escrow-програма не є «чорною скринькою»: вона просто зберігає стан обміну в спеціальному акаунті та виконує перевірки перед кожною транзакцією. Ніхто з учасників не має привілеїв на примусове вилучення коштів поза визначеною логікою.

Сценарій: обмін токена A на токен B

Maker хоче обміняти 100 токенів A на 50 токенів B від конкретного taker. Замість довіряти taker-у або сторонньому сервісу, maker депонує 100 токенів A у vault-акаунт, керований програмою. Коли taker викликає take, програма атомарно переводить 50 токенів B maker-у та 100 токенів A taker-у. Якщо taker не реагує, maker скасовує угоду та повертає свої токени.

Структура escrow

Escrow account: maker, taker, token amounts, vault PDA

Уся інформація про угоду зберігається в одному акаунті, що належить програмі. Його адреса — PDA, похідна від публічного ключа maker та мінта депонованого токена. Це гарантує унікальність: один maker не може створити два активні escrow з одним і тим самим токеном.

Ось повна структура даних:

use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount};

#[account]
    pub struct Escrow {
        pub maker: Pubkey,
        pub taker: Pubkey,
        pub mint_a: Pubkey,
        pub mint_b: Pubkey,
        pub amount_a: u64,
        pub amount_b: u64,
        pub vault: Pubkey,
        pub bump: u8,
        pub state: EscrowState,
    }

#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)]
    pub enum EscrowState {
        Created,
        Completed,
        Cancelled,
    }

Поле vault зберігає адресу PDA токен-акаунта, куди депонуються токени A. Поле bump — це bump vault PDA, необхідний для підпису CPI-транзакцій. Загальний розмір акаунта: 8 (дискримінатор) + 32×5 (Pubkey) + 8×2 (u64) + 1 + 1 = 186 байт. Резервуємо 200 байт з невеликим запасом.

Стан: Created, Completed, Cancelled

Enum EscrowState контролює життєвий цикл. Інструкції take та cancel перевіряють, що стан дорівнює Created, і змінюють його на Completed або Cancelled відповідно. Спроба виконати take або cancel двічі призведе до помилки на рівні обмежень Anchor, ще до входу в логіку функції.

Instruction: create escrow

Депозит токена у vault (PDA)

Ця інструкція виконує дві дії: ініціалізує escrow-акаунт із умовами обміну та переказує токени A від maker до vault. Vault-акаунт створюється як PDA з насінням ["vault", escrow.key()], а його authority встановлюється на цей самий PDA — це дозволяє програмі пізніше підписувати перекази з vault.

#[derive(Accounts)]
#[instruction(amount_a: u64, amount_b: u64)]
    pub struct CreateEscrow<'info> {
        #[account(mut)]
        pub maker: Signer<'info>,

        pub taker: SystemAccount<'info>,

        pub mint_a: Account<'info, Mint>,
        pub mint_b: Account<'info, Mint>,

        #[account(
            mut,
            token::mint = mint_a,
            token::authority = maker,
        )]
        pub maker_ata: Account<'info, TokenAccount>,

        #[account(
            init,
            payer = maker,
            token::mint = mint_a,
            token::authority = vault,
            seeds = [b"vault", escrow.key().as_ref()],
            bump,
        )]
        pub vault: Account<'info, TokenAccount>,

        #[account(
            init,
            payer = maker,
            space = 200,
            seeds = [b"escrow", maker.key().as_ref(), mint_a.key().as_ref()],
            bump,
        )]
        pub escrow: Account<'info, Escrow>,

        pub token_program: Program<'info, Token>,
        pub system_program: Program<'info, System>,
    }

Ключовий момент: обмеження token::authority = vault у блоку init означає, що authority створюваного токен-акаунта встановлюється на адресу самого vault PDA. Це не циклічне посилання, а навмисний патерн — PDA підписує перекази зі свого власного токен-акаунта.

Збереження умов обміну

pub fn create_escrow(ctx: Context<CreateEscrow>, amount_a: u64, amount_b: u64) -> Result<()> {
    let escrow = &mut ctx.accounts.escrow;
    escrow.maker = ctx.accounts.maker.key();
    escrow.taker = ctx.accounts.taker.key();
    escrow.mint_a = ctx.accounts.mint_a.key();
    escrow.mint_b = ctx.accounts.mint_b.key();
    escrow.amount_a = amount_a;
    escrow.amount_b = amount_b;
    escrow.vault = ctx.accounts.vault.key();
    escrow.bump = ctx.bumps.vault;
    escrow.state = EscrowState::Created;

    let cpi_accounts = Transfer {
        from: ctx.accounts.maker_ata.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
        authority: ctx.accounts.maker.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
    token::transfer(cpi_ctx, amount_a)?;

    Ok(())
}

Зверніть увагу: ctx.bumps.vault автоматично заповнюється Anchor під час валідації акаунтів. Ми зберігаємо саме цей bump, оскільки саме vault PDA підписуватиме майбутні перекази. Bump escrow-акаунта нам не потрібен — ми ніколи не підписуємо від його імені.

Instruction: take escrow

Перевірка умов обміну

Перед виконанням переказів програма перевіряє три речі через обмеження Anchor: escrow перебуває у стані Created, викликавець є вказаним taker-ом, та vault належить цьому escrow. Якщо хоча б одна умова не виконується, транзакція відхиляється до входу в тіло функції.

#[derive(Accounts)]
    pub struct TakeEscrow<'info> {
        #[account(mut)]
        pub taker: Signer<'info>,

        #[account(mut)]
        pub maker: SystemAccount<'info>,

        pub mint_a: Account<'info, Mint>,
        pub mint_b: Account<'info, Mint>,

        #[account(
            mut,
            has_one = maker,
            has_one = vault,
            constraint = escrow.taker == taker.key() @ EscrowError::InvalidTaker,
            constraint = escrow.state == EscrowState::Created @ EscrowError::NotCreated,
        )]
        pub escrow: Account<'info, Escrow>,

        #[account(
            mut,
            token::mint = mint_a,
            token::authority = vault,
        )]
        pub vault: Account<'info, TokenAccount>,

        #[account(
            mut,
            token::mint = mint_a,
            token::authority = taker,
        )]
        pub taker_ata_a: Account<'info, TokenAccount>,

        #[account(
            mut,
            token::mint = mint_b,
            token::authority = taker,
        )]
        pub taker_ata_b: Account<'info, TokenAccount>,

        #[account(
            mut,
            token::mint = mint_b,
            token::authority = maker,
        )]
        pub maker_ata_b: Account<'info, TokenAccount>,

        pub token_program: Program<'info, Token>,
    }

Обмеження token::authority = vault на vault-акаунті гарантує, що ми працюємо з правильним vault, а не з випадковим токен-акаунтом. Клієнт повинен заздалегідь створити асоційовані токен-акаунти taker_ata_a та maker_ata_b — інакше транзакція впаде з помилкою відсутнього акаунта.

Передача токенів обом сторонам

pub fn take_escrow(ctx: Context<TakeEscrow>) -> Result<()> {
    let escrow = &ctx.accounts.escrow;
    let cpi_program = ctx.accounts.token_program.to_account_info();

    let cpi_accounts = Transfer {
        from: ctx.accounts.taker_ata_b.to_account_info(),
        to: ctx.accounts.maker_ata_b.to_account_info(),
        authority: ctx.accounts.taker.to_account_info(),
    };
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
    token::transfer(cpi_ctx, escrow.amount_b)?;

    let seeds = &[
        b"vault",
        escrow.key().as_ref(),
        &[escrow.bump],
    ];
    let signer_seeds = &&[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.taker_ata_a.to_account_info(),
        authority: ctx.accounts.vault.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
    token::transfer(cpi_ctx, escrow.amount_a)?;

    ctx.accounts.escrow.state = EscrowState::Completed;
    Ok(())
}

Порядок переказів має значення. Спочатку taker відправляє токени B maker-у, і лише потім програма розблоковує токени A з vault. Якщо перший переказ впаде (недостатньо балансу, неправильний мінт), другий не виконається — атомарність гарантується самою моделлю транзакцій Solana. Після успішного обміну стан змінюється на Completed, що блокує повторні виклики.

Instruction: cancel escrow

Повернення токена maker-у

Скасування доступне лише maker-у і лише поки escrow у стані Created. Програма переказує токени A з vault назад на асоційований токен-акаунт maker, підписуючи переказ vault PDA.

#[derive(Accounts)]
    pub struct CancelEscrow<'info> {
        #[account(mut)]
        pub maker: Signer<'info>,

        pub mint_a: Account<'info, Mint>,

        #[account(
            mut,
            has_one = maker,
            has_one = vault,
            constraint = escrow.state == EscrowState::Created @ EscrowError::NotCreated,
        )]
        pub escrow: Account<'info, Escrow>,

        #[account(
            mut,
            token::mint = mint_a,
            token::authority = vault,
        )]
        pub vault: Account<'info, TokenAccount>,

        #[account(
            mut,
            token::mint = mint_a,
            token::authority = maker,
        )]
        pub maker_ata: Account<'info, TokenAccount>,

        pub token_program: Program<'info, Token>,
    }

Закриття escrow account

pub fn cancel_escrow(ctx: Context<CancelEscrow>) -> Result<()> {
    let escrow = &ctx.accounts.escrow;
    let cpi_program = ctx.accounts.token_program.to_account_info();

    let seeds = &[
        b"vault",
        escrow.key().as_ref(),
        &[escrow.bump],
    ];
    let signer_seeds = &&[&seeds[..]];

    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.maker_ata.to_account_info(),
        authority: ctx.accounts.vault.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
    token::transfer(cpi_ctx, escrow.amount_a)?;

    let close_accounts = CloseAccount {
        account: ctx.accounts.vault.to_account_info(),
        destination: ctx.accounts.maker.to_account_info(),
        authority: ctx.accounts.vault.to_account_info(),
    };
    let close_ctx = CpiContext::new_with_signer(cpi_program, close_accounts, signer_seeds);
    token::close_account(close_ctx)?;

    ctx.accounts.escrow.close(ctx.accounts.maker.to_account_info())?;
    Ok(())
}

Три операції виконуються суворо в цьому порядку. Спочатку токени повертаються maker-у — після закриття vault це було б неможливо. Потім vault-акаунт закривається через CPI до SPL Token програми, а rent повертається maker-у. Нарешті, escrow-акаунт закривається методом close Anchor, який також повертає rent. Після скасування обидва акаунти перестають існувати в мережі.

Для використання CloseAccount додайте імпорт:

use anchor_spl::token::{self, Token, TokenAccount, Transfer, CloseAccount, Mint};

Та визначте помилки:

#[error_code]
    pub enum EscrowError {
        #[msg("Escrow is not in Created state")]
        NotCreated,
        #[msg("Invalid taker")]
        InvalidTaker,
    }

Типові помилки

Vault PDA не має токенів

Ця помилка виникає, коли клієнт передає правильний vault-акаунт за адресою, але баланс токенів на ньому нульовий. Найчастіша причина — клієнт викликав create_escrow, але транзакція депозиту впала через недостатній баланс maker-а, тоді як ініціалізація escrow-акаунта встигла виконатися. У такому разі escrow існує у стані Created з amount_a, але vault порожній.

Як перевірити: після виклику create_escrow прочитайте vault-акаунт через getAccountInfo і переконайтеся, що поле amount дорівнює очікуваному amount_a. У production-рішенні додайте перевірку constraint = vault.amount == escrow.amount_a у take_escrow та cancel_escrow.

Безпечний відкат: якщо escrow створено але vault порожній, викличте cancel_escrow — програма спробує переказати 0 токенів (що успішно завершиться), закриє vault і поверне rent за escrow-акаунт.

Maker спробував take власний escrow

Якщо maker передає свій публічний ключ як taker під час створення escrow, обмеження escrow.taker == taker.key() у take_escrow дозволить maker-у викликати take. Це не є вразливістю (maker обміняє свої ж токени A на свої токени B), але це безглузда операція, яка споживає комісії.

Як запобігти: додайте обмеження на рівні create_escrow:

constraint = maker.key() != taker.key() @ EscrowError::SameMakerTaker,

Це відхилить створення escrow, де maker і taker збігаються, ще до депозиту токенів.

Додаткові помилки, на які варто звернути увагу:

  • Неправильне насіння PDA. Якщо клієнт передає vault з іншим насінням, обмеження has_one = vault спрацює, але підпис з escrow.bump не збігатиметься з реальним bump цього vault. Транзакція впаде з помилкою підпису. Переконайтеся, що клієнт завжди похід vault за формулою ["vault", escrow.key()].
  • ATA не існує. Якщо taker_ata_a або maker_ata_b не створені до виклику take_escrow, транзакція впаде на етапі валідації акаунтів. У production-рішенні розгляньте створення ATA через CPI всередині програми за допомогою associated_token::create_idempotent.
  • Подвійний take або cancel. Після першого успішного take стан змінюється на Completed, і повторний виклик впаде через обмеження escrow.state == EscrowState::Created. Це коректна поведінка — додатковий захист не потрібен.

Збірка та розгортання на Devnet:

solana config set --url devnet
anchor build
anchor deploy --provider.cluster devnet

Після розгортання перевірте, що програма компілюється без попереджень, і викличте create_escrow з тестовими токенами, створеними через solana program deploy або SPL Token CLI. Переконайтеся, що escrow-акаунт створено, vault містить правильний баланс, а стан дорівнює Created. Тільки після цього переходьте до тестування take та cancel.

Наступний крок у навчальному проєкті — Як створити систему голосування на Solana, де ви працюватимете з іншим типом стану та інструкцій.

Джерела