Solana v1 Transactions Developer Guide

Solana v1 transactions run up to 4096 bytes, drop address lookup tables, and carry priority fees and compute limits in a header. Craft and send one byte by byte.

Before We Begin

Solana's v1 transaction format is new, so this guide reads a little differently from the others. It is a reference for the wire format first, following the SIMD-0385 standard, followed by full code examples in JavaScript, TypeScript, and Go. If you have already followed the sendTransaction guide, you know how a signed transaction reaches the network — this guide covers what changes inside the bytes before it gets there.

Different Solana SDKs will likely add v1 transactions, and it's valid to use them. Today, we'll be dealing with raw bytes though, as this is a great way to explore exactly how the new Solana transaction versions work, and what we may do with them.

You will need a FluxRPC API key (a free plan one is enough). There is no need to copy-paste it. Sign in and come back to this page, and we will substitute the key you select into every snippet below.

In this guide, we will accomplish the following:

  1. Understand what changed in v1 transactions, and why it matters
  2. Tell a v1 transaction apart from a legacy or version 0 one
  3. Read the v1 transaction layout field by field
  4. Craft and send a v1 transaction of your own
  5. Read the full code examples in JavaScript, TypeScript and Go

We will provide examples in JavaScript, TypeScript, and Go in this guide.

What changed?

The new V1 transactions on Solana are larger (up to 4096 bytes), and do away with address lookup tables. Priority fees and compute limits no longer require their own transaction instructions, they are included in a header.

Why is that important?

Eliminating address lookup tables reduces the work validators need to do, paving the way for future performance increases. The much larger maximum transaction size means your transactions can include a lot more data and instructions.

However, as v1 transactions must include all referenced accounts in the transaction header, how much usable space you end up with depends on how many accounts your transaction references. Transactions referencing fewer accounts will see the biggest increase in available space.

The inclusion of priority fees and compute limits in a transaction header offers a little extra free space in nearly all cases. Most transactions on Solana needed these as separate instructions until now.

What identifies a v1 transaction?

The first byte of a transaction identifies the version:

  1. If the first byte is less than 128, it's a legacy transaction. In legacy transactions, the first byte indicated the number of signatures, which was always less than 128.
  2. If the first byte is equal to 128, it's a version 0 transaction.
  3. If the first byte is equal to 129, it's a version 1 transaction.

What is the structure of a v1 transaction?

Start ByteFinish ByteWidthDescription
001Version Byte
133Legacy Header
474Transaction Configuration Mask
83932Recent Blockhash
40401Number of Instructions
41411Number of Accounts
42-VariesAddresses referenced by transaction
---Configuration Values
---Instruction Headers
---Instruction Payloads
---Signatures

Up until the actual accounts referenced by the transaction is specified, we've got a static data structure. By reading byte 41, we know the width of the address bytes (32 per address). With very little processing, we can know all the accounts this transaction references.

The number of set bits in the transaction config mask determines the length of the Configuration Values field -- 4 bytes per bit set.

This is much faster than having to process address lookup tables, which involves fetching on-chain data!

How do I craft and send a Solana v1 transaction?

We'll cover both frontend and backend examples for a simple transaction. We'll break it down into steps.

Set the transaction version!

First step is to set the first byte to 129 so that it's identified as a v1 transaction.

Set the legacy header

The legacy header structure is as follows:

Start ByteFinish ByteWidthDescription
111Number of required signatures
221Number of read-only accounts with signatures
331Number of read-only accounts without signatures

Transaction Configuration Bit Mask

This determines the behavior of new features. Some features require more than one bit to be set in the bitmask, if only one is set, the transaction will be rejected. In other words, either all or none of the bits for each feature must be set.

There are only four features to set here right now:

Start BitFinish BitBit WidthDescription
012Priority fee (lamports)
221Compute limit
331Requested account data size limit
441Requested heap size
53127Reserved

Most of the time, you will want to set both priority fee bits. Setting just one of them will cause your transaction to be rejected. Setting neither of them means your transaction will not have any priority fees.

You will want to set the compute limit bit, else a compute limit of 0 will be set, causing your transaction to immediately fail.

You will also want to set the data size limit bit. Else it will default to limiting the requested account data size to 0.

Finally, there's the requested heap size. If not set, this will default to 32 KiB, the current value of MIN_HEAP_FRAME_BYTES. If you enable it, you'll be able to set your transaction to use up to 256 KiB of heap for every program used in your transaction. If you know for sure you don't need more than 32 KiB of heap, you can leave this unset. Otherwise, set it.

In summary, it will be quite common to just set bits 0-4.

The reserved bits should be left unset!

Set the Lifetime Specifier

This is what was previously called the latest block hash. Like before, it's used to determine how long the transaction is valid for. A block hash is valid for 150 slots. After that, transactions using that block hash expire and cannot be processed.

Number of Instructions

This is the number of instructions the transaction will contain. Even though it is a uint8, values over 64 are currently invalid and will result in your transaction being rejected.

Number of Addresses

This is the number of addresses your transaction will contain. Like above, even though it is a uint8, the maximum valid value is currently 64. Anything higher will be rejected.

Additionally, the number of addresses cannot be higher than the sum of (number of required signatures) and (number of read-only accounts without signatures) defined in the legacy header.

Addresses

This part has not changed in v1 transactions. These are the addresses referenced by your transaction. Each is 32 bytes, no duplicates are permitted, and they must be sorted in the following order:

  1. Addresses that must sign this transaction, and are writeable by this transaction. The first one must be the fee payer.
  2. Addresses that must sign this transaction, but are read-only for this transaction.
  3. Addresses that do not need to sign this transaction, but are writeable by this transaction.
  4. Addresses that do not need to sign this transaction, and are read-only for this transaction.

Configuration Values

This is the actual configuration data for the features defined in the Configuration bitmask earlier.

The length of this field is 4 bytes for each bit that is set in the bitmask, for example, the priority fee is the first item and has two bits reserved for it in the Configuration bitmask. As a result, the corresponding value here is 8 bytes wide.

In the case where all currently functional Configuration bitmask bits are set, the configuration values are as follows:

Start ByteFinish ByteWidthDescription
078Priority fee (lamports)
8114Compute limit
12154Requested account data size limit
16194Requested heap size

You do not need to include the bytes for any disabled features. For example, if you did not set any of the bits in the bitmask for priority fees, you do not need to set 8 bytes of zeros. You would just start the configuration values with the Compute limit bytes, completely omitting the priority fee bytes.

Instruction Headers

Earlier, we specified the number of instructions. Each needs the following set here:

Start ByteFinish ByteWidthDescription
001Program Account Index
111Number of Accounts for this Instruction
232Number of Instruction data bytes
  1. For the Program Account Index, this used to be called "program_id_index". In the array of addresses referenced by this transaction, one of them must be the program that invokes the instruction. This byte is the index of that address in the array of addresses.
  2. For number of accounts in the instruction, this is what it sounds like. It's the number of accounts referenced by the instruction. In earlier transaction versions, this was determined implicitly. In v1 transactions it must be set explicitly here.
  3. The number of instruction data bytes, is the number of bytes that this transaction instruction will be passing to the program that invokes it.

Instructions Payload

For each instruction, it starts with the indices of all it's referenced accounts -- the index (one byte), not the full account public keys (32 bytes).

Next, it contains the data bytes, which must be the same length as specified in the header (number of instruction data bytes).

Signatures

For every signature in the number of required signatures specified in the legacy header, there must be a corresponding 64-byte signature here. The index of the signature, must match the index of the address in the address table.

Since the addresses in the address table are sorted such that the signing addresses occur first, this should never be an issue.

There is no padding between signatures, and if you include any extra data after the last signature, the transaction will fail.

Solana V1 Transactions Code Examples

// Full transaction lifecycle in TypeScript: fetch blockhash -> build v1 -> sign
// -> serialize -> submit -> poll for confirmation.
//
//	npm i             # bs58 + tweetnacl
//	node txv1.ts   # fill in the two keys below first
//
// Requires Node 22.6+ for native type stripping (24+ recommended).

import nacl from "tweetnacl";
import bs58 from "bs58";

const APIKey = "<Your-API-Key>";
const RPCRegion = "eu"; // "eu" / "us" hit a region directly; cdn is the edge
const SecretKey = "<Your-Base58-Secret-Key>"; // the fee payer, 64 or 32 bytes

// ===========================================================================
// STAGE 1 — build the message
// ===========================================================================

const versionByteV1 = 129; // 0x80|1
const maxTxSize = 4096;
const signatureLen = 64;

// Mask bits, per solana-message v1. One bit buys one 4-byte slot in
// ConfigValues; the u64 priority fee needs two slots, so it claims two bits
// and both must be set or the tx is rejected.
const maskPriorityFee = 0b00011;
const maskComputeUnits = 0b00100;
const maskDataSize = 0b01000;
const maskHeapSize = 0b10000;
const maskKnownBits = maskPriorityFee | maskComputeUnits | maskDataSize | maskHeapSize;

/** Append-only buffer. Every width here is little-endian. */
class Writer {
  bytes: number[] = [];
  u8(v: number) {
    this.bytes.push(v & 0xff);
  }
  u16(v: number) {
    this.bytes.push(v & 0xff, (v >>> 8) & 0xff);
  }
  u32(v: number) {
    this.bytes.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
  }
  u64(v: bigint) {
    for (let i = 0n; i < 8n; i++) this.bytes.push(Number((v >> (i * 8n)) & 0xffn));
  }
  raw(b: Uint8Array) {
    for (const x of b) this.bytes.push(x);
  }
  out(): Uint8Array {
    return Uint8Array.from(this.bytes);
  }
}

function popcount(x: number): number {
  let n = 0;
  for (let v = x >>> 0; v !== 0; v >>>= 1) n += v & 1;
  return n;
}

interface Instruction {
  programIndex: number;
  accounts: number[];
  data: Uint8Array;
}

interface ConfigFields {
  priorityFeeLamports?: bigint;
  computeUnitLimit?: number;
  loadedDataSizeLimit?: number;
  heapSizeBytes?: number;
}

class Config {
  priorityFeeLamports?: bigint;
  computeUnitLimit?: number;
  loadedDataSizeLimit?: number;
  heapSizeBytes?: number;

  constructor(f: ConfigFields = {}) {
    this.priorityFeeLamports = f.priorityFeeLamports;
    this.computeUnitLimit = f.computeUnitLimit;
    this.loadedDataSizeLimit = f.loadedDataSizeLimit;
    this.heapSizeBytes = f.heapSizeBytes;
  }

  mask(): number {
    let m = 0;
    if (this.priorityFeeLamports !== undefined) m |= maskPriorityFee;
    if (this.computeUnitLimit !== undefined) m |= maskComputeUnits;
    if (this.loadedDataSizeLimit !== undefined) m |= maskDataSize;
    if (this.heapSizeBytes !== undefined) m |= maskHeapSize;
    return m >>> 0;
  }

  valuesLen(): number {
    return popcount(this.mask()) * 4;
  }

  encode(): Uint8Array {
    const w = new Writer();
    if (this.priorityFeeLamports !== undefined) w.u64(this.priorityFeeLamports);
    if (this.computeUnitLimit !== undefined) w.u32(this.computeUnitLimit);
    if (this.loadedDataSizeLimit !== undefined) w.u32(this.loadedDataSizeLimit);
    if (this.heapSizeBytes !== undefined) w.u32(this.heapSizeBytes);
    return w.out();
  }
}

interface TxV1Fields {
  numRequiredSigs: number;
  numReadonlySigned: number;
  numReadonlyUnsigned: number;
  config: Config;
  lifetimeSpecifier: Uint8Array; // 32 bytes
  addresses: Uint8Array[]; // 32 bytes each
  instructions: Instruction[];
}

class TxV1 {
  numRequiredSigs: number;
  numReadonlySigned: number;
  numReadonlyUnsigned: number;
  config: Config;
  lifetimeSpecifier: Uint8Array;
  addresses: Uint8Array[];
  instructions: Instruction[];
  signatures: Uint8Array[] = [];

  constructor(f: TxV1Fields) {
    this.numRequiredSigs = f.numRequiredSigs;
    this.numReadonlySigned = f.numReadonlySigned;
    this.numReadonlyUnsigned = f.numReadonlyUnsigned;
    this.config = f.config;
    this.lifetimeSpecifier = f.lifetimeSpecifier;
    this.addresses = f.addresses;
    this.instructions = f.instructions;
  }

  // Mirrors agave-transaction-view's sanitize.rs. Cheaper to fail here than to
  // spend a fee discovering it on-chain.
  validate(): void {
    if (this.numRequiredSigs < 1) throw new Error("need at least one signature");
    if (this.numReadonlySigned >= this.numRequiredSigs) throw new Error("fee payer must be writable");
    if (this.addresses.length > 64 || this.addresses.length < 1)
      throw new Error(`addresses must be 1..64, got ${this.addresses.length}`);
    if (this.instructions.length > 64)
      throw new Error(`instructions must be <= 64, got ${this.instructions.length}`);
    if (this.numRequiredSigs > 12) throw new Error("signatures must be <= 12");
    const need = this.numRequiredSigs + this.numReadonlyUnsigned;
    if (this.addresses.length < need)
      throw new Error(`need >= ${need} addresses, have ${this.addresses.length}`);
    if (this.lifetimeSpecifier.length !== 32)
      throw new Error(`lifetime specifier must be 32 bytes, got ${this.lifetimeSpecifier.length}`);

    const m = this.config.mask();
    const unknown = (m & ~maskKnownBits) >>> 0;
    if (unknown !== 0) throw new Error(`unknown config bits: 0x${unknown.toString(16)}`);
    const h = this.config.heapSizeBytes;
    if (h !== undefined && (h < 32 * 1024 || h > 256 * 1024 || h % 1024 !== 0))
      throw new Error(`heap must be 32KiB..256KiB and a multiple of 1024, got ${h}`);
    // The trap: an unset bit floors the value, it does not inherit a v0 default.
    if (this.config.computeUnitLimit === undefined)
      throw new Error("compute unit limit unset -> 0 CU -> the tx can execute nothing");
    if (this.config.loadedDataSizeLimit === undefined)
      throw new Error("loaded data size limit unset -> 0 bytes -> nothing loads");

    this.instructions.forEach((ix, i) => {
      if (ix.programIndex === 0) throw new Error(`ix ${i}: program index 0 is the fee payer`);
      if (ix.programIndex >= this.addresses.length)
        throw new Error(`ix ${i}: program index ${ix.programIndex} out of range`);
      if (ix.accounts.length > 255) throw new Error(`ix ${i}: too many accounts`);
      if (ix.data.length > 65535) throw new Error(`ix ${i}: data exceeds the u16 length field`);
      for (const a of ix.accounts)
        if (a >= this.addresses.length) throw new Error(`ix ${i}: account index ${a} out of range`);
    });

    const seen = new Set<string>();
    for (const a of this.addresses) {
      if (a.length !== 32) throw new Error(`address must be 32 bytes, got ${a.length}`);
      const k = Buffer.from(a).toString("hex");
      if (seen.has(k)) throw new Error(`duplicate address ${k.slice(0, 8)}…`);
      seen.add(k);
    }
  }

  // The signed payload: everything before the signatures. Because it is a
  // prefix of the packet, a validator can hash it as bytes arrive.
  message(): Uint8Array {
    this.validate();
    const w = new Writer();
    w.u8(versionByteV1);
    w.u8(this.numRequiredSigs);
    w.u8(this.numReadonlySigned);
    w.u8(this.numReadonlyUnsigned);
    w.u32(this.config.mask());
    w.raw(this.lifetimeSpecifier);
    w.u8(this.instructions.length);
    w.u8(this.addresses.length);
    for (const a of this.addresses) w.raw(a);
    w.raw(this.config.encode());
    // Two loops, not one: merging them produces the v0 interleaved shape.
    for (const ix of this.instructions) {
      w.u8(ix.programIndex);
      w.u8(ix.accounts.length);
      w.u16(ix.data.length);
    }
    for (const ix of this.instructions) {
      w.raw(Uint8Array.from(ix.accounts));
      w.raw(ix.data);
    }
    return w.out();
  }

  // =========================================================================
  // STAGE 2 — sign
  // =========================================================================

  // Keys are nacl's 64-byte secret keys, in header order.
  sign(...keys: Uint8Array[]): void {
    const msg = this.message();
    if (keys.length !== this.numRequiredSigs)
      throw new Error(`have ${keys.length} keys, header requires ${this.numRequiredSigs}`);
    this.signatures = keys.map((k) => nacl.sign.detached(msg, k));
  }

  // Nothing may follow the signatures.
  serialize(): Uint8Array {
    const msg = this.message();
    if (this.signatures.length !== this.numRequiredSigs) throw new Error("unsigned");
    const w = new Writer();
    w.raw(msg);
    for (const s of this.signatures) {
      if (s.length !== signatureLen) throw new Error(`signature must be ${signatureLen} bytes`);
      w.raw(s);
    }
    const out = w.out();
    if (out.length > maxTxSize) throw new Error(`${out.length} bytes exceeds the ${maxTxSize} limit`);
    return out;
  }

  // What explorers index by.
  id(): string {
    return this.signatures.length === 0 ? "" : bs58.encode(this.signatures[0]!);
  }
}

// ===========================================================================
// Keys
// ===========================================================================

/** A wallet's base58 secret key: 64 bytes (seed||pubkey), or a bare 32-byte seed. */
function keypair(base58Secret: string): { publicKey: Uint8Array; secretKey: Uint8Array } {
  const secret = bs58.decode(base58Secret);
  if (secret.length === 64) return nacl.sign.keyPair.fromSecretKey(secret);
  if (secret.length === 32) return nacl.sign.keyPair.fromSeed(secret);
  throw new Error(`secret key must be 32 or 64 bytes, got ${secret.length}`);
}

// ===========================================================================
// STAGE 3 — RPC: blockhash, submit, poll
// ===========================================================================

interface Status {
  slot: number;
  confirmations: number | null;
  confirmationStatus: string;
  err: unknown;
}

class RPC {
  url: string;

  constructor(url: string) {
    this.url = url;
  }

  async call(method: string, params: unknown): Promise<any> {
    const response = await fetch(this.url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
    });
    if (response.status !== 200) throw new Error(`rpc ${method}: HTTP ${response.status}`);
    const json = await response.json();
    if (json["error"]) throw new Error(`rpc ${method}: ${json["error"]["code"]} ${json["error"]["message"]}`);
    return json["result"];
  }

  // lastValidBlockHeight: past that height the tx can no longer land, which is
  // the signal to stop retrying.
  async latestBlockhash(): Promise<{ hash: Uint8Array; lastValid: number }> {
    const result = await this.call("getLatestBlockhash", [{ commitment: "confirmed" }]);
    const raw = result["value"]["blockhash"];
    const hash = bs58.decode(raw);
    if (hash.length !== 32) throw new Error(`bad blockhash ${raw}`);
    return { hash, lastValid: result["value"]["lastValidBlockHeight"] };
  }

  async sendTransaction(raw: Uint8Array): Promise<string> {
    return this.call("sendTransaction", [
      Buffer.from(raw).toString("base64"),
      {
        encoding: "base64",
        // skipPreflight trades a safety net for latency. Keep it false until
        // the tx shape is known good.
        skipPreflight: false,
        preflightCommitment: "confirmed",
        maxRetries: 0, // we retry ourselves, deliberately
      },
    ]);
  }

  async signatureStatus(sig: string): Promise<Status | null> {
    const result = await this.call("getSignatureStatuses", [[sig], { searchTransactionHistory: false }]);
    return result["value"][0] ?? null;
  }

  async blockHeight(): Promise<number> {
    return this.call("getBlockHeight", [{ commitment: "confirmed" }]);
  }
}

// ===========================================================================
// STAGE 4 — submit and confirm
// ===========================================================================

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Rebroadcast until the tx confirms or the blockhash expires. Dropped
// transactions are silent — there is no on-chain record of a tx that was never
// scheduled — so resending is the only way to find out.
async function confirm(rpc: RPC, raw: Uint8Array, sig: string, lastValid: number): Promise<Status> {
  for (;;) {
    const st = await rpc.signatureStatus(sig);
    if (st) {
      if (st.err) throw new Error(`executed but failed: ${JSON.stringify(st.err)}`);
      if (st.confirmationStatus === "confirmed" || st.confirmationStatus === "finalized") return st;
    }

    const height = await rpc.blockHeight();
    if (height > lastValid)
      throw new Error(
        `blockhash expired at height ${height} (last valid ${lastValid}): never landed, no fee charged`,
      );

    // Same bytes, same signature — safe to resend, the network dedupes.
    try {
      await rpc.sendTransaction(raw);
    } catch (e) {
      console.error("  rebroadcast:", (e as Error).message);
    }
    await sleep(2000);
  }
}

// ===========================================================================
// Demo
// ===========================================================================

const url = "https://" + RPCRegion + ".fluxrpc.com?key=" + APIKey;
const rpc = new RPC(url);

const { publicKey, secretKey } = keypair(SecretKey);

const feePayer = publicKey;
const tokenAcct = new Uint8Array(32);
const mint = new Uint8Array(32);
const program = new Uint8Array(32);
tokenAcct[0] = 1;
mint[0] = 2;
program[0] = 3;

const { hash: blockhash, lastValid } = await rpc.latestBlockhash();
console.log(`1. blockhash ${bs58.encode(blockhash).slice(0, 8)}… valid through height ${lastValid}`);

const tx = new TxV1({
  numRequiredSigs: 1,
  numReadonlySigned: 0,
  numReadonlyUnsigned: 2, // mint + program, at the tail
  config: new Config({
    priorityFeeLamports: 100_000n,
    computeUnitLimit: 200_000, // mandatory in practice
    loadedDataSizeLimit: 256 * 1024,
    // heapSizeBytes unset -> 32768, the only safe default
  }),
  lifetimeSpecifier: blockhash,
  // Order: signed-writable, signed-readonly, unsigned-writable,
  // unsigned-readonly.
  addresses: [feePayer, tokenAcct, mint, program],
  instructions: [
    { programIndex: 3, accounts: [0, 1, 2], data: Uint8Array.from([0x0c, 0x40, 0x42, 0x0f]) },
    { programIndex: 3, accounts: [1, 0], data: Uint8Array.from([0x09]) },
  ],
});
tx.validate();
const msg = tx.message();
const maskBits = "0b" + tx.config.mask().toString(2).padStart(5, "0");
console.log(
  `2. built: ${tx.addresses.length} addrs, ${tx.instructions.length} ix, ` +
    `mask ${maskBits}, message ${msg.length} bytes`,
);

tx.sign(secretKey);
const raw = tx.serialize();
console.log(`3. signed: ${raw.length} bytes total, id ${tx.id().slice(0, 16)}…`);
console.log(
  "   verify over the prefix:",
  nacl.sign.detached.verify(raw.subarray(0, msg.length), raw.subarray(msg.length), publicKey),
);

const sig = await rpc.sendTransaction(raw);
console.log(`4. submitted ${sig.slice(0, 16)}…`);

const st = await confirm(rpc, raw, sig, lastValid);
console.log(`5. ${st.confirmationStatus} in slot ${st.slot}`);

← All developer guides