Solana legacy transactions enforce a hard cap of 35 unique accounts. That number includes every program invoked, the fee payer, the token mint, and every source and destination ATA you reference. For a USDC batch payout:
- SPL Token program, System program, USDC mint: 3 slots
- Fee payer and fee payer's source ATA: 2 slots
- Remaining budget: 30 recipient ATAs
A merchant settling weekly revenue to 500 affiliate wallets must issue at least 17 separate transactions. At current priority fee rates — 50,000 micro-lamports on a 30,000-CU transfer — each transaction adds about 500 lamports. Manageable individually; the real cost is operational: 17 nonce advances, 17 confirmation polls, and 17 independent retry windows, any of which can stall during a fee spike.
Versioned transactions solve this. A Solana v0 transaction can reference up to four address lookup tables (ALTs), each holding up to 256 addresses. With a pre-populated ALT, you can batch 200+ USDC payouts in a single transaction without touching the legacy account ceiling.
This post covers the full ALT lifecycle, the TypeScript to build the v0 batch transaction, and the production pitfalls we found shipping this at BchainPay.
Why the account-count limit exists#
Every Solana transaction carries a compact account list in its header. The runtime resolves every account reference and acquires locks on the relevant memory pages before execution begins. Legacy transactions encode account indices as a single byte — 256 possible values — but the BPF security model and program-loader overhead reduce the usable count to 35 unique accounts in practice.
For programs, 35 accounts is rarely a concern. For batch payments it is exhausted immediately, because each recipient's ATA is a distinct account the runtime must resolve and lock.
What versioned transactions add#
Versioned transactions, shipped in Solana 1.10, introduce the v0 message
format. A v0 message adds an addressTableLookups field — a list of
(altAddress, writableIndices[], readonlyIndices[]) tuples. The runtime
expands these tuples at execution time into the full account list.
The static portion of the account list is still bounded at 32 entries. But ALT-derived accounts add up to 256 writable and 256 read-only entries across all referenced tables. A single ALT with 200 writable recipient ATAs is enough for nearly every merchant settlement workload, and four ALTs extend the ceiling to 1,024 accounts.
Address Lookup Table lifecycle#
An ALT is an on-chain account owned by the Address Lookup Table program. Five phases govern its life:
- Create — allocates the on-chain account, sets the authority.
- Extend — appends addresses (up to ~28 per instruction).
- Activate — once the slot containing the last
extendis rooted (1-2 slots, ~400-800 ms), the table is usable in v0 transactions. - Deactivate — marks the table for closure; no further extensions allowed.
- Close — after a 513-slot cooldown (~3.5 minutes), reclaims rent.
The warm-up period is the only operational surprise. Create an ALT in slot N,
try to reference it in the same slot, and the runtime rejects the transaction
with AddressLookupTableAccountNotFound. Always wait at least two slots —
or, better, extend the ALT during recipient registration so it is pre-warmed
long before settlement day.
Creating an ALT#
import {
Connection, Keypair, PublicKey,
AddressLookupTableProgram,
TransactionMessage, VersionedTransaction,
} from '@solana/web3.js';
async function createALT(
conn: Connection,
authority: Keypair,
): Promise<PublicKey> {
// Use finalized slot to avoid referencing a slot that could roll back
const slot = await conn.getSlot('finalized');
const [createIx, altAddress] = AddressLookupTableProgram.createLookupTable({
authority: authority.publicKey,
payer: authority.publicKey,
recentSlot: slot - 1,
});
const { blockhash } = await conn.getLatestBlockhash();
const msg = new TransactionMessage({
payerKey: authority.publicKey,
recentBlockhash: blockhash,
instructions: [createIx],
}).compileToV0Message();
const tx = new VersionedTransaction(msg);
tx.sign([authority]);
await conn.sendTransaction(tx, { skipPreflight: false });
return altAddress;
}Using a finalized slot commitment for getSlot eliminates the small risk of
the referenced slot being rolled back during a fork.
Extending the ALT with recipient ATAs#
Each extendLookupTable instruction accepts up to ~30 addresses before the
transaction size cap is reached. Batch the extension into groups of 28 to leave
room for instruction overhead:
async function extendALT(
conn: Connection,
authority: Keypair,
altAddress: PublicKey,
addresses: PublicKey[],
): Promise<void> {
const BATCH = 28;
for (let i = 0; i < addresses.length; i += BATCH) {
const slice = addresses.slice(i, i + BATCH);
const extendIx = AddressLookupTableProgram.extendLookupTable({
payer: authority.publicKey,
authority: authority.publicKey,
lookupTable: altAddress,
addresses: slice,
});
const { blockhash } = await conn.getLatestBlockhash();
const msg = new TransactionMessage({
payerKey: authority.publicKey,
recentBlockhash: blockhash,
instructions: [extendIx],
}).compileToV0Message();
const tx = new VersionedTransaction(msg);
tx.sign([authority]);
await conn.sendTransaction(tx, { skipPreflight: false });
}
// Wait two slots for the extensions to root before using the table
await new Promise(r => setTimeout(r, 1_500));
}For recurring settlement, resist the temptation to recreate the ALT each run. Maintain one persistent ALT per merchant; extend it whenever a new recipient ATA is registered. By settlement time the extension will have been rooted for hours, not milliseconds.
Building the v0 batch payout transaction#
With the ALT populated and activated, building the batch transaction is
straightforward — pass the fetched ALT account object to compileToV0Message
and the SDK rewrites account references automatically:
import {
createTransferCheckedInstruction,
getAssociatedTokenAddress,
} from '@solana/spl-token';
const USDC_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
const USDC_DECIMALS = 6;
interface Recipient {
address: PublicKey;
usdcAmount: bigint; // in raw units (1 USDC = 1_000_000n)
}
async function buildBatchPayout(
conn: Connection,
payer: Keypair,
altAddress: PublicKey,
recipients: Recipient[],
): Promise<VersionedTransaction> {
const altAccount = await conn.getAddressLookupTable(altAddress);
if (!altAccount.value) {
throw new Error(`ALT ${altAddress.toBase58()} not found or not yet activated`);
}
const payerATA = await getAssociatedTokenAddress(USDC_MINT, payer.publicKey);
const transfers = await Promise.all(
recipients.map(async ({ address, usdcAmount }) => {
const recipientATA = await getAssociatedTokenAddress(USDC_MINT, address);
return createTransferCheckedInstruction(
payerATA,
USDC_MINT,
recipientATA,
payer.publicKey,
usdcAmount,
USDC_DECIMALS,
);
}),
);
const { blockhash } = await conn.getLatestBlockhash('confirmed');
const msg = new TransactionMessage({
payerKey: payer.publicKey,
recentBlockhash: blockhash,
instructions: transfers,
}).compileToV0Message([altAccount.value]);
const tx = new VersionedTransaction(msg);
tx.sign([payer]);
return tx;
}The SDK replaces each ATA reference that matches an address in the table with a compact 1-byte lookup index, shrinking the serialized transaction and eliminating the 35-account constraint for those accounts.
Sizing the batch: CU budget is the real constraint#
With ALTs, account count is no longer the binding limit. Compute units are.
The per-transaction CU ceiling is 1,400,000 (set via setComputeUnitLimit).
A USDC transfer to a warm ATA uses roughly 4,500-6,000 CU when the instruction
is tightly packed inside a v0 batch — substantially less than the ~30,000 CU
overhead of a standalone transfer that includes program dispatch and account
resolution from scratch.
Measure your specific workload with simulateTransaction before committing to
a batch size:
async function measureBatchCUs(
conn: Connection,
tx: VersionedTransaction,
): Promise<number> {
const sim = await conn.simulateTransaction(tx, {
commitment: 'processed',
replaceRecentBlockhash: true,
});
if (sim.value.err) throw new Error(JSON.stringify(sim.value.err));
return sim.value.unitsConsumed ?? 1_400_000;
}Add a 15% buffer to the measured figure and pass it to setComputeUnitLimit.
For USDC transfers to warm ATAs, 200 recipients per transaction is a reliable
upper bound on mainnet; 150 is a conservative starting point.
Legacy vs v0 at a glance#
| Legacy (35-account cap) | v0 + ALT | |
|---|---|---|
| Max recipients | ~30 | ~200 (CU-limited) |
| Transactions for 200 payouts | 7 | 1 |
| Base fee (5,000 lamports/sig) | 35,000 lamports | 5,000 lamports |
| Priority fee surface | 7 separate bids | 1 bid |
| ALT rent | — | ~0.0024 SOL one-time |
| Warm-up delay | none | 1-2 slots |
The 0.0024 SOL ($0.40) rent for a persistent ALT is negligible once
amortized across dozens of settlement runs.
Production ALT management#
A handful of rules we follow at BchainPay:
Extend during recipient registration, not at settlement time. When a new affiliate or revenue-split recipient joins a merchant's account, immediately extend the merchant's ALT with their USDC ATA. By the time the next settlement runs, the extension will have been rooted for hours and no warm-up delay hits the critical path.
Index ALT addresses in your database. Before building each batch, load the ALT account and verify every recipient ATA is already present. If any are missing, extend and wait before proceeding. Checking locally avoids the RPC round-trip cost of re-fetching the table for every transaction.
Use one ALT per merchant, not one per settlement run. Create-and-close costs rent every time and re-introduces warm-up latency. A persistent ALT accumulates addresses over the lifetime of the merchant relationship.
Do not freeze production ALTs. Freezing (removing the authority) makes the ALT immutable. That is useful for protocol-owned constant tables, not for merchant tables that need new recipients as the business grows.
Plan for the 513-slot deactivation cooldown. Any cleanup job that deactivates and closes an ALT must account for ~3.5 minutes between deactivation and the close transaction being valid. Build this into your offboarding pipeline.
BchainPay batch payout API#
Merchants on BchainPay's hosted settlement do not manage ALTs directly. The API handles creation, extension, and warm-up transparently based on each merchant's registered recipient set:
POST /v1/batch_payouts
Authorization: Bearer sk_live_…
Content-Type: application/json
{
"chain": "solana",
"token": "USDC",
"recipients": [
{ "address": "9xQe…", "amount": "125.00" },
{ "address": "4vKg…", "amount": "87.50" }
],
"idempotency_key": "settlement_2026-04-27_weekly"
}{
"id": "bp_01HYZ…",
"status": "processing",
"chain": "solana",
"token": "USDC",
"recipient_count": 148,
"transaction_count": 1,
"alt_strategy": "pre_warmed",
"alt_address": "LUT8xQeF…",
"estimated_fee_lamports": 8200
}When alt_strategy is pre_warmed, the ALT already contains all recipient
ATAs and the settlement lands within one block of the API call. If new ATAs
were registered within the last two slots, the response returns
alt_strategy: "warming" and the job queues with a short delay before the
broadcast phase.
Key takeaways#
- Solana legacy transactions cap unique accounts at 35, limiting USDC batch payouts to ~30 recipients per transaction. Versioned transactions with address lookup tables extend this to ~200 recipients per transaction, constrained by compute budget rather than account count.
- The ALT warm-up period is 1-2 slots after each extension. Eliminate warm-up latency from your settlement critical path by extending ALTs during recipient registration, not at settlement time.
- Maintain one persistent ALT per merchant. The ~0.0024 SOL rent is negligible amortized; recreating ALTs per run burns rent and re-introduces latency.
- Measure batch CU usage with
simulateTransactionand add a 15% buffer. For USDC transfers to warm ATAs, 200 recipients per transaction is a safe upper bound. - Pass the loaded ALT object to
compileToV0Message; the SDK handles address compression automatically — no manual index management required. - After deactivation, the 513-slot cooldown (~3.5 minutes) before closing is a hard protocol rule. Build it into any offboarding or cleanup pipeline.
- Multiple ALTs (up to four per v0 transaction) extend the ceiling to ~800 ALT-derived accounts per transaction if your workload requires it.