in-solana by Supanode

Versioned Transactions and Lookup Tables

TL;DR

  • - Versioned transactions (v0) support Address Lookup Tables, allowing up to 256 accounts per transaction
  • - Legacy transactions are limited to ~35 accounts due to the 1232-byte size limit
  • - Create a lookup table once, then reference it in every transaction that needs those accounts

Solana transactions have a hard size limit of 1232 bytes. In a legacy transaction, every account referenced must be included as a full 32-byte public key in the message. This limits legacy transactions to roughly 35 unique accounts. Versioned transactions (v0) solve this with Address Lookup Tables (ALTs), which store account addresses on-chain and let transactions reference them by index instead of full public key.

Legacy vs. versioned transactions

FeatureLegacyVersioned (v0)
Account key size in message32 bytes each1 byte index (for ALT accounts)
Max accounts (practical)~35Up to 256
Address Lookup TablesNot supportedSupported
maxSupportedTransactionVersionNot requiredRequired (set to 0)

How Address Lookup Tables work

An Address Lookup Table is an on-chain account that stores a list of public keys. When a versioned transaction references an ALT, accounts in that table are identified by their 1-byte index (0–255) instead of their 32-byte address.

Legacy transaction message:
[32 bytes] [32 bytes] [32 bytes] [32 bytes] ... → 128 bytes for 4 accounts

Versioned transaction with ALT:
[32 bytes for ALT address] [1 byte] [1 byte] [1 byte] [1 byte] → 36 bytes for 4 accounts

The savings grow with more accounts. A DEX swap touching 20 accounts saves ~600 bytes by using an ALT.

Creating a lookup table

import {
  AddressLookupTableProgram,
  Connection,
  Keypair,
  PublicKey,
  TransactionMessage,
  VersionedTransaction,
} from "@solana/web3.js";

const connection = new Connection(rpcUrl);
const payer = Keypair.fromSecretKey(/* ... */);

// Step 1: Create the lookup table
const slot = await connection.getSlot();
const [createIx, lookupTableAddress] =
  AddressLookupTableProgram.createLookupTable({
    authority: payer.publicKey,
    payer: payer.publicKey,
    recentSlot: slot,
  });

// Step 2: Add addresses to the table
const extendIx = AddressLookupTableProgram.extendLookupTable({
  payer: payer.publicKey,
  authority: payer.publicKey,
  lookupTable: lookupTableAddress,
  addresses: [
    new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
    new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"),
    // ... more addresses
  ],
});

// Send both instructions in one transaction
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
  payerKey: payer.publicKey,
  recentBlockhash: blockhash,
  instructions: [createIx, extendIx],
}).compileToV0Message();

const tx = new VersionedTransaction(message);
tx.sign([payer]);
await connection.sendTransaction(tx);

After creation, the lookup table needs one slot (~400ms) to become active before transactions can reference it.

Using a lookup table in transactions

// Fetch the lookup table account
const lookupTable = (
  await connection.getAddressLookupTable(lookupTableAddress)
).value;

// Build a versioned transaction referencing the ALT
const message = new TransactionMessage({
  payerKey: payer.publicKey,
  recentBlockhash: blockhash,
  instructions: [/* your instructions */],
}).compileToV0Message([lookupTable]); // Pass ALTs here

const tx = new VersionedTransaction(message);
tx.sign([payer]);
await connection.sendTransaction(tx);

The compileToV0Message method automatically determines which accounts can be referenced via the ALT and uses indices instead of full public keys.

Lookup table management

Extending

Add more addresses (up to 256 total) with extendLookupTable. New addresses need one slot to become active.

Deactivating

const deactivateIx = AddressLookupTableProgram.deactivateLookupTable({
  authority: payer.publicKey,
  lookupTable: lookupTableAddress,
});

After deactivation, the table can no longer be referenced by new transactions. Existing transactions that already included the table are unaffected.

Closing

After deactivation and a cooldown period (~512 slots), you can close the table to reclaim the rent SOL:

const closeIx = AddressLookupTableProgram.closeLookupTable({
  authority: payer.publicKey,
  lookupTable: lookupTableAddress,
  recipient: payer.publicKey,
});

When to use lookup tables

Use ALTs when:

  • Your instructions reference many accounts (DEX swaps, multi-hop routes, complex DeFi operations).
  • You hit the 1232-byte transaction size limit.
  • You frequently transact with the same set of accounts.

Skip ALTs when:

  • Your transactions reference fewer than ~10 accounts.
  • The accounts change every transaction (ALT creation has overhead).
  • You need maximum compatibility with old tools that do not support v0.

RPC compatibility

When fetching transactions or blocks that may contain versioned transactions, always pass maxSupportedTransactionVersion: 0:

const tx = await connection.getTransaction(signature, {
  maxSupportedTransactionVersion: 0,
});

const block = await connection.getBlock(slot, {
  maxSupportedTransactionVersion: 0,
});

Without this parameter, the RPC node returns an error for any versioned transaction.

FAQ

Can I use multiple lookup tables in one transaction? Yes. A single transaction can reference multiple ALTs. Pass them all to compileToV0Message.

Who pays for the lookup table? The payer of the createLookupTable instruction pays the rent-exempt minimum for the table account. At 256 addresses, this is approximately 0.0035 SOL.

Can anyone use my lookup table? Yes. Lookup tables are public. Anyone can reference your table in their transactions. Only the authority can modify (extend, deactivate, close) it.

Are legacy transactions deprecated? No. Legacy transactions continue to work. Use them when you do not need lookup tables. Many simple operations (transfers, single swaps) fit fine in legacy transactions.

Updated: