Résumé
Solana propose trois formats de transaction : legacy, v0 et v1. v0 ajoute des tables de recherche d'adresses (ALTs) pour référencer les comptes via des indices d'1 octet. v1 porte la limite de taille à 4 096 octets, déplace les limites de ressources dans le message lui-même et supprime les ALTs.
Solana prend en charge trois formats de transaction : legacy, v0 et v1. Chacun est décrit ci-dessous selon les mêmes trois aspects : la disposition des octets sur le réseau, la façon dont les comptes sont référencés, et l'origine des limites de ressources.
Statut d'activation de v1
Le format v1 est actif sur mainnet, devnet et testnet. La lecture des transactions constitue un changement majeur : les clients qui n'y adhèrent pas échouent sur les transactions v1. Les applications existantes devraient consulter la préparation pour v1.
Comparaison des formats
| Limite | legacy | v0 | v1 |
|---|---|---|---|
| Taille maximale de la transaction | 1 232 octets | 1 232 octets | 4 096 octets |
| Adresses de comptes | ~32, limité par la taille | 64, via tables de recherche | 64, intégrées (96 avec SIMD-0596) |
| Tables de recherche d'adresses | non supporté | supporté | non supporté |
| Limites de ressources | Instructions ComputeBudget | Instructions ComputeBudget | configuration du message |
Format legacy
Le format d'origine, et toujours le format par défaut dans la plupart des outils. Il ne comporte
aucun préfixe de version : le premier octet de la transaction est le compteur compact-u16 du
tableau de signatures, et le premier octet du message est num_required_signatures,
dont le bit de poids fort est toujours non défini.
Disposition binaire du format legacy
| Champ | Taille | Description |
|---|---|---|
num_signatures | compact-u16 | Nombre de signatures |
signatures | num_signatures x 64 octets | Signatures Ed25519 |
header | 3 octets | MessageHeader — le premier octet a le bit de version non défini |
num_account_keys | compact-u16 | Nombre de clés de compte |
account_keys | num_account_keys x 32 octets | Clés publiques, toutes inline |
recent_blockhash | 32 octets | Spécificateur de durée de vie |
num_instructions | compact-u16 | Nombre d'instructions |
instructions | variable | Chaque instruction sérialisée de manière contiguë |
Chaque tableau de longueur variable est préfixé d'une longueur compact-u16 : 1 octet pour les valeurs 0–127, 2–3 octets pour les valeurs plus grandes. Pour la disposition par instruction et un exemple de calcul de taille, voir le format binaire des transactions.
Comptes dans le format legacy
Chaque compte est écrit en tant que clé publique complète de 32 octets dans account_keys, et
les instructions les référencent par un index d'1 octet dans ce tableau. Il n'existe aucun moyen de
référencer un compte qui n'est pas explicitement inscrit dans la transaction, ce qui limite
une transaction legacy à environ 32 comptes avant d'épuiser ses
1 232 octets.
Limites de ressources dans le format legacy
La limite d'unités de calcul, la limite de taille des données de comptes chargés, la taille du tas et les frais de priorité sont tous demandés en incluant des instructions du programme ComputeBudget dans la transaction. Chacune coûte un slot d'instruction et 150 unités de calcul. Les omettre est sans danger : le runtime revient aux valeurs par défaut de 200 000 CU par instruction (plafonné à 1,4M), une limite de taille de données de 64 Mio, un tas de 32 Kio et des frais de priorité nuls.
Format V0
v0 est un message legacy auquel s'ajoutent deux éléments : un octet de préfixe de version 0x80 et un
tableau address_table_lookups ajouté après les instructions. Tout ce qui précède
ces éléments est identique octet pour octet au format legacy.
Disposition binaire du format V0
| Champ | Taille | Description |
|---|---|---|
num_signatures | compact-u16 | Nombre de signatures |
signatures | num_signatures x 64 octets | Signatures Ed25519 |
0x80 | 1 octet | Octet de préfixe de version — premier octet du message |
header | 3 octets | MessageHeader (identique au legacy) |
num_account_keys | compact-u16 | Nombre de clés de compte statiques |
static_account_keys | num_account_keys x 32 octets | Clés qui apparaissent littéralement dans la transaction |
recent_blockhash | 32 octets | Spécificateur de durée de vie |
num_instructions | compact-u16 | Nombre d'instructions |
instructions | variable | Même format que legacy |
address_table_lookups | compact-u16 + variable | Références ALT (voir ci-dessous) |
Chaque entrée de table de recherche d'adresses contient :
| Champ | Taille | Description |
|---|---|---|
account_key | 32 octets | La clé publique du compte ALT |
writable_indexes | compact-u16 + N x 1 octet | Indices dans l'ALT pour les comptes modifiables |
readonly_indexes | compact-u16 + N x 1 octet | Indices dans l'ALT pour les comptes en lecture seule |
Tables de recherche d'adresses
Une ALT est un compte onchain qui stocke jusqu'à 256 clés publiques. En référençant une ALT, une transaction peut inclure des comptes supplémentaires en utilisant des indices de 1 octet au lieu de clés publiques de 32 octets, réduisant ainsi considérablement la surcharge par compte.
Au moment de l'exécution, avant le début de l'exécution, le validateur résout toutes les références ALT en clés publiques complètes. Les adresses résolues sont ajoutées aux clés de compte statiques pour former la liste complète des clés de compte. Les comptes résolus par ALT suivent le même ordre que les comptes statiques : les recherches modifiables viennent avant les recherches en lecture seule.
Les tables de recherche d'adresses n'affectent que la manière dont les comptes sont référencés dans la transaction sur le réseau. Au moment de l'exécution, le runtime résout tous les indices en adresses de compte complètes. Les comptes résolus par ALT ne peuvent être que modifiables ou en lecture seule (non-signataires) ; ils ne peuvent pas être signataires.
Créer et utiliser une table de recherche d'adresses
Cet exemple crée une table de recherche, y ajoute une adresse, puis utilise la table pour compiler et envoyer une transaction v0.
Seuls les comptes non signataires peuvent être chargés depuis une table de recherche. Le payeur des frais et chaque signataire requis restent dans la liste des comptes statiques du message. Une adresse ne peut pas non plus être recherchée dans le même slot que celui où elle a été ajoutée ; l'exemple attend donc le prochain slot confirmé avant de compiler le message v0.
Limites de ressources dans v0
Inchangées par rapport au format legacy : instructions ComputeBudget, avec les mêmes valeurs par défaut lorsqu'elles sont omises.
Format V1
v1 porte la limite de taille à 4 096 octets et restructure le message autour d'une configuration de transaction : les limites de ressources quittent les instructions ComputeBudget pour rejoindre des champs à position fixe dans le message lui-même. Cela permet au réseau de classer une transaction par frais de priorité avec une simple lecture à offset fixe, sans avoir à parcourir et désérialiser la liste d'instructions.
Disposition binaire du format V1
| Champ | Taille | Description |
|---|---|---|
0x81 | 1 octet | Octet de préfixe de version — le premier octet de la transaction |
header | 3 octets | MessageHeader (identique au legacy) |
config_mask | 4 octets | Masque de bits u32 LE indiquant quelles valeurs de configuration sont présentes |
recent_blockhash | 32 octets | Spécificateur de durée de vie |
num_instructions | 1 octet | Compteur de largeur fixe, max 64 |
num_addresses | 1 octet | Compteur de largeur fixe, max 64 (96 avec SIMD-0596) |
addresses | N x 32 octets | Adresses de comptes, toutes inline — aucune référence à des tables de recherche |
config_values | 0–20 octets | Une valeur par bit de masque défini, dans l'ordre des bits (voir ci-dessous) |
instruction_headers | N x 4 octets | Par instruction : program_id_index (u8), num_accounts (u8), data_len (u16 LE) |
instruction_payloads | variable | Par instruction : indices de comptes, puis instruction data |
signatures | N x 64 octets | En queue, sans préfixe de longueur — le compteur provient du header |
Deux différences structurelles par rapport aux formats legacy et v0 méritent d'être notées lors de
l'écriture d'un décodeur. Les compteurs sont des champs u8 de largeur fixe plutôt que compact-u16, et les
instructions sont divisées en deux séquences : d'abord tous les headers de taille fixe, puis tous les
payloads de longueur variable, au lieu que chaque instruction soit contiguë.
Comptes dans v1 : pas de tables de recherche d'adresses
v1 supprime volontairement la prise en charge des ALT. 64 adresses brutes représentent 2 048 octets, et les 96 adresses autorisées après SIMD-0596 en représentent 3 072, soit dans les deux cas largement sous la limite de 4 096 octets ; chaque adresse est donc intégrée au message, comme dans le format legacy. Si votre application dépend des tables de recherche, passer à v1 implique d'intégrer ces adresses directement.
Limites de ressources dans v1 : la configuration de transaction
La configuration est un masque de bits u32 suivi de valeurs de largeur fixe pour chaque champ
dont le bit est défini :
| Bit(s) | Champ | Largeur | Notes |
|---|---|---|---|
| 0–1 | Frais de priorité | u64 | Total en lamports — les deux bits définis ensemble |
| 2 | Limite d'unités de calcul | u32 | |
| 3 | Limite de taille des données de comptes chargés | u32 | |
| 4 | Taille de tas demandée | u32 |
Les bits inconnus sont rejetés. Le message étant signé, les champs de configuration non reconnus ne peuvent pas être silencieusement ignorés.
Les frais de priorité sont un total en lamports, pas un prix unitaire
Dans les formats legacy et v0, les frais de priorité sont définis via SetComputeUnitPrice en
micro-lamports par unité de calcul, multiplié par la limite d'unités de calcul. Dans
v1, il s'agit d'un total absolu en lamports — sans multiplication, sans arrondi.
N'appliquez pas le calcul par CU. La formule totale des frais reste par ailleurs
inchangée : (signatures × lamports_per_signature) + priority_fee.
Les instructions ComputeBudget sont des no-ops dans v1
Une transaction v1 ne rejette pas les instructions ComputeBudget — elle les ignore pour la configuration. Elles s'exécutent toujours comme des no-ops réussis, consommant 150 unités de calcul et un des 64 slots d'instruction sans aucun effet sur le budget. Supprimez-les lors de la construction de transactions v1, et cessez de les rechercher lors de la lecture de transactions v1 : les valeurs se trouvent dans la configuration du message.
Les champs de configuration doivent être définis explicitement
Le changement de comportement le plus important pour les émetteurs : contrairement aux formats legacy et v0, les transactions v1 doivent définir explicitement la limite d'unités de calcul et la limite de taille des données de comptes chargés, sinon votre transaction échouera.
| Champ non défini | legacy / v0 | v1 | Symptôme si omis |
|---|---|---|---|
| Limite d'unités de calcul | 200k par instruction, max 1,4M | 0 CU | Échec immédiat, budget épuisé |
| Taille des données de comptes chargés | 64 Mio | 0 octet | MaxLoadedAccountsDataSizeExceeded au premier compte chargé |
| Frais de priorité | 0 | 0 | — |
| Taille du tas | 32 Kio | 32 Kio | — |
L'approche recommandée consiste à simuler une fois avec les deux limites au maximum, puis à réinjecter
les valeurs unitsConsumed et loadedAccountsDataSize retournées dans la configuration,
en arrondissant la taille des données à la page de 32 Kio supérieure pour disposer d'une marge (le modèle de coût
du bloc facture par pages de 32 Kio, donc la marge en dessous de la prochaine limite de page
est gratuite).
Construction d'une transaction v1
Un message v1 est assemblé via le même pipeline qu'un message legacy ou v0. La
différence tient à setTransactionMessageConfig : les limites de ressources sont une propriété
du message, de sorte que la liste d'instructions ne contient que les instructions de programme.
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 expose également un setter par champ de configuration —
setTransactionMessagePriorityFeeLamports,
setTransactionMessageComputeUnitLimit,
setTransactionMessageLoadedAccountsDataSizeLimit et
setTransactionMessageHeapSize — pour les définir un par un.
Dimensionner les limites par simulation
Plutôt que de deviner les deux limites obligatoires, mesurez-les. La simulation s'exécute avec les deux limites portées au maximum du runtime : elle ne peut donc pas échouer faute des ressources qu'elle mesure. Le chiffre renvoyé correspond au coût exact d'une exécution simulée ; ajoutez donc une marge avant de vous y fier.
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),);
Préparation pour v1
v1 modifie la lecture des transactions, et pas seulement leur envoi. Tout client qui
appelle getTransaction ou getBlock sans y adhérer échoue sur les transactions v1 :
- Passez
maxSupportedTransactionVersion: 1— l'entier JSON1, et non la chaîne"1"— àgetTransactionetgetBlock. Passer0échoue sur les transactions v1 exactement comme l'omission du paramètre, donc une base de code mise à jour lors du déploiement de v0 doit toujours changer cette valeur. getTransactionéchoue sur une transaction v1 avec l'erreur-32015, et une seule transaction v1 fait échouer toute la réponsegetBlockavec la même erreur — il n'y a pas de résultat partiel.blockSubscribeémetblock: nullet cesse d'avancer ; un consommateur qui interprète cela comme un bloc vide prend silencieusement du retard à partir du premier slot v1.getSignaturesForAddressn'inspecte jamais le contenu des transactions, donc les signatures v1 apparaissent normalement dans la liste.- Les réponses avec adhésion incluent un objet
transactionConfigdans le message pour les transactions v1 (totalement absent pour legacy et v0). Les pipelines qui déduisent les frais de priorité ou les limites de calcul en recherchant les instructions ComputeBudget rapportent silencieusement zéro pour chaque transaction v1. - Utilisez
encoding: 'base64'lorsque vous décodez des transactions côté client, et poursendTransaction/simulateTransactionavec des transactions dépassant 1 232 octets — l'encodage base58 reste plafonné à l'ancienne taille. - La prise en charge par les bibliothèques clientes nécessite des versions récentes :
@solana/kit8.0+, les crates Rust de génération Agave 4.2.x, ou web3.js v3. web3.js v1 lit le format v1 à partir de la version 1.99.0, mais ne peut pas le construire ni l'envoyer.
Lecture d'une transaction v1
L'adhésion se fait avec l'entier JSON 1. Le décodage des octets renvoyés donne la
version et la configuration, où résident les limites de ressources — rechercher les
instructions ComputeBudget dans la liste d'instructions ne donne rien.
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);}
Pour le guide de migration complet — y compris la prise en charge par les bibliothèques clientes, la détection de version en streaming (Geyser/gRPC) et le comportement de la simulation — consultez la page de mise à niveau Transaction Format v1. Des exemples exécutables en TypeScript, Rust, Python et Go se trouvent dans solana-foundation/transaction-v1-examples.
Is this page helpful?