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

Підсумок

Транзакція містить підписи + повідомлення. Повідомлення містить заголовок, адреси облікових записів, останній блокхеш та скомпільовані інструкції. Максимальний серіалізований розмір: 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,
}

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

Адреси облікових записів

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

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

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

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

Останній blockhash

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

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

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

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

Інструкції

Поле 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 байти для більших значень.

Формат застарілої транзакції (у мережі):

ПолеРозмірОпис
num_signatures1-3 байти (compact-u16)Кількість підписів
signaturesnum_signatures x 64 байтиПідписи Ed25519
num_required_signatures1 байтПоле MessageHeader 1
num_readonly_signed1 байтПоле MessageHeader 2
num_readonly_unsigned1 байтПоле MessageHeader 3
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), див. Verify Address.

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

Після відправлення отримайте деталі транзакції за допомогою підпису транзакції та 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?