Цей довідник проведе вас через створення повноцінного full-stack застосунку на Solana: від on-chain програми на Anchor до React-frontend з підключенням гаманця. У результаті ви отримаєте робочий прототип на Devnet, який зберігає стан у блокчейні та керується через браузер.

Архітектура застосунку

On-chain програма (Anchor) + frontend (React)

Застосунок складається з двох частин, які спілкуються через IDL (Interface Definition Language):

  • On-chain програма — смарт-контракт на Rust, скомпільований через Anchor. Він визначає структури даних, правила валідації та інструкції, які виконуються валідаторами Solana.
  • Frontend — React-застосунок, який підключає гаманець користувача, формує транзакції та відправляє їх у мережу.
  • IDL — JSON-файл, який Anchor генерує після компіляції. Він описує всі інструкції, акаунти та типи програми. TypeScript-клієнт використовує IDL для серіалізації та десеріалізації даних без ручного написання байтових інструкцій.

Як вони взаємодіють через IDL

Після розгортання програми на Devnet ви копіюєте IDL у проєкт frontend. Клієнтська бібліотека @coral-xyz/anchor читає IDL і створює типізований обʼєкт Program. Коли користувач натискає кнопку в UI, frontend викликає метод цього обʼєкта — бібліотека автоматично формує правильну транзакцію з потрібними акаунтами та даними.

Концептуальний приклад у цьому довіднику: лічильник (counter), який ініціалізується для конкретного гаманця через PDA (Program Derived Address) і інкрементується повторними викликами. Це навчальний прототип, а не production-рішення — для реальних застосунків потрібна додаткова валідація, обробка помилок та індексація стану.

Крок 1: on-chain програма

Створення програми з однією instruction

Середовище: Ubuntu 24.04 LTS / macOS, Anchor 0.30.1, Solana CLI 1.18.26, Rust 1.75+.

Передумови: встановлені Anchor, Solana CLI, Rust toolchain, Node.js 18.x або 20.x.

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

anchor init counter-app --no-git
cd counter-app

Замініть вміст programs/counter-app/src/lib.rs:

use anchor_lang::prelude::*;

declare_id!("YOUR_PROGRAM_ID_HERE");

#[account]
pub struct Counter {
  pub count: u64,
  pub authority: Pubkey,
  pub bump: u8,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
  #[account(
    init,
    payer = authority,
    space = 8 + 8 + 32 + 1,
    seeds = [b"counter", authority.key().as_ref()],
    bump
  )]
  pub counter: Account<'info, Counter>,
  #[account(mut)]
  pub authority: Signer<'info>,
  pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Increment<'info> {
  #[account(
    mut,
    seeds = [b"counter", authority.key().as_ref()],
    bump = counter.bump
  )]
  pub counter: Account<'info, Counter>,
  pub authority: Signer<'info>,
}

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
  ctx.accounts.counter.count = 0;
  ctx.accounts.counter.authority = ctx.accounts.authority.key();
  ctx.accounts.counter.bump = ctx.bumps.counter;
  Ok(())
}

pub fn increment(ctx: Context<Increment>) -> Result<()> {
  ctx.accounts.counter.count += 1;
  Ok(())
}

Що відбувається: програма створює акаунт-лічильник, привʼязаний до гаманця користувача через PDA з насінням "counter" та публічним ключем автора. Розмір акаунта: 8 байтів (дискримінатор Anchor) + 8 (u64) + 32 (Pubkey) + 1 (u8 bump) = 49 байтів.

розгортання на Devnet

Налаштуйте Anchor.toml для роботи з Devnet:

[features]
seeds = false

[programs.devnet]
counter_app = "YOUR_PROGRAM_ID_HERE"

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

Згенеруйте ключову пару для програми та отримайте SOL на Devnet для розгортання:

solana-keygen new -o target/deploy/counter_app-keypair.json --no-bip39-passphrase
solana airdrop 2 --url devnet

Збірка та розгортання:

anchor build
anchor deploy

Очікуваний результат: у терміналі зʼявляється Program ID. Файл IDL генерується за шляхом target/idl/counter_app.json. Скопіюйте цей Program ID у declare_id! у lib.rs та в Anchor.toml, потім виконайте anchor build і anchor deploy повторно — це гарантує збіг IDL та реальної програми.

Перевірка: solana program show YOUR_PROGRAM_ID --url devnet — має показати статус розгортання.

Крок 2: TypeScript-клієнт

Генерація клієнта з IDL

Середовище: Node.js 18.x, @coral-xyz/anchor 0.30.1, @solana/web3.js 1.95.3.

Створіть окремий каталог для клієнта (або використовуйте tests/ у проєкті Anchor):

mkdir client && cd client
npm init -y
npm install @coral-xyz/anchor @solana/web3.js

Скопіюйте target/idl/counter_app.json у каталог клієнта. Створіть файл index.ts:

import { AnchorProvider, Program, Idl } from "@coral-xyz/anchor";
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import fs from "fs";
import idlJson from "./counter_app.json";

const idl = idlJson as Idl;
const PROGRAM_ID = new PublicKey(idl.address);

async function main() {
  const connection = new Connection("https://api.devnet.solana.com", "confirmed");
  const wallet = Keypair.generate();

  // Запросіть airdrop для оплати транзакцій
  const sig = await connection.requestAirdrop(wallet.publicKey, 1_000_000_000);
  await connection.confirmTransaction(sig, "confirmed");

  const provider = new AnchorProvider(connection, wallet, {
    commitment: "confirmed",
  });

  const program = new Program(idl, PROGRAM_ID, provider);

  // Похідна PDA — насіння має точно збігатися з on-chain кодом
  const [counterPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("counter"), wallet.publicKey.toBuffer()],
    PROGRAM_ID
  );

  // Ініціалізація лічильника
  await program.methods
    .initialize()
    .accounts({ counter: counterPda, authority: wallet.publicKey })
    .signers([wallet])
    .rpc();

  console.log("Counter initialized at:", counterPda.toBase58());

  // Інкремент
  await program.methods
    .increment()
    .accounts({ counter: counterPda, authority: wallet.publicKey })
    .signers([wallet])
    .rpc();

  // Читання стану
  const account = await program.account.counter.fetch(counterPda);
  console.log("Count:", account.count.toNumber()); // Очікувано: 1
}

main().catch(console.error);

Підключення до Devnet

Ключовий момент — рядок підключення. Для Devnet використовуйте https://api.devnet.solana.com. Якщо ви випадково вкажете mainnet-beta, транзакції або не знайдуть програму, або спишуть реальні кошти.

Очікуваний результат: після запуску (npx ts-node index.ts) у консолі зʼявляється адреса PDA-акаунта та значення Count: 1.

Перевірка: solana account COUNTER_PDA_ADDRESS --url devnet — має показати дані акаунта з ненульовим балансом.

Крок 3: frontend

Підключення гаманця

Середовище: React 18, @solana/wallet-adapter-react 0.15.35, @solana/wallet-adapter-react-ui 0.9.35, @solana/wallet-adapter-base 0.9.23.

Ініціалізуйте React-додаток та встановіть залежності:

npx create-react-app frontend --template typescript
cd frontend
npm install @coral-xyz/anchor @solana/web3.js @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-base

Обгорніть додаток провайдерами у App.tsx:

import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom";
import "@solana/wallet-adapter-react-ui/styles.css";
import { CounterApp } from "./CounterApp";

const DEVNET_ENDPOINT = "https://api.devnet.solana.com";

function App() {
  return (
    <ConnectionProvider endpoint={DEVNET_ENDPOINT}>
      <WalletProvider wallets={[new PhantomWalletAdapter()]} autoConnect>
        <WalletModalProvider>
          <CounterApp />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

Відправка транзакції через UI

Створіть компонент CounterApp.tsx, який читає стан та відправляє транзакції:

import { useWallet } from "@solana/wallet-adapter-react";
import { useConnection } from "@solana/wallet-adapter-react-ui";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { Program, AnchorProvider, Idl } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { useState, useEffect } from "react";
import idlJson from "./counter_app.json";

const idl = idlJson as Idl;
const PROGRAM_ID = new PublicKey(idl.address);

export function CounterApp() {
  const { publicKey } = useWallet();
  const { connection } = useConnection();
  const [count, setCount] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  function getProgram() {
    const provider = new AnchorProvider(connection, (window as any).solana, {
      commitment: "confirmed",
    });
    return new Program(idl, PROGRAM_ID, provider);
  }

  function getCounterPda() {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("counter"), publicKey!.toBuffer()],
      PROGRAM_ID
  )[0];
  }

  async function fetchCount() {
    if (!publicKey) return;
    try {
      const program = getProgram();
      const pda = getCounterPda();
      const account = await program.account.counter.fetch(pda);
      setCount(account.count.toNumber());
    } catch {
      setCount(null); // Акаунт ще не ініціалізовано
    }
  }

  useEffect(() => { fetchCount(); }, [publicKey]);

  async function handleInitialize() {
    setLoading(true);
    try {
      const program = getProgram();
      await program.methods
        .initialize()
        .accounts({ counter: getCounterPda(), authority: publicKey })
        .rpc();
      await fetchCount();
    } catch (err) { console.error(err); }
    setLoading(false);
  }

  async function handleIncrement() {
    setLoading(true);
    try {
      const program = getProgram();
      await program.methods
        .increment()
        .accounts({ counter: getCounterPda(), authority: publicKey })
        .rpc();
      await fetchCount();
    } catch (err) { console.error(err); }
    setLoading(false);
  }

  return (
    <div style={{ padding: 24 }}>
      <WalletMultiButton />
      {publicKey && (
        <div style={{ marginTop: 16 }}>
          <p>Підключено: {publicKey.toBase58()}</p>
          <p>Лічильник: {count !== null ? count : "не ініціалізовано"}</p>
          {count === null ? (
            <button onClick={handleInitialize} disabled={loading}>Ініціалізувати</button>
          ) : (
            <button onClick={handleIncrement} disabled={loading}>+1</button>
          )}
        </div>
      )}
    </div>
  );
}

Очікуваний результат: користувач натискає «Connect Wallet», обирає Phantom (або інший гаманець), натискає «Ініціалізувати» — гаманець просить підписати транзакцію — після підтвердження лічильник зʼявляється в UI. Кнопка «+1» інкрементує значення з кожним підписом.

Перевірка: відкрийте Solana Explorer на Devnet за адресою вашого PDA-акаунта — дані мають оновлюватися після кожної транзакції.

Крок 4: тестування

Тестування на Devnet

Anchor має вбудовану підтримку тестів з використанням локального валідатора. Для перевірки повного циклу на Devnet скористайтеся клієнтом із Кроку 2:

cd client
npx ts-node index.ts

Тест має послідовно виконати: airdrop, initialize, increment, fetch. Якщо всі кроки завершуються без помилок, а Count дорівнює 1 — on-chain програма та клієнт працюють коректно.

Перевірка повного циклу

Повний цикл full-stack застосунку включає такі етапи, кожен з яких варто перевірити окремо:

  1. розгортання програми: solana program show PROGRAM_ID --url devnet — програма існує на кластері.
  2. Ініціалізація акаунта: після виклику initialize акаунт за адресою PDA має існувати та містити count: 0.
  3. Інкремент: після виклику increment значення count має збільшитися на 1.
  4. Frontend-цикл: підключення гаманця, ініціалізація через UI, інкремент через UI, відображення оновленого стану без перезавантаження сторінки.
  5. Ідемпотентність: повторний виклик initialize для того ж PDA має повернути помилку (акаунт вже існує) — це правильна поведінка.

Безпечний відкат: якщо щось пішло не так, закрийте акаунт лічильника командою solana close COUNTER_PDA_ADDRESS --url devnet (залишок SOL повернеться на ваш гаманець), після чого можна ініціалізувати заново.

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

IDL не відповідає програмі

Це найчастіша помилка при розробці full-stack на Anchor. Вона виникає, коли ви змінили структуру акаунта або сигнатуру інструкції в Rust-коді, виконали anchor build, але забули виконати anchor deploy — або навпаки, задеплоїли, але забули скопіювати оновлений IDL у frontend.

Симптом: помилка типу InstructionFallbackNotFound або AccountDidNotDeserialize при виклику методів програми.

Рішення: після кожної зміни on-chain коду виконуйте послідовно: anchor build, anchor deploy, скопіювати target/idl/counter_app.json у frontend, перезапустити клієнт. Перевірте, що idl.address збігається з реальним Program ID на Devnet.

Frontend не підключається до Devnet

Симптом: помилка 429 Too Many Requests, або програма «не знайдена», або транзакції не підтверджуються.

Причини:

  • У ConnectionProvider вказано mainnet-beta замість https://api.devnet.solana.com. На mainnet вашої програми не існує, тому транзакції падають.
  • Публічний RPC-ендпоінт Devnet має ліміти на кількість запитів. Якщо ви часто опитуєте стан, додайте затримку або використовуйте власний RPC-вузол.
  • Гаманець користувача переключено на mainnet у налаштуваннях Phantom — у такому разі підписана транзакція піде не на той кластер.

Рішення: перевірте endpoint у ConnectionProvider, переконайтеся, що гаманець переключений на Devnet, додайте логування connection.getRpcEndpoint() для підтвердження.

Додаткова перевірка: якщо PDA-насіння на клієнті не збігається з on-chain кодом (наприклад, "counter" проти "Counter"), адреса PDA буде іншою, і програма не знайде акаунт. Різниця навіть у одному байті насіння дає абсолютно іншу адресу — це властивість криптографічного похідного ключа.

Джерела