Цей довідник проведе вас через створення повноцінного 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 застосунку включає такі етапи, кожен з яких варто перевірити окремо:
- розгортання програми:
solana program show PROGRAM_ID --url devnet— програма існує на кластері. - Ініціалізація акаунта: після виклику
initializeакаунт за адресою PDA має існувати та міститиcount: 0. - Інкремент: після виклику
incrementзначенняcountмає збільшитися на 1. - Frontend-цикл: підключення гаманця, ініціалізація через UI, інкремент через UI, відображення оновленого стану без перезавантаження сторінки.
- Ідемпотентність: повторний виклик
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 буде іншою, і програма не знайде акаунт. Різниця навіть у одному байті насіння дає абсолютно іншу адресу — це властивість криптографічного похідного ключа.