Кратко
Транзакция состоит из подписей и сообщения. Сообщение содержит заголовок, адреса аккаунтов, недавний blockhash и скомпилированные инструкции. Максимальный сериализованный размер: 1 232 байта.
Transaction
имеет два основных поля:
signatures: Массив подписейmessage: Информация о транзакции, включая список инструкций для обработки
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
структуру, содержащую полезную нагрузку транзакции:
header: Заголовок сообщенияaccount_keys: Массив адресов аккаунтов, необходимых инструкциям транзакцииrecent_blockhash: Хэш блока, выступающий в роли временной метки транзакцииinstructions: Массив инструкций
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: Количество неподписанных аккаунтов, доступных только для чтения.
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
представляет собой компактно закодированный массив публичных ключей. Каждая
запись идентифицирует аккаунт, используемый хотя бы одной из инструкций
транзакции. Массив должен включать каждый аккаунт и должен соответствовать
следующему строгому порядку:
- Подписант + Записываемый
- Подписант + Только для чтения
- Не подписант + Записываемый
- Не подписант + Только для чтения
Этот строгий порядок позволяет объединить массив account_keys с тремя
счётчиками в header заголовка сообщения для определения прав
доступа к каждому аккаунту без хранения флагов метаданных для каждого
аккаунта. Счётчики заголовка разбивают массив на четыре группы прав доступа,
перечисленных выше.
Диаграмма, показывающая порядок массива адресов аккаунтов
Последний blockhash
Поле recent_blockhash представляет собой 32-байтовый хэш, который служит двум
целям:
- Временна́я метка: подтверждает, что транзакция была создана недавно.
- Дедупликация: предотвращает повторную обработку одной и той же транзакции.
Blockhash истекает через 150 slot. Если blockhash больше не действителен на
момент прибытия транзакции, она отклоняется с ошибкой BlockhashNotFound,
если только это не действительная
транзакция с устойчивым nonce.
Метод RPC getLatestBlockhash позволяет
получить текущий blockhash и последнюю высоту блока, до которой blockhash
будет действителен.
Инструкции
Поле
instructions
представляет собой компактно закодированный массив структур
CompiledInstruction.
Каждая CompiledInstruction ссылается на аккаунты по индексу в массиве
account_keys, а не по полному публичному ключу. Она содержит:
program_id_index: Индекс вaccount_keys, идентифицирующий вызываемую программу.accounts: Массив индексов вaccount_keys, задающий аккаунты для передачи в программу.data: Байтовый массив, содержащий дискриминатор инструкции и сериализованные аргументы.
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_signatures | 1–3 байта (compact-u16) | Количество подписей |
signatures | num_signatures x 64 байта | Подписи Ed25519 |
num_required_signatures | 1 байт | Поле 1 MessageHeader |
num_readonly_signed | 1 байт | Поле 2 MessageHeader |
num_readonly_unsigned | 1 байт | Поле 3 MessageHeader |
num_account_keys | 1–3 байта (compact-u16) | Количество статических ключей аккаунтов |
account_keys | num_account_keys x 32 байта | Публичные ключи |
recent_blockhash | 32 байта | Blockhash |
num_instructions | 1–3 байта (compact-u16) | Количество инструкций |
instructions | переменный | Массив скомпилированных инструкций |
Каждая скомпилированная инструкция сериализуется следующим образом:
| Поле | Размер | Описание |
|---|---|---|
program_id_index | 1 байт | Индекс в ключах аккаунтов |
num_accounts | 1–3 байта (compact-u16) | Количество индексов аккаунтов |
account_indices | num_accounts x 1 байт | Индексы ключей аккаунтов |
data_len | 1–3 байта (compact-u16) | Длина instruction data |
data | data_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
После отправки транзакции System Program обрабатывает инструкцию перевода и обновляет баланс в lamport обоих аккаунтов.
Схема процесса перевода 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 transferconst { 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 recipientconst 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 transferconst { 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);
В следующем примере показана структура транзакции, содержащей одну инструкцию перевода 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 transferconst 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 recipientconst transferInstruction = client.system.instructions.transferSol({source: sender,destination: recipient.address,amount: transferAmount});// Create transaction messageconst transactionMessage = pipe(createTransactionMessage({ version: 0 }),(tx) => setTransactionMessageFeePayerSigner(sender, tx),(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),(tx) => appendTransactionMessageInstructions([transferInstruction], tx));const signedTransaction =await signTransactionMessageWithSigners(transactionMessage);// Decode the messageBytesconst compiledTransactionMessage =getCompiledTransactionMessageDecoder().decode(signedTransaction.messageBytes);console.log(JSON.stringify(compiledTransactionMessage, null, 2));
Код ниже показывает вывод из предыдущих фрагментов кода. Формат различается в зависимости от 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, которые вы не контролируете.
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);
Этот фрагмент проверяет получателей нативного SOL. Для полной классификации, которая также охватывает отправку токенов SPL (token account'ы, ATA, Token-2022), см. Verify Address.
Получение деталей транзакции
После отправки извлеките детали транзакции с помощью подписи транзакции и RPC-метода getTransaction.
Вы также можете найти транзакцию с помощью Solana Explorer.
{"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?