Версії для прикладів у «Як реалізувати прості платежі через програму» перевірено 2 серпня 2026 року. Стабільна гілка Anchor v1 має релізи 1.0.x і орієнтується на Solana 3.x; Anchor v2 у документації позначений як alpha. Приклади для Anchor 0.29–0.32 залишаються лише відтворюваними прикладами для зафіксованого legacy-середовища: їх не слід переносити в новий проєкт без міграції залежностей і повторного тестування. Клієнт @anchor-lang/core сумісний із legacy @solana/web3.js v1, а не з v2.
Ця інструкція проведе вас через створення програми на Solana, яка реалізує двоетапний платіж: відправник блокує кошти, а отримувач — забирає їх через окремий виклик. Ви отримаєте робочий код, зрозумієте роль кожного компонента та зможете перевірити результат на Devnet.
Середовище: Anchor 0.30.1, Solana CLI 1.18.26, Rust 1.75+, Node.js 18+, кластер Devnet.
Попередня підготовка: встановлені Anchor, Solana CLI, налаштований гаманець з SOL на Devnet (airdrop через solana airdrop 2).
Очікуваний результат: розгорнута на Devnet програма з трьома інструкціями, успішно пройдені тести створення, завершення та скасування платежу.
Обмеження: це навчальний приклад. Він не призначений для production без додаткових перевірок безпеки, обробки edge-кейсів та індексації даних.
Концепція платежів
Перерахування SOL від відправника до отримувача
На Solana прямий переказ між двома гаманцями виконується через System Program без участі кастомної програми. Однак коли вам потрібен контроль над життєвим циклом платежу — фіксація умов, затримка виконання, можливість скасування — необхідна власна програма, яка виступає посередником.
Двоетапна модель працює так: відправник створює запис про платіж і блокує суму на спеціальному рахунку. Платіж перебуває у статусі очікування. Отримувач підтверджує виконання — програма переказує заблоковані кошти й оновлює статус. Якщо умови не виконані, відправник може скасувати платіж і повернути кошти собі.
Програма як посередник (з комісією або без)
У цьому прикладі програма не стягує комісію — вся сума переходить від відправника до отримувача. Архітектура дозволяє додати комісію: достатньо передати додатковий рахунок власника програми та в інструкції complete_payment виконати два перекази замість одного. Проте комісії, білінг та розподіл доходів — це окрема тема, яка виходить за межі цього матеріалу.
Структура програми
Payment account: sender, recipient, amount, status
Основний стан програми — це акаунт Payment, який зберігає всі дані про окремий платіж. Він створюється як PDA (Program Derived Address — детермінована адреса, що генерується з насінків і програми, контроль над якою належить самій програмі).
Структура рахунку:
- sender (
Pubkey) — адреса відправника, який ініціював платіж - recipient (
Pubkey) — адреса отримувача, якому призначені кошти - amount (
u64) — сума платежу в lamports (1 SOL = 1 000 000 000 lamports) - status (
PaymentStatus) — перелік із трьох значень:Pending,Completed,Cancelled
Розмір рахунка: 8 (дискримінатор Anchor) + 32 (sender) + 32 (recipient) + 8 (amount) + 1 (status) = 81 байт.
Instructions: create_payment, complete_payment, cancel_payment
Програма містить три інструкції, кожна з яких відповідає одному етапу життєвого циклу:
- create_payment — ініціалізація рахунку Payment, збереження метаданих, блокування суми на PDA-рахунку
- complete_payment — переказ заблокованих коштів отримувачу через System Program CPI, оновлення статусу на
Completed - cancel_payment — повернення коштів відправнику, оновлення статусу на
Cancelled
CPI (Cross-Program Invocation) — механізм Solana, який дозволяє одній програмі викликати інструкції іншої програми. У нашому випадку ми викликаємо transfer із System Program.
Instruction: create_payment
Перевірка балансу відправника
У Solana перевірка балансу відбувається неявно: коли інструкція намагається переказати кошти через System Program, і на рахунку недостатньо lamports, транзакція завершується помилкою InsufficientFunds. Тому в самій програмі нам не потрібно вручну перевіряти баланс — System Program зробить це за нас під час виклику transfer.
Однак ми повинні переконатися, що передана сума більша за нуль, щоб уникнути створення порожніх платежів:
require!(amount > 0, PaymentError::ZeroAmount);
Створення payment account через PDA
Рахунок Payment створюється з насінням [b"payment", sender.key().as_ref()]. Це означає, що один відправник може мати лише один активний платіж одночасно. Для production-рішення насіння доповнюють унікальним ідентифікатором (наприклад, UUID або лічильником), але для навчального прикладу цієї схеми достатньо.
Повний код інструкції:
pub fn create_payment(ctx: Context<CreatePayment>, amount: u64) -> Result<()> {
require!(amount > 0, PaymentError::ZeroAmount);
let payment = &mut ctx.accounts.payment;
payment.sender = ctx.accounts.sender.key();
payment.recipient = ctx.accounts.recipient.key();
payment.amount = amount;
payment.status = PaymentStatus::Pending;
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.sender.to_account_info(),
to: ctx.accounts.payment.to_account_info(),
},
);
system_program::transfer(cpi_context, amount)?;
Ok(())
}
Структура акаунтів для цієї інструкції:
#[derive(Accounts)]
#[instruction(amount: u64)]
pub struct CreatePayment<'info> {
#[account(mut)]
pub sender: Signer<'info>,
/// CHECK: recipient can be any account
pub recipient: AccountInfo<'info>,
#[account(
init,
payer = sender,
space = 8 + 32 + 32 + 8 + 1,
seeds = [b"payment", sender.key().as_ref()],
bump
)]
pub payment: Account<'info, Payment>,
pub system_program: Program<'info, System>,
}
Ключовий момент: макрос init створює рахунок і виділяє простір, а payer = sender вказує, хто оплачує rent-exempt мінімум. Сума платежу переказується окремим CPI-викликом після ініціалізації.
Instruction: complete_payment
Перерахування SOL через System Program CPI
Коли отримувач викликає complete_payment, програма переказує всі заблоковані кошти з PDA-рахунку на рахунок отримувача. Оскільки PDA належить програмі, лише вона має право підписувати транзакції від імені цього рахунку — це гарантує, що ніхто сторонній не зможе вивести кошти.
Повний код інструкції:
pub fn complete_payment(ctx: Context<CompletePayment>) -> Result<()> {
let payment = &mut ctx.accounts.payment;
require!(payment.status == PaymentStatus::Pending, PaymentError::NotPending);
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.payment.to_account_info(),
to: ctx.accounts.recipient.to_account_info(),
},
);
system_program::transfer(cpi_context, payment.amount)?;
payment.status = PaymentStatus::Completed;
Ok(())
}
Структура акаунтів:
#[derive(Accounts)]
pub struct CompletePayment<'info> {
/// CHECK: recipient can be any account
#[account(mut)]
pub recipient: AccountInfo<'info>,
#[account(
mut,
seeds = [b"payment", payment.sender.as_ref()],
bump = payment.bump
)]
pub payment: Account<'info, Payment>,
pub system_program: Program<'info, System>,
}
Оновлення статусу payment
Після успішного переказу статус змінюється на Completed. Це критично важливо: саме перевірка статусу в наступних викликах запобігає подвійному завершенню платежу. Якби ми оновили статус до переказу, і переказ би впав, статус був би неконсистентним. Тому оновлення йде після успішного CPI.
Інструкція cancel_payment працює аналогічно, але переказує кошти назад на sender і встановлює статус Cancelled. Додаткове обмеження: викликати скасування може лише відправник, що перевіряється через constraint в макросі:
constraint = payment.sender == sender.key() @ PaymentError::Unauthorized
Тестування платежів
Тест: створення, завершення, скасування
Тести пишуться у файлі tests/simple-payment.ts і використовують фреймворк Anchor з пакетом @coral-xyz/anchor. Перед тестуванням переконайтеся, що програма зібрана (anchor build) і ви підключені до Devnet (solana config set --url devnet).
Базова структура тесту:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SimplePayment } from "../target/types/simple_payment";
import { Keypair, SystemProgram, LAMPORTS_PER_SOL } from "@solana/web3.js";
describe("simple-payment", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.SimplePayment as Program<SimplePayment>;
const sender = provider.wallet as anchor.Wallet;
const recipient = Keypair.generate();
const [paymentPda, paymentBump] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("payment"), sender.publicKey.toBuffer()],
program.programId
);
it("Створює платіж", async () => {
const amount = 0.1 * LAMPORTS_PER_SOL;
await program.methods
.createPayment(new anchor.BN(amount))
.accounts({
sender: sender.publicKey,
recipient: recipient.publicKey,
payment: paymentPda,
systemProgram: SystemProgram.programId,
})
.rpc();
const payment = await program.account.payment.fetch(paymentPda);
assert.equal(payment.amount.toNumber(), amount);
assert.equal(payment.status.pending, true);
});
});
Для тесту завершення платежу додайте окремий блок it, який викликає completePayment і перевіряє, що статус змінився на Completed. Для скасування — аналогічно з cancelPayment.
Перевірка балансів після кожної дії
Щоб переконатися, що кошти дійсно рухаються, перевіряйте баланси через provider.connection.getBalance() у ключові моменти:
- До create_payment: зафіксуйте початковий баланс відправника
- Після create_payment: баланс відправника має зменшитися приблизно на
amount + rent-exempt мінімум + комісія транзакції - Після complete_payment: баланс отримувача має збільшитися на
amount, а баланс PDA-рахунку — зменшитися відповідно - Після cancel_payment: баланс відправника має відновитися (за вирахуванням комісій транзакцій)
Приклад перевірки:
const balanceBefore = await provider.connection.getBalance(recipient.publicKey);
// ... виклик completePayment ...
const balanceAfter = await provider.connection.getBalance(recipient.publicKey);
assert.equal(balanceAfter - balanceBefore, amount);
Не порівнюйте баланси з точністю до лампорта, якщо не враховуєте комісії транзакцій — вони відрізняються від запуску до запуску. Порівнюйте різницю балансів отримувача з очікуваною сумою платежу.
Типові помилки
Insufficient funds для платежу
Помилка InsufficientFunds виникає, коли на рахунку відправника немає достатньо SOL для покриття суми платежу плюс rent-exempt мінімум для нового PDA-рахунку плюс комісія транзакції.
Поширена причина: розробник забуває, що init в Anchor виділяє кошти на rent-exempt мінімум окремо від суми платежу. Тому відправнику потрібно мати: amount + rent_min + tx_fee.
Якщо ви тестуєте на Devnet і отримуєте цю помилку, перевірте баланс гаманця командою solana balance і за потреби виконайте додатковий airdrop.
Подвійне завершення платежу
Без перевірки статусу отримувач міг би викликати complete_payment двічі. Під час першого виклику кошти переказуються, під час другого — PDA-рахунок вже не має достатньо lamports, і транзакція впала б з помилкою System Program. Але покладатися на це некоректно: помилка виникне на рівні CPI, а не на рівні бізнес-логіки, що ускладнює діагностику.
Тому перевірка require!(payment.status == PaymentStatus::Pending, PaymentError::NotPending) обов'язкова. Вона дає зрозумілу помилку з власним error-кодом замість неочікуваного падіння CPI. Те саме стосується cancel_payment — без перевірки статусу відправник міг би спробувати скасувати вже завершений платіж.
Додатковий нюанс: після виклику complete_payment PDA-рахунок не закривається автоматично. Він залишається на ланцюгу зі статусом Completed і мінімальним балансом для rent-exempt. У production-рішенні варто закривати рахунок після завершення та повертати rent відправнику, але це вимагає окремої інструкції та виходить за межі цього навчального прикладу.