Структура транзакції

Підсумок

Транзакція містить підписи + повідомлення. Повідомлення містить заголовок, адреси облікових записів, останній блокхеш та скомпільовані інструкції. Максимальний серіалізований розмір: 1 232 байти.

Transaction має два поля верхнього рівня:

  • signatures: масив підписів
  • message: інформація про транзакцію, включаючи список інструкцій для обробки
Transaction
pub struct Transaction {
pub signatures: Vec<Signature>,
pub message: Message,
}

Діаграма, що показує дві частини транзакціїДіаграма, що показує дві частини транзакції

Загальний серіалізований розмір транзакції не повинен перевищувати PACKET_DATA_SIZE (1 232 байти). Це обмеження дорівнює 1 280 байтам (мінімальний MTU IPv6) мінус 48 байтів для мережевих заголовків (40 байтів IPv6 + 8 байтів заголовок фрагмента). 1 232 байти включають як масив signatures, так і структуру message.

Діаграма, що показує формат транзакції та обмеження розміруДіаграма, що показує формат транзакції та обмеження розміру

Підписи

Поле signatures — це компактно закодований масив значень Signature. Кожен Signature — це 64-байтний підпис Ed25519 серіалізованого Message, підписаний приватним ключем облікового запису підписувача. Один підпис потрібен для кожного облікового запису підписувача, на який посилаються інструкції транзакції.

Кожен підпис створюється приватним ключем. Де зберігається цей ключ — локальний keypair, хмарний HSM або KMS, чи керований сервіс гаманця — є рішенням у рамках проєктування виробничої системи. Дивіться Підписання у виробничому середовищі.

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

Вимоги до платника комісії:

  • Повинен бути першим обліковим записом у повідомленні (індекс 0) і підписантом.
  • Повинен бути обліковим записом, що належить System Program, або nonce-обліковим записом (перевіряється validate_fee_payer).
  • Повинен мати достатньо lamport для покриття rent_exempt_minimum + total_fee; інакше транзакція завершується з помилкою InsufficientFundsForFee.

Повідомлення

Поле message є Message структурою, що містить корисне навантаження транзакції:

Message
pub struct Message {
/// The message header, identifying signed and read-only `account_keys`.
pub header: MessageHeader,
/// All the account keys used by this transaction.
#[serde(with = "short_vec")]
pub account_keys: Vec<Pubkey>,
/// The id of a recent ledger entry.
pub recent_blockhash: Hash,
/// Programs that will be executed in sequence and committed in
/// one atomic transaction if all succeed.
#[serde(with = "short_vec")]
pub instructions: Vec<CompiledInstruction>,
}

Заголовок

Поле header є MessageHeader структурою з трьома полями u8, що розподіляють масив account_keys на групи за дозволами:

  • num_required_signatures: Загальна кількість підписів, необхідних для транзакції.
  • num_readonly_signed_accounts: Кількість підписаних облікових записів, доступних лише для читання.
  • num_readonly_unsigned_accounts: Кількість непідписаних облікових записів, доступних лише для читання.
MessageHeader
pub struct MessageHeader {
/// The number of signatures required for this message to be considered
/// valid. The signers of those signatures must match the first
/// `num_required_signatures` of [`Message::account_keys`].
pub num_required_signatures: u8,
/// The last `num_readonly_signed_accounts` of the signed keys are read-only
/// accounts.
pub num_readonly_signed_accounts: u8,
/// The last `num_readonly_unsigned_accounts` of the unsigned keys are
/// read-only accounts.
pub num_readonly_unsigned_accounts: u8,
}

Префікси застарілих і версійних повідомлень

У повідомленні застарілої транзакції перший байт повідомлення — це num_required_signatures, за яким іде два інших байти MessageHeader. У версійному повідомленні транзакції перший байт є префіксом версії; тричайтовий MessageHeader починається одразу після цього префікса. Дивіться версійні транзакції для повного макета повідомлення v0.

Діаграма, що показує три частини заголовка повідомленняДіаграма, що показує три частини заголовка повідомлення

Адреси акаунтів

Поле account_keys є компактно закодованим масивом відкритих ключів. Кожен запис ідентифікує акаунт, що використовується принаймні одною з інструкцій транзакції. Масив повинен містити кожен акаунт і дотримуватись такого суворого порядку:

  1. Підписант + Записуваний
  2. Підписант + Лише для читання
  3. Не підписант + Записуваний
  4. Не підписант + Лише для читання

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

Діаграма, що показує порядок масиву адрес акаунтівДіаграма, що показує порядок масиву адрес акаунтів

Останній блокхеш

Поле recent_blockhash — це 32-байтовий хеш, що виконує дві функції:

  1. Мітка часу: підтверджує, що транзакція була створена нещодавно.
  2. Дедублікація: запобігає повторній обробці однієї й тієї ж транзакції.

Блокхеш закінчує дію після 150 slot. Якщо блокхеш більше не є дійсним на момент надходження транзакції, вона відхиляється з помилкою BlockhashNotFound, якщо це не є дійсною транзакцією зі стійким nonce.

Метод RPC getLatestBlockhash дозволяє отримати поточний блокхеш і останню висоту блоку, до якої блокхеш буде дійсним.

Інструкції

Поле instructions є компактно закодованим масивом структур CompiledInstruction. Кожен CompiledInstruction посилається на акаунти за індексом у масиві account_keys, а не за повним публічним ключем. Він містить:

  1. program_id_index: Індекс у account_keys, що визначає програму для виклику.
  2. accounts: Масив індексів у account_keys, що вказує акаунти для передачі програмі.
  3. data: Масив байтів, що містить дискримінатор інструкції та серіалізовані аргументи.
CompiledInstruction
pub struct CompiledInstruction {
/// Index into the transaction keys array indicating the program account that executes this instruction.
pub program_id_index: u8,
/// Ordered indices into the transaction keys array indicating which accounts to pass to the program.
#[serde(with = "short_vec")]
pub accounts: Vec<u8>,
/// The program input data.
#[serde(with = "short_vec")]
pub data: Vec<u8>,
}

Компактний масив інструкційКомпактний масив інструкцій

Бінарний формат транзакції

Транзакції серіалізуються за допомогою схеми компактного кодування. Усі масиви змінної довжини (підписи, ключі акаунтів, інструкції) мають префікс із довжиною у форматі compact-u16. Цей формат використовує 1 байт для значень 0–127 та 2–3 байти для більших значень.

Структура legacy-транзакції (у мережі):

ПолеРозмірОпис
num_signatures1–3 байти (compact-u16)Кількість підписів
signaturesnum_signatures x 64 байтиПідписи Ed25519
num_required_signatures1 байтПоле 1 MessageHeader
num_readonly_signed1 байтПоле 2 MessageHeader
num_readonly_unsigned1 байтПоле 3 MessageHeader
num_account_keys1–3 байти (compact-u16)Кількість статичних ключів акаунтів
account_keysnum_account_keys x 32 байтиПублічні ключі
recent_blockhash32 байтиBlockhash
num_instructions1–3 байти (compact-u16)Кількість інструкцій
instructionsзміннийМасив скомпільованих інструкцій

Кожна скомпільована інструкція серіалізується таким чином:

ПолеРозмірОпис
program_id_index1 байтІндекс у ключах акаунтів
num_accounts1–3 байти (compact-u16)Кількість індексів акаунтів
account_indicesnum_accounts x 1 байтІндекси ключів акаунтів
data_len1–3 байти (compact-u16)Довжина instruction data
datadata_len байтівНепрозорі instruction data

Розрахунок розміру

За умови PACKET_DATA_SIZE = 1 232 байти, доступний простір можна розрахувати:

Total = 1232 bytes
- compact-u16(num_sigs) # 1 byte
- num_sigs * 64 # signature bytes
- 3 # message header
- compact-u16(num_keys) # 1 byte
- num_keys * 32 # account key bytes
- 32 # recent blockhash
- compact-u16(num_ixs) # 1 byte
- sum(instruction_sizes) # per-instruction overhead + data

Приклад: транзакція переказу SOL

Діаграма нижче показує, як транзакції та інструкції працюють разом, дозволяючи користувачам взаємодіяти з мережею. У цьому прикладі SOL переказується з одного акаунту на інший.

Метадані акаунту відправника вказують, що він повинен підписати транзакцію. Це дозволяє System Program знімати lamport. Обидва акаунти — відправника та одержувача — мають бути доступні для запису, щоб їхній баланс у lamport міг змінитися. Для виконання цієї інструкції гаманець відправника надсилає транзакцію, що містить його підпис і повідомлення з інструкцією переказу SOL.

Діаграма переказу SOLДіаграма переказу SOL

Після надсилання транзакції System Program обробляє інструкцію переказу та оновлює баланс у lamport обох акаунтів.

Діаграма процесу переказу SOLДіаграма процесу переказу SOL

Перевірте одержувача перед надсиланням SOL

Переказ через System Program додає lamport на будь-який акаунт. На рівні протоколу немає перевірки того, чи може одержувач вивести SOL назад. Lamport можуть бути виведені лише програмою-власником акаунту, тому надсилання SOL на токен-мінт, програму або PDA, яким ви не керуєте, загрожує безповоротною втратою коштів — повернути їх може лише уповноважена особа, визначена програмою-власником. SOL, надісланий на token account, може бути повернутий лише власником цього акаунту, але не відправником.

Перекази SPL токенів частково самозахищені: Token Program відхиляє переказ, якщо акаунти не відповідають очікуваному мінту. Перекази нативного SOL не мають такого захисту, тому відправник повинен перевірити одержувача перед підписанням. Повну логіку класифікації див. у розділі Перевірка адреси.

Наведений нижче приклад показує код, що відповідає діаграмам вище. Дивіться функцію transfer System Program.

import { createClient, generateKeyPairSigner, lamports } from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const sender = client.payer;
const recipient = await generateKeyPairSigner();
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Check balance before transfer
const { value: preBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: preBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount // 0.01 SOL in lamports
});
const transactionSignature = await client.sendTransaction([
transferInstruction
]);
// Check balance after transfer
const { value: postBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: postBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
console.log(
"Sender prebalance:",
Number(preBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient prebalance:",
Number(preBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Sender postbalance:",
Number(postBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient postbalance:",
Number(postBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log("Transaction Signature:", transactionSignature.context.signature);
Console
Click to execute the code.

Наступний приклад показує структуру транзакції, що містить одну інструкцію переказу SOL.

import {
createClient,
generateKeyPairSigner,
lamports,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
pipe,
signTransactionMessageWithSigners,
getCompiledTransactionMessageDecoder
} from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
const sender = client.payer;
const recipient = await generateKeyPairSigner();
// Define the amount to transfer
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount
});
// Create transaction message
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([transferInstruction], tx)
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
// Decode the messageBytes
const compiledTransactionMessage =
getCompiledTransactionMessageDecoder().decode(signedTransaction.messageBytes);
console.log(JSON.stringify(compiledTransactionMessage, null, 2));
Console
Click to execute the code.

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

{
"version": 0,
"header": {
"numSignerAccounts": 1,
"numReadonlySignerAccounts": 0,
"numReadonlyNonSignerAccounts": 1
},
"staticAccounts": [
"HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa",
"5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
"11111111111111111111111111111111"
],
"lifetimeToken": "EGCWPUEXhqHJWYBfDirq3mHZb4qDpATmYqBZMBy9TBC1",
"instructions": [
{
"programAddressIndex": 2,
"accountIndices": [0, 1],
"data": {
"0": 2,
"1": 0,
"2": 0,
"3": 0,
"4": 128,
"5": 150,
"6": 152,
"7": 0,
"8": 0,
"9": 0,
"10": 0,
"11": 0
}
}
]
}

Перевірте отримувача перед переказом

Оскільки переказ SOL успішно виконується на будь-який акаунт, перевірте отримувача перед підписанням. Отримайте акаунт і надсилайте кошти лише на гаманець System Program (або на незафінансовану адресу на кривій); відхиляйте мінти, token account, програми та PDA, якими ви не керуєте.

Kit
import {
type Address,
createSolanaRpc,
fetchJsonParsedAccount,
isOffCurveAddress
} from "@solana/kit";
const rpc = createSolanaRpc("https://api.mainnet-beta.solana.com");
const SYSTEM_PROGRAM = "11111111111111111111111111111111" as Address;
/**
* Throws if `recipient` cannot safely receive native SOL.
*
* Only System Program wallets (or unfunded on-curve addresses) are safe. Any
* other account locks the lamports because no authority can debit them.
*/
async function assertSafeSolRecipient(recipient: Address): Promise<void> {
const account = await fetchJsonParsedAccount(rpc, recipient);
if (!account.exists) {
// Off-curve = a PDA with no account; reject conservatively.
if (isOffCurveAddress(recipient)) {
throw new Error(
"Recipient is a PDA with no account; SOL would be locked"
);
}
// On-curve = an unfunded wallet, safe to fund.
return;
}
if (account.programAddress !== SYSTEM_PROGRAM) {
throw new Error(
`Recipient is owned by ${account.programAddress}, not a wallet; SOL would be locked`
);
}
}
// A wallet: safe.
await assertSafeSolRecipient(
"H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS" as Address
);
// The USDC mint: rejected before any SOL leaves the sender.
await assertSafeSolRecipient(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" as Address
);
Console
Click to execute the code.

Цей фрагмент перевіряє отримувачів нативного SOL. Для повної класифікації, яка також охоплює відправлення токенів SPL (token account, ATA, Token-2022), дивіться Перевірка адреси.

Отримання деталей транзакції

Після надсилання отримайте деталі транзакції за допомогою підпису транзакції та RPC-методу getTransaction.

Також можна знайти транзакцію за допомогою Solana Explorer.

Transaction Data
{
"blockTime": 1745196488,
"meta": {
"computeUnitsConsumed": 150,
"err": null,
"fee": 5000,
"innerInstructions": [],
"loadedAddresses": {
"readonly": [],
"writable": []
},
"logMessages": [
"Program 11111111111111111111111111111111 invoke [1]",
"Program 11111111111111111111111111111111 success"
],
"postBalances": [989995000, 10000000, 1],
"postTokenBalances": [],
"preBalances": [1000000000, 0, 1],
"preTokenBalances": [],
"rewards": [],
"status": {
"Ok": null
}
},
"slot": 13049,
"transaction": {
"message": {
"header": {
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1,
"numRequiredSignatures": 1
},
"accountKeys": [
"8PLdpLxkuv9Nt8w3XcGXvNa663LXDjSrSNon4EK7QSjQ",
"7GLg7bqgLBv1HVWXKgWAm6YoPf1LoWnyWGABbgk487Ma",
"11111111111111111111111111111111"
],
"recentBlockhash": "7ZCxc2SDhzV2bYgEQqdxTpweYJkpwshVSDtXuY7uPtjf",
"instructions": [
{
"accounts": [0, 1],
"data": "3Bxs4NN8M2Yn4TLb",
"programIdIndex": 2,
"stackHeight": null
}
],
"indexToProgramIds": {}
},
"signatures": [
"3jUKrQp1UGq5ih6FTDUUt2kkqUfoG2o4kY5T1DoVHK2tXXDLdxJSXzuJGY4JPoRivgbi45U2bc7LZfMa6C4R3szX"
]
},
"version": "legacy"
}

Необроблена відповідь ідентифікує акаунти за індексом і зберігає внутрішні (CPI) інструкції у вигляді закодованих блоків. Щоб перетворити їх на адреси та пройти повне дерево інструкцій, дивіться Інтроспекція транзакцій.

Is this page helpful?