SPL Token — це програма на Solana, яка реалізує стандарт fungible-токенів. Вона визначає правила створення токенів, обліку балансів та передачі між користувачами. Якщо ви плануєте працювати з токенами на Solana — від простого NFT-маркетплейсу до DeFi-протоколу — вам потрібно розуміти, як саме ця програма працює на рівні акаунтів та інструкцій.
У цьому матеріалі ви отримаєте ментальну модель SPL Token, побачите структуру акаунтів, виконаєте базові операції на Devnet та зрозумієте, як ваші власні програми взаємодіють із Token Program через CPI.
Середовище для прикладів: Node.js 20.x LTS, @solana/web3.js ^1.95.0, @solana/spl-token ^0.1.0, кластер — Devnet. Перед запуском прикладів переконайтеся, що ваш гаманець має тестові SOL (можете отримати їх через faucet). Актуальні патч-версії пакетів перевірте на npm.
Огляд SPL Token Program
Стандарт токенів у Solana
SPL Token Program — це одна з базових програм у Solana, розгорнута за адресою TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. Вона не зберігає жодного стану всередині себе. Натомість вона читає та модифікує зовнішні акаунти, які відповідають її формату. Це ключова відмінність архітектури Solana від Ethereum.
Token Program виконує чітко визначений набір інструкцій: створення міну, створення токен-акаунту, мінтинг, передача, спалювання, делегування та заморожування. Кожна інструкція очікує конкретний набір акаунтів у конкретному порядку. Програма перевіряє власника кожного акаунту, права підпису та внутрішні інваріанти (наприклад, що баланс не стане від'ємним).
Існує також Token-2022 Program (Token Extensions) — наступне покоління стандарту з розширеними можливостями. Ця тема розкрита в окремому матеріалі, тут ми фокусуємося виключно на класичному Token Program.
Відмінність від ERC-20
Найчастіше питання від розробників, які приходять з Ethereum: чому не можна просто викликати метод контракту, як у ERC-20? Відмінність фундаментальна.
| Аспект | ERC-20 (Ethereum) | SPL Token (Solana) |
|---|---|---|
| Де живе стан | Всередині одного смарт-контракту (mapping address → balance) | У окремих акаунтах (mint-акаунт + токен-акаунти) |
| Скільки контрактів на один токен | Один розгортання = один токен | Один Token Program обслуговує всі токени |
| Як дізнатися баланс | Виклик balanceOf(address) контракту |
Читання токен-акаунта за його адресою |
| Вартість створення токена | Gas за розгортання контракту | Rent-exempt депозит для mint-акаунта |
| Вартість створення балансу | Безкоштовно (стан уже в контракті) | Rent-exempt депозит для токен-акаунта |
Головний висновок: у Solana кожен баланс користувача для конкретного токена — це окремий акаунт на ланцюжку, а не запис у стані контракту. Це означає, що створення нового токен-акаунта коштує реальні SOL (rent-exempt депозит), але читання балансу не вимагає виклику програми — достатньо просто прочитати акаунт.
Типи токен-акаунтів
Усі операції з токенами в SPL Token Program працюють із двома типами акаунтів. Розуміння їхньої структури — це основа, без якої неможливо дебажити транзакції.
Mint account — створення токена
Mint-акаунт — це «ідентичність» токена. Він не зберігає балансів, але містить метадані, які визначають токен як цілісність.
Що зберігає mint-акаунт:
- mint authority — публічний ключ, який має право викликати інструкцію
MintTo(створювати нові одиниці токена). Може бутиnull, якщо токен має фіксовану пропозицію. - freeze authority — публічний ключ, який має право заморожувати токен-акаунти (блокувати передачі). Може бути
null. - decimals — кількість знаків після коми (як у ERC-20). Наприклад, 9 означає, що 1 токен = 1 000 000 000 найменших одиниць.
- supply — загальна кількість випущених токенів у найменших одиницях.
- is_initialized — прапорець, що акаунт ініціалізовано.
Приклад створення mint-акаунта на Devnet:
Очікуваний результат: новий mint-акаунт з 9 знаками після коми, де мінт-авторитетом є ваш гаманець. У консолі буде надруковано адресу нового міну.
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import { createMint, getMint, TOKEN_PROGRAM_ID } from "@solana/spl-token";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const payer = Keypair.generate(); // на практиці — ваш гаманець
const mintAuthority = payer.publicKey;
const freezeAuthority = null; // без права заморожування
const decimals = 9;
const mintAddress = await createMint(
connection,
payer,
mintAuthority,
freezeAuthority,
decimals
);
// Перевірка: читаємо стан створеного міну
const mintInfo = await getMint(connection, mintAddress);
console.log("Mint address:", mintAddress.toBase58());
console.log("Decimals:", mintInfo.decimals); // 9
console.log("Supply:", mintInfo.supply.toString()); // 0
console.log("Mint authority:", mintInfo.mintAuthority?.toBase58());
console.log("Owner (program):", mintInfo.owner.toBase58()); // TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
Зверніть увагу на останній рядок: власником mint-акаунта є Token Program, а не ви. Це критично важливо для розуміння помилки TokenInvalidOwner, про яку ми поговоримо нижче.
Token account — баланс конкретного власника
Токен-акаунт — це акаунт, який зберігає баланс одного токена для одного власника. Зв'язок «власник → токен → баланс» реалізується через токен-акаунт.
Що зберігає токен-акаунт:
- mint — посилання на mint-акаунт (який саме токен тут зберігається).
- owner — публічний ключ власника балансу. Сам власник не підписує транзакції з цього акаунта безпосередньо — але Token Program перевіряє, що переданий у інструкції
authorityзбігається зowner. - amount — баланс у найменших одиницях.
- delegate — адреса делегата (якщо встановлено).
- delegated_amount — сума, делегована поточному делегату.
- is_frozen — чи заморожено акаунт.
- rent_exempt_reserve — кількість SOL, заблокованих для rent-exempt статусу.
Існує два способи створити токен-акаунт:
- Прямо через Token Program — інструкція
InitializeAccount. Ви самі генеруєте keypair, створюєте акаунт з потрібним розміром та ініціалізуєте його. Цей спосіб дає більше контролю, але вимагає більше кроків. - Через Associated Token Account (ATA) — детерміновано обчислена адреса токен-акаунта для пари (власник, мінт). Створюється через окрему програму — Associated Token Program. Це стандартний спосіб для більшості застосунків.
Приклад створення ATA та мінтингу токенів:
Очікуваний результат: створено асоційований токен-акаунт для вашого гаманця, на який замінтовано 1000 токенів (у найменших одиницях: 1000 × 10^9).
import {
createAssociatedTokenAccount,
getAccount,
mintTo
} from "@solana/spl-token";
// Створення ATA для payer для нашого міну
const tokenAccount = await createAssociatedTokenAccount(
connection,
payer, // хто платить за rent
mintAddress, // який токен
payer.publicKey // для кого створюємо акаунт
);
// Мінтинг 1000 токенів (у найменших одиницях)
const amount = 1000 * Math.pow(10, decimals); // 1000000000000
await mintTo(
connection,
payer,
mintAddress, // куди мінтуємо
tokenAccount, // на який токен-акаунт
payer, // mint authority (має підписати)
amount
);
// Перевірка балансу
const accountInfo = await getAccount(connection, tokenAccount);
console.log("Token account:", tokenAccount.toBase58());
console.log("Owner:", accountInfo.owner.toBase58());
console.log("Mint:", accountInfo.mint.toBase58());
console.log("Amount:", accountInfo.amount.toString()); // 1000000000000
Важливий нюанс: адреса ATA обчислюється детерміновано. Для однієї й тієї ж пари (власник, мінт) вона завжди однакова. Ви можете обчислити її без запиту до мережі за допомогою функції getAssociatedTokenAddress з @solana/spl-token.
Операції з токенами
Mint, transfer, burn, approve
Усі чотири базові операції реалізовані як інструкції Token Program. Розглянемо кожну з практичною точки зору.
Mint (мінтинг) — створення нових одиниць токена та зарахування їх на токен-акаунт. Викликає інструкцію MintTo. Вимагає: mint-акаунт, цільовий токен-акаунт, mint authority як signer. Збільшує supply у mint-акаунті та amount у токен-акаунті.
Transfer (передача) — переміщення токенів між двома токен-акаунтами одного й того ж міну. Викликає інструкцію Transfer. Вимагає: вихідний токен-акаунт, цільовий токен-акаунт, owner вихідного акаунта як signer. Зменшує amount на джерелі, збільшує на отримувачі.
import { transfer } from "@solana/spl-token";
// Передача 100 токенів на інший токен-акаунт
const recipientTokenAccount = await createAssociatedTokenAccount(
connection, payer, mintAddress, recipientPublicKey
);
await transfer(
connection,
payer,
tokenAccount, // відкуда
recipientTokenAccount, // куди
payer, // owner вихідного акаунта (signer)
100 * Math.pow(10, decimals)
);
Burn (спалювання) — знищення одиниць токена. Викликає інструкцію Burn. Вимагає: токен-акаунт, mint-акаунт, owner токен-акаунта як signer. Зменшує як amount у токен-акаунті, так і supply у mint-акаунті.
import { burn } from "@solana/spl-token";
await burn(
connection,
payer,
tokenAccount, // звідки спалюємо
mintAddress, // який мінт (для оновлення supply)
payer, // owner токен-акаунта (signer)
50 * Math.pow(10, decimals)
);
Approve (делегування) — надання іншому акаунту права витратити вказану кількість токенів від вашого імені. Це не передає токени — це лише дозвіл. Викликає інструкцію Approve.
import { approve } from "@solana/spl-token";
const delegate = Keypair.generate().publicKey;
await approve(
connection,
payer,
tokenAccount, // ваш токен-акаунт
delegate, // кому даємо дозвіл
payer, // owner токен-акаунта (signer)
200 * Math.pow(10, decimals) // сума дозволу
);
Delegate authority та allowance
Механізм делегування в SPL Token працює за принципом allowance, аналогічним до ERC-20 approve / allowance, але з важливою архітектурною різницею.
Коли ви викликаєте Approve, Token Program записує два поля безпосередньо у ваш токен-акаунт:
- delegate — публічний ключ того, кому надано дозвіл.
- delegated_amount — максимальна сума, яку делегат може витратити.
Після цього делегат може викликати Transfer (або Burn), передаючи свій ключ як authority замість owner. Token Program перевірить, що переданий authority збігається з delegate у токен-акаунті, а сума не перевищує delegated_amount.
Після кожної успішної операції делегата delegated_amount зменшується на суму передачі. Коли делегат витрачає весь allowance, делегування фактично закінчується, хоча поле delegate залишається встановленим. Що скинути делегування повністю, викличте Revoke.
Типове застосування: делегування використовується в AMM-протоколах, ліквідаторах, маркетплейсах — скрізь, де контракт має витратити токени користувача без попередньої передачі на свій акаунт.
Token Program у контексті програм
Як програма взаємодіє з Token Program
Коли ви пишете власну програму на Solana (Rust або Anchor), вона не «імпортує» логіку Token Program. Натомість вона викликає інструкції Token Program через механізм CPI (Cross-Program Invocation) — міжпрограмний виклик.
Це працює так:
- Ваша програма отримує токен-акаунти у своїй інструкції як обов'язкові акаунти.
- Ваша програма формує набір акаунтів, який очікує Token Program для конкретної інструкції (наприклад,
Transfer). - Ваша програма викликає
invokeабоinvoke_signed, передаючи адресу Token Program, сформовані акаунти та дані інструкції. - Token Program виконує свою логіку: перевіряє власників, підписи, баланси — і модифікує акаунти.
У Anchor цей процес абстрагується через CPI-контексти, але під капотом відбувається саме цей механізм.
Концептуальний приклад на Anchor (Rust) — передача токенів із програми:
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.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 Program через CPI. Якщо Token Program поверне помилку (наприклад, недостатньо коштів), ваша програма також поверне помилку.
Передача токен-акаунтів у instruction
Коли клієнт формує транзакцію, що викликає вашу програму, він повинен передати всі необхідні токен-акаунти у правильному порядку. Це вимога архітектури Solana: програма не може самостійно «знайти» акаунт за адресою — всі акаунти мають бути явно передані в транзакції.
Для типового переказу токенів через вашу програму клієнт має передати:
- Токен-акаунт відправника (
from) — має бутиmut, оскільки баланс змінюється. - Токен-акаунт отримувача (
to) — має бутиmut. - Authority (власник
from) — має бути signer. - Token Program — обов'язковий акаунт для CPI.
- Ваша програма — як виконувана програма.
Порядок акаунтів має точно збігатися з тим, як вони оголошені у вашій структурі Accounts в Anchor. Якщо порядок порушено — транзакція впаде з помилкою десеріалізації.
Якщо ваша програма використовує PDA як authority для токен-акаунта (наприклад, escrow-акаунт), вам знадобиться invoke_signed замість invoke, щоб передати seeds PDA як підпис. У Anchor це робиться через CpiContext::new_with_signer.
Типові помилки
TokenInvalidOwner
Що означає: ви передали акаунт у інструкцію Token Program, але цей акаунт не належить Token Program (поле owner акаунта не дорівнює TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA).
Типові причини:
- Ви передали системний акаунт (гаманець) замість токен-акаунта. Гаманець належить System Program, а не Token Program.
- Ви передали mint-акаунт там, де очікується токен-акаунт (або навпаки). Хоча обидва належать Token Program, їхній формат різний, і програма розрізняє їх за типом даних.
- Ви передали акаунт з іншої програми (наприклад, з Token-2022 Program) у інструкцію класичного Token Program.
- Ви створили акаунт вручну, але вказали неправильного owner під час створення.
Як діагностувати: прочитайте акаунт, який ви передаєте, і перевірте поле owner. У TypeScript:
const accountInfo = await connection.getAccountInfo(suspectAddress);
console.log("Owner:", accountInfo.owner.toBase58());
// Має бути: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
Як виправити: переконайтеся, що ви передаєте саме токен-акаунт (для користувача — зазвичай ATA), а не публічний ключ гаманця. Використовуйте getAssociatedTokenAddress для обчислення правильної адреси.
Insufficient funds для токен-транзакції
Що означає: у контексті токен-транзакції ця помилка майже завжди стосується SOL, а не токенів. Вона означає, що акаунт, який платить за транзакцію (payer), не має достатньо SOL для оплати комісії за транзакцію або для створення нового токен-акаунта (rent-exempt депозит).
Це одна з найпоширеніших помилок у новачків, які приходять з Ethereum, де «insufficient funds» зазвичай означає нестачу ERC-20 токенів. На Solana ці речі розділені:
- Insufficient funds — нестача SOL для комісій або rent.
- InsufficientFunds (від Token Program) — нестача токенів на токен-акаунті для виконання transfer або burn.
Типові сценарії:
- Ви створюєте новий ATA для отримувача, але payer не має достатньо SOL для rent-exempt депозиту (~0.00203 SOL на Devnet, але перевірте актуальне значення через
connection.getMinimumBalanceForRentExemption). - Ви виконуєте серію транзакцій і не відстежуєте залишок SOL на payer.
Як діагностувати: перед транзакцією перевірте баланс SOL payer-акаунта:
const balance = await connection.getBalance(payer.publicKey);
console.log("SOL balance:", balance / 1e9);
// Перевірка мінімального балансу для створення ATA
const rentExempt = await connection.getMinimumBalanceForRentExemption(165); // 165 байт — розмір токен-акаунта
console.log("Rent for token account:", rentExempt / 1e9, "SOL");
Як виправити: переконайтеся, що payer має достатньо SOL. Якщо ви створюєте токен-акаунт для отримувача, врахуйте rent-exempt депозит у розрахунку необхідної суми. На Devnet отримайте додаткові SOL через faucet.
Додаткова порада: якщо отримувач вже має ATA для цього токена, створення нового акаунта не потрібне — передача обійдеться лише комісією транзакції. Перевірте наявність ATA перед створенням за допомогою getAccountInfo або обробіть помилку TokenAccountAlreadyExists graciousно.