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

Середовище та версії:

  • Anchor CLI: 0.29.0
  • Solana CLI: 1.18.x
  • Rust: 1.75.0+
  • Node.js: 18.x
  • Кластер: Devnet
  • Залежності Rust: anchor-lang 0.29.0, anchor-spl 0.29.0
  • Залежності Node: @coral-xyz/anchor ^0.29.0, @solana/spl-token ^0.3.9, @solana/web3.js ^1.87.6

Попередні вимоги: встановлені Solana CLI, Anchor CLI, Rust, Node.js, налаштований гаманець з SOL на Devnet (airdrop).

Очікуваний результат: розгорнутий на Devnet контракт із трьома інструкціями та пройдені тести, що підтверджують створення токена, карбування та переказ.

Обмеження: це навчальний приклад. Він не містить механізмів безпеки production-рівня (pause, freeze authority, обмеження на карбування, мультисиг). Для реального застосунку ці механізми необхідно додати.

Що робитиме контракт

Створення mint, mint-нення, transfer

Контракт реалізує три інструкції:

  • create_token — створює новий mint-акаунт у Token Program. Mint-акаунт — це реєстр токена, який визначає його назву (off-chain), кількість знаків після коми та хто має право карбувати нові одиниці.
  • mint_tokens — карбує (створює) вказану кількість токенів на конкретному токен-рахунку. Ця інструкція викликає Token Program через CPI (Cross-Program Invocation — механізм виклику однією програмою іншої програми на Solana).
  • transfer_tokens — переказує токени між двома токен-рахунками. Також виконується через CPI до Token Program.

Обмеження: лише owner може mint-нути

Право карбування належить не особистому ключу розробника, а PDA (Program Derived Address — детерміновано згенерована адреса, похідна від ідентифікатора програми та seeds). Це означає, що лише наша програма може підписувати транзакції карбування від імені цього PDA. Зовнішні гаманці не можуть карбувати токени без виклику нашої інструкції mint_tokens.

Структура програми

State: mint account, authority

Програма не зберігає власного стану в окремому акаунті. Усі необхідні дані вже існують у стандартних акаунтах Token Program:

  • Mint-акаунт — створюється під час create_token, зберігає decimals та mint authority.
  • PDA mint authority — похідна адреса з seed "mint_authority". Не містить даних, слугує виключно підписантом для карбування.

Instructions: create_token, mint_tokens, transfer_tokens

Кожна інструкція отримує набір акаунтів, перевіряє їх через Anchor-констрейнти та виконує логіку. Дві з трьох інструкцій делегують роботу Token Program через CPI.

Реалізація create_token

Створення mint через CPI до Token Program

Ініціалізуємо проєкт:

anchor init simple_token --no-git cd simple_token

У файлі Cargo.toml у секції [dependencies] переконайтеся, що:

anchor-lang = "0.29.0"
anchor-spl = "0.29.0"

Повний вміст programs/simple_token/src/lib.rs:

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

declare_id!("YOUR_PROGRAM_ID_HERE");

#[program]
pub mod simple_token {
  use super::*;

  pub fn create_token(ctx: Context<CreateToken>) -> Result<()> {
    Ok(())
  }

  pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
    let cpi_accounts = token::MintTo {
      mint: ctx.accounts.mint.to_account_info(),
      to: ctx.accounts.token_account.to_account_info(),
      authority: ctx.accounts.mint_authority.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
    token::mint_to(cpi_ctx, amount)?;
    Ok(())
  }

  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();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
    token::transfer(cpi_ctx, amount)?;
    Ok(())
  }
}

#[derive(Accounts)]
pub struct CreateToken<'info> {
  #[account(
    init,
    payer = authority,
    mint::decimals = 9,
    mint::authority = mint_authority,
  )]
  pub mint: Account<'info, Mint>,

  /// CHECK: PDA використовується виключно як mint authority
  #[account(
    seeds = [b"mint_authority"],
    bump
  )]
  pub mint_authority: AccountInfo<'info>,

  #[account(mut)]
  pub authority: Signer<'info>,

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

#[derive(Accounts)]
pub struct MintTokens<'info> {
  #[account(mut)]
  pub mint: Account<'info, Mint>,

  #[account(mut)]
  pub token_account: Account<'info, TokenAccount>,

  /// CHECK: PDA використовується виключно як mint authority
  #[account(
    seeds = [b"mint_authority"],
    bump
  )]
  pub mint_authority: AccountInfo<'info>,

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

#[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>,
}

Тіло create_token порожнє — Ok(()). Це не помилка. Констрейнти init, mint::decimals та mint::authority у структурі CreateToken інструкують Anchor автоматично створити акаунт і викликати initialize_mint з Token Program через CPI під капотом. Вам не потрібно писати цей CPI вручну.

PDA як mint authority

PDA обчислюється за формулою: seed "mint_authority" + programId. Оскільки PDA не має відповідного приватного ключа, підписати транзакцію від його імені може лише програма, яка його породила. Це гарантує, що карбування можливе виключно через інструкцію mint_tokens нашого контракту.

Після виконання anchor build у файлі target/idl/simple_token.json з'явиться ID програми. Скопіюйте його та вставте замість "YOUR_PROGRAM_ID_HERE" у declare_id!. Потім виконайте anchor build ще раз.

Реалізація mint та transfer

Mint через CPI

Інструкція mint_tokens отримує параметр amount: u64 і викликає token::mint_to з Token Program. Ключовий момент: authority у структурі MintTo вказує на PDA mint_authority. Anchor автоматично додає PDA-підпис через seeds та bump, вказані в констрейнтах акаунта.

Логіка крок за кроком:

  1. Формуємо структуру token::MintTo з трьома акаунтами: mint, токен-рахунок отримувача, authority (PDA).
  2. Створюємо CpiContext з посиланням на Token Program.
  3. Викликаємо token::mint_to з переданою кількістю.

Token Program перевірить, що переданий authority збігається з mint_authority у mint-акаунті, і що підпис PDA є валідним. Якщо хоча б одна умова не виконується — транзакція відхиляється.

Transfer через CPI

Інструкція transfer_tokens делегує переказ Token Program через структуру Transfer. На відміну від mint, тут authority — це Signer, тобто власник токен-рахунку, який підписує транзакцію своїм приватним ключем.

Важливий нюанс: Token Program не перевіряє, чи є authority власником акаунта from. Він перевіряє лише наявність валідного підпису. Однак Anchor-констрейнт Signer гарантує, що акаунт дійсно підписав транзакцію. Перевірка власності лежить на боці клієнта, який формує транзакцію з правильним акаунтом.

Тестування контракту

Тест: створення токена, mint, transfer

У файлі tests/simple-token.ts (проєкт створюється з TypeScript-шаблоном) розмістіть наступний код:

import * as anchor from "@coral-xyz/anchor";
import { SystemProgram, Keypair, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import {
  TOKEN_PROGRAM_ID,
  getOrCreateAssociatedTokenAccount,
  getAccount,
} from "@solana/spl-token";

describe("simple-token", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.SimpleToken;

  const [mintAuthority] = PublicKey.findProgramAddressSync(
    [Buffer.from("mint_authority")],
    program.programId
  );

  const mint = Keypair.generate();
  const receiver = Keypair.generate();
  let senderTokenAccount;
  let receiverTokenAccount;

  it("Створює новий токен", async () => {
    await program.methods
      .createToken()
      .accounts({
        mint: mint.publicKey,
        mintAuthority,
        authority: provider.wallet.publicKey,
        tokenProgram: TOKEN_PROGRAM_ID,
        systemProgram: SystemProgram.programId,
      })
      .signers([mint])
      .rpc();

    const mintInfo = await getAccount(provider.connection, mint.publicKey);
    console.log("Mint створено:", mint.publicKey.toBase58());
    console.log("Decimals:", mintInfo.decimals);
    console.log("Mint authority:", mintInfo.mintAuthority.toBase58());
  });

  it("Карбує 1 токен на рахунок автора", async () => {
    senderTokenAccount = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      provider.wallet.payer,
      mint.publicKey,
      provider.wallet.publicKey
    );

    const amount = 1_000_000_000;
    await program.methods
      .mintTokens(new anchor.BN(amount))
      .accounts({
        mint: mint.publicKey,
        tokenAccount: senderTokenAccount.address,
        mintAuthority,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .rpc();

    const account = await getAccount(
      provider.connection,
      senderTokenAccount.address
    );
    console.log("Баланс після mint:", account.amount.toString());
  });

  it("Переказує 0.1 токена отримувачу", async () => {
    const sig = await provider.connection.requestAirdrop(
      receiver.publicKey,
      0.1 * LAMPORTS_PER_SOL
    );
    await provider.connection.confirmTransaction(sig, "confirmed");

    receiverTokenAccount = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      provider.wallet.payer,
      mint.publicKey,
      receiver.publicKey
    );

    const transferAmount = 100_000_000;
    await program.methods
      .transferTokens(new anchor.BN(transferAmount))
      .accounts({
        from: senderTokenAccount.address,
        to: receiverTokenAccount.address,
        authority: provider.wallet.publicKey,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .rpc();

    const senderBalance = await getAccount(
      provider.connection,
      senderTokenAccount.address
    );
    const receiverBalance = await getAccount(
      provider.connection,
      receiverTokenAccount.address
    );
    console.log("Баланс відправника:", senderBalance.amount.toString());
    console.log("Баланс отримувача:", receiverBalance.amount.toString());
  });
});

Перед запуском переконайтеся, що у файлі Anchor.toml вказано Devnet:

[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"

Запуск тестів:

solana config set --url devnet
solana airdrop 2
anchor test --skip-build

Прапорець --skip-build використовується, якщо контракт уже зібрано. Якщо змінювали код — виконайте anchor test без прапорця.

Перевірка балансів

Після успішного проходження тестів у консолі ви маєте побачити:

  • Mint створено: адреса mint-акаунта
  • Decimals: 9
  • Mint authority: адреса PDA (має збігатися з результатом PublicKey.findProgramAddressSync)
  • Баланс після mint: 1000000000
  • Баланс відправника: 900000000
  • Баланс отримувача: 100000000

Якщо хочете перевірити баланси поза тестами, використовуйте CLI:

spl-token account <TOKEN_ACCOUNT_ADDRESS>

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

Mint authority не встановлено на PDA

Симптом: помилка SignatureVerificationFailed або ConstraintSeeds під час виклику mint_tokens.

Причина: у констрейнтах CreateToken вказано інший акаунт як mint::authority, або seed для PDA у mint_tokens не збігається з тим, що використовувався при створенні mint.

Перевірка: порівняйте значення mint::authority у структурі CreateToken з seeds у структурах MintTokens та CreateToken. Обидва мають використовувати однаковий seed — у нашому випадку b"mint_authority".

Виправлення: переконайтеся, що рядок seed ідентичний у всіх структурах, де зустрічається mint_authority. Не використовуйте різні seeds для різних інструкцій — PDA має бути одним і тим самим.

Transfer без достатнього балансу

Симптом: помилка InsufficientFunds від Token Program.

Причина: акаунт from не має достатньої кількості токенів для переказу вказаної суми. Це може статися, якщо mint не було викликано перед transfer, або якщо сума transfer перевищує доступний баланс.

Перевірка: перед викликом transfer_tokens прочитайте баланс акаунта from через getAccount (у тестах) або spl-token account (у CLI). Переконайтеся, що amount менший або дорівнює account.amount.

Виправлення: переконайтеся, що тести виконуються послідовно: спочатку створення токена, потім карбування, і лише після цього — переказ. Якщо використовуєте skip у тестах, перевірте, що попередні тести не були пропущені.

Додатковий нюанс: переказ токенів не вимагає плати за транзакцію з боку токен-рахунку — комісія сплачується з SOL-балансу відправника (authority). Якщо гаманець відправника не має SOL для комісії, ви отримаєте помилку insufficient lamports від системної програми, а не від Token Program.

Джерела