Transaction v1
What changes for clients now that transaction format v1 is live on Solana mainnet.
Solana mainnet carries a third transaction format, version 1 (v1), defined in SIMD-0385. Legacy and version 0 (v0) are unchanged. Three things differ for client code:
| Change | Who it affects |
|---|---|
Reads must opt in with maxSupportedTransactionVersion | Anyone calling getTransaction, getBlock or blockSubscribe |
The compute budget and priority fee moved into the message, in a transactionConfig object | Anyone who reads or sets fees |
| The wire format changed | Anyone who decodes raw transaction bytes |
Activation
The feature gate enable_tx_v1 (txv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL) activated on mainnet at the start of epoch 1035, on 2026-09-15 at about 01:00 UTC. It is active on devnet and testnet.
constant-k counted the transactions in one finalized mainnet block on 2026-09-20, five days after activation:
| Version | Transactions |
|---|---|
| legacy | 501 |
| v0 | 409 |
| v1 | 164 |
v1 was about 15% of that block.
RPC nodes older than solana-core 4.2.2 downgrade a v1 message to v0 on the way into storage, so the transaction comes back with the wrong version and a zeroed compute budget. constant-k nodes run 4.2.2 or later. getVersion reports the version of any other node you read from.
Read requests
Every read that returns transactions needs maxSupportedTransactionVersion set to 1 in its config object.
{ "encoding": "json", "maxSupportedTransactionVersion": 1 }- Upgrade the SDK first. The parameter tells the node what your client can decode. If the client cannot decode v1, raising the value moves the failure from the RPC call into your deserializer. Upgrade, confirm that decoding works, then raise the value. See Client minimums.
- It is a ceiling, not a filter.
1returns legacy, v0 and v1. It does not change how legacy and v0 come back, and it cannot request v1 transactions only. Omitting the parameter means legacy only, and fails on the first versioned transaction. - It must be the JSON integer
1. The string"1", usually from an unparsed environment variable, is rejected before the node looks at any transaction.
Calls that take the parameter
| Call | Where the parameter goes | Without it |
|---|---|---|
getTransaction | Config object, second parameter | Fails on a v1 signature |
getBlock | Config object, second parameter | One v1 transaction fails the whole block |
blockSubscribe | Config object, second parameter | A block holding a v1 transaction cannot be delivered |
getSignaturesForAddress is not affected: it returns signatures, not transactions.
{
"jsonrpc": "2.0",
"id": 1,
"method": "getTransaction",
"params": [
"YOUR_TRANSACTION_SIGNATURE",
{ "encoding": "json", "commitment": "confirmed", "maxSupportedTransactionVersion": 1 }
]
}Errors without the parameter
A read that has not opted in returns an error instead of data. There is no partial block and no graceful degrade. Both errors arrive as a JSON-RPC error with HTTP 200, so the response body matters, not only the status code. See Errors.
This is a response from a constant-k node to a getBlock call with no maxSupportedTransactionVersion:
{
"jsonrpc": "2.0",
"error": {
"code": -32015,
"message": "Transaction version (0) is not supported by the requesting client. Please try the request again with the following configuration parameter: \"maxSupportedTransactionVersion\": 0"
},
"id": 1
}The number in the message is the version that failed, not the value to send. This block failed on a v0 transaction, so the message suggests 0. Following that suggestion makes the next v1 transaction fail again. The correct value is 1.
This is the response to the string "1":
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "Invalid params: data did not match any variant of untagged enum RpcEncodingConfigWrapper."
},
"id": 1
}On WebSocket, a notification whose block is null is a failure, not an empty block.
Priority fees
A v1 transaction carries no ComputeBudget instructions. The fee and the resource requests are fields in the message.
Fee code that scans instructions reports zero for v1, and raises no error
Analytics, fee dashboards, paymaster caps and transaction ranking return wrong numbers rather than failing. A branch on transactionConfig is the reliable test.
| Format | Where the priority fee lives | Unit |
|---|---|---|
| Legacy, v0 | setComputeUnitPrice ComputeBudget instruction | Micro-lamports per compute unit |
| v1 | transactionConfig.priorityFee in the message | Total lamports for the transaction |
The per-CU multiplication does not carry across. A legacy or v0 price converts to a total with ceil(price * compute unit limit / 1,000,000) lamports, which puts both formats in one unit.
This is a transactionConfig from that block, inside transaction.message:
{
"computeUnitLimit": 38,
"heapSize": null,
"loadedAccountsDataSizeLimit": 32000,
"priorityFee": 19
}null means the sender did not set that field. Legacy and v0 messages have no transactionConfig at all, so its presence is a reliable test for v1 even when the response carries no version field.
jsonParsed has no parser for the ComputeBudget program, so on legacy and v0 those instructions still come back with base58 data that you decode yourself. The helper below does that.
A helper that reads all three formats
This takes one entry from getBlock's transactions array, or the whole result of getTransaction, with encoding: "json".
import { getBase58Encoder } from "@solana/kit"; // 8.0.0 or later
const COMPUTE_BUDGET = "ComputeBudget111111111111111111111111111111";
const base58ToBytes = getBase58Encoder(); // a Kit encoder turns the string into bytes
type Entry = {
version?: "legacy" | number; // absent unless you opted in
transaction: {
message: {
accountKeys: string[];
instructions: { programIdIndex: number; data: string }[];
transactionConfig?: {
priorityFee: number | null;
computeUnitLimit: number | null;
};
};
};
};
export type PriorityFee = {
version: "legacy" | 0 | 1;
/** Total priority fee in lamports. null when only the runtime can work it out. */
lamports: bigint | null;
/** Price in micro-lamports per compute unit. Legacy and v0 only. */
microLamportsPerCu: bigint | null;
computeUnitLimit: number | null;
};
function readLe(bytes: ArrayLike<number>, offset: number, length: number): bigint {
let value = 0n;
for (let i = length - 1; i >= 0; i--) value = (value << 8n) | BigInt(bytes[offset + i]);
return value;
}
export function priorityFee(entry: Entry): PriorityFee {
const message = entry.transaction.message;
const config = message.transactionConfig;
// v1: the numbers are in the message. Never look for instructions.
if (config) {
return {
version: 1,
lamports: BigInt(config.priorityFee ?? 0), // a total, in lamports
microLamportsPerCu: null,
computeUnitLimit: config.computeUnitLimit ?? 0, // unset means 0, not a default
};
}
// legacy and v0: the numbers are in ComputeBudget instructions.
let price: bigint | null = null; // micro-lamports per compute unit
let limit: number | null = null; // compute units
for (const ix of message.instructions) {
if (message.accountKeys[ix.programIdIndex] !== COMPUTE_BUDGET) continue;
const data = base58ToBytes.encode(ix.data);
if (data[0] === 2 && data.length >= 5) limit = Number(readLe(data, 1, 4));
if (data[0] === 3 && data.length >= 9) price = readLe(data, 1, 8);
}
return {
version: entry.version === 0 ? 0 : "legacy",
lamports:
price === null
? 0n
: limit === null
? null // no explicit limit: the runtime default depends on the programs called
: (price * BigInt(limit) + 999_999n) / 1_000_000n,
microLamportsPerCu: price,
computeUnitLimit: limit,
};
}A legacy or v0 transaction with no setComputeUnitPrice instruction paid no priority fee, which is why the absence of ComputeBudget instructions is never a safe test for v1. When a transaction sets a price but no limit, the runtime applies a per-instruction default that depends on which programs it calls, so the helper returns null rather than a guess. The exact number is meta.fee minus the signature fee.
Client minimums
These are the minimum versions for raising maxSupportedTransactionVersion.
| Component | Minimum | What it can do |
|---|---|---|
@solana/kit | 8.0.0 | Read and send. The recommended client |
@solana/web3.js 1.x | 1.99.0 | Read only. Cannot build, sign or send v1 |
@solana/web3.js 3.x | 3.0.0-rc.3 | Read and send, but 3.x has no stable release yet |
Rust solana-* crates | 4.2.x | Read and send |
yellowstone-grpc-client | 12.0.0 (13.3.0 or later recommended) | gRPC streaming |
yellowstone-grpc-proto | 12.6.0 | The first generated code carrying the v1 config |
The two Yellowstone crates are versioned separately
Both belong in the same Cargo.toml. A proto left to resolve on its own can come in below 12.6.0, which drops the v1 config silently instead of erroring.
Send requests
An unset config field is the minimum, not a default. The runtime fills in nothing.
| Field left unset | Legacy and v0 | v1 |
|---|---|---|
| Compute unit limit | 200,000 per instruction, capped at 1.4M | 0 |
| Loaded accounts data size | 64 MiB | 0 bytes |
| Priority fee | 0 | 0 |
| Heap size | 32 KiB | 32 KiB |
A v1 transaction sent with an empty config fails at account loading with MaxLoadedAccountsDataSizeExceeded. The compute unit limit and the loaded accounts data size have to be set explicitly:
- Simulate once with both values maxed.
- Read
unitsConsumedandloadedAccountsDataSizefrom the result. - Write those back, with headroom.
In @solana/kit, the setters are setTransactionMessageComputeUnitLimit, setTransactionMessageLoadedAccountsDataSizeLimit and setTransactionMessagePriorityFeeLamports, on a message built with createTransactionMessage({ version: 1 }).
ComputeBudget instructions configure nothing in v1. One left in still executes as a no-op, burns compute units and takes an instruction slot, so a v1 message carries none.
For the rest of the send path, see Sending Transactions.
Format limits
v1 has no address lookup tables. A transaction that needs one stays on v0. In v1 the accounts go directly into the transaction, which is what the larger size limit is for.
| Limit | Legacy and v0 | v1 |
|---|---|---|
| Transaction size | 1,232 bytes | 4,096 bytes |
| Signatures | 12 | 12 |
| Accounts | 64 | 64 |
| Instructions | 64 | 64 |
| Accounts per instruction | 255 | 255 |
Only the size changed. v1 states the other limits in the format, so breaking one is a sanitization failure and the transaction never lands.
The address list must hold no duplicates. Legacy tolerated them.
Decoding raw bytes
This section applies to ShredPrism entries and base64 RPC responses, not to json or jsonParsed responses.
- The first byte is
0x81(129). v0 is0x80, and a legacy transaction starts with its signature count. A v1 transaction pulled from a constant-k node was 342 bytes long and started with0x81. - Signatures moved to the end. The message comes first. A decoder that expects a leading signature array reads garbage or throws.
- There are no compact-u16 length prefixes. Counts are fixed-width fields in explicit headers.
| Language | Decoder |
|---|---|
| Rust | agave-transaction-view, or VersionedTransaction from the 4.2.x solana-* crates |
| Rust, ShredPrism entries | wincode with solana-entry 4.2 or later. bincode fails on v1. See the ShredPrism Quickstart |
| TypeScript | getTransactionDecoder from @solana/kit 8.0.0 or later |
A hand-written decoder has to branch on the first byte before anything else.
Reading a v1 transaction with encoding: "base58" works on constant-k endpoints. Sending is different: base58 request payloads are capped at 1,232 bytes whatever the version, so a v1 transaction larger than that has to be submitted with encoding: "base64".
Streaming consumers
Kaldera carries v1. The subscribe request, filters, endpoint and API key do not change, and nothing in the request opts you in or out. Only your dependencies matter. The protocol has no version field, so a stale client does not error on a v1 transaction: it reads it as a v0 with an empty compute budget.
Both crates need the versions in Client minimums, any self-generated stubs need regenerating, and the version is then detected structurally, in this order:
| Check, in this order | Version |
|---|---|
| Message config present | v1 |
| Config absent, versioned flag true | v0 |
| Neither | legacy |
The order matters. The versioned flag is true for both v0 and v1, so testing it first labels every v1 transaction a v0, silently, with a zeroed budget.
Kaldera Quickstart
A first stream, and the client versions it needs.
Turbo Kaldera
The pre-execution stream, and what its events carry.
Migration checklist
- Find every read. Grep for
getTransaction,getBlockandblockSubscribe, including SDK wrappers such asgetParsedTransaction. - Upgrade the SDK to the versions in Client minimums, and confirm it decodes a v1 transaction.
- Set
maxSupportedTransactionVersionto the integer1on all of those calls. Replace every remaining0. - Replace ComputeBudget fee scanning with a branch on
transactionConfig, and keep the units apart: total lamports for v1, micro-lamports per compute unit for legacy and v0. - Fix any raw decoders to handle a leading
0x81and signatures at the end. - Bump your streaming dependencies, regenerate stubs, and detect the version on the message config rather than the versioned flag.
- If you send v1, set the compute unit limit and the loaded accounts data size explicitly, and drop your ComputeBudget instructions and lookup tables.
- Grep your logs for
-32015. Anything left is a read you have not fixed.