1. Concepts

Decoders

How AccountDecoder and InstructionDecoder turn raw bytes into Rust types, how discriminators and ArrangeAccounts work, and how decoder crates are laid out.

On this page

A decoder recognizes the accounts or instructions of one program and turns their bytes into Rust types. Every account and instruction pipe has one. This page covers the two decoder traits, how decoding works, how a generated decoder crate is organized and where decoders come from.

The decoder traits#

Rust
pub trait AccountDecoder<'a> {
    type AccountType;

    fn decode_account(
        &self,
        account: &'a solana_account::Account,
    ) -> Option<DecodedAccount<Self::AccountType>>;
}

pub trait InstructionDecoder<'a> {
    type InstructionType;

    fn decode_instruction(
        &self,
        instruction: &'a solana_instruction::Instruction,
    ) -> Option<Self::InstructionType>;
}

Both return None for data that isn't theirs. The pipe then skips the update and the processor isn't called, so a pipe with a Meteora decoder ignores every non-Meteora instruction in a transaction. A decoder that returns Some for everything sees every update of its kind.

DecodedAccount<T> carries the decoded data plus the account header:

Rust
pub struct DecodedAccount<T> {
    pub lamports: u64,
    pub data: T,
    pub owner: Pubkey,
    pub executable: bool,
    pub rent_epoch: u64,
}

The instruction passed to decode_instruction is a solana_instruction::Instruction with the program ID, the account metas (pubkey, signer, writable) and the data. Carbon builds it from the transaction and resolves address lookup tables first, so account keys are complete. Pass a decoder to the pipeline by value: .instruction(MeteoraDlmmDecoder, processor) or .account(MeteoraDlmmDecoder, processor).

How decoding works#

Discriminators#

Programs put a discriminator at the start of their data to say what it is:

  • Anchor programs use 8 bytes: the first 8 bytes of the SHA-256 of global:<instruction_name> for instructions, account:<AccountName> for accounts and event:<EventName> for events. Meteora DLMM's swap instruction starts with [248, 198, 158, 145, 225, 117, 135, 200].
  • Native and SPL programs use their own layouts. SPL Token instructions start with a one-byte tag (Transfer is 3), and token accounts have no discriminator: the decoder recognizes them by size.

A generated decoder gives each account, instruction and event type a decode(data: &[u8]) -> Option<Self> method that checks the discriminator and deserializes the rest with Borsh, ignoring trailing bytes. The program's decode_instruction first checks instruction.program_id == PROGRAM_ID, then tries each instruction type in turn; decode_account first checks account.owner == PROGRAM_ID, then tries each account type. The first match wins.

CarbonDeserialize#

carbon_core::deserialize::CarbonDeserialize is the trait form of the same idea, for types you write yourself:

Rust
pub trait CarbonDeserialize
where
    Self: Sized + borsh::BorshDeserialize,
{
    const DISCRIMINATOR: &'static [u8];

    fn deserialize(data: &[u8]) -> Option<Self>;
}

decode_log_events needs it to decode events from transaction logs. Generated decoders in 2.0.0 don't implement it; they use the inherent decode methods above. The same module provides extract_discriminator(length, data), which splits data into (discriminator, rest).

ArrangeAccounts#

An instruction's accounts arrive as a list in the order the program expects them. ArrangeAccounts maps that list to a struct with named fields:

Rust
pub trait ArrangeAccounts {
    type ArrangedAccounts: Clone + Send + Sync + std::fmt::Debug;

    fn arrange_accounts(accounts: &[solana_instruction::AccountMeta]) -> Option<Self::ArrangedAccounts>;
}

Generated instruction types implement it. Optional accounts in the IDL become Option<Pubkey>, and any accounts beyond the IDL's list land in a remaining: Vec<AccountMeta> field. If the instruction has fewer accounts than it requires, arrange_accounts returns None and the instruction isn't decoded.

Generated decoder crates#

Every published decoder, and every decoder the CLI generates, has the same layout. For Meteora DLMM (carbon-meteora-dlmm-decoder):

Files
  • src/
    • lib.rsMeteoraDlmmDecoder, PROGRAM_ID
    • accounts/one module per account type, MeteoraDlmmAccount, AccountDecoder impl
    • instructions/one module per instruction, MeteoraDlmmInstruction, InstructionDecoder impl
    • events/one module per event type, each with decode()
    • types/structs and enums the IDL defines and the others use
    • graphql/behind the graphql feature
ItemWhat it is
MeteoraDlmmDecoderA unit struct that implements both AccountDecoder and InstructionDecoder.
PROGRAM_IDThe program's address, a const Pubkey. Use it to configure datasources.
accounts::MeteoraDlmmAccountOne variant per account type, such as LbPair(Box<LbPair>).
instructions::MeteoraDlmmInstructionOne struct variant per instruction: Swap { program_id, data, accounts }, where data holds the arguments (Swap { amount_in, min_amount_out }) and accounts the named accounts (SwapInstructionAccounts).
events::swap::SwapEventAn event payload, with its own decode.
accounts::postgres, instructions::postgresRow types and migrations, behind the postgres feature.

The account and instruction enums derive Debug, Clone and PartialEq, plus Serialize and Deserialize with the serde feature. Decoders generated by CLI 2.0.0 for programs with Anchor events also export EVENT_CPI_DISCRIMINATOR; the published 2.0.0 crates predate that and don't. The decoder catalog documents each published crate.

Events and CPI events#

Anchor programs publish events in one of two ways, and Carbon decodes each differently:

  • Event CPI (emit_cpi!): the program calls itself with an instruction whose data is the 8-byte event-CPI tag [228, 69, 165, 46, 81, 203, 154, 29], the event's discriminator and the event. A generated decoder decodes this inner instruction as a CpiEvent variant: MeteoraDlmmInstruction::CpiEvent { data: CpiEvent::Swap(SwapEvent { .. }), .. }. It reaches your instruction processor like any other instruction, and also appears among the parent instruction's nested instructions.
  • Log events (emit!): the program writes a Program data: <base64> log line. Carbon doesn't turn these into instructions; decode them from the instruction's metadata with decode_log_events.

Instructions shows both in a processor.

Where decoders come from#

  • Published crates. Carbon 2.0 publishes 63 decoder crates, carbon-<program>-decoder, for SPL Token, Token-2022, System, Jupiter, Raydium, Orca, Meteora, Pump.fun, Kamino and more. See the catalog.
  • Generated with the CLI. carbon-cli parse turns an Anchor or Codama IDL, or an on-chain IDL fetched by program address, into a decoder crate with this layout. See Generate a decoder.
  • Written by hand. For programs without an IDL, or when you only need one or two types, implement the traits yourself. See Write a custom decoder and the second example below.

Examples#

Decode an instruction with a generated decoder#

This program builds a Meteora DLMM swap instruction by hand and decodes it. In a pipeline, the instruction comes from a transaction instead.

Terminal
cargo add carbon-core@2.0.0 carbon-meteora-dlmm-decoder@2.0.0
cargo add solana-instruction@3.4.1 solana-pubkey@4.2.1
src/main.rs
use {
    carbon_core::instruction::InstructionDecoder,
    carbon_meteora_dlmm_decoder::{
        instructions::MeteoraDlmmInstruction, MeteoraDlmmDecoder, PROGRAM_ID,
    },
    solana_instruction::{AccountMeta, Instruction},
    solana_pubkey::Pubkey,
};

fn main() {
    // Anchor discriminator of `swap`, then the Borsh-encoded arguments.
    let mut data = vec![248, 198, 158, 145, 225, 117, 135, 200];
    data.extend_from_slice(&1_000_000u64.to_le_bytes()); // amount_in
    data.extend_from_slice(&990_000u64.to_le_bytes()); // min_amount_out

    let accounts = (0..15)
        .map(|_| AccountMeta::new(Pubkey::new_unique(), false))
        .collect();
    let instruction = Instruction { program_id: PROGRAM_ID, accounts, data };

    match MeteoraDlmmDecoder.decode_instruction(&instruction) {
        Some(MeteoraDlmmInstruction::Swap { data, accounts, .. }) => println!(
            "swap: amount_in={} min_amount_out={} lb_pair={}",
            data.amount_in, data.min_amount_out, accounts.lb_pair
        ),
        Some(_) => println!("another DLMM instruction"),
        None => println!("not a DLMM instruction"),
    }
}
Output
swap: amount_in=1000000 min_amount_out=990000 lb_pair=11157t3sqMV725NVRLrVQbAu98Jjfk1uCKehJnXXQs

Write an account decoder by hand#

This decoder reads a CounterState account of a hypothetical Anchor program: an 8-byte discriminator followed by a Borsh-encoded authority and count.

Terminal
cargo add carbon-core@2.0.0 solana-account@4.3.2 solana-pubkey@4.2.1
cargo add borsh --features derive
src/main.rs
use {
    borsh::BorshDeserialize,
    carbon_core::{
        account::{AccountDecoder, DecodedAccount},
        deserialize::CarbonDeserialize,
    },
    solana_account::Account,
    solana_pubkey::Pubkey,
};

const PROGRAM_ID: Pubkey = Pubkey::from_str_const("Counter111111111111111111111111111111111111");

/// On-chain layout: 8-byte discriminator, then Borsh fields.
#[derive(Debug, BorshDeserialize)]
pub struct CounterState {
    pub authority: Pubkey,
    pub count: u64,
}

impl CarbonDeserialize for CounterState {
    // The first 8 bytes of sha256("account:CounterState")
    const DISCRIMINATOR: &'static [u8] = &[98, 23, 205, 159, 14, 202, 79, 139];

    fn deserialize(data: &[u8]) -> Option<Self> {
        let mut body = data.strip_prefix(Self::DISCRIMINATOR)?;
        // `deserialize` (not `try_from_slice`) tolerates trailing padding bytes.
        <Self as BorshDeserialize>::deserialize(&mut body).ok()
    }
}

pub struct CounterDecoder;

impl<'a> AccountDecoder<'a> for CounterDecoder {
    type AccountType = CounterState;

    fn decode_account(&self, account: &'a Account) -> Option<DecodedAccount<CounterState>> {
        if account.owner != PROGRAM_ID {
            return None;
        }
        Some(DecodedAccount {
            lamports: account.lamports,
            data: <CounterState as CarbonDeserialize>::deserialize(&account.data)?,
            owner: account.owner,
            executable: account.executable,
            rent_epoch: account.rent_epoch,
        })
    }
}

fn main() {
    let mut data = CounterState::DISCRIMINATOR.to_vec();
    data.extend_from_slice(&[7; 32]); // authority
    data.extend_from_slice(&42u64.to_le_bytes()); // count
    data.extend_from_slice(&[0; 16]); // padding

    let account = Account {
        lamports: 1_224_960,
        data,
        owner: PROGRAM_ID,
        executable: false,
        rent_epoch: u64::MAX,
    };

    let decoded = CounterDecoder.decode_account(&account).expect("a CounterState account");
    println!("{:?}", decoded.data);
}
Output
CounterState { authority: US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx, count: 42 }

CounterDecoder works in a pipeline like a generated one: .account(CounterDecoder, processor).

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.