Transactions versionnées

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

Limitelegacyv0v1
Taille maximale de la transaction1 232 octets1 232 octets4 096 octets
Adresses de comptes~32, limité par la taille64, via tables de recherche64, intégrées (96 avec SIMD-0596)
Tables de recherche d'adressesnon supportésupporténon supporté
Limites de ressourcesInstructions ComputeBudgetInstructions ComputeBudgetconfiguration du message

Aller à : Legacy · v0 · v1

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

ChampTailleDescription
num_signaturescompact-u16Nombre de signatures
signaturesnum_signatures x 64 octetsSignatures Ed25519
header3 octetsMessageHeader — le premier octet a le bit de version non défini
num_account_keyscompact-u16Nombre de clés de compte
account_keysnum_account_keys x 32 octetsClés publiques, toutes inline
recent_blockhash32 octetsSpécificateur de durée de vie
num_instructionscompact-u16Nombre d'instructions
instructionsvariableChaque 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

ChampTailleDescription
num_signaturescompact-u16Nombre de signatures
signaturesnum_signatures x 64 octetsSignatures Ed25519
0x801 octetOctet de préfixe de version — premier octet du message
header3 octetsMessageHeader (identique au legacy)
num_account_keyscompact-u16Nombre de clés de compte statiques
static_account_keysnum_account_keys x 32 octetsClés qui apparaissent littéralement dans la transaction
recent_blockhash32 octetsSpécificateur de durée de vie
num_instructionscompact-u16Nombre d'instructions
instructionsvariableMême format que legacy
address_table_lookupscompact-u16 + variableRéférences ALT (voir ci-dessous)

Chaque entrée de table de recherche d'adresses contient :

ChampTailleDescription
account_key32 octetsLa clé publique du compte ALT
writable_indexescompact-u16 + N x 1 octetIndices dans l'ALT pour les comptes modifiables
readonly_indexescompact-u16 + N x 1 octetIndices 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

ChampTailleDescription
0x811 octetOctet de préfixe de version — le premier octet de la transaction
header3 octetsMessageHeader (identique au legacy)
config_mask4 octetsMasque de bits u32 LE indiquant quelles valeurs de configuration sont présentes
recent_blockhash32 octetsSpécificateur de durée de vie
num_instructions1 octetCompteur de largeur fixe, max 64
num_addresses1 octetCompteur de largeur fixe, max 64 (96 avec SIMD-0596)
addressesN x 32 octetsAdresses de comptes, toutes inline — aucune référence à des tables de recherche
config_values0–20 octetsUne valeur par bit de masque défini, dans l'ordre des bits (voir ci-dessous)
instruction_headersN x 4 octetsPar instruction : program_id_index (u8), num_accounts (u8), data_len (u16 LE)
instruction_payloadsvariablePar instruction : indices de comptes, puis instruction data
signaturesN x 64 octetsEn 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)ChampLargeurNotes
0–1Frais de prioritéu64Total en lamports — les deux bits définis ensemble
2Limite d'unités de calculu32
3Limite de taille des données de comptes chargésu32
4Taille de tas demandéeu32

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éfinilegacy / v0v1Symptôme si omis
Limite d'unités de calcul200k par instruction, max 1,4M0 CUÉchec immédiat, budget épuisé
Taille des données de comptes chargés64 Mio0 octetMaxLoadedAccountsDataSizeExceeded au premier compte chargé
Frais de priorité00—
Taille du tas32 Kio32 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.

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 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.

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.

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 JSON 1, et non la chaîne "1" — à getTransaction et getBlock. Passer 0 é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éponse getBlock avec la même erreur — il n'y a pas de résultat partiel.
  • blockSubscribe émet block: null et cesse d'avancer ; un consommateur qui interprète cela comme un bloc vide prend silencieusement du retard à partir du premier slot v1.
  • getSignaturesForAddress n'inspecte jamais le contenu des transactions, donc les signatures v1 apparaissent normalement dans la liste.
  • Les réponses avec adhésion incluent un objet transactionConfig dans 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 pour sendTransaction/simulateTransaction avec 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/kit 8.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.

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.

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?

© 2026 Fondation Solana. Tous droits réservés.