Skip to main content

How to Use Lookup Tables on Solana

Created on
Updated on
Dec 17, 2024

17 min read

Overview

On October 10, 2022 (Epoch 358), Solana added support for new transaction version types through a concept known as "Versioned Transactions". After this change, the Solana runtime now supports two types of transactions: "legacy" (older transactions) and "0" (transactions that include Address Lookup Tables). Address lookup tables bring a new way for developers to efficiently load many addresses into a transaction, so if you have had issues with storage size due to accounts, this could help you out!

The Address Lookup Table Program (Program ID: AddressLookupTab1e1111111111111111111111111) allows you to store public keys in on-chain lookup tables and call the Lookup Table in your Versioned Transaction. Because serialized transactions transmitted to Solana validators must not exceed 1,232 bytes (Source Code, Reference), leveraging a Lookup Table can reduce transaction size and enable more complex transaction instructions (e.g., more accounts, more integrated Cross-Program Invocations, etc.). Solana lookup tables "effectively 'compress' a 32-byte address into a 1-byte index value" (Source). This means by using lookup tables, our transactions sizes will be smaller (or that we can pack more into our transactions)!

What You Will Do

In this guide, you will:

  • create and execute a Version 0 (V0) Transaction,
  • create and populate an Address Lookup Table, and
  • compare the transaction size of two nearly identical transactions (one using a lookup table and one without).

If you need help with ensuring your existing client-side apps can support Versioned Transactions, check out our Guide: How to Update Your Solana Client to Handle Versioned Transactions.

What You Will Need

If you're coming over from our previous Guide: How to Use Versioned Transactions on Solana, you can reuse your app.ts and can Skip to the Assemble a Version 0 Transaction section. Otherwise, set up your project:

Set Up Your Project

Create a new project directory in your terminal with the following:

mkdir solana-versioned-tx
cd solana-versioned-tx

Create a file for your app, app.ts:

echo > app.ts

Initialize your project with the "yes" flag to use default values for your new package:

yarn init --yes
#or
npm init --yes

Create a tsconfig.json with .json importing enabled:

tsc -init --resolveJsonModule true

Install Solana Web3 Dependency

We will need to add the Solana Web3 library for this exercise. In your terminal, type:

yarn add @solana/web3.js@1
#or
npm install @solana/web3.js@1

Let's get started.

Set Up Your App

Import Necessary Dependencies

Open app.ts, and paste the following imports on line 1:

import { AddressLookupTableProgram, Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, TransactionInstruction, TransactionMessage, VersionedTransaction, TransactionSignature, TransactionConfirmationStatus, SignatureStatus } from '@solana/web3.js';

We are importing a few essential methods and classes from the Solana Web3 library. There are probably a couple of imports you have not seen before (e.g., AddressLookupTableProgram and VersionedTransaction)--we will cover those later in this guide.

Create a Wallet and Airdrop SOL

The first task we'll need to accomplish is creating an account with a wallet and funding it. We'll be using the handy tool below to automatically generate a new wallet and airdrop 1 SOL to it. (You can also achieve this with the Keypair.generate() and requestAirdrop() functions if you prefer a more manual approach).

🔑Generate a new wallet with Devnet SOL

Once you've successfully generated your keypair, you'll notice two new constants: secret and SIGNER_WALLET, a Keypair. The secret is a 32-byte array that is used to generate the public and private keys. The SIGNER_WALLET is a Keypair instance that is used to sign transactions (we've airdropped some devnet SOL to cover the gas fees). Make sure to add it to your code below your other constants if you haven't yet.

Below your imports, paste your new secret, and add:

const secret = [0,...,0]; // 👈 Replace with your secret
const SIGNER_WALLET = Keypair.fromSecretKey(new Uint8Array(secret));
const DESTINATION_WALLET = Keypair.generate();
//const LOOKUP_TABLE_ADDRESS = new PublicKey(""); // We will add this later

We have defined two wallets: SIGNER_WALLET will send SOL to our DESTINATION_WALLET. We have also added a constant, LOOKUP_TABLE_ADDRESS, which we will update later to reference our Lookup Table's on-chain account ID.

Set Up Your QuickNode Endpoint

To build on Solana, you'll need an API endpoint to connect with the network. You're welcome to use public nodes or deploy and manage your own infrastructure; however, if you'd like 8x faster response times, you can leave the heavy lifting to us.


QuickNode Now Accepts Solana Payments 🚀

You can now pay for a QuickNode plan using USDC on Solana. As the first multi-chain provider to accept Solana payments, we're streamlining the process for developers — whether you're creating a new account or managing an existing one. Learn more about paying with Solana here.

See why over 50% of projects on Solana choose QuickNode and sign up for a free account here. We're going to use a Solana Devnet node.

Copy the HTTP Provider link:

Inside app.ts under your import statements, declare your RPC and establish your Connection to Solana:

const QUICKNODE_RPC = 'https://example.solana-devnet.quiknode.pro/0123456/';
const SOLANA_CONNECTION = new Connection(QUICKNODE_RPC);

Your environment should look like this:

Alright, let's BUILD!

Assemble a Version 0 Transaction

To use Lookup Tables, you must use Version 0 transactions. If you're unfamiliar with constructing and executing Versioned Transactions, check out our guide, here.

Let's start by creating a new function, createAndSendV0Tx, that accepts an array of TransactionInstructions, txInstructions:

async function createAndSendV0Tx(txInstructions: TransactionInstruction[]) {
// Step 1 - Fetch Latest Blockhash
let latestBlockhash = await SOLANA_CONNECTION.getLatestBlockhash('finalized');
console.log(" ✅ - Fetched latest blockhash. Last valid height:", latestBlockhash.lastValidBlockHeight);

// Step 2 - Generate Transaction Message
const messageV0 = new TransactionMessage({
payerKey: SIGNER_WALLET.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: txInstructions
}).compileToV0Message();
console.log(" ✅ - Compiled transaction message");
const transaction = new VersionedTransaction(messageV0);

// Step 3 - Sign your transaction with the required `Signers`
transaction.sign([SIGNER_WALLET]);
console.log(" ✅ - Transaction Signed");

// Step 4 - Send our v0 transaction to the cluster
const txid = await SOLANA_CONNECTION.sendTransaction(transaction, { maxRetries: 5 });
console.log(" ✅ - Transaction sent to network");

// Step 5 - Confirm Transaction
const confirmation = await confirmTransaction(SOLANA_CONNECTION, txid);
if (confirmation.value.err) { throw new Error(" ❌ - Transaction not confirmed.") }
console.log('🎉 Transaction succesfully confirmed!', '\n', `https://explorer.solana.com/tx/${txid}?cluster=devnet`);
}

Let's walk through our code:

  • Step 1: We fetch the latest blockhash from the network. Note: We pass the parameter, 'finalized', to make sure the block does not belong to a dropped fork.
  • Step 2: Using our txInstructions parameter and the latestBlockhash, we create a new MessageV0 by building a Message and executing the .compileToV0Message() method.
  • Step 3: We sign the transaction with an array of signers. In this case, it is just our SIGNER_WALLET.
  • Step 4: We send the transaction to the cluster using sendTransaction, which will return a transaction signature/id.
  • Step 5: We wait for the cluster to confirm the transaction has succeeded. If it succeeds, we log our explorer URL; otherwise, we throw an error.

Let's define the confirmTransaction function we called in our createAndSendV0Tx function. Add the following code to your app.ts file:

async function confirmTransaction(
connection: Connection,
signature: TransactionSignature,
desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
timeout: number = 30000,
pollInterval: number = 1000,
searchTransactionHistory: boolean = false
): Promise<SignatureStatus> {
const start = Date.now();

while (Date.now() - start < timeout) {
const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });

if (!statuses || statuses.length === 0) {
throw new Error('Failed to get signature status');
}

const status = statuses[0];

if (status === null) {
// If status is null, the transaction is not yet known
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}

if (status.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
}

if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
return status;
}

if (status.confirmationStatus === 'finalized') {
return status;
}

await new Promise(resolve => setTimeout(resolve, pollInterval));
}

throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
}

This will simply poll the Solana network for the transaction status until it is either confirmed or the timeout is reached. We have included some default values for the timeout and poll interval, but you can adjust them as needed.

Alright! We're all set. Let's add some instructions.

Create an Address Lookup Table

Create a new async function, createLookupTable, that will build our transaction instruction and invoke createAndSendV0Tx:

async function createLookupTable() {
// Step 1 - Get a lookup table address and create lookup table instruction
const [lookupTableInst, lookupTableAddress] =
AddressLookupTableProgram.createLookupTable({
authority: SIGNER_WALLET.publicKey,
payer: SIGNER_WALLET.publicKey,
recentSlot: await SOLANA_CONNECTION.getSlot(),
});

// Step 2 - Log Lookup Table Address
console.log("Lookup Table Address:", lookupTableAddress.toBase58());

// Step 3 - Generate a transaction and send it to the network
createAndSendV0Tx([lookupTableInst]);
}

Breaking down our code:

  • Step 1: We create two variables, lookupTableInst and lookupTableAddress, by destructuring the results of the createLookupTable method. This method returns the public key for the table once created and a TransactionInstruction that can be passed into our createAndSendV0Tx function.
  • Step 2: We log the table's address (which we will need later in this exercise).
  • Step 3: Finaly, we call createAndSendV0Tx by passing lookupTableInst inside of an array to match our type requirements.

Awesome! At this point, you should be able to run your code and create an empty lookup table. After your function, call it by adding:

createLookupTable();

Then in your terminal enter:

ts-node app.ts

You should see your transaction progressing in your terminal and ultimately receive a URL to your transaction page on Solana Explorer:

Our lookup table account address is: 3uBhgRWPTPLfvfqxi4M9eVZC8nS1kDG9XPkdHKgG69nw:

Great Job! You have made your first lookup table.

Before we move on, let's do some cleanup:

  1. Remove your call to createLookupTable(). We won't need it again.
  2. Remember the LOOKUP_TABLE_ADDRESS constant we created a while ago? Remove the comment backslashes, and add your table lookup address from your console to your PublicKey declaration like so (this is line 6 for us):
const LOOKUP_TABLE_ADDRESS = new PublicKey("YOUR_TABLE_ADDRESS_HERE"); 
// e.g., const LOOKUP_TABLE_ADDRESS = new PublicKey("3uBhgRWPTPLfvfqxi4M9eVZC8nS1kDG9XPkdHKgG69nw");

Add Addresses to Your Lookup Table

Since we have already created createAndSendV0Tx, adding an address to a lookup table is easy! All we need to do is create a TransactionInstruction. Create a new async function, addAddressesToTable that uses the AddressLookupTableProgram.extendLookupTable() method:

async function addAddressesToTable() {
// Step 1 - Create Transaction Instruction
const addAddressesInstruction = AddressLookupTableProgram.extendLookupTable({
payer: SIGNER_WALLET.publicKey,
authority: SIGNER_WALLET.publicKey,
lookupTable: LOOKUP_TABLE_ADDRESS,
addresses: [
Keypair.generate().publicKey,
Keypair.generate().publicKey,
Keypair.generate().publicKey,
Keypair.generate().publicKey,
Keypair.generate().publicKey
],
});
// Step 2 - Generate a transaction and send it to the network
await createAndSendV0Tx([addAddressesInstruction]);
console.log(`Lookup Table Entries: `,`https://explorer.solana.com/address/${LOOKUP_TABLE_ADDRESS.toString()}/entries?cluster=devnet`)

}

Let's break down what's going on with the extendLookupTable method:

  1. We pass our SIGNER_WALLET to pay the transaction fees and any additional rent incurred.
  2. We define our update authority - in our case, we set that as the SIGNER_WALLET in our table creation step above.
  3. We pass in the lookup table account address (which we defined as LOOKUP_TABLE_ADDRESS).
  4. We pass an array of addresses into our lookup table. We will pass in a few random public keys, but you can pass in any public key that you like! The Program's "compression" supports storing up to 256 addresses in a single lookup table!
  5. Let's also log a link to our lookup table entries for easy access after the transaction is complete. Finally, we pass our TransactionInstruction into createAndSendV0Tx to generate a transaction and send it to the network!

After your function, call your new function by adding:

addAddressesToTable();

Run your code--in your terminal, use the up arrow on your keyboard to retrieve the previous command, ts-node app.ts, and hit Enter to run your code.

You should see a similar transaction flow in your terminal and success with a link to your transaction and your lookup table entries on Solana Explorer. Go to the lookup table entries. You should see a list of all of your table's stored public keys:

Nice work! You can modify your addresses array and rerun it to add additional public keys to your lookup table.

Before moving on, delete your call to addAddressesToTable();. We won't need it again.

Lookup Addresses in Your Lookup Table

Okay, so if you're following along, you should have an Address Lookup Table populated with a few addresses. Now, we are going to fetch all of our addresses and log them to your terminal. Create a new function, findAddressesInTable:

async function findAddressesInTable() {
// Step 1 - Fetch our address lookup table
const lookupTableAccount = await SOLANA_CONNECTION.getAddressLookupTable(LOOKUP_TABLE_ADDRESS)
console.log(`Successfully found lookup table: `, lookupTableAccount.value?.key.toString());

// Step 2 - Make sure our search returns a valid table
if (!lookupTableAccount.value) return;

// Step 3 - Log each table address to console
for (let i = 0; i < lookupTableAccount.value.state.addresses.length; i++) {
const address = lookupTableAccount.value.state.addresses[i];
console.log(` Address ${(i + 1)}: ${address.toBase58()}`);
}
}

Here's a summary of what we are doing here:

  1. Fetch our lookup table using a method called getAddressLookupTable() and pass our lookup table's account address. If successful, the query should return an AddressLookupTableAccount object.
  2. Stop the function if we don't find a valid lookup table.
  3. Iterate through each address found in our lookup table and log it to the console.

Not bad, right?

Call your new function and then run it:

findAddressesInTable();

In your terminal, use the up arrow on your keyboard to retrieve the previous command, ts-node app.ts, and hit Enter to run your code.

You should see your list of addresses like this:

If you're not seeing that or having trouble with any of the code, feel free to drop us a line on Discord -- we're here to help!

Remove or comment out your call to findAddressesInTable(); before moving on.

Put Lookup Tables to the Test

You're well on your way to becoming a Solana lookup table master--give yourself a quick pat on the back for making it this far. But what's the point of all of this? Good question! Solana lookup tables "effectively 'compress' a 32-byte address into a 1-byte index value" (Source). This means by using lookup tables, our transactions sizes will be smaller (or that we can pack more into our transactions)--let's prove it!

We're going to create a function that generates two transactions using the same transfer instruction; in one of them, we will pass our lookup table, and in the other we will not. We will check each transaction's size and then see which is smaller!

Add this new function, compareTxSize, to your code, and then we will walk through it together:

async function compareTxSize() {
// Step 1 - Fetch the lookup table
const lookupTable = (await SOLANA_CONNECTION.getAddressLookupTable(LOOKUP_TABLE_ADDRESS)).value;
if (!lookupTable) return;
console.log(" ✅ - Fetched lookup table:", lookupTable.key.toString());

// Step 2 - Generate an array of Solana transfer instruction to each address in our lookup table
const txInstructions: TransactionInstruction[] = [];
for (let i = 0; i < lookupTable.state.addresses.length; i++) {
const address = lookupTable.state.addresses[i];
txInstructions.push(
SystemProgram.transfer({
fromPubkey: SIGNER_WALLET.publicKey,
toPubkey: address,
lamports: 0.01 * LAMPORTS_PER_SOL,
})
)
}

// Step 3 - Fetch the latest Blockhash
let latestBlockhash = await SOLANA_CONNECTION.getLatestBlockhash('finalized');
console.log(" ✅ - Fetched latest blockhash. Last valid height:", latestBlockhash.lastValidBlockHeight);

// Step 4 - Generate and sign a transaction that uses a lookup table
const messageWithLookupTable = new TransactionMessage({
payerKey: SIGNER_WALLET.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: txInstructions
}).compileToV0Message([lookupTable]); // 👈 NOTE: We DO include the lookup table
const transactionWithLookupTable = new VersionedTransaction(messageWithLookupTable);
transactionWithLookupTable.sign([SIGNER_WALLET]);

// Step 5 - Generate and sign a transaction that DOES NOT use a lookup table
const messageWithoutLookupTable = new TransactionMessage({
payerKey: SIGNER_WALLET.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: txInstructions
}).compileToV0Message(); // 👈 NOTE: We do NOT include the lookup table
const transactionWithoutLookupTable = new VersionedTransaction(messageWithoutLookupTable);
transactionWithoutLookupTable.sign([SIGNER_WALLET]);

console.log(" ✅ - Compiled transactions");

// Step 6 - Log our transaction size
console.log('Transaction size without address lookup table: ', transactionWithoutLookupTable.serialize().length, 'bytes');
console.log('Transaction size with address lookup table: ', transactionWithLookupTable.serialize().length, 'bytes');
}

This looks like a lot, but it's reusing the code we created earlier in this guide. Let's step through: Step 1. We fetch our lookup table and make sure that we return a valid result. Step 2. Using a similar loop to our previous address log exercise, we iterate through each address in our table, create a transfer instruction to that address, and we push each TransactionInstruction to an array, txInstructions. Step 3. We fetch the latest blockhash, which we need to generate our messages. Step 4. We generate a transaction that uses our lookup table. Note that we pass [lookupTable] into the .compileToV0Message() method. When we used this before, we did not pass a value here. This lets our transaction know to use our lookup table. To calculate the size of our transaction, we also need to sign it--we do so with .sign(). Step 5. We generate the same transaction WITHOUT calling our lookup table. We now have two transactions that should process the same instructions, but one uses a lookup table, and the other just uses the public keys that were passed into each instruction. Step 6. Finally, we log each transaction's size. You can calculate a transaction size by serializing it using .serialize() (which yields a Uint8Array) and calculating its length using .length.

We did a lot there, but I hope everything is starting to feel familiar! Call your function by adding compareTxSize(); to the end of your code:

compareTxSize();

In your terminal, use the up arrow on your keyboard to retrieve the previous command, ts-node app.ts, and hit Enter to run your code.

Do you see results like this?

Transaction Size without Address Lookup Table:  413 bytes
Transaction Size with Address Lookup Table: 292 bytes

Wow! That's a lot of savings. My lookup table saved me over 120 bytes (your savings may be more or less depending on the number of addresses in your table)! In an environment where transactions are limited to 1,232 bytes, that's a big deal.

Wrap Up

That concludes our guide! If you'd like to check your final code against ours, you can find it on GitHub, here.

Address Lookup Tables are an exciting new addition to Solana. As you continue building out more complex transactions, you should find that they create flexibility in how you approach your programs and dApps.

Want to keep building?

If you're stuck, have questions, or just want to talk about what you're building, drop us a line on Discord or Twitter!

We <3 Feedback!

If you have any feedback on this guide, let us know. We’d love to hear from you.

Share this guide