Версії для прикладів у «Як побудувати 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, де ви працюватимете з іншим типом стану та інструкцій.