Усі приклади нижче перевірені в такому середовищі: Node.js 18+, @solana/web3.js 1.95.3, @coral-xyz/anchor 0.30.1, кластер — Devnet. Припускається, що ви вже маєте обʼєкт Connection з указаним RPC-ендпоінтом Devnet.

Отримання інформації про акаунт

getAccountInfo — запит до RPC

Метод getAccountInfo — це базовий інструмент для читання стану будь-якого акаунта в Solana. Він повертає метадані акаунта (лампорти, власник, прапорець executable) та необроблені байти даних.

import { Connection, PublicKey } from '@solana/web3.js';

const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
const address = new PublicKey('7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU');

const accountInfo = await connection.getAccountInfo(address);

if (accountInfo === null) {
  console.log('Акаунт не існує');
} else {
  console.log('Лампорти:', accountInfo.lamports);
  console.log('Власник:', accountInfo.owner.toBase58());
  console.log('Executable:', accountInfo.executable);
  console.log('Розмір даних (байти):', accountInfo.data.length);
  console.log('Rent epoch:', accountInfo.rentEpoch);
}

Очікуваний результат: обʼєкт AccountInfo<Buffer> або null, якщо акаунт не знайдено. За замовчуванням дані повертаються як Buffer (внутрішньо використовується кодування base64).

Корисний параметр — dataSlice, який дозволяє завантажити лише частину даних, економлячи трафік:

const partialInfo = await connection.getAccountInfo(address, {
  encoding: 'base64',
  dataSlice: { offset: 0, length: 8 }, // лише перші 8 байтів
});

Це особливо корисно, коли вам потрібне лише одне поле з великого акаунта, і ви не хочете завантажувати кілобайти даних через мережу.

Десеріалізація отриманих даних

Raw-байти з accountInfo.data самі по собі не мають сенсу — їх треба десеріалізувати згідно зі структурою, яку визначає програма-власник. Для програм, написаних без Anchor, зазвичай використовують Borsh — компактний бінарний формат серіалізації.

Приклад: програма зберігає лічильник як u64 (8 байтів).

import * as borsh from 'borsh';

class CounterAccount {
  count = 0;
  constructor(fields?: { count: number }) {
    if (fields) this.count = fields.count;
  }
}

const schema = new Map([
  [CounterAccount, { kind: 'struct', fields: [['count', 'u64']] }],
]);

// accountInfo.data — Buffer з попереднього кроку
const deserialized = borsh.deserialize(
  schema,
  CounterAccount,
  accountInfo.data
);

// u64 десеріалізується як BN-екземпляр
console.log('Лічильник:', deserialized.count.toString());

Ключовий момент: порядок полів у схемі має точно збігатися з порядком, у якому програма на Rust записує дані. Навіть одна переставлена позиція призведе до некоректного результату без помилки на рівні десеріалізації.

Робота з програмними акаунтами

Читання стану Anchor-програми через IDL

Anchor-програми генерують IDL (Interface Definition Language) — JSON-опис усіх акаунтів, інструкцій та типів. Завдяки цьому вам не потрібно вручну писати схему Borsh: Anchor-клієнт десеріалізує дані автоматично.

import { Program, AnchorProvider } from '@coral-xyz/anchor';
import idl from '../target/idl/my_program.json';

const provider = new AnchorProvider(connection, wallet, {});
const programId = new PublicKey('11111111111111111111111111111111');
const program = new Program(idl, programId, provider);

const pda = PublicKey.findProgramAddressSync(
  [Buffer.from('counter')],
  programId
)[0];

const account = await program.account.counter.fetch(pda);
console.log('Лічильник:', account.count.toString());

Очікуваний результат: типізований обʼєкт, де кожне поле відповідає структурі в IDL. Метод fetch під капотом викликає getAccountInfo і десеріалізує відповідь.

Використання згенерованого клієнта

Після виконання anchor build у папці target/types/ зʼявляються згенеровані TypeScript-типи. Їхнє підключення дає перевірку типів на етапі компіляції, а не лише під час виконання.

import { MyProgram } from '../target/types/my_program';

const program = new Program<MyProgram>(idl, programId, provider);

// Тепер account.count має коректний тип (BN)
// Помилка типу виникне, якщо ви звернетеся до неіснуючого поля
const account = await program.account.counter.fetch(pda);
console.log('Лічильник:', account.count.toString());

Різниця між підходами: IDL без згенерованих типів дає десеріалізацію під час виконання, але без автодоповнення в редакторі. Згенерований клієнт додає статичну типізацію — це концептуальний приклад для розробки, у production-проєктах завжди використовуйте згенеровані типи.

Отримання балансу

getBalance — запит балансу SOL

Для читання балансу SOL не потрібен getAccountInfo — є спеціалізований метод, який повертає лише кількість лампортів.

const lamports = await connection.getBalance(address);
const sol = lamports / 1_000_000_000;
console.log('Баланс:', sol, 'SOL');

Очікуваний результат: число (lamports як number). Для акаунтів із дуже великим балансом врахуйте обмеження Number.MAX_SAFE_INTEGER — у таких випадках використовуйте getBalance з параметром commitment і обробляйте значення обережно.

getTokenAccountBalance — запит балансу токена

Для SPL-токенів баланс зберігається в спеціальному акаунті — token account (власник якого — Token Program). Адреса такого акаунта відрізняється від адреси гаманця користувача.

const tokenAccountAddress = new PublicKey('TokenAccountAddressHere');

const tokenBalance = await connection.getTokenAccountBalance(tokenAccountAddress);

console.log('Сирий баланс:', tokenBalance.amount);       // string
console.log('Десяткові знаки:', tokenBalance.decimals);  // number
console.log('Форматований:', tokenBalance.uiAmountString); // string | null

Очікуваний результат: обʼєкт TokenBalance з полями amount (рядок), decimals (число), uiAmount (число або null), uiAmountString (рядок або null). Поле amount завжди рядок, оскільки значення може перевищувати Number.MAX_SAFE_INTEGER.

Важливо: якщо передати адресу звичайного акаунта (не token account), RPC поверне помилку. Переконайтеся, що акаунт належить Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA).

Підписка на зміни стану

onAccountChange — відстеження оновлень

Solana RPC підтримує WebSocket-підписки, які дозволяють отримувати оновлення акаунта в реальному часі без повторних опитувань.

const subscriptionId = connection.onAccountChange(
  address,
  (updatedAccountInfo, context) => {
    console.log('Слот оновлення:', context.slot);
    console.log('Нові дані:', updatedAccountInfo.data.toString('base64'));
    console.log('Лампорти:', updatedAccountInfo.lamports);
  },
  'confirmed' // рівень commitment для підписки
);

// Для скасування підписки:
// connection.removeAccountChangeListener(subscriptionId);

Очікуваний результат: callback викликається щоразу, коли стан акаунта змінюється. Параметр context.slot містить номер слота, в якому відбулася зміна.

Обовʼязково зберігайте subscriptionId і викликайте removeAccountChangeListener при завершенні роботи. Нескасовані підписки призводять до витоку ресурсів як на клієнті, так і на стороні RPC-провайдера.

Коли використовувати підписки

  • Використовуйте підписки: фронтенд-застосунки, де потрібно миттєво оновлювати UI після зміни стану (наприклад, баланс гаманця, стан лічильника в грі, статус ордера).
  • Використовуйте polling (періодичні виклики getAccountInfo): серверні процеси, пакетна обробка, скрипти міграції, ситуації, коли WebSocket-зʼєднання нестабільне.
  • Не використовуйте підписки: якщо вам потрібне одноразове читання, якщо ви обробляєте історичні дані, якщо RPC-провайдер обмежує кількість одночасних WebSocket-підключень.

На Devnet публічні RPC-ендпоінти можуть обмежувати кількість активних підписок. У production-середовищі використовуйте власний RPC-вузол або платного провайдера з гарантованою підтримкою WebSocket.

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

Акаунт не знайдено (null response)

getAccountInfo повертає null, коли акаунт з такою адресою ніколи не створювався або був закритий (лампорти виведено, дані очищено). Це не помилка RPC — це коректна відповідь.

const info = await connection.getAccountInfo(address);

// Неправильно — спроба звернутися до .data викличе TypeError
// console.log(info.data.length);

// Правильно — обовʼязкова перевірка на null
if (!info) {
  console.log('Акаунт не знайдено. Можливо, його ще не створено або вже закрито.');
  // Тут: ініціалізація акаунта або альтернативна логіка
}

Поширена причина в Devnet: акаунт створювався в одній сесії, але його адреса була втрачена, або airdrop-лампорти не були використані для створення акаунта через SystemProgram.createAccount.

Невірна десеріалізація даних

Ця помилка не викликає винятку під час десеріалізації — ви просто отримуєте некоректні значення, які виглядають правдоподібно.

Типові причини:

  • Невідповідність схеми: порядок полів у вашій схемі Borsh не збігається з порядком у Rust-структурі програми. Рішення: звірити з вихідним кодом програми.
  • Невірний розмір буфера: ви очікуєте 8 байтів, а акаунт містить 16 (наприклад, програма була оновлена і додала нове поле). Рішення: перевірити accountInfo.data.length перед десеріалізацією.
  • Зміна ендіанності: Borsh використовує little-endian, і це враховано автоматично. Але якщо ви десеріалізуєте вручну через DataView, легко помилитися з порядком байтів.
  • Застарілий IDL: при використанні Anchor ви завантажили стару версію IDL, яка не відповідає розгорнутій програмі. Рішення: перезібрати програму і скопіювати свіжий IDL.
// Безпечний патерн: перевірка розміру перед десеріалізацією
const EXPECTED_SIZE = 8; // u64 = 8 байтів

if (accountInfo.data.length !== EXPECTED_SIZE) {
  throw new Error(
    `Несподіваний розмір даних: очікувалось ${EXPECTED_SIZE}, ` +
    `отримано ${accountInfo.data.length}. Можливо, програму оновлено.`
  );
}

const deserialized = borsh.deserialize(schema, CounterAccount, accountInfo.data);

Цей патерн дозволяє виявити невідповідність на ранньому етапі замість мовчазної роботи з неправильними даними.

Джерела