Engine Room

Versioned Transactions and Address Lookup Tables

Account slots, not compute, are what usually stop a swap from fitting. Versioned transactions exist so that a message can reference addresses by index instead of by 32 bytes each, and that indirection has a real price.

The Engine Room Desk 2142 words 10 min read Updated 13 August 2026

Component sheet

Component class
Builder sub-stage
Inputs
An instruction list and its full account set
Outputs
A v0 message that fits inside 1232 bytes
Hardest boundary
Table state on chain versus builder assumptions

Address lookup tables let a versioned transaction reference an on-chain address by a one-byte index instead of embedding its full 32 bytes in the message. That matters because a Solana transaction must fit inside a 1232-byte packet, and on a multi-hop route the account list, not the instruction data or the compute, is what fills that budget first.

The mechanism is straightforward and the consequences are not. A table is an on-chain account with its own lifecycle, its own rent and its own activation timing, and referencing one couples your builder to state it does not control. Engines that adopt lookup tables without planning that coupling trade a size problem for an availability problem.

The constraint is the packet, not the compute

People assume the first wall an engine hits is compute, because compute is the thing with a visible price. In practice the first wall is size. A transaction has a fixed byte budget that must contain every signature at 64 bytes each, every account address at 32 bytes each, the instruction data, the blockhash at 32 bytes, and a handful of header fields.

Do that arithmetic on a route that touches four pools and the picture becomes obvious quickly. Twenty distinct accounts is 640 bytes of addresses before a single byte of instruction data. Add a signature, a blockhash, the compute budget instructions and the swap payload, and a legitimately-constructed transaction can exceed the limit while consuming a perfectly ordinary amount of compute.

This is why the builder must check serialised size before signing rather than discovering it at submission. A too-large transaction is a construction error with three possible fixes: use a shorter route, split the work into separate transactions, or move addresses into a lookup table. Only the third one preserves both the route and the atomicity.

Legacy and v0 messages compared

PropertyLegacy messagev0 message
Address encodingEvery address inline, 32 bytesStatic keys inline, the rest by index
Practical account ceilingLimited by the packet budgetMuch higher, bounded by table contents
External dependenciesNone beyond the blockhashOne or more tables must exist and be active
Build complexityLowResolve addresses, fetch tables, assemble indices
Failure surfaceSize, accounts, computeAll of those, plus table state and activation timing
When to prefer itShort, stable routesAccount-heavy routes that will not otherwise fit

Nothing in that table says v0 is better. It says v0 is the answer to one specific problem. An engine whose routes comfortably fit gains nothing from versioned messages except an additional dependency, and a design that adopts them by default has taken on maintenance it did not need. The public Solana documentation at solana.com/docs is the authority on the current message formats and is worth reading directly rather than through any summary, including this one.

What an address lookup table actually is

A lookup table is an ordinary on-chain account owned by a dedicated program. It stores an ordered list of addresses, up to 256 of them, plus an authority that may extend it and a deactivation slot. A transaction that wants to use it names the table's address in the message and then refers to entries by their position in the list.

Three properties follow from that, and each one is an operational fact rather than a detail. First, the table is created by a transaction and costs rent, so it is capital you have committed. Second, it has an authority, which is a key that can add addresses, and that key is now part of your custody design. Third, a table that has just been extended is not immediately usable for the new entries; the change becomes visible to transactions in a subsequent slot, so an engine that extends and immediately builds against the new entry will fail.

The third property is the one that bites in automation. A human creating a table by hand naturally waits a few seconds between the extend and the first use. A script does not, and the resulting failure names an invalid index rather than a timing problem, which is a genuinely unpleasant thing to debug at three in the morning.

The account budget, worked through

The following is illustrative arithmetic using published constants, and it is worth doing on paper before you decide whether a table is necessary for your route.

  1. Start from 1232 bytes total. Subtract the blockhash at 32 bytes and a single signature at 64 bytes, leaving roughly 1136 bytes for the message body, headers and instruction data.
  2. Each distinct account referenced statically costs 32 bytes. Ten accounts is 320 bytes; twenty is 640; thirty is 960 and you are effectively out of room before adding instruction payloads.
  3. Each account referenced through a lookup table costs one byte in the index list, plus a one-time 32 bytes for naming the table itself. Ten table-referenced accounts cost roughly 42 bytes instead of 320.
  4. Signers stay static regardless. If a route needs one signer and twenty non-signer accounts, the static portion is one address plus the table reference, and the other twenty collapse into twenty bytes of indices.
  5. Compare against your actual route. If your worst-case route touches twelve accounts, you are nowhere near the limit and a table is pure overhead. If it touches twenty-five, you have no choice.

The break-even is usually somewhere in the high teens of distinct accounts, but the exact point depends on your instruction data size and how many signatures you carry. Measure your own worst case rather than adopting a rule of thumb, because the answer moves with the venue and with whether you batch.

Table lifecycle: create, extend, use, retire

create(authority, payer)         -> table_address, rent locked
extend(table, addresses[])       -> entries appended
wait                             -> new entries usable from a later slot
build(v0 message, tables[])      -> indices resolved against table contents
deactivate(table, authority)     -> table enters cooldown
wait cooldown                    -> measured in slots, not seconds
close(table, destination)        -> rent returned

Two waits appear in that sequence and both are frequently forgotten. The wait after extending is short and catches automation that moves faster than a block. The cooldown before closing is long enough that it cannot be part of an interactive shutdown, which means the correct design treats table retirement as an asynchronous job with its own record rather than as a step in the run teardown.

The authority key deserves explicit thought. It can add addresses to a table your transactions trust, which makes it a meaningful key even though it never signs a swap. If your custody design classifies keys by what they can move, this one moves nothing and still deserves the same handling as a key that does, because a table you trust is an input to every transaction that references it.

When a table earns its cost

Trade-off: headroom versus a dependency you do not control

A lookup table buys account space, and account space is what lets a complex route fit. What it costs is a second on-chain artefact that must exist, be active, contain the right addresses in the right positions, and be reachable at build time. Every one of those is a new way for a transaction to fail before it reaches the network.

The right question is not whether tables are good but whether your route needs them. Adding indirection to a route that already fits is a pure increase in failure surface with no compensating benefit.

Tables earn their cost in three situations. When the route is inherently account-heavy and cannot be shortened without materially worse pricing. When the same account set is reused across thousands of transactions, so the setup cost amortises to nothing. And when you are already dependent on an aggregator that publishes and maintains tables for its own program accounts, in which case you are consuming a table rather than operating one, and most of the lifecycle burden is somebody else's.

They do not earn their cost on short, direct routes, on runs short enough that setup dominates, or in any design where the operator cannot explain what happens if the table is unavailable at build time.

The coupling nobody plans for

A builder that uses lookup tables must fetch table contents in order to resolve indices, which means it now performs a network read as part of building. That read is on the hot path, it consumes endpoint credits, and it can fail. The natural response is to cache table contents, and the natural bug that follows is a cache that goes stale after the table is extended.

Cache the contents with an explicit version or slot, treat a resolution failure as a build failure rather than a retryable network error, and re-fetch on any index mismatch. Those three rules cover almost every table-related incident. The one they do not cover is a third-party table whose owner reorders or repurposes entries, which is a reason to pin the specific entries you depend on and to verify them rather than trusting position alone.

There is also a testing consequence. A builder with a network dependency cannot be unit tested against a fixture without stubbing that dependency, so the stub becomes part of your test design from the beginning. Retrofitting it later usually means discovering that table resolution was scattered through the builder rather than isolated behind one call.

Route shape on pool-heavy venues

Different venues produce different account pressure, and any volume bot on Solana DEXs has to know which side of the break-even each target sits on before anything is designed around it. A single direct pool swap references the pool, its two token vaults, the program, the user's two token accounts, the user, and a small number of system accounts. That is comfortably inside the static budget.

An automated market maker route that crosses two or three pools multiplies most of that list, and a route assembled by an aggregator across different program types can add authority accounts, oracle accounts and per-pool configuration accounts. This is where versioned transactions stop being optional. Post-migration trading against established pools is the usual context in which an engine first meets the limit, which is why anyone evaluating a Raydium volume bot should be asking about route shape and account handling rather than about raw send speed.

It is worth measuring rather than assuming, because the account count for a given venue changes when programs are upgraded and when new pool types appear. A route that fit comfortably six months ago can stop fitting after a program adds a configuration account to its instruction, and the failure arrives as a size error on a code path nobody touched. Recording the serialised size of every transaction, or at least its distribution, turns that from a surprise into a trend you can watch approaching the limit.

Curve-style venues sit at the other end. A bonding curve trade is typically a short, fixed account set with no routing decision at all, which is why engines that only ever traded on a curve are often surprised by their first migrated pool: the same code path suddenly produces transactions that do not fit.

Failure modes specific to tables

  • Index out of range, because the table was extended and the new entry is not yet usable in the current slot.
  • Stale cache, where the builder resolves against contents that have since changed position.
  • Table not found, because it was deactivated or closed by a process nobody connected to this engine.
  • Wrong table referenced, which produces an account mismatch error that names an account rather than the table.
  • Rent surprise, where several per-run tables were created and never closed, leaving lamports locked across accounts nobody tracks.
  • Authority key handled casually, because it never signs a value transfer and therefore never entered the custody inventory.

Note how many of these produce errors that describe a symptom rather than the cause. That is the strongest argument for logging the table addresses and the resolved index list alongside every transaction: without it, the error message sends you looking at the route when the problem is the table.

Lookup table review checklist

  • Has the worst-case route been measured for serialised size, rather than assumed to fit?
  • Does the builder reject an oversized message before signing?
  • Are lookup tables used only where the account count genuinely requires them?
  • Is table resolution isolated behind one call that can be stubbed in tests?
  • Is the table cache keyed by a slot or version, and invalidated on index mismatch?
  • Are the table addresses and resolved indices recorded with each transaction?
  • Is the table authority key inventoried with the same care as a signing key?
  • Does the shutdown procedure deactivate and later close tables the engine created?

If your routes are short and stable, the honest answer to this whole note is that you do not need it yet, and knowing that is worth as much as the mechanism. Read the compute budget note next, since size and compute are the two limits a builder has to satisfy at the same time.

Questions this note gets asked

Why is a Solana transaction limited to 1232 bytes?

The limit follows from networking rather than from the runtime: it is derived from a conservative maximum transmission unit so that a transaction fits in a single packet without fragmentation. That budget has to cover signatures, the account list, instruction data and the blockhash, which is why account-heavy routes run out of room before anything else goes wrong.

What does a versioned transaction change?

The v0 message format adds the ability to reference addresses stored in on-chain lookup tables by a one-byte index rather than embedding each 32-byte address in the message. Legacy transactions still work and are simpler; v0 exists specifically to relieve the account-space pressure that complex routes create.

Can signers be loaded from a lookup table?

No. Accounts that must sign the transaction have to appear in the static account keys, because the signature layout is derived from that list. Lookup tables are for the long tail of pool accounts, program ids and token accounts that the instruction references but does not need a signature from.

How many addresses fit in one lookup table?

A single table holds up to 256 addresses, and a transaction can reference more than one table. In practice the binding constraint is rarely table capacity; it is whether the addresses you need are already in a table that is active on chain at the moment you build.

Do I have to create my own lookup tables?

Not always. Aggregators and venues frequently publish tables covering their own program and pool accounts, and using an existing table avoids the setup transaction and the locked rent entirely. Creating your own makes sense when your route touches a stable set of accounts nobody else has already published.

Is the rent for a lookup table recoverable?

Yes, once the table is deactivated and the cooldown has passed, closing it returns the lamports to a destination you nominate. The important operational point is that this is a two-step process with a waiting period between the steps, so it belongs in a shutdown procedure rather than in an ad hoc cleanup.

Filed under Pipeline by The Engine Room Desk. Arithmetic on this page is labelled illustrative and built from protocol constants or values you supply yourself. How the desk sources and corrects a note is set out in the editorial policy.