1. Guides

Test decoders and processors

Unit-test Carbon decoders with carbon-test-utils fixtures captured from mainnet, and run processors through a real pipeline in tests without a network.

On this page

A Carbon indexer has two things worth testing: that the decoder turns real bytes into the right types, and that your processor does the right thing with them. This guide tests both offline, from fixtures captured once from mainnet:

  • Decoder tests with carbon-test-utils: instruction data from an explorer, an instruction fixture, and an account fixture.
  • Pipeline tests that replay a saved transaction through Pipeline::run with a fixture datasource and assert on what the processor recorded.

The code under test is SwapStats, a processor that sums Meteora DLMM swap volume per pool. The fixture is a real Jupiter route that swaps through two DLMM pools in one transaction.

You need: Rust 1.96.1 or newer, plus curl and jq to capture fixtures. Tests themselves need no network.

How the Carbon repository tests#

  • carbon-core keeps real transaction metadata under crates/core/tests/fixtures/*.json (with the Solscan links in a README) and loads them with carbon_test_utils::read_transaction_meta in unit tests of its transformers.
  • Decoders get unit tests next to the code: carbon-token-program-decoder packs SPL accounts with solana_program_pack::Pack and decodes the bytes; carbon-token-2022-decoder builds mints with extensions in tests/.
  • Generated decoders add carbon-test-utils as a dev-dependency so you can add fixture tests the same way.

carbon-test-utils provides:

ItemUse
read_instruction(path)Load an Instruction from JSON: program_id, accounts (pubkey, is_signer, is_writable) and hex data.
read_account(path)Load an Account from JSON: base64 data, executable, lamports, owner, rent_epoch.
read_transaction_meta(path)Load a UiTransactionStatusMeta (the meta object of a getTransaction response). Convert it with transformers::transaction_metadata_from_original_meta.
base58_deserialize::ix_data(str)Decode base58 instruction data, as explorers display it, into bytes.

All three readers return anyhow::Result. The full formats are in the test-utils reference.

Create the crate#

Terminal
cargo new --lib swap-stats
cd swap-stats
mkdir -p tests/fixtures src/bin
Cargo.toml
[package]
name = "swap-stats"
version = "0.1.0"
edition = "2021"

[dependencies]
carbon-core = "2.0.0"
carbon-meteora-dlmm-decoder = "2.0.0"
hex = "0.4.3"
serde_json = "1.0.145"
solana-pubkey = "4.2.1"
solana-transaction-status = "=4.2.2"

[dev-dependencies]
async-trait = "0.1.89"
carbon-test-utils = "2.0.0"
solana-transaction-error = "3.3.2"
tokio = { version = "1.48.0", features = ["full"] }
tokio-util = "0.7.17"

hex and serde_json are used by the fixture tool below; the rest of the library needs only Carbon and the two Solana crates.

Write the code under test#

SwapStats keeps its totals behind an Arc<Mutex<..>>, so a test can keep a clone and read them after the pipeline has finished. load_transaction turns a saved getTransaction response into the TransactionUpdate a datasource would send; it's what the RPC datasources do with a live response.

src/lib.rs
use {
    carbon_core::{
        datasource::TransactionUpdate,
        error::{CarbonResult, Error},
        instruction::InstructionProcessorInputType,
        processor::Processor,
        transformers::transaction_metadata_from_original_meta,
    },
    carbon_meteora_dlmm_decoder::instructions::MeteoraDlmmInstruction,
    solana_pubkey::Pubkey,
    solana_transaction_status::EncodedConfirmedTransactionWithStatusMeta,
    std::{
        collections::HashMap,
        path::Path,
        sync::{Arc, Mutex},
    },
};

#[derive(Debug, Default, Clone, PartialEq)]
pub struct PairTotals {
    pub swaps: u64,
    pub amount_in: u128,
}

/// The pool and input amount of a swap instruction, if it is one.
pub fn swap_amount(instruction: &MeteoraDlmmInstruction) -> Option<(Pubkey, u64)> {
    match instruction {
        MeteoraDlmmInstruction::Swap { data, accounts, .. } => Some((accounts.lb_pair, data.amount_in)),
        MeteoraDlmmInstruction::Swap2 { data, accounts, .. } => {
            Some((accounts.lb_pair, data.amount_in))
        }
        _ => None,
    }
}

/// Sums swap volume per DLMM pool. Clone it to keep a handle on the totals.
#[derive(Clone, Default)]
pub struct SwapStats {
    pub totals: Arc<Mutex<HashMap<Pubkey, PairTotals>>>,
}

impl Processor<InstructionProcessorInputType<'_, MeteoraDlmmInstruction>> for SwapStats {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, MeteoraDlmmInstruction>,
    ) -> CarbonResult<()> {
        if input.metadata.transaction_metadata.meta.status.is_err() {
            return Ok(()); // failed transactions moved no funds
        }
        if let Some((pair, amount_in)) = swap_amount(input.decoded_instruction) {
            let mut totals = self.totals.lock().map_err(|e| Error::Custom(e.to_string()))?;
            let entry = totals.entry(pair).or_default();
            entry.swaps += 1;
            entry.amount_in += u128::from(amount_in);
        }
        Ok(())
    }
}

/// Loads a `getTransaction` result saved as JSON (Base64 encoding) into a `TransactionUpdate`.
pub fn load_transaction(path: impl AsRef<Path>) -> TransactionUpdate {
    let path = path.as_ref();
    let json = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let fetched: EncodedConfirmedTransactionWithStatusMeta =
        serde_json::from_slice(&json).expect("a getTransaction result");

    let transaction = fetched
        .transaction
        .transaction
        .decode()
        .expect("Base64-encoded transaction");
    let meta = transaction_metadata_from_original_meta(fetched.transaction.meta.expect("meta"))
        .expect("convertible meta");

    TransactionUpdate {
        signature: transaction.signatures[0],
        transaction,
        meta,
        is_vote: false,
        slot: fetched.slot,
        index: None,
        block_time: fetched.block_time,
        block_hash: None,
    }
}

Capture a transaction#

Save the getTransaction result for one signature. Request base64 encoding: Carbon can't rebuild a transaction from json or jsonParsed.

Terminal
SIG=SZE5CFK6gkG91yUWfovgDWd7XYFT9DFaHphCnQq2TbghcBY1c1uSzdksAxsayxgh27aBPhrvbBAahUbUNiNB757
curl -s https://api.mainnet-beta.solana.com -H 'content-type: application/json' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getTransaction\",\"params\":[\"$SIG\",{\"encoding\":\"base64\",\"maxSupportedTransactionVersion\":1}]}" \
  | jq '.result' > tests/fixtures/route.json
jq '.slot, (.meta.logMessages | map(select(contains("Instruction: Swap"))))' tests/fixtures/route.json
Output
467107994
[
  "Program log: Instruction: Swap2",
  "Program log: Instruction: Swap2",
  "Program log: Instruction: SwapV2"
]

Two Swap2 instructions are Meteora DLMM CPIs from Jupiter; SwapV2 belongs to another program. If route.json contains null, the RPC rejected the request (rate limit or unknown signature): run curl without jq to see the error.

Extract instruction fixtures#

read_instruction expects resolved accounts with signer and writable flags, which a raw transaction doesn't contain. This tool extracts them with Carbon's own instruction extraction and writes one fixture per DLMM instruction:

src/bin/extract-instructions.rs
//! Writes every Meteora DLMM instruction of a saved transaction as a
//! `carbon-test-utils` instruction fixture.
use {
    carbon_core::{transaction::TransactionMetadata, transformers::extract_instructions_with_metadata},
    carbon_meteora_dlmm_decoder::PROGRAM_ID,
    serde_json::json,
    std::sync::Arc,
};

fn main() {
    let path = std::env::args().nth(1).expect("usage: extract-instructions <tx.json>");
    let update = swap_stats::load_transaction(&path);
    let metadata = Arc::new(TransactionMetadata::try_from(update.clone()).expect("metadata"));

    let instructions = extract_instructions_with_metadata(&metadata, &update).expect("instructions");
    for (n, (meta, ix)) in instructions.iter().enumerate() {
        if ix.program_id != PROGRAM_ID {
            continue;
        }
        let fixture = json!({
            "program_id": ix.program_id.to_string(),
            "accounts": ix.accounts.iter().map(|a| json!({
                "pubkey": a.pubkey.to_string(),
                "is_signer": a.is_signer,
                "is_writable": a.is_writable,
            })).collect::<Vec<_>>(),
            "data": hex::encode(&ix.data),
        });
        let out = format!("{}.ix{n}.json", path.trim_end_matches(".json"));
        std::fs::write(&out, serde_json::to_string_pretty(&fixture).unwrap()).unwrap();
        println!("{out}: path={:?} stack_height={}", meta.absolute_path, meta.stack_height);
    }
}
Terminal
cargo run --bin extract-instructions -- tests/fixtures/route.json
Output
tests/fixtures/route.ix9.json: path=[4, 0] stack_height=2
tests/fixtures/route.ix10.json: path=[4, 0, 0] stack_height=3
tests/fixtures/route.ix13.json: path=[4, 0, 3] stack_height=3
tests/fixtures/route.ix14.json: path=[4, 1] stack_height=2
tests/fixtures/route.ix15.json: path=[4, 1, 0] stack_height=3
tests/fixtures/route.ix18.json: path=[4, 1, 3] stack_height=3

The two stack-height-2 entries are the swaps; the others are the DLMM program's self-CPI events. Keep the swaps under readable names and delete the rest:

Terminal
mv tests/fixtures/route.ix9.json tests/fixtures/swap2_first.json
mv tests/fixtures/route.ix14.json tests/fixtures/swap2_second.json
rm tests/fixtures/route.ix*.json

Capture an account#

read_account expects the account fields with data as a base64 string. getAccountInfo with base64 encoding returns data as [string, "base64"], so reshape it with jq:

Terminal
curl -s https://api.mainnet-beta.solana.com -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["Bnoev1Gp1o247d7zTwSbznRwqjkRmbrx3R6Qt4P16YbQ",{"encoding":"base64"}]}' \
  | jq '.result.value | {data: .data[0], executable, lamports, owner, rent_epoch: .rentEpoch}' \
  > tests/fixtures/lb_pair.json
jq '{executable, lamports, owner, rent_epoch}' tests/fixtures/lb_pair.json
Output
{
  "executable": false,
  "lamports": 6535656,
  "owner": "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo",
  "rent_epoch": 18446744073709551615
}

rent_epoch is u64::MAX for rent-exempt accounts. Use jq 1.7 or newer, which keeps large integers exact; older versions round it to a float and read_account fails to parse it.

Write decoder tests#

The first test decodes instruction data copied from an explorer, without any fixture file. The others load the fixtures and go through MeteoraDlmmDecoder, so they also check account arrangement and account discriminators.

tests/decoder.rs
use {
    carbon_core::{account::AccountDecoder, instruction::InstructionDecoder},
    carbon_meteora_dlmm_decoder::{
        accounts::MeteoraDlmmAccount,
        instructions::{MeteoraDlmmInstruction, Swap2},
        MeteoraDlmmDecoder,
    },
    carbon_test_utils::{base58_deserialize, read_account, read_instruction},
    solana_pubkey::Pubkey,
};

const POOL: Pubkey = Pubkey::from_str_const("Bnoev1Gp1o247d7zTwSbznRwqjkRmbrx3R6Qt4P16YbQ");
const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112");

#[test]
fn decodes_swap2_data_from_base58() {
    // Instruction data as an explorer shows it (base58).
    let data = base58_deserialize::ix_data("fx9RHbGFfZA1Se5ZmKqQdyyUnuKrrydfLZcDLj");

    let swap = Swap2::decode(&data).expect("Swap2 data");
    assert_eq!(swap.amount_in, 91_444_688);
    assert_eq!(swap.min_amount_out, 0);
}

#[test]
fn decodes_swap2_instruction_fixture() {
    let instruction = read_instruction("tests/fixtures/swap2_first.json").expect("fixture");

    let decoded = MeteoraDlmmDecoder
        .decode_instruction(&instruction)
        .expect("a DLMM instruction");

    let MeteoraDlmmInstruction::Swap2 { data, accounts, .. } = decoded else {
        panic!("expected Swap2, got {decoded:?}");
    };
    assert_eq!(data.amount_in, 91_444_688);
    assert_eq!(accounts.lb_pair, POOL);
    assert_eq!(accounts.token_y_mint, WSOL);
}

#[test]
fn decodes_lb_pair_account_fixture() {
    let account = read_account("tests/fixtures/lb_pair.json").expect("fixture");

    let decoded = MeteoraDlmmDecoder.decode_account(&account).expect("a DLMM account");

    let MeteoraDlmmAccount::LbPair(pair) = decoded.data else {
        panic!("expected LbPair");
    };
    assert_eq!(pair.token_y_mint, WSOL);
}

Write pipeline tests#

FixtureDatasource sends saved updates and returns. Once it returns, the pipeline processes what's queued and run() returns, so the test can assert right after. The second test edits the saved transaction to check that failed transactions are ignored.

tests/pipeline.rs
use {
    async_trait::async_trait,
    carbon_core::{
        datasource::{Datasource, DatasourceId, Update, UpdateType},
        error::{CarbonResult, Error},
        pipeline::Pipeline,
    },
    carbon_meteora_dlmm_decoder::MeteoraDlmmDecoder,
    solana_pubkey::Pubkey,
    swap_stats::{load_transaction, PairTotals, SwapStats},
    tokio::sync::mpsc::Sender,
    tokio_util::sync::CancellationToken,
};

/// Sends saved updates once, then returns. The pipeline stops when it has processed them.
struct FixtureDatasource(Vec<Update>);

#[async_trait]
impl Datasource for FixtureDatasource {
    async fn consume(
        &self,
        id: DatasourceId,
        sender: Sender<(Update, DatasourceId)>,
        _cancellation_token: CancellationToken,
    ) -> CarbonResult<()> {
        for update in &self.0 {
            sender
                .send((update.clone(), id.clone()))
                .await
                .map_err(|e| Error::Custom(e.to_string()))?;
        }
        Ok(())
    }

    fn update_types(&self) -> Vec<UpdateType> {
        vec![UpdateType::Transaction]
    }
}

#[tokio::test]
async fn sums_both_swaps_of_a_routed_transaction() {
    let transaction = load_transaction("tests/fixtures/route.json");
    let stats = SwapStats::default();

    Pipeline::builder()
        .datasource(FixtureDatasource(vec![Update::Transaction(Box::new(transaction))]))
        .instruction(MeteoraDlmmDecoder, stats.clone())
        .build()
        .unwrap()
        .run()
        .await
        .unwrap();

    let totals = stats.totals.lock().unwrap();
    assert_eq!(totals.len(), 2);
    assert_eq!(
        totals[&Pubkey::from_str_const("Bnoev1Gp1o247d7zTwSbznRwqjkRmbrx3R6Qt4P16YbQ")],
        PairTotals { swaps: 1, amount_in: 91_444_688 }
    );
    assert_eq!(
        totals[&Pubkey::from_str_const("EZyszDEx1LZDt7TsSFV8xdPi49sDKC3mdfv2MVMEQLtU")],
        PairTotals { swaps: 1, amount_in: 405_807_562 }
    );
}

#[tokio::test]
async fn ignores_failed_transactions() {
    let mut transaction = load_transaction("tests/fixtures/route.json");
    transaction.meta.status = Err(solana_transaction_error::TransactionError::AccountInUse);
    let stats = SwapStats::default();

    Pipeline::builder()
        .datasource(FixtureDatasource(vec![Update::Transaction(Box::new(transaction))]))
        .instruction(MeteoraDlmmDecoder, stats.clone())
        .build()
        .unwrap()
        .run()
        .await
        .unwrap();

    assert!(stats.totals.lock().unwrap().is_empty());
}

Run the tests#

Terminal
cargo test
Output
     Running tests/decoder.rs (target/debug/deps/decoder-e08750c32c07deb0)

running 3 tests
test decodes_swap2_instruction_fixture ... ok
test decodes_swap2_data_from_base58 ... ok
test decodes_lb_pair_account_fixture ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

     Running tests/pipeline.rs (target/debug/deps/pipeline-f12b833c4b961edf)

running 2 tests
test sums_both_swaps_of_a_routed_transaction ... ok
test ignores_failed_transactions ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
…

What to test#

  • Every variant you handle. One fixture per instruction and account type your processors match on. A decoder regenerated from a newer IDL that changes a layout then fails a test instead of returning None in production.
  • CPIs, not only top-level calls. Most DEX volume arrives through aggregators. The route fixture above has both swaps at stack height 2; a processor that only looked at top-level instructions would count zero.
  • Rejections. Failed transactions, other programs' instructions, truncated data. See the rejection test in Write a decoder by hand.
  • Your write path. For processors that write to a database, run the pipeline test against a disposable database and query it afterwards. With the postgres feature, check that every row you expect is there: sibling CPIs can share a primary key.

Test a processor without a pipeline#

process is an ordinary async method, but its input borrows metadata, the decoded value, the nested instructions and the raw instruction. Building that by hand is more work than running the pipeline, so prefer the fixture datasource above. Logic that doesn't need the input, like swap_amount here, is easiest to test as a plain function.

Common problems#

Couldn't read fixture: No such file or directory#

Paths in tests are relative to the crate root (CARGO_MANIFEST_DIR), where cargo test runs them. Use tests/fixtures/…, or build the path with concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/route.json") for tests that may run from elsewhere.

Couldn't deserialize fixture: invalid type: floating point …, expected u64#

rent_epoch was rounded by an old jq. Upgrade to jq 1.7 or newer, or edit the value back to 18446744073709551615.

a getTransaction result: Error("invalid type: null …")#

The saved file is null: the RPC returned an error instead of the transaction. Capture it again and check the raw response.

Base64-encoded transaction panic in load_transaction#

The transaction was captured with json or jsonParsed encoding. Capture it with "encoding":"base64".

The pipeline test hangs#

The datasource never returns, or keeps a clone of sender alive in a spawned task. The pipeline only stops when every sender is dropped or the cancellation token is cancelled.

Decoding the account fixture returns None#

The account was captured from a different program, or after it was closed and reopened with another layout. Check owner in the fixture and the first 8 bytes of data against the decoder's discriminator.

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.