Підсумок
Solana має три формати транзакцій: legacy, v0 та v1. v0 додає таблиці пошуку адрес (ALT) для посилань на акаунти через 1-байтові індекси. v1 підвищує ліміт розміру до 4 096 байт, переміщує ліміти ресурсів безпосередньо до повідомлення та прибирає ALT.
Solana підтримує три формати транзакцій: legacy, v0 та v1. Кожен з них описано нижче з однаковими трьома складовими: як він розміщує байти при передачі, як посилається на акаунти та звідки беруться його ліміти ресурсів.
Статус активації v1
Формат v1 активний у mainnet, devnet і testnet. Читання транзакцій є несумісною зміною: клієнти, які не ввімкнули його підтримку, завершуються помилкою на транзакціях v1. Наявним застосункам варто ознайомитися з розділом підготовка до v1.
Порівняння форматів
| Ліміт | legacy | v0 | v1 |
|---|---|---|---|
| Максимальний розмір транзакції | 1 232 байти | 1 232 байти | 4 096 байт |
| Адреси акаунтів | ~32, обмежено розміром | 64, через таблиці пошуку | 64, вбудовані (96 з SIMD-0596) |
| Таблиці пошуку адрес | не підтримуються | підтримуються | не підтримуються |
| Ліміти ресурсів | Інструкції ComputeBudget | Інструкції ComputeBudget | конфігурація повідомлення |
Формат legacy
Початковий формат, який досі є типовим у більшості інструментів. Він не має
жодного префікса версії: перший байт транзакції — це compact-u16 лічильник
масиву підписів, а перший байт повідомлення — num_required_signatures,
старший біт якого завжди скинутий.
Розміщення байт legacy при передачі
| Поле | Розмір | Опис |
|---|---|---|
num_signatures | compact-u16 | Кількість підписів |
signatures | num_signatures x 64 байти | Підписи Ed25519 |
header | 3 байти | MessageHeader — старший біт першого байта скинутий |
num_account_keys | compact-u16 | Кількість ключів акаунтів |
account_keys | num_account_keys x 32 байти | Публічні ключі, всі вбудовані |
recent_blockhash | 32 байти | Специфікатор терміну дії |
num_instructions | compact-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_signatures | compact-u16 | Кількість підписів |
signatures | num_signatures x 64 байти | Підписи Ed25519 |
0x80 | 1 байт | Байт-префікс версії — перший байт повідомлення |
header | 3 байти | MessageHeader (такий самий, як у застарілому) |
num_account_keys | compact-u16 | Кількість статичних ключів акаунтів |
static_account_keys | num_account_keys x 32 байти | Ключі, що з'являються буквально в транзакції |
recent_blockhash | 32 байти | Специфікатор терміну дії |
num_instructions | compact-u16 | Кількість інструкцій |
instructions | змінна | Той самий формат, що й у застарілому |
address_table_lookups | compact-u16 + змінний | Посилання на ALT (див. нижче) |
Кожен запис таблиці пошуку адрес містить:
| Поле | Розмір | Опис |
|---|---|---|
account_key | 32 байти | Публічний ключ облікового запису ALT |
writable_indexes | compact-u16 + N x 1 байт | Індекси в ALT для облікових записів з правом запису |
readonly_indexes | compact-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 при передачі
| Поле | Розмір | Опис |
|---|---|---|
0x81 | 1 байт | Байт-префікс версії — перший байт транзакції |
header | 3 байти | MessageHeader (такий самий, як у застарілому) |
config_mask | 4 байти | Бітова маска u32 LE, що позначає наявні значення конфігурації |
recent_blockhash | 32 байти | Специфікатор терміну дії |
num_instructions | 1 байт | Лічильник фіксованої ширини, макс. 64 |
num_addresses | 1 байт | Лічильник фіксованої ширини, макс. 64 (96 з SIMD-0596) |
addresses | N x 32 байти | Адреси акаунтів, всі вбудовані — без посилань на таблиці пошуку |
config_values | 0–20 байт | Одне значення на встановлений біт маски, у порядку бітів (дивіться нижче) |
instruction_headers | N x 4 байти | На інструкцію: program_id_index (u8), num_accounts (u8), data_len (u16 LE) |
instruction_payloads | змінна | На інструкцію: індекси акаунтів, потім instruction data |
signatures | N 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 / v0 | v1 | Симптом за відсутності |
|---|---|---|---|
| Ліміт обчислювальних одиниць | 200k на інструкцію, макс. 1,4M | 0 CU | Негайна відмова, бюджет вичерпано |
| Розмір даних завантажених акаунтів | 64 МіБ | 0 байт | MaxLoadedAccountsDataSizeExceeded під час завантаження першого акаунта |
| Комісія за пріоритет | 0 | 0 | — |
| Розмір кучі | 32 КіБ | 32 КіБ | — |
Рекомендований підхід: виконайте симуляцію один раз із максимальними значеннями
обох лімітів, а потім запишіть повернуті unitsConsumed та loadedAccountsDataSize
назад у конфігурацію, округливши розмір даних до наступної сторінки по 32 КіБ
для запасу (модель вартості блоку тарифікує в сторінках по 32 КіБ, тому запас
нижче межі наступної сторінки є безкоштовним).
Побудова транзакції v1
Повідомлення v1 збирається через той самий конвеєр, що й повідомлення legacy або v0. Різниця
полягає в setTransactionMessageConfig: ліміти ресурсів є властивістю повідомлення, тому список
інструкцій містить лише інструкції програм.
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);
Kit також надає окремий сеттер для кожного поля конфігурації —
setTransactionMessagePriorityFeeLamports,
setTransactionMessageComputeUnitLimit,
setTransactionMessageLoadedAccountsDataSizeLimit та
setTransactionMessageHeapSize — для встановлення їх по одному.
Визначення лімітів за допомогою симуляції
Замість того щоб вгадувати два обов'язкові ліміти, виміряйте їх. Симуляція виконується з обома лімітами, піднятими до максимуму середовища виконання, тому вона не може завершитися невдачею через брак ресурсів, які вона вимірює. Повернуте значення — це точна вартість одного симульованого запуску, тож додайте запас, перш ніж на нього покладатися.
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),);
Підготовка до v1
v1 змінює читання транзакцій, а не лише їх надсилання. Будь-який клієнт, який
викликає getTransaction або getBlock без явного ввімкнення підтримки, завершується помилкою на транзакціях v1:
- Передайте
maxSupportedTransactionVersion: 1— ціле число JSON1, а не рядок"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/kit8.0+, Rust-крейти покоління Agave 4.2.x або web3.js v3. web3.js v1 читає v1 починаючи з версії 1.99.0, але не може будувати чи надсилати транзакції v1.
Читання транзакції v1
Для ввімкнення підтримки потрібне JSON-ціле число 1. Декодування повернутих байтів дає
версію та конфігурацію, де й зберігаються ліміти ресурсів — сканування
списку інструкцій на наявність інструкцій ComputeBudget нічого не знаходить.
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);}
Повний посібник із міграції — зокрема підтримку клієнтських бібліотек, визначення версії у потокових даних (Geyser/gRPC) та поведінку симуляції — дивіться на сторінці оновлення до формату транзакцій v1. Працездатні приклади на TypeScript, Rust, Python і Go розміщено в solana-foundation/transaction-v1-examples.
Is this page helpful?