Solana CookbookTransactions

Versioned Transactions

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:

VersionSize limitAddress Lookup TablesResource limits
legacy1,232 bytesNot supportedComputeBudget instructions
01,232 bytesSupportedComputeBudget instructions
14,096 bytesRemovedSet in the message config

Jump to the format you are building with:

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 maxSupportedTransactionVersion value is set, then only legacy transactions will be allowed in the RPC response. Therefore, your RPC requests WILL fail if any version 0 transactions are returned. The same applies one version up: requests that set 0 will fail on version 1 transactions. Set the value to the integer 1 to 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:

  • payer is a valid Keypair wallet, funded with SOL
  • toAccount a valid Keypair

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 status
const 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 message
const 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 signatures into the VersionedTransaction method, or
  • call the transaction.sign() method, passing an array of the required Signers

NOTE: After calling the transaction.sign() method, all the previous transaction signatures will be fully replaced by new signatures created from the provided in Signers.

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 cluster
const txId = await connection.sendTransaction(transaction);
console.log(`https://explorer.solana.com/tx/${txId}?cluster=devnet`);

NOTE: Unlike legacy transactions, sending a VersionedTransaction via sendTransaction does NOT support transaction signing via passing in an array of Signers as the second parameter. You will need to sign the transaction before calling connection.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.

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.

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.

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.

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.

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.

More Resources

Is this page helpful?

© 2026 Solana Foundation. All rights reserved.