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:

ChangeWho it affects
Reads must opt in with maxSupportedTransactionVersionAnyone calling getTransaction, getBlock or blockSubscribe
The compute budget and priority fee moved into the message, in a transactionConfig objectAnyone who reads or sets fees
The wire format changedAnyone 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:

VersionTransactions
legacy501
v0409
v1164

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.

Request config
{ "encoding": "json", "maxSupportedTransactionVersion": 1 }
  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.
  2. It is a ceiling, not a filter. 1 returns 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.
  3. 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

CallWhere the parameter goesWithout it
getTransactionConfig object, second parameterFails on a v1 signature
getBlockConfig object, second parameterOne v1 transaction fails the whole block
blockSubscribeConfig object, second parameterA 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:

JSON-RPC error -32015
{
  "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":

JSON-RPC error -32602
{
  "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.

FormatWhere the priority fee livesUnit
Legacy, v0setComputeUnitPrice ComputeBudget instructionMicro-lamports per compute unit
v1transactionConfig.priorityFee in the messageTotal 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:

A v1 message config
{
  "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".

priority-fee.ts
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.

ComponentMinimumWhat it can do
@solana/kit8.0.0Read and send. The recommended client
@solana/web3.js 1.x1.99.0Read only. Cannot build, sign or send v1
@solana/web3.js 3.x3.0.0-rc.3Read and send, but 3.x has no stable release yet
Rust solana-* crates4.2.xRead and send
yellowstone-grpc-client12.0.0 (13.3.0 or later recommended)gRPC streaming
yellowstone-grpc-proto12.6.0The 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 unsetLegacy and v0v1
Compute unit limit200,000 per instruction, capped at 1.4M0
Loaded accounts data size64 MiB0 bytes
Priority fee00
Heap size32 KiB32 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:

  1. Simulate once with both values maxed.
  2. Read unitsConsumed and loadedAccountsDataSize from the result.
  3. 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.

LimitLegacy and v0v1
Transaction size1,232 bytes4,096 bytes
Signatures1212
Accounts6464
Instructions6464
Accounts per instruction255255

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 is 0x80, and a legacy transaction starts with its signature count. A v1 transaction pulled from a constant-k node was 342 bytes long and started with 0x81.
  • 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.
LanguageDecoder
Rustagave-transaction-view, or VersionedTransaction from the 4.2.x solana-* crates
Rust, ShredPrism entrieswincode with solana-entry 4.2 or later. bincode fails on v1. See the ShredPrism Quickstart
TypeScriptgetTransactionDecoder 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 orderVersion
Message config presentv1
Config absent, versioned flag truev0
Neitherlegacy

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.

Migration checklist

  1. Find every read. Grep for getTransaction, getBlock and blockSubscribe, including SDK wrappers such as getParsedTransaction.
  2. Upgrade the SDK to the versions in Client minimums, and confirm it decodes a v1 transaction.
  3. Set maxSupportedTransactionVersion to the integer 1 on all of those calls. Replace every remaining 0.
  4. 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.
  5. Fix any raw decoders to handle a leading 0x81 and signatures at the end.
  6. Bump your streaming dependencies, regenerate stubs, and detect the version on the message config rather than the versioned flag.
  7. If you send v1, set the compute unit limit and the loaded accounts data size explicitly, and drop your ComputeBudget instructions and lookup tables.
  8. Grep your logs for -32015. Anything left is a read you have not fixed.

On this page