1. Guides

Index token transfers

Decode SPL Token and Token-2022 transfers of one mint with the token decoders, and turn raw amounts into token amounts.

On this page

Every token movement on Solana is an instruction of the SPL Token program or of Token-2022, often nested inside a swap or another program's call. This guide indexes the transfers of one mint from both programs with the published decoders, and prints each one with its token amount.

You need: Rust 1.96.1 or newer. The example uses Solana's public RPC, so no account is needed.

Which instructions move tokens#

InstructionInHas the mint?Notes
TransferCheckedSPL Token, Token-2022Yes, plus decimalsWhat wallets and most programs send today. Use it to attribute a transfer to a mint.
TransferSPL Token, Token-2022NoLegacy. The mint is only known from the token accounts; see Plain Transfer has no mint.
TransferCheckedWithFeeToken-2022YesFor mints with a transfer fee. Its fields decode wrongly in 2.0.0; see the Token-2022 decoder.
MintTo, Burn (and *Checked)BothYesSupply changes rather than transfers; handle them the same way if you need balances.

A mint belongs to one program: classic tokens such as USDC and JUP use SPL Token, newer ones may use Token-2022. Register both decoders and your indexer works for any mint.

Create a project#

Terminal
cargo new token-transfers
cd token-transfers
cargo add carbon-core@2.0.0 carbon-rpc-transaction-crawler-datasource@2.0.0 carbon-token-program-decoder@2.0.0 carbon-token-2022-decoder@2.0.0 carbon-log-metrics@2.0.0 solana-pubkey@4.2.1
cargo add tokio --features full
cargo add env_logger

Write the indexer#

The RPC transaction crawler fetches every transaction that references the mint's address. Both decoders feed the same processor, which keeps TransferChecked instructions for the mint and converts the raw amount with the instruction's own decimals.

src/main.rs
use {
    carbon_core::{
        error::CarbonResult, instruction::InstructionProcessorInputType, pipeline::Pipeline,
        processor::Processor,
    },
    carbon_log_metrics::LogMetrics,
    carbon_rpc_transaction_crawler_datasource::{ConnectionConfig, Filters, RpcTransactionCrawler},
    carbon_token_2022_decoder::{instructions::Token2022Instruction, Token2022Decoder},
    carbon_token_program_decoder::{instructions::TokenProgramInstruction, TokenProgramDecoder},
    solana_pubkey::{pubkey, Pubkey},
    std::sync::Arc,
};

/// The mint whose transfers we index: JUP.
const MINT: Pubkey = pubkey!("JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN");

#[tokio::main]
async fn main() -> CarbonResult<()> {
    env_logger::init();

    let rpc_url = std::env::var("RPC_URL")
        .unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string());

    // Every transaction that references the mint, newest first, then new ones as they land.
    let datasource = RpcTransactionCrawler::new(
        rpc_url,
        MINT,
        ConnectionConfig::default(),
        Filters::new(None, None, None),
        None,
    );

    Pipeline::builder()
        .datasource(datasource)
        .metrics(Arc::new(LogMetrics::new()))
        // A mint belongs to one token program; register both so the code works for any mint.
        .instruction(TokenProgramDecoder, TransferLogger)
        .instruction(Token2022Decoder, TransferLogger)
        .build()?
        .run()
        .await
}

struct TransferLogger;

fn print_transfer(amount: u64, decimals: u8, mint: Pubkey, from: Pubkey, to: Pubkey) {
    if mint != MINT {
        return; // a transfer of another token in the same transaction
    }
    let ui_amount = amount as f64 / 10f64.powi(decimals as i32);
    println!("{ui_amount:>14.6} from {from} to {to}");
}

impl Processor<InstructionProcessorInputType<'_, TokenProgramInstruction>> for TransferLogger {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, TokenProgramInstruction>,
    ) -> CarbonResult<()> {
        if let TokenProgramInstruction::TransferChecked { data, accounts, .. } = input.decoded_instruction {
            print_transfer(data.amount, data.decimals, accounts.mint, accounts.source, accounts.destination);
        }
        Ok(())
    }
}

impl Processor<InstructionProcessorInputType<'_, Token2022Instruction>> for TransferLogger {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, Token2022Instruction>,
    ) -> CarbonResult<()> {
        if let Token2022Instruction::TransferChecked { data, accounts, .. } = input.decoded_instruction {
            print_transfer(data.amount, data.decimals, accounts.mint, accounts.source, accounts.destination);
        }
        Ok(())
    }
}

Run it#

Terminal
cargo run
Output
     15.817487 from CcXcW9Ad7DpvcWjv7iDz6RUUdmBSP8dXZS9EbQktQzXq to DqHAep7hD2VJ4rsWSxkLoBSRMiLkdw7y8DLx1htrryts
     15.817487 from DqHAep7hD2VJ4rsWSxkLoBSRMiLkdw7y8DLx1htrryts to 4gPSQHK96TttxoK3AmAVKk6mJvcDDNhAiRmbZh1sDkyA
     13.616322 from CcXcW9Ad7DpvcWjv7iDz6RUUdmBSP8dXZS9EbQktQzXq to CugEgUFKcEE8CayShReXpkQjfYVJXEGqasidZr99ViiJ
     13.616322 from CugEgUFKcEE8CayShReXpkQjfYVJXEGqasidZr99ViiJ to 4gPSQHK96TttxoK3AmAVKk6mJvcDDNhAiRmbZh1sDkyA
    210.276046 from CcXcW9Ad7DpvcWjv7iDz6RUUdmBSP8dXZS9EbQktQzXq to 47cA3idUSGNbJ6tuKEx8UwPLmPkPZRvBkVjeqTbtDYkZ
…

Transfers often come in pairs or chains: a swap routed through an aggregator moves the tokens from the user to an intermediate account and on to the pool. Each hop is its own instruction, nested inside the aggregator's call, and Carbon decodes nested instructions like top-level ones. Use input.metadata.stack_height and input.metadata.absolute_path to tell a direct transfer from one inside another program's call.

Index a different mint#

Change MINT. For a Token-2022 mint nothing else changes. For very busy addresses, such as the USDC mint, Solana's public endpoint can return months-old signatures; use your own RPC endpoint with RPC_URL, or stream instead of crawling (below).

Stream transfers in real time#

The crawler polls. For production, subscribe to transactions that include the mint with a Geyser stream, using the same processor:

Plain Transfer has no mint#

Transfer lists only the source account, the destination and the authority. To attribute one to a mint, look up the source token account's mint: from the transaction's token balances (input.metadata.transaction_metadata.meta.pre_token_balances, which give each token account's mint by account index), or from your own table of token accounts filled by an account processor. TransferChecked avoids the lookup, which is why most programs send it.

Common problems#

Nothing prints for a while#

The public endpoint is rate limited, and the crawler skips failed transactions. Busy mints print within a few seconds; quiet mints may have no transfers in the last minutes. Set RPC_URL to your own endpoint for steady throughput.

The same transfer prints twice#

If you also run a second datasource, deduplicate with DeduplicationFilter. Two lines with different accounts are two hops of a routed swap, not duplicates.

Next steps#

Your endpoints

Code samples use these instead of the placeholders, so you can copy and run them as they are. They’re stored only in this browser and never sent anywhere.