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

Кратко

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

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

Требования к плательщику комиссии:

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

Сообщение

Поле message — это структура Message, содержащая полезную нагрузку транзакции:

  • header: Заголовок сообщения (header)
  • account_keys: Массив адресов аккаунтов, необходимых для инструкций транзакции
  • recent_blockhash: blockhash, который выступает в роли временной метки для транзакции
  • instructions: Массив инструкций
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 слотов. Если 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. Для значений 0–127 используется 1 байт, для больших значений — 2–3 байта.

Структура legacy-транзакции (в сети):

ПолеРазмерОписание
num_signatures1–3 байта (compact-u16)Количество подписей
signaturesnum_signatures × 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 × 32 байтаПубличные ключи
recent_blockhash32 байтаBlockhash
num_instructions1–3 байта (compact-u16)Количество инструкций
instructionsпеременныйМассив скомпилированных инструкций

Каждая скомпилированная инструкция сериализуется следующим образом:

ПолеРазмерОписание
program_id_index1 байтИндекс в ключах аккаунтов
num_accounts1–3 байта (compact-u16)Количество индексов аккаунтов
account_indicesnum_accounts × 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 переводится с одного аккаунта на другой.

Метаданные аккаунта отправителя metadata указывают, что он должен подписать транзакцию. Это позволяет System Program списать lamport. Оба аккаунта — отправителя и получателя — должны быть доступны для записи, чтобы их баланс lamport мог измениться. Для выполнения этой инструкции кошелёк отправителя отправляет транзакцию с подписью и сообщением, содержащим инструкцию перевода SOL.

Диаграмма перевода SOLДиаграмма перевода SOL

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

Диаграмма процесса перевода SOLДиаграмма процесса перевода SOL

Проверьте получателя перед отправкой SOL

Перевод через System Program добавляет lamport на любой аккаунт. На уровне протокола не предусмотрено проверки того, сможет ли получатель вывести SOL обратно. Lamport могут быть выведены только программой-владельцем аккаунта, поэтому отправка SOL на 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 (или на незафондированный адрес на кривой); отклоняйте mint'ы, 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 accounts, 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"
}

Is this page helpful?