Версійні транзакції

Підсумок

Solana має три формати транзакцій: legacy, v0 та v1. v0 додає таблиці пошуку адрес (ALT) для посилань на акаунти через 1-байтові індекси. v1 підвищує ліміт розміру до 4 096 байт, переміщує ліміти ресурсів безпосередньо до повідомлення та прибирає ALT.

Solana підтримує три формати транзакцій: legacy, v0 та v1. Кожен з них описано нижче з однаковими трьома складовими: як він розміщує байти при передачі, як посилається на акаунти та звідки беруться його ліміти ресурсів.

Статус активації v1

Формат v1 активний у mainnet, devnet і testnet. Читання транзакцій є несумісною зміною: клієнти, які не ввімкнули його підтримку, завершуються помилкою на транзакціях v1. Наявним застосункам варто ознайомитися з розділом підготовка до v1.

Порівняння форматів

Лімітlegacyv0v1
Максимальний розмір транзакції1 232 байти1 232 байти4 096 байт
Адреси акаунтів~32, обмежено розміром64, через таблиці пошуку64, вбудовані (96 з SIMD-0596)
Таблиці пошуку адресне підтримуютьсяпідтримуютьсяне підтримуються
Ліміти ресурсівІнструкції ComputeBudgetІнструкції ComputeBudgetконфігурація повідомлення

Перейти до: Legacy · v0 · v1

Формат legacy

Початковий формат, який досі є типовим у більшості інструментів. Він не має жодного префікса версії: перший байт транзакції — це compact-u16 лічильник масиву підписів, а перший байт повідомлення — num_required_signatures, старший біт якого завжди скинутий.

Розміщення байт legacy при передачі

ПолеРозмірОпис
num_signaturescompact-u16Кількість підписів
signaturesnum_signatures x 64 байтиПідписи Ed25519
header3 байтиMessageHeader — старший біт першого байта скинутий
num_account_keyscompact-u16Кількість ключів акаунтів
account_keysnum_account_keys x 32 байтиПублічні ключі, всі вбудовані
recent_blockhash32 байтиСпецифікатор терміну дії
num_instructionscompact-u16Кількість інструкцій
instructionsзміннаКожна інструкція серіалізована послідовно

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

Акаунти у форматі legacy

Кожен акаунт записується як повний 32-байтовий публічний ключ у account_keys, а інструкції посилаються на них за 1-байтовим індексом у цьому масиві. Немає можливості посилатися на акаунт, якого немає в транзакції, що й обмежує транзакцію legacy приблизно 32 акаунтами до вичерпання 1 232 байт.

Ліміти ресурсів у форматі legacy

Ліміт обчислювальних одиниць, ліміт розміру даних завантажених акаунтів, розмір кучі та комісія за пріоритет запитуються шляхом включення інструкцій програми ComputeBudget до транзакції. Кожна з них витрачає один slot інструкції та 150 обчислювальних одиниць. Їх відсутність безпечна: середовище виконання використовує значення за замовчуванням — 200 000 CU на інструкцію (не більше 1,4M), ліміт розміру даних 64 МіБ, кучу 32 КіБ та нульову комісію за пріоритет.

Формат v0

v0 — це повідомлення legacy плюс дві речі: байт-префікс версії 0x80 та масив address_table_lookups, доданий після інструкцій. Усе, що передує їм, побайтово ідентично legacy.

Розміщення байт v0 при передачі

ПолеРозмірОпис
num_signaturescompact-u16Кількість підписів
signaturesnum_signatures x 64 байтиПідписи Ed25519
0x801 байтБайт-префікс версії — перший байт повідомлення
header3 байтиMessageHeader (такий самий, як у застарілому)
num_account_keyscompact-u16Кількість статичних ключів акаунтів
static_account_keysnum_account_keys x 32 байтиКлючі, що з'являються буквально в транзакції
recent_blockhash32 байтиСпецифікатор терміну дії
num_instructionscompact-u16Кількість інструкцій
instructionsзміннаТой самий формат, що й у застарілому
address_table_lookupscompact-u16 + зміннийПосилання на ALT (див. нижче)

Кожен запис таблиці пошуку адрес містить:

ПолеРозмірОпис
account_key32 байтиПублічний ключ облікового запису ALT
writable_indexescompact-u16 + N x 1 байтІндекси в ALT для облікових записів з правом запису
readonly_indexescompact-u16 + N x 1 байтІндекси в ALT для облікових записів лише для читання

Таблиці пошуку адрес

ALT — це онлайн-обліковий запис, який зберігає до 256 публічних ключів. Посилаючись на ALT, транзакція може включати додаткові облікові записи, використовуючи 1-байтові індекси замість 32-байтових публічних ключів, що значно зменшує витрати на кожен обліковий запис.

Під час виконання, перед початком обробки, валідатор розв'язує всі посилання на ALT у повні публічні ключі. Розв'язані адреси додаються до статичних ключів облікових записів, формуючи повний список ключів облікових записів. Облікові записи, розв'язані через ALT, дотримуються того ж порядку, що й статичні облікові записи: записи з правом запису йдуть перед записами лише для читання.

Таблиці пошуку адрес впливають лише на те, як облікові записи посилаються в он-вайр транзакції. Під час виконання середовище виконання розв'язує всі індекси в повні адреси облікових записів. Облікові записи, розв'язані через ALT, можуть бути лише з правом запису або лише для читання (не підписувачі); вони не можуть бути підписувачами.

Створення та використання таблиці пошуку адрес

Цей приклад створює таблицю пошуку, додає до неї адресу, а потім використовує таблицю для компіляції та надсилання транзакції v0.

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

Ліміти ресурсів у форматі v0

Незмінні порівняно з legacy: інструкції ComputeBudget з тими самими значеннями за замовчуванням за їх відсутності.

Формат v1

v1 підвищує ліміт розміру до 4 096 байт та реструктурує повідомлення навколо конфігурації транзакції: ліміти ресурсів переходять з інструкцій ComputeBudget у поля фіксованої позиції безпосередньо в повідомленні. Це дозволяє мережі визначати пріоритет транзакції за комісією одним зчитуванням зі фіксованим зміщенням, без сканування та десеріалізації списку інструкцій.

Розміщення байт v1 при передачі

ПолеРозмірОпис
0x811 байтБайт-префікс версії — перший байт транзакції
header3 байтиMessageHeader (такий самий, як у застарілому)
config_mask4 байтиБітова маска u32 LE, що позначає наявні значення конфігурації
recent_blockhash32 байтиСпецифікатор терміну дії
num_instructions1 байтЛічильник фіксованої ширини, макс. 64
num_addresses1 байтЛічильник фіксованої ширини, макс. 64 (96 з SIMD-0596)
addressesN x 32 байтиАдреси акаунтів, всі вбудовані — без посилань на таблиці пошуку
config_values0–20 байтОдне значення на встановлений біт маски, у порядку бітів (дивіться нижче)
instruction_headersN x 4 байтиНа інструкцію: program_id_index (u8), num_accounts (u8), data_len (u16 LE)
instruction_payloadsзміннаНа інструкцію: індекси акаунтів, потім instruction data
signaturesN x 64 байтиУ кінці, без префікса довжини — кількість береться із заголовка

Варто відзначити дві структурні відмінності від legacy та v0 під час написання декодера. Лічильники є полями фіксованої ширини u8, а не compact-u16, а інструкції розбиті на два блоки: спочатку всі заголовки фіксованого розміру, потім всі корисні навантаження змінної довжини, замість того щоб кожна інструкція йшла послідовно.

Акаунти у v1: без таблиць пошуку адрес

v1 свідомо прибирає підтримку ALT. 64 «сирі» адреси — це 2 048 байт, а 96 адрес, дозволених після SIMD-0596, — це 3 072 байти; обидва значення комфортно вкладаються в ліміт 4 096 байт, тому кожна адреса вбудована в повідомлення, як і у форматі legacy. Якщо ваш застосунок залежить від таблиць пошуку, перехід на v1 означає вбудовування цих адрес.

Ліміти ресурсів у v1: конфігурація транзакції

Конфігурація — це бітова маска u32, за якою йдуть значення фіксованої ширини для кожного поля, чий біт встановлено:

Біт(и)ПолеШиринаПримітки
0–1Комісія за пріоритетu64Загальна кількість lamport — обидва біти встановлюються разом
2Ліміт обчислювальних одиницьu32
3Ліміт розміру даних завантажених акаунтівu32
4Запитуваний розмір кучіu32

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

Комісія за пріоритет — це загальна кількість lamport, а не ціна

У legacy та v0 комісія за пріоритет встановлюється через SetComputeUnitPrice як мікро-lamport на обчислювальну одиницю, помножені на ліміт обчислювальних одиниць. У v1 це абсолютна загальна сума в lamport — без множення, без округлення. Не переносьте арифметику per-CU. Формула загальної комісії залишається незмінною: (signatures × lamports_per_signature) + priority_fee.

Інструкції ComputeBudget є холостими операціями у v1

Транзакція v1 не відхиляє інструкції ComputeBudget — вона ігнорує їх для налаштування. Вони все одно виконуються як успішні холості операції, витрачаючи 150 обчислювальних одиниць та один із 64 slot інструкцій, не vпливаючи на бюджет. Видаляйте їх при побудові транзакцій v1 та припиняйте сканувати їх при читанні транзакцій v1: значення містяться в конфігурації повідомлення.

Поля конфігурації мають бути встановлені явно

Найважливіша зміна поведінки для відправників: на відміну від legacy та v0, транзакції v1 повинні явно встановлювати ліміт обчислювальних одиниць та ліміт розміру даних завантажених акаунтів, інакше транзакція завершиться помилкою.

Невстановлене полеlegacy / v0v1Симптом за відсутності
Ліміт обчислювальних одиниць200k на інструкцію, макс. 1,4M0 CUНегайна відмова, бюджет вичерпано
Розмір даних завантажених акаунтів64 МіБ0 байтMaxLoadedAccountsDataSizeExceeded під час завантаження першого акаунта
Комісія за пріоритет00—
Розмір кучі32 КіБ32 КіБ—

Рекомендований підхід: виконайте симуляцію один раз із максимальними значеннями обох лімітів, а потім запишіть повернуті unitsConsumed та loadedAccountsDataSize назад у конфігурацію, округливши розмір даних до наступної сторінки по 32 КіБ для запасу (модель вартості блоку тарифікує в сторінках по 32 КіБ, тому запас нижче межі наступної сторінки є безкоштовним).

Побудова транзакції v1

Повідомлення v1 збирається через той самий конвеєр, що й повідомлення legacy або v0. Різниця полягає в setTransactionMessageConfig: ліміти ресурсів є властивістю повідомлення, тому список інструкцій містить лише інструкції програм.

Kit
import {
appendTransactionMessageInstruction,
assertIsTransactionWithBlockhashLifetime,
createClient,
createTransactionMessage,
generateKeyPairSigner,
getSignatureFromTransaction,
lamports,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageConfig,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";
import { airdropPayer, payer } from "@solana/kit-plugin-signer";
import { getTransferSolInstruction } from "@solana-program/system";
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
const client = await createClient()
.use(payer(sender))
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900",
}),
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)));
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
// A v1 message is assembled through the same pipeline as a legacy or v0 one.
// The difference is the config: a v1 transaction carries its resource limits in
// the message itself, so no ComputeBudget instructions are compiled in.
const transactionMessage = pipe(
createTransactionMessage({ version: 1 }),
(m) => setTransactionMessageFeePayerSigner(sender, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(10_000_000n),
}),
m,
),
// The compute unit limit and loaded accounts data size limit both default to
// zero on v1, so a transaction that leaves them unset always fails. The
// priority fee is an absolute total in lamports, not a price per compute
// unit.
(m) =>
setTransactionMessageConfig(
{
computeUnitLimit: 20_000,
loadedAccountsDataSizeLimit: 64 * 1024,
priorityFeeLamports: 5_000n,
},
m,
),
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
assertIsTransactionWithBlockhashLifetime(signedTransaction);
await sendAndConfirmTransactionFactory({
rpc: client.rpc,
rpcSubscriptions: client.rpcSubscriptions,
})(signedTransaction, { commitment: "confirmed" });
const transactionSignature = getSignatureFromTransaction(signedTransaction);
console.log("Transaction Signature:", transactionSignature);
Console
Click to execute the code.

Kit також надає окремий сеттер для кожного поля конфігурації — setTransactionMessagePriorityFeeLamports, setTransactionMessageComputeUnitLimit, setTransactionMessageLoadedAccountsDataSizeLimit та setTransactionMessageHeapSize — для встановлення їх по одному.

Визначення лімітів за допомогою симуляції

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

Kit
import {
appendTransactionMessageInstruction,
assertIsTransactionWithBlockhashLifetime,
createClient,
createTransactionMessage,
estimateAndSetResourceLimitsFactory,
estimateResourceLimitsFactory,
fillTransactionMessageProvisoryResourceLimits,
generateKeyPairSigner,
getSignatureFromTransaction,
lamports,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
setTransactionMessagePriorityFeeLamports,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";
import { airdropPayer, payer } from "@solana/kit-plugin-signer";
import { getTransferSolInstruction } from "@solana-program/system";
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
const client = await createClient()
.use(payer(sender))
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900",
}),
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)));
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
// The priority fee is a pricing decision that simulation cannot measure, so it
// is set directly. The two resource limits are measured below instead of
// guessed. The provisory fill writes placeholder limits so the message
// simulates at the same size it will be sent at.
const draftMessage = pipe(
createTransactionMessage({ version: 1 }),
(m) => setTransactionMessageFeePayerSigner(sender, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(10_000_000n),
}),
m,
),
(m) => setTransactionMessagePriorityFeeLamports(5_000n, m),
fillTransactionMessageProvisoryResourceLimits,
);
// Simulation runs with both limits raised to the runtime maximum, so it cannot
// fail for want of the resources it is measuring.
const estimateAndSetResourceLimits = estimateAndSetResourceLimitsFactory(
estimateResourceLimitsFactory({ rpc: client.rpc }),
);
const transactionMessage = await estimateAndSetResourceLimits(draftMessage, {
commitment: "confirmed",
});
console.log("Compute unit limit:", transactionMessage.config?.computeUnitLimit);
console.log(
"Loaded accounts data size limit:",
transactionMessage.config?.loadedAccountsDataSizeLimit,
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
assertIsTransactionWithBlockhashLifetime(signedTransaction);
await sendAndConfirmTransactionFactory({
rpc: client.rpc,
rpcSubscriptions: client.rpcSubscriptions,
})(signedTransaction, { commitment: "confirmed" });
// The estimate is the exact cost of one simulated run, with nothing to spare.
// Add a margin before relying on it in production.
console.log(
"Transaction Signature:",
getSignatureFromTransaction(signedTransaction),
);
Console
Click to execute the code.

Підготовка до v1

v1 змінює читання транзакцій, а не лише їх надсилання. Будь-який клієнт, який викликає getTransaction або getBlock без явного ввімкнення підтримки, завершується помилкою на транзакціях v1:

  • Передайте maxSupportedTransactionVersion: 1 — ціле число JSON 1, а не рядок "1" — до getTransaction та getBlock. Передача 0 завершується помилкою на транзакціях v1 так само, як і відсутність параметра, тому кодова база, оновлена під час розгортання v0, все одно потребує зміни цього значення.
  • getTransaction завершується помилкою на транзакції v1 з кодом -32015, а одна транзакція v1 призводить до помилки цілої відповіді getBlock з тим самим кодом — часткового результату немає.
  • blockSubscribe повертає block: null і припиняє просування, тому споживач, який сприймає це як порожній блок, мовчки відстає починаючи з першого slot v1.
  • getSignaturesForAddress ніколи не перевіряє тіла транзакцій, тому підписи v1 відображаються в звичайному режимі.
  • Відповіді з ввімкненою підтримкою містять для транзакцій v1 об'єкт transactionConfig у повідомленні (для legacy і v0 він повністю відсутній). Конвеєри, які визначають пріоритетні комісії або ліміти обчислень скануванням інструкцій ComputeBudget, мовчки повідомляють нуль для кожної транзакції v1.
  • Використовуйте encoding: 'base64' при декодуванні транзакцій на стороні клієнта, а також для sendTransaction/simulateTransaction з транзакціями понад 1 232 байти — кодування base58 залишається обмеженим старим розміром.
  • Підтримка клієнтських бібліотек вимагає останніх версій: @solana/kit 8.0+, Rust-крейти покоління Agave 4.2.x або web3.js v3. web3.js v1 читає v1 починаючи з версії 1.99.0, але не може будувати чи надсилати транзакції v1.

Читання транзакції v1

Для ввімкнення підтримки потрібне JSON-ціле число 1. Декодування повернутих байтів дає версію та конфігурацію, де й зберігаються ліміти ресурсів — сканування списку інструкцій на наявність інструкцій ComputeBudget нічого не знаходить.

Kit
import {
decompileTransactionMessage,
getBase64Encoder,
getCompiledTransactionMessageDecoder,
getTransactionDecoder,
} from "@solana/kit";
// Reading a v1 transaction requires opting in. The integer 1 is mandatory:
// omitting the parameter, or passing 0, fails on a v1 transaction rather than
// returning it in a degraded form.
const fetched = await client.rpc
.getTransaction(transactionSignature, {
commitment: "confirmed",
encoding: "base64",
maxSupportedTransactionVersion: 1,
})
.send();
if (fetched === null) {
throw new Error("the transaction just sent was not found");
}
// The compiled message stores the config as a bitmask plus a positional list,
// so it is decompiled to address the fields by name. Decompiling fetches no
// accounts, because v1 does not support address lookup tables.
const wireTransaction = getBase64Encoder().encode(fetched.transaction[0]);
const transaction = getTransactionDecoder().decode(wireTransaction);
const compiledMessage = getCompiledTransactionMessageDecoder().decode(
transaction.messageBytes,
);
const message = decompileTransactionMessage(compiledMessage);
console.log("Version:", message.version);
if ("config" in message) {
// Scanning the instruction list for ComputeBudget instructions finds nothing
// here — on v1 the limits live in the config.
console.log("Compute unit limit:", message.config?.computeUnitLimit);
console.log("Priority fee (lamports):", message.config?.priorityFeeLamports);
}
Console
Click to execute the code.

Повний посібник із міграції — зокрема підтримку клієнтських бібліотек, визначення версії у потокових даних (Geyser/gRPC) та поведінку симуляції — дивіться на сторінці оновлення до формату транзакцій v1. Працездатні приклади на TypeScript, Rust, Python і Go розміщено в solana-foundation/transaction-v1-examples.

Is this page helpful?