Skip to main content
EventDescription
New mintsRaw mint data
Mint updatesSupply changes, authority changes
TokenMetadataName, symbol, URI, additional_metadata
Cold/hot transitionsMint compressed or decompressed
This guide is for teams building custom data pipelines (aggregators, market makers). If you just need account lookups, use get_account_interface instead.

Architecture

Light mints are Solana accounts owned by the Light Token Program. The streaming setup requires two gRPC subscriptions:
SubscriptionDetectsHow
Account sub (owner: cToken..., account_type == 1)Hot state + cold-to-hotPubkey cache lookup
Transaction sub (account_include: cToken...)Hot-to-coldBalance heuristic (pre > 0, post == 0)
The account subscription delivers all state changes while mints are hot. The transaction subscription is needed to detect mints going cold (CompressAndCloseMint changes the owner to System Program, which the account subscription no longer matches).

Setup

Cargo.toml
[dependencies]
helius-laserstream = "0.1"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
bs58 = "0.5"
borsh = "0.10"
light-token-interface = "0.3.0"
use borsh::BorshDeserialize;
use futures::StreamExt;
use helius_laserstream::grpc::subscribe_request_filter_accounts_filter::Filter;
use helius_laserstream::grpc::subscribe_request_filter_accounts_filter_memcmp::Data;
use helius_laserstream::grpc::{
    SubscribeRequestFilterAccounts, SubscribeRequestFilterAccountsFilter,
    SubscribeRequestFilterAccountsFilterMemcmp, SubscribeRequestFilterTransactions,
};
use helius_laserstream::{subscribe, LaserstreamConfig};
use light_token_interface::state::{ExtensionStruct, Mint};

const LIGHT_TOKEN_PROGRAM_ID: &str = "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m";
const ACCOUNT_TYPE_OFFSET: u64 = 165;
1

Connect

let config = LaserstreamConfig::new(
    "https://laserstream-mainnet-ewr.helius-rpc.com".to_string(),
    std::env::var("HELIUS_API_KEY")?,
);
2

Subscribe

let mut request = helius_laserstream::grpc::SubscribeRequest::default();

// 1. Account sub: mint state tracking + cold-to-hot detection.
//    account_type == 1 (Mint) at byte offset 165.
request.accounts.insert(
    "light_mints".to_string(),
    SubscribeRequestFilterAccounts {
        owner: vec![LIGHT_TOKEN_PROGRAM_ID.to_string()],
        filters: vec![SubscribeRequestFilterAccountsFilter {
            filter: Some(Filter::Memcmp(SubscribeRequestFilterAccountsFilterMemcmp {
                offset: ACCOUNT_TYPE_OFFSET,
                data: Some(Data::Bytes(vec![1])),
            })),
        }],
        nonempty_txn_signature: Some(true),
        ..Default::default()
    },
);

// 2. Transaction sub: hot-to-cold detection.
request.transactions.insert(
    "light_token_txns".to_string(),
    SubscribeRequestFilterTransactions {
        vote: Some(false),
        failed: Some(false),
        account_include: vec![LIGHT_TOKEN_PROGRAM_ID.to_string()],
        ..Default::default()
    },
);

let (stream, _handle) = subscribe(config, request);
tokio::pin!(stream);
3

Deserialize mint accounts

use helius_laserstream::grpc::subscribe_update::UpdateOneof;

Some(UpdateOneof::Account(account_update)) => {
    if let Some(account_info) = account_update.account {
        let pubkey: [u8; 32] = account_info.pubkey.as_slice().try_into().unwrap();

        match Mint::deserialize(&mut account_info.data.as_slice()) {
            Ok(mint) => {
                cold_cache.remove(&pubkey); // no longer cold
                cache.insert(pubkey, mint);
            }
            Err(e) => {
                eprintln!(
                    "Failed to deserialize mint {}: {}",
                    bs58::encode(&pubkey).into_string(),
                    e
                );
            }
        }
    }
}
4

Detect mints going cold

Two data structures:
  • cache: HashMap<[u8; 32], T> — hot account state (for quoting/routing)
  • cold_cache: HashMap<[u8; 32], AccountInterface> — cold accounts with ColdContext (for building load instructions)
use helius_laserstream::grpc::subscribe_update::UpdateOneof;

Some(UpdateOneof::Transaction(tx_update)) => {
    if let Some(ref tx_info) = tx_update.transaction {
        for pubkey in find_closed_accounts(tx_info) {
            if cache.remove(&pubkey).is_some() {
                // Async: fetch AccountInterface with ColdContext.
                // Cold accounts are inactive, so this completes well
                // before anyone tries to swap through them.
                let rpc = rpc.clone();
                let cold_cache = cold_cache.clone();
                tokio::spawn(async move {
                    if let Ok(Some(iface)) = rpc.get_account_interface(&pubkey, None).await {
                        cold_cache.insert(pubkey, iface);
                    }
                });
            }
        }
    }
}
fn find_closed_accounts(
    tx_info: &helius_laserstream::grpc::SubscribeUpdateTransactionInfo,
) -> Vec<[u8; 32]> {
    let meta = match &tx_info.meta {
        Some(m) => m,
        None => return vec![],
    };
    let msg = match tx_info.transaction.as_ref().and_then(|t| t.message.as_ref()) {
        Some(m) => m,
        None => return vec![],
    };

    let mut all_keys: Vec<&[u8]> = msg.account_keys.iter().map(|k| k.as_slice()).collect();
    all_keys.extend(meta.loaded_writable_addresses.iter().map(|k| k.as_slice()));
    all_keys.extend(meta.loaded_readonly_addresses.iter().map(|k| k.as_slice()));

    let mut closed = Vec::new();
    for (i, key) in all_keys.iter().enumerate() {
        if key.len() == 32
            && meta.pre_balances.get(i).copied().unwrap_or(0) > 0
            && meta.post_balances.get(i).copied().unwrap_or(1) == 0
        {
            closed.push(<[u8; 32]>::try_from(*key).unwrap());
        }
    }
    closed
}
cache.remove filters out unrelated closures in the same transaction. No discriminator check is needed — compress_and_close always drains lamports to zero.To build transactions that decompress cold accounts, see Router Integration.
5

Extract TokenMetadata

fn extract_metadata(mint: &Mint) -> Option<(String, String, String)> {
    let extensions = mint.extensions.as_ref()?;

    for ext in extensions {
        if let ExtensionStruct::TokenMetadata(m) = ext {
            let name = String::from_utf8_lossy(&m.name).to_string();
            let symbol = String::from_utf8_lossy(&m.symbol).to_string();
            let uri = String::from_utf8_lossy(&m.uri).to_string();
            return Some((name, symbol, uri));
        }
    }
    None
}

Data Layouts

Mint

#[repr(C)]
pub struct Mint {
    pub base: BaseMint,
    pub metadata: MintMetadata,
    pub reserved: [u8; 16],
    pub account_type: u8,
    pub compression: CompressionInfo,
    pub extensions: Option<Vec<ExtensionStruct>>,
}

#[repr(C)]
pub struct BaseMint {
    pub mint_authority: Option<Pubkey>,
    pub supply: u64,
    pub decimals: u8,
    pub is_initialized: bool,
    pub freeze_authority: Option<Pubkey>,
}

#[repr(C)]
pub struct MintMetadata {
    pub version: u8,
    pub mint_decompressed: bool,
    pub mint: Pubkey,
    pub mint_signer: [u8; 32],
    pub bump: u8,
}

TokenMetadata

#[repr(C)]
pub struct TokenMetadata {
    pub update_authority: Pubkey,  // [0u8; 32] = immutable
    pub mint: Pubkey,
    pub name: Vec<u8>,
    pub symbol: Vec<u8>,
    pub uri: Vec<u8>,
    pub additional_metadata: Vec<AdditionalMetadata>,
}

pub struct AdditionalMetadata {
    pub key: Vec<u8>,
    pub value: Vec<u8>,
}

Streaming Token Accounts