Skip to main content

How to Read and Send Solana v1 Transactions

Updated on
Sep 08, 2026

16 min read

v1 Transactions Are Not Live on Mainnet Yet

Transaction v1 ships in Agave v4.2.2 behind the feature gate txv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL. The gate is active on testnet at epoch 1025 on September 1, 2026. Anza has scheduled mainnet activation for September 9, 2026, and that date may move. Until the gate activates, mainnet rejects v1 transactions and every transaction you fetch is legacy or v0.

Overview

A Solana transaction has always had to fit in 1,232 bytes, a cap derived from the 1,280-byte IPv6 minimum MTU. SIMD-0296 raises that ceiling to 4,096 bytes, and SIMD-0385 defines transaction v1, the format that carries the larger payload.

The larger size limit comes with a new transaction layout, so anything that reads Solana data has to change to handle it, and the failures may be quiet.

You will run a local validator with the v1 feature gate active, send a real v1 transaction, fix four places where reading transaction data breaks with v1, and build v1 transactions correctly on the sending side.


TLDR
  • Run a local validator with the v1 feature gate active and send a real v1 transaction
  • Learn the new wire layout: 0x81 at byte zero, resource config in the message, no Address Lookup Tables, and signatures at the tail
  • Fix four places that can break when reading v1 transaction data
  • Normalize priority fees so legacy, v0, and v1 report comparable numbers
  • Build v1 transactions with the compute unit and data size limits that no longer have defaults

What You Will Need


  • Node.js version 22 or higher, to run the TypeScript examples with tsx
  • The Solana CLI version 4.2 or higher (the first release whose bundled solana-test-validator can activate the v1 feature gate)
  • A basic understanding of Solana transactions, instructions, and compute units
  • A Quicknode Solana endpoint for the block subscription example, which needs a wss:// URL that serves blockSubscribe. Quicknode endpoints serve v1 transactions with no configuration change, so the same endpoint works when you point the rest of the code at testnet or mainnet

Every other example runs against a local validator, so no funded account is required to follow along.

What Changed in Solana v1 Transactions?

Solana v1 transactions move the compute unit limit and priority fee out of ComputeBudget instructions into a bitmask-described config section, remove Address Lookup Tables in favor of up to 64 inline accounts, and reorder the envelope so it starts with the byte 0x81 and ends with the signatures. Any parser that assumes the legacy or v0 layout reads v1 data incorrectly.

v1 reorders the entire layout of a transaction envelope:


Three horizontal rows of labelled boxes compare the byte layout of Solana transaction formats, read left to right from a dashed byte zero guide to the end of the transaction. The legacy row runs Signatures, Header, Accounts, Blockhash, Instructions. The v0 row runs Signatures, a one byte 0x80 version prefix, Header, Accounts, Blockhash, Instructions, and address lookup tables labelled ALTs. The v1 row, highlighted in green, is reordered entirely: a 0x81 version prefix at byte zero, Header, Mask, Blockhash, Counts, Addresses, Values, Instructions, and Signatures last, carrying no length prefix. Captions under the v1 row read u32 bitmask under Mask, 2 by u8 max 64 under Counts, and config values under Values. Two notes follow. What moved: resource config leaves the instructions for Mask and Values, so a parser reads the fee without decoding an instruction. What is gone: v1 has no address table lookups, all 64 accounts are inline, and duplicate addresses are rejected.Three horizontal rows of labelled boxes compare the byte layout of Solana transaction formats, read left to right from a dashed byte zero guide to the end of the transaction. The legacy row runs Signatures, Header, Accounts, Blockhash, Instructions. The v0 row runs Signatures, a one byte 0x80 version prefix, Header, Accounts, Blockhash, Instructions, and address lookup tables labelled ALTs. The v1 row, highlighted in green, is reordered entirely: a 0x81 version prefix at byte zero, Header, Mask, Blockhash, Counts, Addresses, Values, Instructions, and Signatures last, carrying no length prefix. Captions under the v1 row read u32 bitmask under Mask, 2 by u8 max 64 under Counts, and config values under Values. Two notes follow. What moved: resource config leaves the instructions for Mask and Values, so a parser reads the fee without decoding an instruction. What is gone: v1 has no address table lookups, all 64 accounts are inline, and duplicate addresses are rejected.

Resource Configuration

In legacy and v0 transactions, a compute unit limit or priority fee is an instruction to the ComputeBudget program. In v1, those values live in a fixed-position config section described by a 32-bit bitmask, so a validator can read a transaction's fee before parsing any instruction.

The bit assignments, shown here as the low byte of the u32 mask, are:

Config ValueMask BitsType
Priority fee (total lamports)0b00000011u64
Compute unit limit0b00000100u32
Loaded accounts data size limit0b00001000u32
Requested heap size0b00010000u32

Address Lookup Tables Removed

A v1 transaction carries up to 64 accounts inline as full 32-byte public keys, and the runtime rejects duplicate addresses outright.

Roughly 62% of v0 transactions reference at least one lookup table, and converting them to v1 costs under 420 bytes for half of them and under 1,400 bytes for 90%. The 64-account ceiling did not move, so it remains the binding constraint for account-heavy strategies.

Envelope Reordering

A v1 transaction starts with the byte 0x81, which makes format detection a single-byte check. Signatures move to the tail with no length prefix, since the header already implies the count, and fixed-width u8 counts replace compact-u16 encoding. Legacy and v0 both open with a signature count instead, which is why neither can be identified from its first byte alone.

The fixed run at the front means a parser can read a v1 transaction's fee without touching a single instruction:

OffsetFieldSize
0Version discriminator, always 0x811 byte
1Message header3 bytes
4Config mask4 bytes
8Recent blockhash, the lifetime specifier32 bytes
40Instruction and account counts2 bytes
42Inline addresses32 bytes each

Legacy and v0 transactions are unaffected and keep working exactly as before, still subject to the 1,232-byte cap.

Run a Local Validator With v1 Active

solana-test-validator activates all known feature gates at genesis, so a 4.2 or newer validator gives you a v1-capable cluster immediately. Confirm your toolchain first:

solana --version

You need 4.2.0 or higher. If you are on an older release, switch with agave-install:

agave-install init 4.2.2

Start the validator. This guide uses port 8999 to avoid colliding with anything already bound to the default 8899:

solana-test-validator --reset --quiet --ledger test-ledger --rpc-port 8999 --faucet-port 9901

In a second terminal, verify the v1 feature gate is live:

solana feature status -u http://127.0.0.1:8999 | grep txv1

You should see the gate active from epoch zero:

txv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL | active since epoch 0 | 0 | SIMD-0385: Transaction V1

If that line reads inactive, your validator predates 4.2 and the rest of this guide will not produce v1 data.

Set Up the Project

Create a project and install the client libraries. Reading and sending v1 transactions requires @solana/kit 8.0.0 or later, so pin a current version:

mkdir solana-v1-lab && cd solana-v1-lab
npm init -y
npm pkg set type=module
npm install @solana/kit@8.2.0 @solana/kit-plugin-rpc@0.19.0 @solana/kit-plugin-signer@0.19.0 @solana-program/system@0.14.1
npm install -D tsx

Send a v1 Transaction

You need real v1 data before you can test reading code against it, so start by producing some. Create send-v1.ts. It builds a Kit plugin client, wires up the RPC and subscription connections along with transaction planning and sending, and airdropSigner funds the payer.

The important part is transactionConfig, which tells the client to build every transaction as version: 1 with the priority fee in the message header instead of in a ComputeBudget instruction. Simulation fills in the compute unit and loaded accounts data size limits before the send:

send-v1.ts
import { createClient, generateKeyPairSigner, getBase64EncodedWireTransaction, lamports } from '@solana/kit';
import { solanaLocalRpc } from '@solana/kit-plugin-rpc';
import { airdropSigner, generatedSigner } from '@solana/kit-plugin-signer';
import { getTransferSolInstruction } from '@solana-program/system';

const client = await createClient()
.use(generatedSigner())
.use(
solanaLocalRpc({
rpcUrl: 'http://127.0.0.1:8999',
rpcSubscriptionsUrl: 'ws://127.0.0.1:9000',
// Every transaction this client sends is v1, with the priority fee in the header.
transactionConfig: { version: 1, priorityFeeLamports: lamports(5_000n) },
}),
)
.use(airdropSigner(lamports(1_000_000_000n)));

console.log('payer funded:', client.payer.address);

const recipient = await generateKeyPairSigner();
const result = await client.sendTransaction(
getTransferSolInstruction({
source: client.payer,
destination: recipient.address,
amount: lamports(1_000_000n),
}),
);

const wire = Buffer.from(getBase64EncodedWireTransaction(result.context.transaction), 'base64');
console.log('first wire byte (hex):', wire[0].toString(16));
console.log('wire size (bytes):', wire.length);
console.log('sent v1 tx:', result.context.signature);

// The block fetch later in the guide needs the slot this transaction landed in.
const { value: [status] } = await client.rpc.getSignatureStatuses([result.context.signature]).send();
console.log('slot:', `${status?.slot}`);

Run it:

npx tsx send-v1.ts

Expected output:

payer funded: GHQKbpEWLxGpiUDmGLnzQHjGT4uwgBzVAFfiPx4ECpPz
first wire byte (hex): 81
wire size (bytes): 236
sent v1 tx: 3UUXn78vCkuxcvKhQhSKYuRR8vVt1rM756mLBbGKRV9iUQGbuGkkZo95ZbaAAH6rAHopeuHqZfctfCSgWVaQVpa9
slot: 81

That leading 81 is the v1 discriminator. Save the signature and the slot, because the next sections use both.

Fix Transaction and Block Fetches

Ask the RPC for your v1 transaction the way most existing code does, with no version parameter:

curl -s -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getTransaction","params":["YOUR_V1_SIGNATURE",{"encoding":"json","commitment":"confirmed"}]}' \
http://127.0.0.1:8999

Expected result:

{
"jsonrpc": "2.0",
"error": {
"code": -32015,
"message": "Transaction version (1) is not supported by the requesting client. Please try the request again with the following configuration parameter: \"maxSupportedTransactionVersion\": 1"
},
"id": 1
}

The fix is to opt in with maxSupportedTransactionVersion: 1. It must be the JSON integer 1, not the string "1":

curl -s -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getTransaction","params":["YOUR_V1_SIGNATURE",{"encoding":"json","commitment":"confirmed","maxSupportedTransactionVersion":1}]}' \
http://127.0.0.1:8999

The response now carries version: 1 alongside the slot and fee. Trimmed to the fields that matter here:

{
"jsonrpc": "2.0",
"result": {
"slot": 81,
"version": 1,
"meta": { "fee": 10000 },
"transaction": { "message": { "transactionConfig": { "priorityFee": 5000 } } }
},
"id": 1
}

getBlock behaves the same way. A single v1 transaction fails the entire block response rather than just that one entry, so a block containing one v1 transaction and hundreds of legacy ones returns nothing at all. Request the slot that send-v1.ts printed without the version parameter and you get the same -32015 error. With it:

curl -s -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getBlock","params":[YOUR_SLOT,{"encoding":"json","commitment":"confirmed","transactionDetails":"full","rewards":false,"maxSupportedTransactionVersion":1}]}' \
http://127.0.0.1:8999

The block holds two transactions: the validator's own vote, which is legacy, and your v1 transfer. Each carries a version field, so a block reader can branch on it directly. Trimmed to those fields:

{
"blockHeight": 81,
"transactions": [
{
"version": "legacy",
"transaction": { "message": { "accountKeys": ["<validator identity>", "<vote account>", "Vote111111111111111111111111111111111111111"] } }
},
{
"version": 1,
"transaction": { "message": { "transactionConfig": { "priorityFee": 5000 } } }
}
]
}
Missing Transactions

A missing transaction returns null rather than error -32015. Because getTransaction defaults to finalized commitment, querying a freshly confirmed transaction can return null and hide the version problem entirely. Use commitment: 'confirmed' while testing so you see the real error.

Fix Block Subscriptions

blockSubscribe fails less politely than the request and response methods. Per Solana's larger transaction sizes upgrade notes, a subscription that has not opted into v1 emits block: null when it reaches a v1 transaction and then stops advancing. There is no error frame and no disconnect, so the socket looks healthy while the stream is dead.

Pass the version parameter when you subscribe, and add a staleness check regardless, because nothing else will surface a wedged subscription. blockSubscribe is an unstable RPC method, so Kit exposes it through createSolanaRpcSubscriptions_UNSTABLE rather than through the plugin client. Create watch-blocks.ts:

watch-blocks.ts
import { createSolanaRpcSubscriptions_UNSTABLE } from '@solana/kit';

// Or your Quicknode endpoint's wss:// URL, pasted as-is with no port added.
const WS_URL = 'ws://127.0.0.1:9000';

// blockSubscribe is an unstable RPC method, so it lives behind the _UNSTABLE factory.
const rpcSubscriptions = createSolanaRpcSubscriptions_UNSTABLE(WS_URL);
const abortController = new AbortController();

// Without maxSupportedTransactionVersion, the first v1 transaction wedges the stream.
const blockNotifications = await rpcSubscriptions
.blockNotifications('all', {
commitment: 'confirmed',
encoding: 'json',
maxSupportedTransactionVersion: 1,
showRewards: false,
transactionDetails: 'full',
})
.subscribe({ abortSignal: abortController.signal });

let lastBlockAt = Date.now();

// Alert if no block arrives for 30 seconds.
setInterval(() => {
if (Date.now() - lastBlockAt > 30_000) {
console.error('blockSubscribe appears stalled, no block in 30s');
}
}, 5_000);

for await (const notification of blockNotifications) {
if (notification.value.block == null) {
console.error('received null block, check maxSupportedTransactionVersion');
continue;
}
lastBlockAt = Date.now();
const versions = notification.value.block.transactions.map((tx) => tx.version);
console.log(`slot ${notification.value.slot}: ${versions.length} transactions, versions ${JSON.stringify(versions)}`);
}
Where to Run This

solana-test-validator in Agave 4.2.2 exits at startup when passed --rpc-pubsub-enable-block-subscription, so this file cannot run against the local cluster from this guide. Point WS_URL at a Quicknode Solana endpoint's wss:// URL, which serves blockSubscribe. The :9000 port belongs to the local validator only, so paste the Quicknode URL unchanged. The script then prints one line per confirmed block with the version of every transaction inside.

Fix Resource Config Parsing

This is a silent error. Look at what the RPC returns for the v1 transaction you sent:

"transactionConfig": {
"computeUnitLimit": 450,
"heapSize": null,
"loadedAccountsDataSizeLimit": 149,
"priorityFee": 5000
}

And its instruction list:

"instructions": [
{
"accounts": [0, 1],
"data": "3Bxs4Bc3VYuGVB19",
"programIdIndex": 2,
"stackHeight": 1
}
]

Just one instruction, the transfer. There is no ComputeBudget instruction, and the ComputeBudget program does not even appear in the account keys. Any pipeline that derives fees by scanning instructions finds nothing and records a zero without raising an error.

A v0 transaction expressing the same economics has three instructions instead and lists ComputeBudget111111111111111111111111111111 among its accounts, with no transactionConfig present.

That difference also gives you a reliable version test. The versioned flag is true for both v0 and v1, so checking it first misclassifies every v1 transaction as v0. Check for the config object first.

If you can hand Kit the raw transaction bytes instead of the JSON, it reads the version from the envelope and exposes the fee fields through version-aware getters, so the only logic left to write is the legacy and v0 fee math. Create audit.ts:

audit.ts
import {
createClient,
decompileTransactionMessageFetchingLookupTables,
getBase64Encoder,
getCompiledTransactionMessageDecoder,
getTransactionDecoder,
getTransactionMessageComputeUnitLimit,
getTransactionMessageComputeUnitPrice,
getTransactionMessagePriorityFeeLamports,
signature,
type TransactionMessage,
} from '@solana/kit';
import { solanaRpcConnection } from '@solana/kit-plugin-rpc';

const client = createClient().use(solanaRpcConnection({ rpcUrl: 'http://127.0.0.1:8999' }));

// Builtin programs, which the runtime budgets at 3,000 compute units each when no limit is set.
const BUILTIN_PROGRAMS = new Set([
'11111111111111111111111111111111',
'ComputeBudget111111111111111111111111111111',
'Vote111111111111111111111111111111111111111',
'Stake11111111111111111111111111111111111111',
'Config1111111111111111111111111111111111111',
'AddressLookupTab1e1111111111111111111111111',
'BPFLoader1111111111111111111111111111111111',
'BPFLoader2111111111111111111111111111111111',
'BPFLoaderUpgradeab1e11111111111111111111111',
'LoaderV411111111111111111111111111111111111',
'Ed25519SigVerify111111111111111111111111111',
'KeccakSecp256k11111111111111111111111111111',
]);

/** The limit the runtime assumes for a legacy or v0 transaction that never set one. */
function defaultComputeUnitLimit(message: TransactionMessage): number {
let reserved = 0;
for (const ix of message.instructions) {
reserved += BUILTIN_PROGRAMS.has(ix.programAddress) ? 3_000 : 200_000;
}
return Math.min(reserved, 1_400_000);
}

/** Return the priority fee in total lamports for any transaction version. */
function priorityFeeLamports(message: TransactionMessage): bigint {
// v1 states the fee directly, as an absolute lamport total.
if (message.version === 1) return getTransactionMessagePriorityFeeLamports(message) ?? 0n;

// legacy and v0 state a price per compute unit, so multiply by the limit.
const microLamportsPerCu = getTransactionMessageComputeUnitPrice(message);
if (microLamportsPerCu === undefined) return 0n;
const units = getTransactionMessageComputeUnitLimit(message) ?? defaultComputeUnitLimit(message);
// The runtime rounds up to the next whole lamport, so match it or read 1 lamport low.
return (microLamportsPerCu * BigInt(units) + 999_999n) / 1_000_000n;
}

// Add any legacy or v0 signature from the same cluster to exercise both paths.
const signatures = ['YOUR_V1_SIGNATURE'];

for (const sig of signatures) {
const tx = await client.rpc
.getTransaction(signature(sig), {
commitment: 'confirmed',
encoding: 'base64',
maxSupportedTransactionVersion: 1,
})
.send();

if (tx == null) {
console.error(`${sig} not found`);
continue;
}

// Decode the wire bytes and let Kit read the version from the envelope.
const wire = getBase64Encoder().encode(tx.transaction[0]);
const { messageBytes } = getTransactionDecoder().decode(wire);
const compiled = getCompiledTransactionMessageDecoder().decode(messageBytes);
const message = await decompileTransactionMessageFetchingLookupTables(compiled, client.rpc);

const version = message.version === 'legacy' ? 'legacy' : `v${message.version}`;
console.log(version.padEnd(6), `priorityFee=${priorityFeeLamports(message)} lamports`, `totalFee=${tx.meta!.fee}`);
}

Substitute your signature and run it:

npx tsx audit.ts

Expected output:

v1 priorityFee=5000 lamports totalFee=10000

The v1 transaction reports its priority fee correctly, and the 10,000 lamport total is a 5,000 lamport base fee plus the 5,000 lamport priority fee. Without the normalizer, that row would have read zero. Pass a legacy or v0 signature through the same script and Kit reads the price from its SetComputeUnitPrice instruction instead, multiplied by the limit the transaction set or the default the runtime would have assumed.

Compute Unit Limits

Compute unit limits need the same treatment, and getTransactionMessageComputeUnitLimit already does it: it reads config.computeUnitLimit for v1 and the SetComputeUnitLimit instruction for legacy and v0. An absent limit in legacy and v0 means the runtime reserves 3,000 units per builtin instruction and 200,000 per other instruction, capped at 1,400,000 (SIMD-0170), which is what defaultComputeUnitLimit reproduces. In v1 an absent limit means zero.

Fix Geyser and gRPC Streams

Streaming consumers have a version problem of their own. Yellowstone Geyser plugin releases before 15.1.1 downgrade v1 transactions to v0 on the way out, which discards the config section and corrupts exactly the data you were trying to capture. Upgrade the plugin to 15.1.1 or later, and regenerate your protobuf stubs at 12.6.0 or later so the Message.config field exists in your generated types.

Once the field is available, detect the version structurally, in the same order as the JSON path:

function messageVersion(message: any): 'legacy' | 'v0' | 'v1' {
if (message.config !== undefined) return 'v1';
return message.versioned ? 'v0' : 'legacy';
}

Set Resource Limits When You Build v1 Transactions

Reading is most of the work, but there are a few rules you cannot skip on the sending side.

Unset limits are zero. Legacy and v0 transactions fall back to the instruction-based default described in the compute unit limits note above. A v1 transaction with no limit set gets zero and fails. You must set both computeUnitLimit and loadedAccountsDataSizeLimit explicitly. The plugin client in send-v1.ts handles this for you, because estimateResourceLimits defaults to true.

If you build transaction messages by hand, do the same thing yourself: simulate and let Kit fill the limits in rather than guessing. Create estimate-limits.ts, which reuses the funded client and builds the transfer message manually:

estimate-limits.ts
import {
appendTransactionMessageInstruction,
createClient,
createTransactionMessage,
estimateAndSetResourceLimitsFactory,
estimateResourceLimitsFactory,
fillTransactionMessageProvisoryResourceLimits,
generateKeyPairSigner,
getTransactionMessageComputeUnitLimit,
getTransactionMessageLoadedAccountsDataSizeLimit,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
} from '@solana/kit';
import { solanaLocalRpc } from '@solana/kit-plugin-rpc';
import { airdropSigner, generatedSigner } from '@solana/kit-plugin-signer';
import { getTransferSolInstruction } from '@solana-program/system';

// Same funded client as send-v1.ts. Simulation fails if the payer cannot cover the transfer.
const client = await createClient()
.use(generatedSigner())
.use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8999', rpcSubscriptionsUrl: 'ws://127.0.0.1:9000' }))
.use(airdropSigner(lamports(1_000_000_000n)));

const recipient = await generateKeyPairSigner();
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();

// A v1 message built by hand, with no resource limits set yet.
const messageWithoutLimits = pipe(
createTransactionMessage({ version: 1 }),
(m) => setTransactionMessageFeePayerSigner(client.payer, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: client.payer,
destination: recipient.address,
amount: lamports(1_000_000n),
}),
m,
),
);

const estimateResourceLimits = estimateResourceLimitsFactory({ rpc: client.rpc });
const setResourceLimits = estimateAndSetResourceLimitsFactory(estimateResourceLimits);

// Simulate with provisory limits, then set the measured values.
const draft = fillTransactionMessageProvisoryResourceLimits(messageWithoutLimits);
const message = await setResourceLimits(draft);

console.log('computeUnitLimit :', getTransactionMessageComputeUnitLimit(message));
console.log('loadedAccountsDataSize:', getTransactionMessageLoadedAccountsDataSizeLimit(message));

Run it:

npx tsx estimate-limits.ts

Expected output:

computeUnitLimit : 150
loadedAccountsDataSize: 149

These are the raw measured values from simulation, with no margin. Compare them with the transactionConfig the RPC returned for send-v1.ts: computeUnitLimit was 450 there because the plugin client adds a buffer of at least 300 compute units on top of the estimate, while loadedAccountsDataSizeLimit was 149 because it passes the data size through as measured. When you set limits by hand, add your own compute headroom, and consider rounding the data size up to the next 32 KiB page, because accounts can be created or grow between your simulation and your send.

Drop ComputeBudget instructions. v1 accepts them and ignores them. They cost 150 compute units each and change nothing, so remove them from v1 builders.

Priority fees are absolute. Set priorityFeeLamports as a total in lamports. There is no compute unit multiplication in v1.

Inline your lookup table addresses. v1 has no ALT support, so resolve any table addresses into the inline account list, keep the total at or below 64, and remove duplicates because v1 rejects them.

Submit as base64. The RPC caps base58 submission at 1,232 bytes regardless of version, so any transaction that uses the larger envelope must be sent with encoding: "base64".


Paymasters and Co-signers

Programs cannot read a v1 transaction's resource config, because no sysvar exposes the message config. If you operate a paymaster or co-signer that inspects fees before signing, detect v1 by the leading 0x81 byte and read transactionConfig off the message rather than looking for ComputeBudget instructions.

Wrapping Up

You now have a local cluster that produces v1 transactions on demand, so you can test against the new v1 format. You ran two of the four breakages yourself: the -32015 error that takes a whole block down with it, and the missing ComputeBudget instruction that turns a real fee into a zero. The other two, stalled blockSubscribe streams and Geyser downgrades, need a node you configure, so this guide gave you the code and the version floors to check against. audit.ts reads the v1 priority fee correctly today, and the same code handles legacy and v0 signatures when you feed it some.

Resources


Frequently Asked Questions

What is the maximum size of a Solana v1 transaction?

A v1 transaction can be up to 4,096 bytes, raised from the 1,232 byte limit under SIMD-0296. Legacy and v0 transactions keep the 1,232 byte cap. The account limit did not change: v1 still allows a maximum of 64 accounts, and because Address Lookup Tables are unsupported, all 64 travel inline as full 32-byte public keys.

Why does my getBlock call fail with error -32015?

The block contains at least one v1 transaction and your request did not include maxSupportedTransactionVersion. A single unsupported transaction fails the entire block response rather than just that entry. Add maxSupportedTransactionVersion: 1 as a JSON integer, not the string "1", to both getBlock and getTransaction.

Can I still use Address Lookup Tables with v1 transactions?

No. v1 removes ALT support entirely, so every referenced account must be inlined as a full 32-byte public key, and duplicate addresses are rejected. Roughly 62% of v0 transactions use at least one lookup table, and converting them costs under 420 bytes for half and under 1,400 bytes for 90%, which the larger envelope absorbs. v0 transactions continue to support ALTs, so you can keep using them where the format fits.

Why does my v1 transaction fail when I do not set a compute unit limit?

Unset resource limits are zero in v1 rather than a default. A legacy or v0 transaction that sets no limit gets a default derived from its instruction list under SIMD-0170. A v1 transaction with no computeUnitLimit gets zero units and fails immediately. Set both computeUnitLimit and loadedAccountsDataSizeLimit explicitly, ideally from a simulation rather than a guess.

Which client libraries support v1 transactions?

@solana/kit 8.0.0 and later can both read and send v1. @solana/web3.js 3.0.0-rc.3 and later can read and send it, while @solana/web3.js 1.x v1 support is still in prerelease and is read-only. On other stacks, use solana-go 1.23.0 or later, solders 0.29.0 or later for Python, and the 4.2.x Rust crates. Yellowstone Geyser needs 15.1.1 or later with protobuf stubs at 12.6.0 or later.