Структура транзакции

Кратко

Транзакция состоит из подписей и сообщения. Сообщение содержит заголовок, адреса аккаунтов, недавний blockhash и скомпилированные инструкции. Максимальный сериализованный размер: 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, или нонс-аккаунтом (проверяется 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 заголовка сообщения для определения прав доступа к каждому аккаунту без хранения флагов метаданных для каждого аккаунта. Счётчики заголовка разбивают массив на четыре группы прав доступа, перечисленных выше.

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

Последний 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 байтПоле 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 на token mint, программу или PDA, которым вы не управляете, грозит безвозвратной потерей средств — вернуть их может только авторитет, указанный программой-владельцем. SOL, отправленный на token account, может получить обратно только владелец этого аккаунта, но не отправитель.

Переводы SPL токенов частично защищены самой системой: Token Program отклоняет перевод, если аккаунты не соответствуют ожидаемому mint. Переводы нативного 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 (или на on-curve адрес без средств); отклоняйте минты, 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?