TestContext API Reference

April 27, 2026 · View on GitHub

Index

Program Loading

ctx.add_program(&program_id, "path/to/program.so")?;

Account Creation

// Generic account
ctx.create_account()
    .pubkey(address)
    .lamports(1_000_000_000)
    .owner(system_program::id())
    .size(128)  // optional
    .create()?;

// Mint account
ctx.create_mint()
    .pubkey(mint_address)
    .mint_authority(authority)
    .decimals(9)
    .create()?;

// Token account
ctx.create_token_account()
    .pubkey(token_address)
    .mint(mint_address)
    .token_owner(owner)
    .amount(1000)
    .create()?;

Program Calls

ctx.program(program_id)
    .call(instruction::DoSomething { amount })
    .accounts(accounts::DoSomething {
        user: user.pubkey(),
        pool: pool_pda,
        system_program: system_program::id(),
    })
    .signers(&[&*user])
    .send()?;

Transaction Results (TxOutcome)

The .send() method returns Result<TxOutcome>, a parsed transaction result:

use crucible_test_context::TxOutcome;

let result = ctx.program(program_id)
    .call(instruction::DoSomething { amount })
    .accounts(accounts::DoSomething { /* ... */ })
    .signers(&[&*user])
    .send()?;

match result {
    TxOutcome::Success { compute_units, logs } => {
        println!("Used {} CU", compute_units);
    }
    TxOutcome::ProgramError { error, error_code, logs, .. } => {
        if let Some(code) = error_code {
            println!("Failed with error code: {}", code);
        }
    }
}

Helper methods:

  • is_success() / is_error() - Check outcome type
  • error_code() - Extract Custom(N) error codes
  • logs() - Get program logs
  • unwrap() / expect(msg) - Panic on error with detailed message
  • into_result() - Convert to Result<(), TxError> for ? operator

Raw Instruction Calls

For non-Anchor programs or custom instructions:

use solana_instruction::{AccountMeta, Instruction};

let instruction = Instruction {
    program_id: my_program_id,
    accounts: vec![
        AccountMeta::new(user.pubkey(), true),
        AccountMeta::new(pool_pda, false),
        AccountMeta::new_readonly(config, false),
    ],
    data: my_instruction_data.try_to_vec()?,
};

ctx.raw_call(instruction)
    .signers(&[&*user])
    .send()?;

Transaction Batching

Combine multiple instructions into a single atomic transaction:

ctx.program(program_id)
    .call(instruction::Initialize {})
    .accounts(accounts::Initialize { /* ... */ })
    .signers(&[&*payer])
    .add_transaction()?;

ctx.program(program_id)
    .call(instruction::Deposit { amount: 1000 })
    .accounts(accounts::Deposit { /* ... */ })
    .signers(&[&*user])
    .add_transaction()?;

// Send all queued instructions as ONE atomic transaction
ctx.send_batch()?;

Account Reading/Writing

let account: MyAccount = ctx.read_anchor_account(&address)?;
let account: Account = ctx.get_account(&address)?;
ctx.write_anchor_account(&address, &my_data)?;

Read-modify-write a single account atomically:

ctx.update_account(&address, |account| {
    account.lamports += 1_000_000;
    Ok(())
})?;

Check whether an account exists with at least min_size bytes of data (useful in invariants that need to skip uninitialized accounts):

if ctx.account_has_data(&pubkey, 8) {
    let data: MyAccount = ctx.read_anchor_account(&pubkey)?;
    // ...
}

Zero-Copy Accounts

For accounts using bytemuck/zero-copy layouts. Default discriminator length is 8 bytes; use the _with_discriminator variants for non-Anchor zero-copy formats.

let pool: Pool = ctx.read_zero_copy_account(&pool_address)?;
ctx.write_zero_copy_account(&pool_address, &updated_pool)?;

// Custom discriminator length (e.g., 0 for raw layouts):
let raw: RawState = ctx.read_zero_copy_account_with_discriminator(&address, 0)?;
ctx.write_zero_copy_account_with_discriminator(&address, &raw, 0)?;

SPL Token Balance

let amount = ctx.token_balance(&token_account_address);

RPC Account Cloning

Clone accounts directly from mainnet/devnet into your test environment. Accounts are cached to disk in .fuzz-cache/accounts/ - subsequent runs load from cache without RPC calls.

Requires the rpc-clone feature in your fuzz harness Cargo.toml:

crucible-test-context = { ..., features = ["rpc-clone"] }

Basic Usage

fn setup() -> Self {
    let mut ctx = TestContext::new();
    let mut cloner = ctx.clone_from_rpc("https://api.mainnet-beta.solana.com");

    // Clone a program (auto-handles BPF Upgradeable Loader)
    cloner.clone_account(&program_id)?;

    // Batch clone accounts (PDAs, token accounts, oracles, etc)
    cloner.clone_accounts(&[reserve_a, reserve_b, oracle_a])?;

    // ...
}

Cache Management

// Force re-fetch from RPC (ignore disk cache)
let mut cloner = ctx.clone_from_rpc(rpc_url).force_refresh();
cloner.clone_account(&stale_account)?;

// Custom cache directory
let mut cloner = ctx.clone_from_rpc(rpc_url).cache_dir("./my-cache");

// Invalidate specific account or entire cache
cloner.invalidate(&pubkey)?;
cloner.clear_cache()?;

Program Account Fetching

use solana_rpc_client_api::filter::{RpcFilterType, Memcmp};

// Fetch all accounts owned by a program (requires filters)
// WARNING: Unfiltered getProgramAccounts can return millions of results
let filters = vec![RpcFilterType::DataSize(200)];
let pubkeys = cloner.clone_program_accounts(&program_id, &filters)?;

How Caching Works

  • First run: fetches from RPC and saves to .fuzz-cache/accounts/<pubkey>.json + .bin
  • Subsequent runs: loads from disk (no network calls)
  • Use .force_refresh() to re-fetch stale accounts
  • Add .fuzz-cache/ to .gitignore

Time Control

let current = ctx.slot();
ctx.warp_to_slot(current + 1000);
ctx.advance_slots(100);

Mock Pyth Oracles

let oracle = ctx.create_mock_pyth_oracle()
    .price(100_00000000)  // \$100 with 8 decimals
    .exponent(-8)
    .confidence(100_000)
    .build()?;

ctx.update_pyth_price(&oracle, 95_00000000, -8)?;
ctx.refresh_pyth_oracle(&oracle)?;

Fixture Hooks

#[fuzz_fixture] recognises a few special method names on the fixture impl: