Summary
- Versioned Transactions in Solana allows support for both legacy and newer transaction formats. The original format is referred to as "legacy," while new formats begin at version 0. Versioned transactions were introduced to accommodate the use of Address Lookup Tables (LUTs).
- Address Lookup Tables are special accounts that store the addresses of other accounts. In versioned transactions, these addresses can be referenced by a 1-byte index instead of the full 32-byte address. This optimization enables more complex transactions than previously possible.
Lesson
By design, Solana transactions are limited to 1232 bytes. Transactions exceeding this limit will fail, which restricts the size of atomic operations that can be performed. While this limit allows for optimizations at the network level, it imposes restrictions on transaction complexity.
To address transaction size limitations, Solana introduced a new transaction format supporting multiple versions. Currently, two transaction versions are supported:
legacy
- The original transaction format0
- The latest format, which supports Address Lookup Tables.
Existing Solana programs do not require changes to support versioned transactions. However, client-side code created prior to their introduction should be updated. In this lesson, we'll cover the basics of versioned transactions and how to use them, including:
- Creating versioned transactions
- Creating and managing lookup tables
- Using lookup tables in versioned transactions
Versioned Transactions
In Solana transactions, one of the largest space consumers is account addresses, which are 32 bytes each. For transactions with 39 accounts, the size limit is exceeded even before accounting for instruction data. Typically, transactions become too large with around 20 accounts.
Versioned transactions address this issue by introducing Address Lookup Tables, which allow addresses to be stored separately and referenced via a 1-byte index. This greatly reduces transaction size by minimizing the space needed for account addresses.
Even if Address Lookup Tables are not required for your use case, understanding
versioned transactions is crucial for maintaining compatibility with the latest
Solana features. The @solana/web3.js
library provides all necessary tools to
work with versioned transactions and lookup tables.
Create versioned transactions
To create a versioned transaction, you first create a TransactionMessage
with
the following parameters:
payerKey
- the public key of the account that will pay for the transactionrecentBlockhash
- a recent blockhash from the networkinstructions
- the instructions to be executed in the transaction.
Once the message object is created, you can convert it into a version 0
transaction using the compileToV0Message()
method.
Next, pass the compiled message into the VersionedTransaction
constructor to
create a versioned transaction. The transaction is then signed and sent to the
network, similar to how legacy transactions are handled.
Address Lookup Table
Address Lookup Tables (LUTs) are accounts that store references to other account addresses. These LUT accounts, owned by the Address Lookup Table Program, increase the number of accounts that can be included in a transaction.
In versioned transactions, LUT addresses are included, and additional accounts are referenced with a 1-byte index instead of the full 32-byte address, reducing space used by the transaction.
The @solana/web3.js
library offers an AddressLookupTableProgram
class,
providing methods to manage LUTs:
createLookupTable
- creates a new LUT account.freezeLookupTable
- makes a LUT immutable.extendLookupTable
- adds addresses to an existing LUT.deactivateLookupTable
- begins the deactivation period for an LUT.closeLookupTable
- permanently closes an LUT account.
Create a lookup table
You can use the createLookupTable
method to construct the instruction for
creating a lookup table. This requires the following parameters:
authority
- the account authorized to modify the lookup table.payer
- the account responsible for paying the account creation fees.recentSlot
- a recent slot used to derive the lookup table's address.
The function returns both the instruction for creating the LUT and its address.
Under the hood, the lookup table address is a Program Derived Address (PDA)
generated using the authority
and recentSlot
as seeds.
Using the most recent slot sometimes results in errors when submitting the
transaction. To avoid this, it’s recommended to use a slot that is one slot
before the most recent one (recentSlot: currentSlot - 1
). If you still
encounter errors when sending the transaction, try resubmitting it.
Extend a lookup table
The extendLookupTable
method creates an instruction to add addresses to an
existing lookup table. It requires the following parameters:
payer
- the account responsible for paying transaction fees and any additional rent.authority
- the account authorized to modify the lookup table.lookupTable
- the address of the lookup table to be extended.addresses
- the list of addresses to add to the lookup table.
The function returns an instruction to extend the lookup table.
Note that when extending a lookup table, the number of addresses that can be added in a single instruction is limited by the transaction size limit of 1232 bytes. You can add approximately 30 addresses in one transaction. If you need to add more than that, multiple transactions are required. Each lookup table can store up to 256 addresses.
Send Transaction
After creating the instructions, you can add them to a transaction and send it to the network:
Note that after you create or extend a lookup table, it must "warm up" for one slot before the lookup table or newly added addresses can be used in transactions. You can only access lookup tables and addresses added in slots prior to the current one.
If you encounter the following error, it may indicate that you're trying to access a lookup table or an address before the warm-up period has completed:
To avoid this issue, ensure you add a delay after extending the lookup table before attempting to reference the table in a transaction.
Deactivate a lookup table
When a lookup table (LUT) is no longer needed, you can deactivate it to reclaim its rent balance. Deactivating a LUT puts it into a "cool-down" period (approximately 513 slots) during which it can still be used by transactions. This prevents transactions from being censored by deactivating and recreating LUTs within the same slot.
To deactivate a LUT, use the deactivateLookupTable
method with the following
parameters:
lookupTable
- the address of the lookup table to be deactivated.authority
- the account with the authority to deactivate the LUT.
Close a lookup table
Once a LUT has been deactivated and the cool-down period has passed, you can
close the lookup table to reclaim its rent balance. Use the closeLookupTable
method, which requires the following parameters:
lookupTable
- the address of the LUT to be closed.authority
- the account with the authority to close the LUT.recipient
- the account that will receive the reclaimed rent balance.
Attempting to close a LUT before it has been fully deactivated will result in the following error:
Freeze a lookup table
In addition to standard CRUD operations, you can "freeze" a lookup table. This makes it immutable so that it can no longer be extended, deactivated, or closed.
The freezeLookupTable
method is used for this operation and takes the
following parameters:
lookupTable
- the address of the LUT to freeze.authority
- the account with the authority to freeze the LUT.
Once a LUT is frozen, any attempt to modify it will result in an error like the following:
Using lookup tables in versioned transactions
To utilize a lookup table in a versioned transaction, first retrieve the lookup table account using its address:
Once you have the lookup table account, you can create the list of instructions
for the transaction. When constructing the TransactionMessage
, pass the lookup
table accounts as an array to the compileToV0Message()
method. You can include
multiple lookup table accounts if needed.
Lab
Let's go ahead and practice using lookup tables!
This lab will guide you through creating, extending, and using a lookup table in a versioned transaction.
1. Create the try-large-transaction.ts
file
To begin, create a new file named try-large-transaction.ts
in your project
directory. This file will contain the code to illustrate a scenario where a
legacy transaction is created to transfer SOL to 22 recipients in a single
atomic transaction. The transaction will include 22 separate instructions, each
transferring SOL from the payer (signer) to a different recipient.
This example highlights a key limitation of legacy transactions when trying to accommodate many account addresses within a single transaction. As expected, when attempting to send this transaction, it will likely fail due to exceeding the transaction size limits.
Here’s the code to include in try-large-transaction.ts
:
To run the example, execute npx esrun try-large-transaction.ts
. This process
will:
- Generate a new keypair.
- Store the keypair details in the
.env
file. - Request airdrop of devnet SOL to the generated keypair.
- Attempt to send the transaction.
- Since the transaction includes 22 instructions, it is expected to fail with the error: "Transaction too large".
2. Create the use-lookup-tables.ts
File
Next, we'll explore how to use lookup tables in combination with versioned transactions to overcome the limitation of legacy transactions and include a greater number of addresses in a single transaction.
Create a new file named use-lookup-tables.ts
in your project directory. This
file will contain the code to demonstrate the use of lookup tables.
Here’s the starter code to include in use-lookup-tables.ts
file:
Next, we will create a few helper functions that will be crucial for working with versioned transactions and lookup tables. These functions will simplify our process and make our code more modular and reusable.
3. Create a sendV0Transaction
helper function
To handle versioned transactions, we will create a helper function in
use-lookup-tables.ts
file, called sendV0Transaction
, to simplify the
process. This function will accept the following parameters:
connection
: the solana connection to the cluster (e.g., devnet).user
: the keypair of the user (payer) signing the transaction.instructions
: an array of TransactionInstruction objects to include in the transaction.lookupTableAccounts
(optional): an array of lookup table accounts, if applicable, to reference additional addresses.
This helper function will:
- Retrieve the latest blockhash and last valid block height from the Solana network.
- Compile a versioned transaction message using the provided instructions.
- Sign the transaction using the user's keypair.
- Send the transaction to the network.
- Confirm the transaction and log the transaction's URL using Solana Explorer.
4. Create a waitForNewBlock
helper function
When working with lookup tables, it's important to remember that newly created or extended lookup tables cannot be referenced immediately. Therefore, before submitting transactions that reference these tables, we need to wait for a new block to be generated.
We will create a waitForNewBlock
helper function that accepts:
connection
: the Solana network connection.targetBlockHeight
: the target block height to wait for.
This function will:
- Start an interval that checks the current block height of the network every second (1000ms).
- Resolve the promise once the current block height exceeds the target block height.
5. Create an initializeLookupTable
function
Next, we need to initialize a lookup table to hold the addresses of the
recipients. The initializeLookupTable
function will accept the following
parameters:
user
: the user's keypair (payer and authority).connection
: the Solana network connection.addresses
: an array of recipient addresses (public keys) to add to the lookup table.
The function will:
- Retrieve the current slot to derive the lookup table's address.
- Generate the necessary instructions to create and extend the lookup table with the provided recipient addresses.
- Send and confirm a transaction that includes these instructions.
- Return the address of the newly created lookup table.
Although the transaction includes the full recipient addresses, using the lookup table allows Solana to reference those addresses with significantly fewer bytes in the actual transaction. By including the lookup table in the versioned transaction, the framework optimizes the transaction size, replacing addresses with pointers to the lookup table.
This design is crucial for enabling the transaction to support more recipients by staying within Solana’s transaction size limits.
6. Modify main
to use lookup tables
With the helper functions in place, we are now ready to modify the main
function to utilize versioned transactions and address lookup tables. To do so,
we will follow these steps:
- Call
initializeLookupTable
: Create and extend the lookup table with the recipients' addresses. - Call
waitForNewBlock
: Ensure the lookup table is activated by waiting for a new block. - Retrieve the Lookup Table: Use
connection.getAddressLookupTable
to fetch the lookup table and reference it in the transaction. - Create Transfer Instructions: Generate a transfer instruction for each recipient.
- Send the Versioned Transaction: Use
sendV0Transaction
to send a single transaction with all transfer instructions, referencing the lookup table.
Even though we will create transfer instructions with full recipient addresses,
the use of lookup tables allows the @solana/web3.js
framework to optimize the
transaction size. The addresses in the transaction that match entries in the
lookup table will be replaced with compact pointers referencing the lookup
table. By doing this, addresses will be represented using only a single byte in
the final transaction, significantly reducing the transaction's size.
Use npx esrun use-lookup-tables.ts
in the command line to execute the main
function. You should see an output similar to the following:
The first transaction link in the console represents the transaction for creating and extending the lookup table. The second transaction represents the transfers to all recipients. Feel free to inspect these transactions in the explorer.
Remember, this same transaction was failing when you first downloaded the starter code. Now that we're using lookup tables, we can do all 22 transfers in a single transaction.
6. Add more addresses to the lookup table
Keep in mind that the solution we've come up with so far only supports transfers to up to 30 accounts since we only extend the lookup table once. When you factor in the transfer instruction size, it's actually possible to extend the lookup table with an additional 27 addresses and complete an atomic transfer to up to 57 recipients. Let's go ahead and add support for this now!
All we need to do is go into initializeLookupTable
and do two things:
- Modify the existing call to
extendLookupTable
to only add the first 30 addresses (any more than that and the transaction will be too large) - Add a loop that will keep extending a lookup table of 30 addresses at a time until all addresses have been added
Congratulations! If you feel good about this lab, you're probably ready to work with lookup tables and versioned transactions on your own. If you want to take a look at the final solution code you can find it on the solution branch.
Challenge
As a challenge, experiment with deactivating, closing, and freezing lookup tables. Remember that you need to wait for a lookup table to finish deactivating before you can close it. Also, if a lookup table is frozen, it cannot be modified (deactivated or closed), so you will have to test separately or use separate lookup tables.
- Create a function for deactivating the lookup table.
- Create a function for closing the lookup table
- Create a function for freezing the lookup table
- Test the functions by calling them in the
main()
function
You can reuse the functions we created in the lab for sending the transaction and waiting for the lookup table to activate/deactivate. Feel free to reference this solution code.
Push your code to GitHub and tell us what you thought of this lesson!