Versioned Transactions are the transaction formats that allow for additional
functionality in the Solana runtime, beyond what the original legacy format
supports.
While changes to onchain programs are NOT required to support the new functionality of versioned transactions (or for backwards compatibility), developers WILL need update their client side code to prevent errors due to different transaction versions.
Current Transaction Versions
The Solana runtime supports three transaction versions:
| Version | Size limit | Address Lookup Tables | Resource limits |
|---|---|---|---|
legacy | 1,232 bytes | Not supported | ComputeBudget instructions |
0 | 1,232 bytes | Supported | ComputeBudget instructions |
1 | 4,096 bytes | Removed | Set in the message config |
Jump to the format you are building with:
- V0 transactions — raise the effective account limit with Address Lookup Tables
- V1 transactions — fit more into a single transaction, and set resource limits in the message itself
For the wire layout of each format, see versioned transactions.
Max supported transaction version
All RPC requests that return a transaction should specify the highest
version of transactions they will support in their application using the
maxSupportedTransactionVersion option, including
getBlock and
getTransaction.
An RPC request will fail if a Versioned Transaction is returned that is higher
than the set maxSupportedTransactionVersion. (i.e. if a version 0
transaction is returned when legacy is selected)
WARNING: If no
maxSupportedTransactionVersionvalue is set, then onlylegacytransactions will be allowed in the RPC response. Therefore, your RPC requests WILL fail if any version0transactions are returned. The same applies one version up: requests that set0will fail on version1transactions. Set the value to the integer1to support every format.
How to set max supported version
You can set the maxSupportedTransactionVersion using both the
@solana/web3.js
library and JSON formatted requests directly to an RPC endpoint.
Using web3.js
Using the
@solana/web3.js
library, you can retrieve the most recent block or get a specific transaction:
// connect to the `devnet` cluster and get the current `slot`const connection = new web3.Connection(web3.clusterApiUrl("devnet"));const slot = await connection.getSlot();// get the latest block (allowing for v0 transactions)const block = await connection.getBlock(slot, {maxSupportedTransactionVersion: 0});// get a specific transaction (allowing for v0 transactions)const getTx = await connection.getTransaction("3jpoANiFeVGisWRY5UP648xRXs3iQasCHABPWRWnoEjeA93nc79WrnGgpgazjq4K9m8g2NJoyKoWBV1Kx5VmtwHQ",{maxSupportedTransactionVersion: 0});
JSON requests to the RPC
Using a standard JSON formatted POST request, you can set the
maxSupportedTransactionVersion when retrieving a specific block:
curl https://api.devnet.solana.com -X POST -H "Content-Type: application/json" -d \'{"jsonrpc": "2.0", "id":1, "method": "getBlock", "params": [430, {"encoding":"json","maxSupportedTransactionVersion":0,"transactionDetails":"full","rewards":false}]}'
V0 transactions
Version 0 transactions can be created similar to the older method of creating
transactions. There are differences in using certain libraries that should be
noted.
Below is an example of how to create a Versioned Transaction, using the
@solana/web3.js library, to send perform a SOL transfer between two accounts.
Notes:
payeris a validKeypairwallet, funded with SOLtoAccounta validKeypair
Firstly, import the web3.js library and create a connection to your desired
cluster.
We then define the recent blockhash and minRent we will need for our
transaction and the account:
const web3 = require("@solana/web3.js");// connect to the cluster and get the minimum rent for rent exempt statusconst connection = new web3.Connection(web3.clusterApiUrl("devnet"));let minRent = await connection.getMinimumBalanceForRentExemption(0);let blockhash = await connection.getLatestBlockhash().then((res) => res.blockhash);
Create an array of all the instructions you desire to send in your
transaction. In this example below, we are creating a simple SOL transfer
instruction:
// create an array with your desired `instructions`const instructions = [web3.SystemProgram.transfer({fromPubkey: payer.publicKey,toPubkey: toAccount.publicKey,lamports: minRent})];
Next, construct a MessageV0 formatted transaction message with your desired
instructions:
// create v0 compatible messageconst messageV0 = new web3.TransactionMessage({payerKey: payer.publicKey,recentBlockhash: blockhash,instructions}).compileToV0Message();
Then, create a new VersionedTransaction, passing in our v0 compatible message:
const transaction = new web3.VersionedTransaction(messageV0);// sign your transaction with the required `Signers`transaction.sign([payer]);
You can sign the transaction by either:
- passing an array of
signaturesinto theVersionedTransactionmethod, or - call the
transaction.sign()method, passing an array of the requiredSigners
NOTE: After calling the
transaction.sign()method, all the previous transactionsignatureswill be fully replaced by new signatures created from the provided inSigners.
After your VersionedTransaction has been signed by all required accounts, you
can send it to the cluster and await the response:
// send our v0 transaction to the clusterconst txId = await connection.sendTransaction(transaction);console.log(`https://explorer.solana.com/tx/${txId}?cluster=devnet`);
NOTE: Unlike
legacytransactions, sending aVersionedTransactionviasendTransactiondoes NOT support transaction signing via passing in an array ofSignersas the second parameter. You will need to sign the transaction before callingconnection.sendTransaction().
V1 transactions
Version 1 raises the transaction size limit to 4,096 bytes and moves the
resource limits out of ComputeBudget instructions and into the message itself.
Address Lookup Tables are not available on v1 — every account a v1 transaction
touches is listed in the message.
Building a v1 transaction
A v1 message is assembled through the same pipeline as a legacy or v0 one; the difference is the config it carries. The compute unit limit and loaded accounts data size limit both default to zero, so a v1 transaction that leaves them unset always fails.
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);
Estimating the resource limits
Rather than hardcoding the limits, simulate the transaction to measure them. Simulation runs with both limits raised to the runtime maximum, so it cannot fail for want of the resources it is measuring.
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),);
The estimate is the exact cost of one simulated run, with nothing to spare. Add a margin before relying on it in production.
Reading a v1 transaction
Fetching a v1 transaction requires setting
maxSupportedTransactionVersion to the
integer 1. The compiled message stores the config as a bitmask plus a
positional list, so decompile it to address the fields by name.
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);}
More Resources
- using Versioned Transactions for Address Lookup Tables
- view an example of a v0 transaction on Solana Explorer
- read the accepted proposal for Versioned Transaction and Address Lookup Tables
- read about the v1 message format and how its resource limits are encoded
Is this page helpful?