Ця інструкція проведе вас через повний цикл відправки транзакції в мережу Solana за допомогою TypeScript: від створення об'єкта транзакції до обробки результату та типових помилок. У результаті ви отримаєте робочий скрипт, який можна запустити проти Devnet і перевірити в блокчейн-експлорері.

Середовище: Node.js 18+, TypeScript 5.x
Пакет: @solana/web3.js версії 1.95.x
Кластер: Devnet (https://api.devnet.solana.com)
Передумови: встановлений Node.js, ініціалізований проєкт із npm init та встановлений @solana/web3.js
Очікуваний результат: транзакція переказу SOL відправлена, підписана, підтверджена в Devnet, signature виведено в консоль.

Нижче наведено концептуальний навчальний приклад. Для production-рішень потрібні додаткові механізми: перевірка балансу перед відправкою, обробка rate-limits від RPC-провайдера, ідемпотентність та логування. Ці теми виходять за межі поточного матеріалу.

Створення транзакції

Transaction об'єкт та додавання instructions

Будь-яка транзакція в Solana — це контейнер, який містить одну або кілька інструкцій (instructions). Інструкція — це атомарна операція, яку виконує окрема програма. Сама по собі транзакція не виконує жодної логіки: вона лише групує інструкції та визначає, хто їх ініціює та хто платить комісію.

Створимо транзакцію з однією інструкцією — переказ SOL за допомогою SystemProgram.transfer:

import {
  Connection,
  Keypair,
  Transaction,
  SystemProgram,
  LAMPORTS_PER_SOL,
  PublicKey
} from "@solana/web3.js";

const connection = new Connection(
  "https://api.devnet.solana.com",
  "confirmed"
);

// Відправник — нова ключова пара (тільки для навчального прикладу)
const fromKeypair = Keypair.generate();

// Отримувач — будь-який публічний ключ у Devnet
const toPublicKey = new PublicKey(
  "11111111111111111111111111111111"
);

const transaction = new Transaction();

const transferInstruction = SystemProgram.transfer({
  fromPubkey: fromKeypair.publicKey,
  toPubkey: toPublicKey,
  lamports: LAMPORTS_PER_SOL / 100, // 0.01 SOL
});

transaction.add(transferInstruction);

На цьому етапі транзакція містить інструкцію, але ще не готова до відправки. Вона не має двох обов'язкових полів: recentBlockhash та feePayer.

Встановлення recentBlockhash та feePayer

recentBlockhash — це хеш останнього блоку, який прив'язує транзакцію до конкретного моменту ланцюга. Solana використовує його для запобігання replay-атакам: транзакція з застарілим blockhash буде відхилена. feePayer — це акаунт, який сплачує комісію за транзакцію (зазвичай відправник).

const { blockhash, lastValidBlockHeight } =
  await connection.getLatestBlockhash("confirmed");

transaction.recentBlockhash = blockhash;
transaction.feePayer = fromKeypair.publicKey;

console.log("Blockhash:", blockhash);
console.log("Last valid block height:", lastValidBlockHeight);

Значення lastValidBlockHeight зберігайте — воно знадобиться для коректної обробки таймауту при підтвердженні. Транзакція є дійсною приблизно 150 блоків після цього height, що в Devnet становить близько 60–90 секунд.

Підписання транзакції

Підпис ключовою парою або гаманцем

Перед відправкою транзакцію потрібно підписати. Підпис підтверджує, що feePayer дійсно ініціює операцію. Існує два основні сценарії підписання.

Сценарій 1: Підпис ключовою парою (backend, скрипти). Ви маєте прямий доступ до приватного ключа через об'єкт Keypair. Це підходить для серверних сервісів, автоматизації та навчальних прикладів:

transaction.sign(fromKeypair);

Сценарій 2: Підпис через підключений гаманець (frontend). Приватний ключ залишається в гаманці користувача. Ваш застосунок передає серіалізовану транзакцію гаманцю і отримує назад підписану версію. Деталі підключення гаманця розглядаються в окремому матеріалі, тут — лише патерн підписання:

// Припустимо, wallet — це об'єкт підключеного гаманця
const signedTransaction = await wallet.signTransaction(transaction);

У цьому випадку ви не викликаєте transaction.sign() вручну — гаманець робить це внутрішньо.

Partial sign та multiple signers

Деякі транзакції вимагають підписів кількох сторін. Наприклад, якщо ви ініціюєте переказ з акаунта, який належить іншій людині, ця людина повинна також підписати транзакцію. Для цього використовується partialSign — кожен підписувач додає свій підпис, не скасовуючи попередні:

// Перший підписувач (ініціатор)
transaction.partialSign(fromKeypair);

// Другий підписувач (власник акаунта-джерела, якщо це інша особа)
transaction.partialSign(ownerKeypair);

Порядок partialSign не має значення — кожен підписувач додає свій підпис у відповідне місце серіалізованої транзакції. Головне правило: усі обов'язкові підписувачі мають додати свої підписи до моменту відправки.

Відправка та підтвердження

sendAndConfirm — відправка з очікуванням

Найпростіший спосіб надіслати транзакцію і дочекатися її підтвердження — метод sendAndConfirmTransaction. Він об'єднує два кроки: відправку серіалізованої транзакції в мережу та опитування (polling) статусу до підтвердження:

import { sendAndConfirmTransaction } from "@solana/web3.js";

// Попередньо отримуємо SOL на Devnet для оплати комісії
const airdropSig = await connection.requestAirdrop(
  fromKeypair.publicKey,
  2 * LAMPORTS_PER_SOL
);
await connection.confirmTransaction(airdropSig, "confirmed");

const signature = await sendAndConfirmTransaction(
  connection,
  transaction,
  [fromKeypair],
  {
    commitment: "confirmed",
    maxRetries: 3,
  }
);

console.log("Transaction signature:", signature);

Параметр maxRetries: 3 означає, що якщо валідатор не прийняв транзакцію одразу, клієнт зробить до трьох повторних спроб. commitment: "confirmed" вказує рівень підтвердження — транзакція має бути включена в блок, який отримав щонайменше один голос від супервалідатора (у Devnet це зазвичай відбувається швидко).

Якщо ви підписували транзакцію через гаманець (сценарій 2), використовуйте sendRawTransaction замість sendAndConfirmTransaction, оскільки транзакція вже підписана:

import { sendRawTransaction, confirmTransaction } from "@solana/web3.js";

const serialized = signedTransaction.serialize();
const signature = await connection.sendRawTransaction(serialized, {
  maxRetries: 3,
});

await connection.confirmTransaction(
  { signature, blockhash, lastValidBlockHeight },
  "confirmed"
);

Обробка таймауту та повторна відправка

Транзакція має обмежений час життя, визначений lastValidBlockHeight. Якщо мережа перевантажена або транзакція не потрапила в блок до цього моменту, confirmTransaction викине помилку TransactionExpiredBlockheightExceededError.

Правильна обробка таймауту виглядає так:

try {
  const { blockhash, lastValidBlockHeight } =
    await connection.getLatestBlockhash("confirmed");
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = fromKeypair.publicKey;

  const signature = await connection.sendRawTransaction(
    transaction.serialize(),
    { maxRetries: 3 }
  );

  await connection.confirmTransaction(
    { signature, blockhash, lastValidBlockHeight },
    "confirmed"
  );

  console.log("Підтверджено:", signature);
} catch (error) {
  if (error.message?.includes("Blockheight exceeded")) {
    console.log("Транзакція застаріла. Потрібно повторити з новим blockhash.");
  } else {
    throw error;
  }
}

При повторній відправці не можна використовувати ту саму транзакцію з тим самим recentBlockhash — потрібно отримати свіжий blockhash, пересобрати транзакцію та підписати заново. Solana не дозволяє replay однієї й тієї ж транзакції.

Обробка результату

Читання signature та статусу

Після успішного підтвердження sendAndConfirmTransaction повертає рядок — signature (підпис транзакції). Це базовий ідентифікатор, за яким можна знайти транзакцію в блокчейн-експлорері (наприклад, на https://explorer.solana.com з обраним кластером Devnet).

Для детальної перевірки статусу в будь-який момент після відправки використовуйте getSignatureStatuses:

const status = await connection.getSignatureStatuses([signature]);

const details = status.value[0];
if (details) {
  console.log("Confirmation status:", details.confirmationStatus);
  console.log("Confirmations:", details.confirmations);
  console.log("Err:", details.err);
}

Поле confirmationStatus може мати значення "processed", "confirmed" або "finalized". Для Devnet достатньо рівня "confirmed". Поле err дорівнює null для успішних транзакцій і містить об'єкт помилки, якщо транзакція була включена в блок, але виконання завершилося невдало (наприклад, недостатньо SOL на балансі).

Обробка помилок відправки

Помилки при відправці діляться на дві категорії: помилки мережі/RPC (транзакція не потрапила в блок) та помилки виконання (транзакція в блоці, але програма повернула помилку).

Перша категорія викидається як виняток під час sendRawTransaction або confirmTransaction. Друга категорія не завжди викидає виняток — іноді транзакція підтверджується, але з помилкою в err. Тому після підтвердження завжди перевіряйте статус:

const status = await connection.getSignatureStatuses([signature]);
const err = status.value[0]?.err;

if (err) {
  console.log("Транзакція відхилена на рівні виконання:", err);
  // err може бути рядком або об'єктом з полями InstructionError
} else {
  console.log("Транзакція успішно виконана.");
}

У production-рішеннях ця перевірка має бути обов'язковою частиною потоку після кожної відправки.

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

Transaction signature verification failed

Ця помилка означає, що серіалізована транзакція не містить коректного підпису для feePayer або для одного з обов'язкових підписувачів інструкції. Причини та рішення:

  • Транзакція не підписана взагалі. Переконайтеся, що transaction.sign(), partialSign() або wallet.signTransaction() викликано до серіалізації.
  • Підписувач не збігається з feePayer. Якщо feePayer встановлено на ключ A, але підписано ключем B — помилка. Перевірте, що feePayer збігається з тим, хто підписує.
  • Транзакція підписана двічі. Якщо ви викликали sign(), а потім передали той самий Keypair у масив signers sendAndConfirmTransaction — клієнт спробує підписати повторно, що спричинить помилку. Обирайте щось одне: або підписати вручну і використати sendRawTransaction, або передати signers у sendAndConfirmTransaction і не викликати sign() раніше.
  • Порядок signers у масиві не відповідає очікуваному. Деякі інструкції очікують підписувачів у конкретному порядку. Дивіться документацію програми.

Blockhash expired

Помилка TransactionExpiredBlockheightExceededError або повідомлення про застарілий blockhash виникає, коли між отриманням recentBlockhash і включенням транзакції в блок минуло забагато часу. У Devnet це може статися через низьку частоту блоків або перевантаження кластера.

Що робити:

  • Отримайте свіжий blockhash через connection.getLatestBlockhash("confirmed").
  • Пересоберіть транзакцію: створіть новий об'єкт Transaction, додайте ті самі інструкції, встановіть новий recentBlockhash та feePayer.
  • Підпишіть заново — старий підпис не дійсний для нової транзакції з іншим blockhash.
  • Відправте повторно.

У навченому прикладі це означає, що весь цикл (створення, підпис, відправка) має бути обгорнутий у функцію, яку можна викликати повторно:

async function sendWithRetry(
  connection: Connection,
  instructions: TransactionInstruction[],
  signers: Keypair[]
): Promise<string> {
  const { blockhash, lastValidBlockHeight } =
    await connection.getLatestBlockhash("confirmed");

  const transaction = new Transaction();
  instructions.forEach((ix) => transaction.add(ix));
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = signers[0].publicKey;

  const signature = await connection.sendRawTransaction(
    transaction.serialize(),
    { maxRetries: 3 }
  );

  await connection.confirmTransaction(
    { signature, blockhash, lastValidBlockHeight },
    "confirmed"
  );

  return signature;
}

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

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

Джерела