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
- How the Carbon repository tests
- Create the crate
- Write the code under test
- Capture a transaction
- Extract instruction fixtures
- Capture an account
- Write decoder tests
- Write pipeline tests
- Run the tests
- What to test
- Test a processor without a pipeline
- Common problems
- Couldn't read fixture: No such file or directory
- Couldn't deserialize fixture: invalid type: floating point …, expected u64
- a getTransaction result: Error("invalid type: null …")
- Base64-encoded transaction panic in load_
transaction - The pipeline test hangs
- Decoding the account fixture returns None
- Next steps
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::with a fixture datasource and assert on what the processor recorded.run
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-corekeeps real transaction metadata undercrates/(with the Solscan links in a README) and loads them withcore/ tests/ fixtures/ *. json carbon_in unit tests of its transformers.test_ utils:: read_ transaction_ meta - Decoders get unit tests next to the code:
carbon-token-program-decoderpacks SPL accounts withsolana_and decodes the bytes;program_ pack:: Pack carbon-token-2022-decoderbuilds mints with extensions intests/. - Generated decoders add
carbon-test-utilsas a dev-dependency so you can add fixture tests the same way.
carbon-test-utils provides:
| Item | Use |
|---|---|
read_ | Load an Instruction from JSON: program_id, accounts (pubkey, is_signer, is_writable) and hex data. |
read_ | Load an Account from JSON: base64 data, executable, lamports, owner, rent_epoch. |
read_ | Load a UiTransactionStatusMeta (the meta object of a getTransaction response). Convert it with transformers::. |
base58_ | Decode base58 instruction data, as explorers display it, into bytes. |
All three readers return anyhow::. The full formats are in the test-utils reference.
Create the crate#
cargo new --lib swap-stats
cd swap-stats
mkdir -p tests/fixtures src/bin[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<, so a test can keep a clone and read them after the pipeline has finished. load_ turns a saved getTransaction response into the TransactionUpdate a datasource would send; it's what the RPC datasources do with a live response.
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.
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.json467107994
[
"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_ 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:
//! 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);
}
}cargo run --bin extract-instructions -- tests/fixtures/route.jsontests/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=3The 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:
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*.jsonCapture an account#
read_ expects the account fields with data as a base64 string. getAccountInfo with base64 encoding returns data as [string, "base64"], so reshape it with jq:
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{
"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_ 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.
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.
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#
cargo test 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
Nonein 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
postgresfeature, 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_), where cargo test runs them. Use tests/, or build the path with concat!(env!("CARGO_ 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.