Версії для прикладів у «Як передати токен через програму» перевірено 2 серпня 2026 року. Стабільна гілка Anchor v1 має релізи 1.0.x і орієнтується на Solana 3.x; Anchor v2 у документації позначений як alpha. Приклади для Anchor 0.29–0.32 залишаються лише відтворюваними прикладами для зафіксованого legacy-середовища: їх не слід переносити в новий проєкт без міграції залежностей і повторного тестування.
Передача токена через програму на Solana відрізняється від прямої переказу з гаманця: ваша програма виступає посередником, який формує та підписує інструкцію для Token Program. Нижче — повний шлях від розуміння необхідних акаунтів до робочого коду на Anchor і перевірки результату на Devnet.
Що потрібно для токен-транзакції
Кожна інструкція передачі токена, навіть викликана з вашої програми, у підсумку обробляється Token Program. Тому Token Program потребує чіткий набір акаунтів.
Mint account, source token account, destination token account
Token Program не оперує «токенами» як абстракцією — вона працює з трьома конкретними акаунтами:
- Mint account — акаунт токена, що визначає його тип, кількість десяткових знаків та поточну пропозицію. У контексті передачі він потрібен Token Program для валідації, але не змінюється.
- Source token account — асоційований акаунт (Associated Token Account) або окремий токен-акаунт відправника. Саме тут зменшується баланс. Цей акаунт має бути ініціалізованим і мати достатній баланс.
- Destination token account — токен-акаунт отримувача. Він також має бути ініціалізованим для того самого mint. Якщо акаунта не існує, передача завершиться помилкою ще до виконання.
Ключовий момент: source і destination — це не гаманці (системні акаунти), а окремі акаунти, прив'язані до гаманців через механізм Associated Token Account.
Token Program у списку акаунтів
Оскільки ваша програма делегує фактичну передачу Token Program, цей акаунт обов'язковий у структурі контексту. Без нього Anchor не зможе побудувати CPI-виклик. Стандартна адреса Token Program на Devnet і Mainnet-Beta однакова: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA.
Повний мінімальний набір акаунтів для передачі:
| Акаунт | Роль | Мутабельність |
|---|---|---|
| authority | Власник source-акаунта (або PDA) | Ні |
| source | Токен-акаунт відправника | Так |
| destination | Токен-акаунт отримувача | Так |
| token_program | Token Program | Ні |
Передача через Anchor
Нижче наведено повний приклад, який можна розгорнути на Devnet. Середовище: Anchor 0.30.1, Solana CLI 1.18.26, Rust 1.77.0, кластер — Devnet.
token::transfer у CpiContext
Anchor надає готову функцію anchor_spl::token::transfer, яка інкапсулює CPI-виклик до Token Program. Вам не потрібно вручну формувати інструкцію — достатньо передати правильний контекст.
Повний код інструкції:
src/lib.rs
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
#[program]
pub mod token_transfer_example {
use super::*;
pub fn transfer_token(ctx: Context<TransferToken>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
token::transfer(CpiContext::new(cpi_program, cpi_accounts), amount)
}
}
Функція token::transfer приймає два аргументи: CpiContext з усіма акаунтами та amount — кількість найменших одиниць токена (з урахуванням decimals). Наприклад, для передачі 1.5 токена з 9 десятковими знаками передайте 1_500_000_000.
Визначення source, destination, authority
Структура акаунтів визначає, які саме акаунти очікує інструкція та які обмеження накладає:
#[derive(Accounts)]
pub struct TransferToken<'info> {
pub authority: Signer<'info>,
#[account(
mut,
constraint = source.owner == authority.key()
)]
pub source: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
Розбір ключових моментів:
- Signer — гарантує, що
authorityпідписав транзакцію. Без цього будь-хто міг би передати токени з чужого акаунта. - constraint = source.owner == authority.key() — явна перевірка, що підписувач є власником source-акаунта. Це захист від передачі акаунта, який належить іншій адресі.
- mut на source та destination — обидва акаунти змінюють баланс, тому Anchor має знати, що їх потрібно серіалізувати назад після виконання.
- Account<'info, TokenAccount> — типізований обгортка, яка автоматично десеріалізує акаунт як токен-акаунт і перевіряє, що власником є Token Program.
Зверніть увагу: у цій структурі немає явної перевірки, що source.mint дорівнює destination.mint. Token Program виконає цю перевірку самостійно, і якщо mint-и різні, транзакція завершиться з помилкою на рівні CPI.
Передача від імені PDA
Частіший у реальних застосунках сценарій: програма утримує токени на власному PDA-акаунті і передає їх за певною логікою. PDA не має приватного ключа, тому для підпису використовується механізм invoke_signed, який в Anchor інкапсульований у CpiContext::new_with_signer.
PDA як authority для токен-акаунта
Щоб PDA міг передати токени, виконайте два попередні кроки:
- Створіть PDA за фіксованими seeds (наприклад,
b"vault"). - Створіть токен-акаунт, де owner — цей PDA, а mint — потрібний токен. Це можна зробити через
anchor_spl::associated_token::initабо вручну черезtoken::initialize_account.
Коли PDA є власником токен-акаунта, лише ваша програма (яка знає seeds) може легітимно підписати передачу з цього акаунта.
invoke_signed для підпису від PDA
У Anchor ви не викликаєте invoke_signed безпосередньо. Замість цього передаєте seeds у CpiContext::new_with_signer:
pub fn transfer_from_vault(ctx: Context<TransferFromVault>, amount: u64) -> Result<()> {
let seeds = &[
b"vault",
&[ctx.accounts.vault_authority.bump],
];
let signer_seeds = &&[seeds[..]];
let cpi_accounts = Transfer {
from: ctx.accounts.vault_token_account.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
token::transfer(
CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds),
amount,
)
}
Структура акаунтів для цього випадку:
#[derive(Accounts)]
pub struct TransferFromVault<'info> {
/// CHECK: PDA, що є власником vault-акаунта
#[account(
seeds = [b"vault"],
bump = vault_authority.bump,
)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
mut,
constraint = vault_token_account.owner == vault_authority.key()
)]
pub vault_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
Важливі нюанси:
- bump зберігається в контексті через
bump = vault_authority.bump. Це дозволяє відновити канонічний bump без повторного пошуку. - UncheckedAccount використовується для PDA, оскільки він не є ні TokenAccount, ні системним акаунтом з відомою структурою. Коментар
/// CHECKє обов'язковим — Anchor вимагає явного підтвердження, що ви розумієте ризики. - Seeds у структурі та в інструкції мають бути ідентичними. Якщо вони розійдуться, підпис не буде валідним, і транзакція впаде з помилкою
SignatureVerificationFailure.
Перевірка результату
Після розгортання та виклику інструкції на Devnet переконайтеся, що передача відбулася фактично. Не покладайтеся лише на відсутність помилок у логах.
Перевірка балансів після transfer
Найпростіший спосіб — CLI-команда для кожного токен-акаунта:
solana token account-info <SOURCE_TOKEN_ACCOUNT> --url devnet
solana token account-info <DESTINATION_TOKEN_ACCOUNT> --url devnet
У полі amount ви маєте побачити відповідне зменшення на source та збільшення на destination.
Альтернативно, у клієнтському коді (TypeScript) можна прочитати баланс безпосередньо:
const balance = await connection.getTokenAccountBalance(destinationTokenAccount);
console.log(balance.amount); // рядок з кількістю найменших одиниць
Функція getTokenAccountBalance повертає об'єкт з полями amount (рядок), decimals та uiAmount. Використовуйте amount для точних порівнянь, оскільки uiAmount може мати помилки округлення з плаваючою комою.
Обробка InsufficientFunds
Якщо на source-акаунті недостатньо токенів, Token Program поверне помилку з логом InsufficientFunds. У Anchor ця помилка пробрасується автоматично, і клієнт отримає відповідний об'єкт помилки.
На стороні клієнта обробіть це так:
try {
await program.methods.transferToken(new BN(amount))
.accounts({ ... })
.rpc();
} catch (err) {
if (err.toString().includes("InsufficientFunds")) {
console.error("Недостатньо токенів на source-акаунті");
} else {
throw err;
}
}
Не намагайтеся перехоплювати помилку всередині програми — Token Program відхилить інструкцію до того, як ваша програма зможе її обробити. Валідацію балансу на стороні клієнта виконуйте до відправки транзакції, щоб зекономити комісії.
Типові помилки
Token account not found
Помилка виникає, коли переданий акаунт не існує, не ініціалізований як токен-акаунт, або належить іншому Token Program (наприклад, Token-2022 замість стандартного Token Program).
Причини та рішення:
- Акаунт не створено — переконайтеся, що для отримувача існує Associated Token Account. Створіть його через
spl-token create-accountабоgetOrCreateAssociatedTokenAccountу клієнтському коді до виклику вашої інструкції. - Передано системний акаунт замість токен-акаунта — перевірте, що ви передаєте адресу токен-акаунта, а не адресу гаманця. Вони різні.
- Token-2022 замість Token Program — якщо mint створено через Token-2022 Program, усі токен-акаунти мають належати саме йому. Передавайте правильний token_program у контексті.
Owner mismatch — PDA не є owner
Помилка owner mismatch або ConstraintSeeds виникає, коли PDA, указаний як authority, не є фактичним власником source-акаунта.
Типові сценарії:
- PDA створено з іншими seeds — якщо seeds у
#[account(seeds = [...])]не збігаються з тими, що використовувалися при створенні токен-акаунта, адреса PDA буде іншою, і перевірка власника не пройде. - Токен-акаунт створено з owner = користувач, а не PDA — при ініціалізації токен-акаунта для vault обов'язково передайте PDA як owner. Якщо ви випадково передали адресу користувача, PDA не зможе підписати передачу.
- Використано неканонічний bump — якщо ви зберегли bump вручну і він не відповідає канонічному (знайденому через
find_program_address), підпис буде недійсним. Завжди використовуйте bump, який повертаєPubkey::find_program_address.
Спосіб діагностики: виведіть адресу PDA з програми (msg!("PDA: {}", ctx.accounts.vault_authority.key())) і порівняйте її з полем owner токен-акаунта через solana token account-info. Вони мають збігатися байт-в-байт.
Наступний крок у вивченні — як викликати іншу програму через CPI, де розглядаються загальні принципи міжпрограмних викликів, та як mint-нути токен через Anchor, де показано створення нових токенів програмно.