Shreder Docs
Data Streaming

Resolving LUTs

Resolve Address Lookup Tables client-side for Decoded Shreds and Binary using Geyser / Fastlane, RPC, and a local cache

To resolve Address Lookup Tables (LUTs, also called ALTs) in Decoded Shreds and Binary, maintain a local cache of LUT accounts. Populate it through RPC, keep it up to date with Geyser gRPC / Fastlane account updates, and use it to resolve transaction account addresses before applying your own filters.

Both streams carry transaction messages before execution. Decoding a message, or deserializing Binary's VersionedTransaction bytes, does not fetch the lookup tables it references.

What needs to be resolved?

A Solana v0 transaction can contain static account addresses and references to addresses stored in on-chain lookup tables. Each lookup identifies a LUT account and the indexes of the writable and read-only addresses to load from it. The transaction carries those references, not the table contents. See Solana's LUT guide.

For example, a transaction may reference index 7 in a LUT instead of including a pool's full address among its static keys. Your client must read that LUT to determine which address index 7 represents.

The two products expose the same information in different formats:

StreamWhere to read the lookup references
Decoded ShredsExtract the protobuf shredstream::Transaction from the update, then read its nested message.address_table_lookups; message.account_keys are the static keys
BinaryDeserialize binary_transaction into a Solana SDK VersionedTransaction, then match its message as VersionedMessage::V0 to read address_table_lookups

The fields in each lookup are account_key (the LUT address), writable_indexes, and readonly_indexes. These are defined in the ShredStream proto. Transactions without lookup references need no LUT fetch.

  1. Start a Geyser / Fastlane subscription for LUT account updates.
  2. Fetch any known LUTs through RPC to prewarm the cache while collecting live updates.
  3. Subscribe to Decoded Shreds or Binary using filters for the programs or static accounts relevant to your application.
  4. Resolve each transaction's lookups from the cache; fetch missing or stale tables through RPC when necessary.
  5. Apply local account filters to the complete account list, then run your application logic.

Keep the account subscription and transaction subscription running independently. A cache hit should require only local reads; RPC belongs on the cache-miss path.

1. Subscribe to LUT account updates

Use the accounts subscription on Geyser / Fastlane. A transaction subscription alone does not maintain your LUT cache.

Track all LUTs

Set the account owner filter to the Address Lookup Table program:

AddressLookupTab1e1111111111111111111111111

The following Rust fragment constructs a Yellowstone SubscribeRequest for your Geyser / Fastlane client:

use std::collections::HashMap;
use yellowstone_grpc_proto::geyser::{
    CommitmentLevel, SubscribeRequest, SubscribeRequestFilterAccounts,
};

let request = SubscribeRequest {
    accounts: HashMap::from([(
        "luts".to_owned(),
        SubscribeRequestFilterAccounts {
            owner: vec![
                "AddressLookupTab1e1111111111111111111111111".to_owned(),
            ],
            ..Default::default()
        },
    )]),
    commitment: Some(CommitmentLevel::Processed as i32),
    accounts_data_slice: vec![],
    ..Default::default()
};

An empty accounts_data_slice requests full account data. You need the table contents to decode its addresses. The request fields follow the Yellowstone subscription proto.

This filter receives updates for LUT accounts across the stream. It is not a complete snapshot of all existing LUTs. A table that has not changed since you connected may never have produced an update for your client, even while transactions continue to use it.

Track specific LUTs

If you know which tables your application needs, replace the account filter above with:

SubscribeRequestFilterAccounts {
    account: vec![lut_a.to_string(), lut_b.to_string()],
    ..Default::default()
}

Here, lut_a and lut_b are LUT account addresses, not pool, wallet, or token addresses stored inside a table. Tracking specific tables reduces update traffic and cache size. If a transaction introduces a new LUT, add it to your subscription and fetch it through RPC using the prewarm flow below, so later changes reach the cache too.

2. Prewarm and maintain the cache

Store decoded tables by LUT account address. Each entry should retain the ordered address list, table metadata, and information about the state you observed, such as the update slot and source.

For every account update:

  1. Check that the account belongs to the Address Lookup Table program.
  2. Decode the account data with your Solana SDK's LUT decoder, such as Rust's AddressLookupTable::deserialize.
  3. Publish a consistent cache entry so a transaction cannot read a partially updated table.

When the LUT addresses are known in advance, fetch them before processing transactions. Use getMultipleAccounts in batches of up to 100 addresses, or getAccountInfo for individual tables. Request full account data with base64 encoding, decode it, and populate the same cache used by your stream handler.

For example, replace the placeholder below with a LUT address and send this JSON-RPC request to your Solana RPC endpoint:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "getMultipleAccounts",
  "params": [
    ["<LUT_ACCOUNT_ADDRESS>"],
    {
      "encoding": "base64",
      "commitment": "processed"
    }
  ]
}

Start collecting live updates before the RPC prewarm finishes, then reconcile them with the snapshot. Otherwise, an extension between the snapshot and subscription can be missed. Do not let a delayed RPC response overwrite a newer cached update. Retain RPC context.slot and stream ordering information; an RPC context slot is not an account write version.

Use endpoints on the same cluster and choose commitment deliberately. The examples use processed for freshness; state observed at that level can be rolled back. A higher commitment delays visibility of recent table changes. Mixing providers or commitments requires reconciliation, not just choosing whichever response arrives last.

3. Handle missing or stale LUTs

A cache miss is expected during startup, when a transaction references a table for the first time, or after a subscription gap. A cached table can also be stale: the transaction may reference an index added by an extension your client has not received yet.

On either a missing table or an out-of-range lookup index:

  1. Fetch the LUT through RPC and validate the returned account.
  2. Update the cache without replacing newer state with an older response.
  3. Retry resolution against the refreshed table.

Deduplicate concurrent fetches for the same LUT so a burst of transactions shares one in-flight request. Keep RPC work asynchronous and bound both retries and the number of transactions waiting for resolution, so a slow RPC does not stop stream consumption.

If RPC returns null, the account cannot be decoded, or the required index is still unavailable, keep the transaction explicitly unresolved. Retry within your latency budget, or skip it with a recorded reason. Do not substitute an empty table or treat incomplete resolution as a successful filter result. A node may be behind the stream; a single missing-account response is not enough to conclude that a table never existed.

4. Build the full account list

Resolve only the indexes referenced by the transaction, preserving their order. Do not append every address stored in a LUT.

The resulting account list must have this order:

  1. Static message account keys.
  2. All loaded writable addresses, in lookup order and then index-list order.
  3. All loaded read-only addresses, in lookup order and then index-list order.

This is the ordering used by Solana's v0 message format. With multiple LUTs, collect writable addresses from all tables before appending any loaded read-only addresses.

For example:

Message componentAddresses selected
Static keyspayer, program
LUT A: writable indexes [2, 0], read-only indexes [1]Writable: A2, A0; read-only: A1
LUT B: writable indexes [1], read-only indexes [0]Writable: B1; read-only: B0
Full account listpayer, program, A2, A0, B1, A1, B0

Instruction account index 4 now refers to B1. Grouping each table's writable and read-only addresses together would produce the wrong mapping.

Rust example: Binary transactions

Binary delivers serialized VersionedTransaction bytes in tx.binary_transaction. After deserialization, pass the resulting transaction directly to the function below. It reads transaction.message: legacy transactions already contain all account keys, while v0 transactions may need LUT resolution.

Add these dependencies, matching the SDK version used by the Shreder Rust examples:

[dependencies]
solana-message = "=2.2.1"
solana-pubkey = "=2.2.1"
solana-transaction = { version = "=2.2.1", features = ["serde"] }

Here, the cache maps each LUT account address to its decoded, ordered address list:

use solana_message::VersionedMessage;
use solana_pubkey::Pubkey;
use solana_transaction::versioned::VersionedTransaction;
use std::collections::HashMap;

pub fn resolve_accounts(
    transaction: &VersionedTransaction,
    cache: &HashMap<Pubkey, Vec<Pubkey>>,
) -> Result<Vec<Pubkey>, Pubkey> {
    let message = match &transaction.message {
        VersionedMessage::Legacy(message) => return Ok(message.account_keys.clone()),
        VersionedMessage::V0(message) => message,
    };

    let mut writable = Vec::new();
    let mut readonly = Vec::new();

    for lookup in &message.address_table_lookups {
        let addresses = cache.get(&lookup.account_key).ok_or(lookup.account_key)?;

        for &index in &lookup.writable_indexes {
            let address = addresses
                .get(usize::from(index))
                .ok_or(lookup.account_key)?;
            writable.push(*address);
        }

        for &index in &lookup.readonly_indexes {
            let address = addresses
                .get(usize::from(index))
                .ok_or(lookup.account_key)?;
            readonly.push(*address);
        }
    }

    let mut account_keys = message.account_keys.clone();
    account_keys.extend(writable);
    account_keys.extend(readonly);
    Ok(account_keys)
}

In the Binary receive loop, call resolve_accounts(&versioned_tx, &cache) after deserializing tx.binary_transaction into versioned_tx.

Ok(account_keys) contains the complete account list. Err(lut_address) identifies a missing table or a table whose cached address list does not contain a required index. The caller should fetch or refresh that LUT through RPC and retry resolution.

This function expands addresses; it does not perform full runtime validation. Keep lifecycle metadata alongside the cached address lists in production. Preserve the returned order for instruction decoding; derive a separate set if you want fast membership checks for filters.

Using the same approach with Decoded Shreds

The Decoded Shreds receive loop receives a protobuf shredstream::Transaction. Its message field is an optional protobuf message, rather than the SDK's VersionedMessage enum.

Extract tx.message, convert its static account keys and each lookup's account_key from 32-byte values into Pubkey, and apply the same cache lookups and writable/read-only ordering shown above. Handle a missing message or an invalid key length as a decoding error. The function above accepts the SDK transaction used by Binary; the decoded protobuf transaction needs its own adapter.

5. Apply filters locally

Start with a filtered Decoded Shreds or Binary subscription. Choose programs or accounts present in the transaction's static keys as your subscription criteria. Then resolve LUTs for each received transaction and apply more specific rules locally.

For example, subscribe to transactions involving the Pump.fun program with account_required:

{
  "transactions": {
    "pumpfun": {
      "account_include": [],
      "account_exclude": [],
      "account_required": [
        "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
      ]
    }
  }
}

For each transaction delivered by this subscription:

  1. Resolve its LUT references using your local cache and RPC fallback.
  2. Build the complete account list from static keys and loaded addresses.
  3. Check that list for the specific pools, token accounts, or other addresses your application tracks.

For example, a pool address may appear only through a LUT. The subscription selects transactions involving the program; your local filter checks whether a received transaction also uses that pool, including when its address is loaded from a table.

Apply rules that depend on LUT-loaded addresses after resolution. Match against the addresses actually loaded by that transaction, not every address held by its referenced tables. Local filtering narrows the transactions delivered by your subscription; it cannot recover transactions excluded by the subscription's filters.

Production considerations

Recover after disconnects. Re-establish the LUT subscription and refresh tables that may have changed during the gap. Track cache hits, RPC fetches, unresolved transactions, and resolution latency to detect when the cache stops keeping up.

Retain lifecycle metadata. Newly appended LUT addresses become usable in a later slot, and deactivation has a cooldown before closure. Do not equate a deactivation marker with immediate unavailability. Use Solana's slot-aware lookup rules if you need to determine whether a table was usable for a particular transaction. See the LUT lifecycle specification.

Confirm outcomes separately. Decoded Shreds and Binary provide early transaction data. Use Geyser / Fastlane transaction updates when your application also needs execution status, logs, balances, or other post-execution metadata.

On this page