Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

anchor-litesvm drives Anchor programs through LiteSVM, a fast in-process Solana VM. On top of it sits an ergonomic testing surface, built on three ideas worth naming before you meet them in code.

Your IDL (Interface Definition Language: Anchor’s generated JSON description of a program’s instructions, accounts, and events) drives a typed instruction builder, so a test calls ordinary Rust functions with named fields instead of hand-packing instruction bytes.

A named cast of actors: funded, aliased keypairs standing in for the people driving the test, so a scenario reads “Alice deposits” instead of one raw pubkey doing something to another.

And structured transaction logs, rendered as a CPI tree: a nested view of every cross-program invocation a transaction made, one line per call, so a failure or an emitted event shows up in context instead of buried in a wall of base64.

This book is a tutorial and reference. Every block of program output you see is captured from a real test in this repository (a deployed .so, a real transaction), not hand-typed, so it cannot drift from what the code emits.

The two crates

  • anchor-litesvm: Anchor-specific testing. Generated instruction bundles, account/event decoding, the AnchorContext world.
  • litesvm-utils: the framework-agnostic helpers underneath it (actors, aliases, token setup, clock warping, the log renderer). anchor-litesvm re-exports it, so Anchor users get everything.

What you will build

Three worked examples, each a real deployed program:

  • Vault (initialize/deposit/withdraw/close, emits a Deposited event): the simplest happy path, event decoding, and the negative-account escape hatch.
  • Escrow (make/take/refund, SPL token CPIs, a 90-day time-lock): token setup, the CPI tree, and time-travel via the clock helpers.
  • Stake (mpl-core NFT staking, a freeze-period day lock): the deepest CPI tree, and driving a program with raw hand-built instructions.

Quickstart

Deploy an Anchor program, deposit into it, and print the structured logs: that’s the fastest path to a passing test.

See the Vault chapter for the same program driven in depth.

Get the code

The programs and tests live in one repo, whose workspace already wires up anchor-litesvm and the litesvm fork it builds on. Until those land upstream, clone the fork and work from inside it:

git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_vault

There is nothing to add to a Cargo.toml: the workspace resolves every dependency. That command runs the vault chapter’s test against the committed vault.so fixture, and the rest of this page is the code it runs.

The test

Three things below are worth naming before you hit them, since the code leans on all three without pausing to explain itself.

ctx.cast_actor("Alice") casts an actor: a funded, named keypair standing in for a real signer. From here on, the test (and the printed logs) refer to her as Alice instead of a 44-character pubkey.

The comment about PDAs refers to Program Derived Addresses: account addresses derived deterministically from seeds instead of from a keypair, which is how a program can own and sign for vault_state and vault below without anyone holding a private key for them.

And InitializeBundle / DepositBundle are bundles: generated structs that hold exactly the accounts a given instruction’s caller needs to supply, PDAs already derived, so you only fill in what actually varies per call (here, just user).

#![allow(unused)]
#![allow(unexpected_cfgs)]

fn main() {
use anchor_lang::{self};
use anchor_litesvm::AnchorLiteSVM;
use solana_signer::Signer;

anchor_lang::declare_program!(vault);
anchor_litesvm::bundles_from_idl!(vault);

#[test]
fn deposit_happy_path() {
    // vault.so is the committed fixture; book_vault.rs loads the same bytes
    // with common::fixture_bytes("vault").
    let mut ctx = AnchorLiteSVM::build_with_program(
        vault::ID,
        "vault",
        include_bytes!("fixtures/vault.so"),
    );
    let alice = ctx.cast_actor("Alice");

    // initialize creates the vault_state + vault PDAs for Alice.
    ctx.tx(&[&alice])
        .build(
            InitializeBundle {
                user: alice.pubkey(),
            },
            vault::client::args::Initialize {},
        )
        .send_ok();

    // deposit 1 SOL.
    ctx.tx(&[&alice])
        .build(
            DepositBundle {
                user: alice.pubkey(),
            },
            vault::client::args::Deposit {
                amount: 1_000_000_000,
            },
        )
        .send_ok()
        .print_logs();
}
}

declare_program! (from anchor_lang) is the macro that generates the typed client from the vault IDL. bundles_from_idl! (from anchor_litesvm) is the one that generates the bundles you just met above, one per instruction.

print_logs() renders the transaction as a CPI tree, program logs and all: the same format introduced above, printed instead of just returned.

Next: the Vault chapter drives the same program deeper (events, the escape hatch, a rejected transaction).

Concepts covers aliases, the World, and structured logs from here, in more depth than a quickstart has room for.

Aliases & Actors

Every scenario in this book has a cast, and the cast follows a crypto convention: Alice and Bob are honest counterparties, Charlie an honest third party, Mallory the attacker.

The point of using names instead of roles is that a test reads as a story: “Mallory substitutes her own account” reads naturally, where “attacker_keypair swaps accounts[3]” makes the reader do the translating work themselves.

cast_actor casts a member of that cast:

#![allow(unused)]
fn main() {
let alice = ctx.cast_actor("Alice");
}

This does three things in one call: it derives a deterministic keypair from the program id and the name "Alice" (same test, same program, same key every run), airdrops it 100 SOL (far more than any scenario in this book spends, so funding is never what fails a test), and registers alice.pubkey() -> "Alice" in the context’s alias table, the lookup this book leans on throughout to print names instead of pubkeys.

Why deterministic, specifically? Because it matters for reproducing a captured failure. Given the same program id, "Alice" is always the same keypair, so reproducing a captured log needs no hardcoded pubkey pinned anywhere; anyone can re-run the test and land on the exact same address Alice had when the log was captured.

Every subsequent send_ok / send_err / send_err_named and tx() chain draws on that alias table automatically.

That’s the payoff: failures render in the test’s own vocabulary. The vault chapter’s happy-path deposit runs as Alice, and the tree renders her name directly:


── vault::Deposit ──────────────────────────────────────────
Transaction  signers=[Alice]
└── vault::Deposit [1] ✓ 6874cu  signer=Alice
    ├── System [2] ✓ (no cu)
    └── 🔔 Deposited { user: Alice, amount: 1000000000, vault_balance: 1000000000 }
Compute Units (this run): 6874
Fee: 5000 lamports
Legend (2):
  Alice = F1xntdTLP71JkUsheiwBUT4F5LnYgKe1NGPkceL6p6gc
  vault = 6RviLVy2WPGm7QYfCuZq66vKWF58WVTNWfFE7RgWxcfP

Two things read off the alias table here. The 🔔 Deposited badge resolves user to Alice instead of a 44-character base58 key. The Legend at the bottom lists every alias that appears in this render, mapped back to its real address: Alice and the vault PDA.

The renderer only surfaces aliases it actually draws from: transaction signers and frame program ids, not every account passed in an instruction’s metas. A mint or an ATA, or even a non-signing actor like escrow’s maker, won’t show up in the legend unless it’s also a signer or a program id.

Note: well-known programs like System and Token are aliased by default too, but only non-default entries make the legend; a run touching only System prints no legend at all.

Casting isn’t limited to signers, since not everything a scenario needs is one. cast_account casts a passive, non-signing pubkey; cast_mint casts a token mint; fund_ata funds a holder’s associated token account and aliases it "<owner>/<mint>".

All of them register into the same alias table, so a token-heavy scenario like the escrow example still reads by name end to end. See Setup for the full cast vocabulary.

The World

AnchorContext (conventionally bound as ctx) is the World: a stage you set once and direct through the rest of the test.

It owns the LiteSVM instance (ctx.svm), the alias table you just met in Aliases & Actors, the cast of actors it has minted, and the event registry that decodes a program’s emit!ed logs. Everything else in this book is a method on it, or a value it hands back.

Why route everything through one object instead of passing an svm handle and an alias map around separately? Because it lets a scenario get described in terms of actors, not raw pubkeys and byte layouts: “Alice deposits”, “Mallory substitutes her own account”, rather than “airdrop this key, derive that PDA, splice account index 3”.

Setup covers the methods that do the describing.

You might expect this to be a testing convenience layered on top of how Solana actually works, a friendly fiction. It isn’t. Everything on Solana is an account: an actor is a keypair, which owns an account; a PDA, a mint, a token account are all accounts too, just non-signing ones.

Casting every participant in a scenario, signer or not, as a named entry in one alias table isn’t an abstraction bolted onto the account model: it’s the same model, given names.

The cast doesn’t just act on the World; they observe it. A send_ok / send_err / send_err_named call returns a TransactionResult, the record of what happened, in the World’s own vocabulary: which frames ran, which account was whose, what a decoded event said, what failed and why.

That record is what Structured Logs covers: tree_string() renders it as a CPI tree, aliases resolved, so reading a failure back is reading what the actors themselves would have witnessed on the stage, not a raw byte dump.

Setup

Setup has two jobs: get the program(s) onto the SVM, then populate the World’s cast and event registry, so the rest of the test can talk about actors and events instead of raw accounts and log bytes.

Deploying

AnchorLiteSVM::build_with_program deploys a single program and returns a ready AnchorContext:

#![allow(unused)]
fn main() {
let mut ctx =
    AnchorLiteSVM::build_with_program(vault::ID, "vault", &common::fixture_bytes("vault"));
}

program_bytes is a compiled .so’s contents, typically a committed fixture loaded with include_bytes!("../fixtures/vault.so"). Using a committed .so rather than compiling the program as part of the test is what keeps the test fast and independent of the program crate’s own build toolchain.

The "vault" name is registered as an alias for vault::ID, so a failing transaction’s tree names the program vault instead of its raw pubkey, the same alias-table mechanism Aliases & Actors covers for actors.

When a program CPIs into another one your test must also deploy (the stake example calls into mpl-core), use build_with_programs instead, which takes a list and aliases each entry the same way:

#![allow(unused)]
fn main() {
let mut ctx = AnchorLiteSVM::build_with_programs(&[
    (STAKING_ID, "staking", &common::fixture_bytes("staking")),
    (MPL_CORE_ID, "mpl_core", &common::fixture_bytes("mpl_core")),
]);
}

The first program passed becomes the context’s primary program_id.

Casting the scenario

With the context built, cast the actors and accounts the scenario needs. The vault chapter’s full setup:

#![allow(unused)]
fn main() {
fn boot() -> anchor_litesvm::AnchorContext {
    let mut ctx =
        AnchorLiteSVM::build_with_program(vault::ID, "vault", &common::fixture_bytes("vault"));
    // Decode `Deposited` badges from the committed IDL.
    ctx.register_events_from_idl(include_str!("../idls/vault.json"));
    ctx
}
}
#![allow(unused)]
fn main() {
let mut ctx = boot();
let alice = ctx.cast_actor("Alice");
}

cast_actor(name) casts a funded, aliased signer, as covered in Aliases & Actors.

Two more cast methods round out the vocabulary for token scenarios (used throughout the escrow example):

  • cast_mint(name, &authority, decimals) casts a token mint under authority, aliased name.
  • fund_ata(&owner, &mint, &authority, amount) creates owner’s associated token account for mint, mints amount into it from authority, and aliases the ATA "<owner>/<mint>".

register_events_from_idl(idl_json) reads an Anchor IDL (embedded with include_str! so it travels with the test binary) and registers a decoder for every event it declares.

From then on, any emit!ed event in the program’s logs decodes into a typed value (result.parse_event::<T>()) and renders as a 🔔 badge in the printed tree, covered next in Structured Logs.

For a program with no IDL (the stake example’s hand-built .so), there’s nothing for register_events_from_idl to read, so errors need a different path: register_program_errors is the error-side equivalent, naming custom error codes directly by hand instead of reading them out of an IDL.

Structured Logs

Every send_ok / send_err / send_err_named call returns a TransactionResult. Its tree_string() method renders the run’s raw logs as a CPI tree: one line per invoke frame, decoded events as badges, a failing leaf named instead of a bare error code, and a legend mapping every alias back to its address.

This is the anatomy the rest of the book’s captures are made of. This chapter walks it line by line against the vault deposit capture, then the negative-path capture for the failure-specific parts.

Anatomy of a passing run


── vault::Deposit ──────────────────────────────────────────
Transaction  signers=[Alice]
└── vault::Deposit [1] ✓ 6874cu  signer=Alice
    ├── System [2] ✓ (no cu)
    └── 🔔 Deposited { user: Alice, amount: 1000000000, vault_balance: 1000000000 }
Compute Units (this run): 6874
Fee: 5000 lamports
Legend (2):
  Alice = F1xntdTLP71JkUsheiwBUT4F5LnYgKe1NGPkceL6p6gc
  vault = 6RviLVy2WPGm7QYfCuZq66vKWF58WVTNWfFE7RgWxcfP
  • ── vault::Deposit ── is the title bar: the top frame’s program (alias vault) and, when the logs name it, the instruction (Deposit).
  • Transaction signers=[Alice] lists the transaction’s required-signature keys, alias-resolved. One line, regardless of how many frames follow.
  • └── vault::Deposit [1] ✓ 6874cu signer=Alice is the top-level frame: [1] is the invoke depth, the outcome, 6874cu the compute units this frame consumed, and signer=Alice names who signed for it (only top-level frames carry a signer annotation).
  • ├── System [2] ✓ (no cu) is a nested CPI one level deeper ([2]): the lamport transfer deposit makes into the vault PDA via system_program. (no cu) appears when the runtime’s logs don’t report a per-frame figure for that invocation, not when the frame is somehow free.
  • └── 🔔 Deposited { user: Alice, amount: 1000000000, vault_balance: 1000000000 } is a decoded event badge: a leaf sibling of the CPI frames, inside the frame that emitted it. register_events_from_idl (see Setup) is what makes this renderable at all; without a registered decoder for Deposited, the raw base64 payload would print instead. user reads Alice for the same reason everything else does: the decoder resolves pubkey fields through the alias table.
  • The footer reports total compute units and the fee charged, then Legend (2): lists the two non-default aliases this run actually touched, Alice and vault, next to their real addresses. See Aliases & Actors for why System doesn’t appear here too.

├──/└── connectors and indentation track invoke depth and whether a frame is the last child at its level: standard tree-drawing rules. A frame with siblings after it gets ├──; the last sibling gets └──.

Anatomy of a failing run

The mark and a leaf under the failing frame name the failure directly, so you never have to decode a raw error number by hand to see what broke:


── vault::Deposit ──────────────────────────────────────────
Transaction  signers=[Alice]
└── vault::Deposit [1] ✗ 5225cu  signer=Alice
    └── Error: ConstraintSeeds
Error: InstructionError(0, Custom(2006))
Compute Units (this run): 5225
Fee: 5000 lamports
Legend (2):
  Alice = F1xntdTLP71JkUsheiwBUT4F5LnYgKe1NGPkceL6p6gc
  vault = 6RviLVy2WPGm7QYfCuZq66vKWF58WVTNWfFE7RgWxcfP

└── vault::Deposit [1] ✗ 5225cu signer=Alice then └── Error: ConstraintSeeds names the constraint that rejected the swapped account, resolved from the Error Code: ConstraintSeeds. Error Number: 2006. line Anchor itself logs (an AnchorError).

The Error: InstructionError(0, Custom(2006)) line beneath the tree is the raw TransactionError the runtime returned. It’s always there on failure; the named leaf above it is what makes the tree readable without decoding the custom code by hand.

Not every failure comes from an Anchor-logged error, though: a program built without an IDL has no Error Code: log line to read, so its custom codes need a name registered by hand with register_program_errors (FreezePeriodNotElapsed in the Stake chapter).

Because both sources can be in play, the failure leaf resolves in a fixed order: the AnchorError log line first, then the registered error-name table, then the raw error as a last resort. Escrow’s and Vault’s failure leaves (ConstraintSeeds, ConstraintTokenOwner, EscrowExpired, …) all come from Anchor’s own logs; see the Vault and Escrow chapters for those captures.

Sending: send_ok / send_err / send_err_named

The three context-level senders share a signature and differ only in what they assert:

  • ctx.send_ok(ix, &[&signer]) asserts the transaction succeeds.
  • ctx.send_err(ix, &[&signer]) asserts it fails, any error.
  • ctx.send_err_named(ix, &[&signer], "ConstraintSeeds") asserts it fails and that the failure resolves to (or its logs contain) the given name.

All three return the TransactionResult whose tree_string() is what’s captured above.

The fluent ctx.tx(&[&signer]).build(bundle, args) chain terminates in the same three verbs (.send_ok(), .send_err(), .send_err_named("Name")), so whichever style a test uses, the result is the same kind of value with the same rendering.

Printing vs. capturing

result.print_logs() prints the tree to stdout and returns self, so it chains at the end of a call (ctx.send_ok(ix, signers).print_logs();).

result.tree_string() returns the same content as a String instead of printing it. That’s the method this book actually relies on: every {{#include}} block in this book is a tree_string() capture, verbatim, checked against a committed fixture, so it can’t drift from what the code actually renders.

Test-Driving a Voting Program

What we're building towards

The program grows one instruction per step; the tests grow one file per instruction.

examples/voting/programs/voting/src/
├── lib.rs
├── state.rs
├── error.rs
└── instructions/
    ├── initialize_poll.rs
    ├── initialize_candidate.rs
    └── vote.rs

crates/anchor-litesvm/tests/
├── book_voting_poll.rs
├── book_voting_candidate.rs
├── book_voting_vote.rs
└── voting_interface.rs

The example chapters that follow test finished programs. This one builds one, test first, and watches the tests do work beyond checking answers: they pin down the program’s interface and its exact semantics before a line of logic gets written.

That loop is viable here because anchor-litesvm runs in-process with no validator, so red to green is sub-second. Writing the test first is then a forcing function. To call an instruction you have to name it, its accounts, and its arguments, which settles the interface; to assert on the result you have to say what the result should be, which settles the semantics. Both get decided in the test, before the handler exists to have an opinion.

In this framework the forcing function has a concrete shape, and it is worth stating the chain the chapter leans on:

  1. Anchor generates the IDL from the program’s interface: the #[derive(Accounts)] structs and the #[program] function signatures, not the handler bodies.
  2. declare_program! generates the typed client from that IDL.
  3. Your test is written against that client.
flowchart LR
    src["Program interface<br/>accounts structs + fn signatures"] -->|"anchor build"| idl["IDL<br/>one per instruction"]
    idl -->|"declare_program!"| client["Typed client<br/>client::args"]
    client -->|".build(...)"| test["Your test"]

So declaring an instruction, even with an empty body, is what makes its type appear in the client. The red-green loop gains a step plain Rust does not have: declare the instruction, regenerate the IDL, and the client picks up its type. The chapter watches the IDL grow one instruction at a time, then freeze, as the codified record of the boundary you have decided on. The drift-checked test at crates/anchor-litesvm/tests/voting_interface.rs reads the committed IDLs and asserts each lists exactly the instructions declared so far; that is the boundary pinned in place.

Tip

Following along. Each snippet is tagged with the file it lives in: the program source under examples/voting/programs/voting/src/, the tests under crates/anchor-litesvm/tests/. Run a step’s test with, for example, cargo test -p anchor-litesvm --test book_voting_poll. To capture every red and green as a real drift-checked artifact, the book builds the program in stages behind cargo features and splits the tests into book_voting_{poll,candidate,vote}.rs, one per instruction. To build your own from scratch, start with anchor init voting and grow a single program and a single test file, adding each instruction as its step introduces it.

Step 1: what is a poll?

The fuzzy want: “create a poll.” Writing the test forces the question the handler cannot answer yet, what a poll actually consists of. The test commits to an answer: a name, a description, a voting window, and a counter for how many candidates have registered.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_voting_poll.rs
let poll_account = common::voting::poll_pda(&voting_poll::ID, poll_id);

let result = ctx
    .tx(&[&alice])
    .build(
        InitializePollBundle {
            signer: alice.pubkey(),
            poll_account,
        },
        voting_poll::client::args::InitializePoll {
            poll_id,
            start: 1_000,
            end: 2_000,
            name: "Best Pet".to_string(),
            description: "Vote for the best pet".to_string(),
        },
    )
    .send_ok();

let acct: voting_poll::accounts::PollAccount =
    get_anchor_account(&ctx.svm, &poll_account).expect("poll account exists");
assert_eq!(acct.poll_name, "Best Pet");
assert_eq!(acct.poll_voting_start, 1_000);
assert_eq!(acct.poll_voting_end, 2_000);
assert_eq!(acct.poll_option_index, 0);
}

Against a program that declares no instructions, this does not compile:

error[E0422]: cannot find struct, variant or union type `InitializePoll` in module `voting_empty::client::args`
5 |     let _ = voting_empty::client::args::InitializePoll { poll_id: 1, start: 0, end: 0, name: String::new(), description: String::new...
  |                                          ^^^^^^^^^^^^^^ not found in `voting_empty::client::args`

The error is the chain read backwards. The program has no initialize_poll declared, so Anchor’s IDL generation emits no instruction for it; the IDL is empty; declare_program! mints an empty client::args module; the type the test names is not there. An empty interface has nothing to mint.

So declare it. The #[derive(Accounts)] struct is the interface (it, and the function signature, are what the IDL is generated from); the body is the logic:

#![allow(unused)]
fn main() {
// examples/voting/programs/voting/src/instructions/initialize_poll.rs
#[derive(Accounts)]
#[instruction(poll_id: u64, start: u64, end: u64, name: String, description: String)]
pub struct InitializePoll<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(
        init,
        payer = signer,
        space = 8 + PollAccount::INIT_SPACE,
        seeds = [SEED_POLL, poll_id.to_le_bytes().as_ref()],
        bump
    )]
    pub poll_account: Account<'info, PollAccount>,
    pub system_program: Program<'info, System>,
}

impl<'info> InitializePoll<'info> {
    pub fn initialize_poll(
        &mut self,
        _poll_id: u64,
        start: u64,
        end: u64,
        name: String,
        description: String,
    ) -> Result<()> {
        self.poll_account.set_inner(PollAccount {
            poll_name: name,
            poll_description: description,
            poll_voting_start: start,
            poll_voting_end: end,
            poll_option_index: 0,
        });
        Ok(())
    }
}
}

Regenerate the IDL and its instruction set goes from [] to ["initialize_poll"]. declare_program! now mints InitializePoll, the test compiles, and it runs green:


── voting::InitializePoll ──────────────────────────────────
Transaction  signers=[Alice]
└── voting::InitializePoll [1] ✓ 7930cu  signer=Alice
    └── System [2] ✓ (no cu)
Compute Units (this run): 7930
Fee: 5000 lamports
Legend (2):
  Alice  = HBc5Y5izwnuY55kUxmWurPx8ciFqavHj1eBzUqsWvWgz
  voting = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

poll_account is a plain bundle field rather than a derived one because its seeds reference poll_id, an instruction argument the macro cannot see at build time; the caller derives it with poll_pda and passes it in, the same demotion an arg-seeded PDA takes in the escrow chapter.

Step 2: a candidate belongs to a poll

The want: “register a candidate.” The test forces two facts the interface has to carry: a candidate is scoped to a specific poll (its PDA is seeded by the poll id), and registering one bumps that poll’s option counter.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_voting_candidate.rs
let candidate_account = common::voting::candidate_pda(&voting_candidate::ID, poll_id, "Cat");

let result = ctx
    .tx(&[&alice])
    .build(
        InitializeCandidateBundle {
            signer: alice.pubkey(),
            poll_account,
            candidate_account,
        },
        voting_candidate::client::args::InitializeCandidate {
            poll_id,
            candidate: "Cat".to_string(),
        },
    )
    .send_ok();

let c: voting_candidate::accounts::CandidateAccount =
    get_anchor_account(&ctx.svm, &candidate_account).expect("candidate exists");
assert_eq!(c.candidate_name, "Cat");
assert_eq!(c.candidate_votes, 0);

let p: voting_candidate::accounts::PollAccount =
    get_anchor_account(&ctx.svm, &poll_account).expect("poll exists");
assert_eq!(p.poll_option_index, 1);
}

The voting_poll client knows only initialize_poll, so the new call misses the same way:

error[E0422]: cannot find struct, variant or union type `InitializeCandidate` in module `voting_poll::client::args`
5 |     let _ = voting_poll::client::args::InitializeCandidate { poll_id: 1, candidate: String::new() };
  |                                          ^^^^^^^^^^^^^^^^^^^ not found in `voting_poll::client::args`

Declaring the handler settles the two facts the test asked for: the candidate_account PDA is seeded by poll_id and the candidate name, and the body increments the poll’s counter.

#![allow(unused)]
fn main() {
// examples/voting/programs/voting/src/instructions/initialize_candidate.rs
#[derive(Accounts)]
#[instruction(poll_id: u64, candidate: String)]
pub struct InitializeCandidate<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(mut, seeds = [SEED_POLL, poll_id.to_le_bytes().as_ref()], bump)]
    pub poll_account: Account<'info, PollAccount>,
    #[account(
        init,
        payer = signer,
        space = 8 + CandidateAccount::INIT_SPACE,
        seeds = [poll_id.to_le_bytes().as_ref(), candidate.as_ref()],
        bump,
    )]
    pub candidate_account: Account<'info, CandidateAccount>,
    pub system_program: Program<'info, System>,
}

impl<'info> InitializeCandidate<'info> {
    pub fn initialize_candidate(&mut self, _poll_id: u64, candidate: String) -> Result<()> {
        self.candidate_account.candidate_name = candidate;
        self.poll_account.poll_option_index += 1;
        Ok(())
    }
}
}

The IDL grows to ["initialize_poll", "initialize_candidate"], the client mints InitializeCandidate, and the test runs green:


── voting::InitializeCandidate ─────────────────────────────
Transaction  signers=[Alice]
└── voting::InitializeCandidate [1] ✓ 10605cu  signer=Alice
    └── System [2] ✓ (no cu)
Compute Units (this run): 10605
Fee: 5000 lamports
Legend (2):
  Alice  = HBc5Y5izwnuY55kUxmWurPx8ciFqavHj1eBzUqsWvWgz
  voting = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

Step 3: a vote counts once

The want: “let someone vote.” The test forces the third instruction into the interface and asserts the tally moves. It carries a vote_receipt account whose reason to exist arrives in step 5.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_voting_vote.rs
let bundle = vote_bundle(&alice, "Cat");
let result = ctx
    .tx(&[&alice])
    .build(
        bundle,
        voting_vote::client::args::Vote {
            poll_id: POLL_ID,
            candidate: "Cat".to_string(),
        },
    )
    .send_ok();

let c: voting_vote::accounts::CandidateAccount =
    get_anchor_account(&ctx.svm, &candidate).expect("candidate exists");
assert_eq!(c.candidate_votes, 1);
}

The voting_candidate client has initialize_poll and initialize_candidate, not vote:

error[E0422]: cannot find struct, variant or union type `Vote` in module `voting_candidate::client::args`
5 |     let _ = voting_candidate::client::args::Vote { poll_id: 1, candidate: String::new() };
  |                                          ^^^^ not found in `voting_candidate::client::args`

Declare vote. This first version is naive: it increments the tally and writes the receipt, with no notion of when voting is open.

#![allow(unused)]
fn main() {
// examples/voting/programs/voting/src/instructions/vote.rs
#[derive(Accounts)]
#[instruction(poll_id: u64, candidate: String)]
pub struct Vote<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(mut, seeds = [SEED_POLL, poll_id.to_le_bytes().as_ref()], bump)]
    pub poll_account: Account<'info, PollAccount>,
    #[account(mut, seeds = [poll_id.to_le_bytes().as_ref(), candidate.as_ref()], bump)]
    pub candidate_account: Account<'info, CandidateAccount>,
    #[account(
        init,
        payer = signer,
        space = 8 + VoteReceiptAccount::INIT_SPACE,
        seeds = [SEED_VOTE_RECEIPT, poll_id.to_le_bytes().as_ref(), signer.key().as_ref()],
        bump,
    )]
    pub vote_receipt: Account<'info, VoteReceiptAccount>,
    pub system_program: Program<'info, System>,
}

impl<'info> Vote<'info> {
    pub fn vote(&mut self, poll_id: u64, _candidate: String) -> Result<()> {
        self.candidate_account.candidate_votes += 1;
        self.vote_receipt.poll_id = poll_id;
        self.vote_receipt.voter = self.signer.key();
        self.vote_receipt.candidate = self.candidate_account.key();
        Ok(())
    }
}
}

The IDL reaches ["initialize_poll", "initialize_candidate", "vote"] and the happy-path vote runs green:


── voting::Vote ────────────────────────────────────────────
Transaction  signers=[Alice]
└── voting::Vote [1] ✓ 13594cu  signer=Alice
    └── System [2] ✓ (no cu)
Compute Units (this run): 13594
Fee: 5000 lamports
Legend (2):
  Alice  = HBc5Y5izwnuY55kUxmWurPx8ciFqavHj1eBzUqsWvWgz
  voting = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

The interface is now complete. From here the tests stop growing the IDL and start pinning behavior, so voting_interface.rs asserts this instruction set and never sees it change again.

Step 4: when exactly is voting open?

“Voting has a window” is fuzzy in a way the earlier steps were not: the interface already carries start and end, but nothing enforces them. The test forces the boundary to be made exact. Open a poll whose window is entirely in the future, leave the clock before start, and vote:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_voting_vote.rs
let now = ctx.svm.get_unix_timestamp();
let start = (now + 10_000) as u64;
let end = (now + 20_000) as u64;
setup_poll(&mut ctx, &alice, start, end);

// Clock is `now`, well before `start`: this vote should not be allowed.
let result = ctx.tx(&[&alice]).build(/* Vote for "Cat" */).send_ok();
assert_eq!(c.candidate_votes, 1, "naive program let the early vote through");
}

The naive program accepts it. The transaction succeeds and the tally moves:


── voting::Vote ────────────────────────────────────────────
Transaction  signers=[Alice]
└── voting::Vote [1] ✓ 13594cu  signer=Alice
    └── System [2] ✓ (no cu)
Compute Units (this run): 13594
Fee: 5000 lamports
Legend (2):
  Alice  = HBc5Y5izwnuY55kUxmWurPx8ciFqavHj1eBzUqsWvWgz
  voting = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

Warning

The capture is an ordinary green tree, and that is the red: an out-of-window vote that should have been rejected went through. The naive vote has no notion of a window, so nothing stops it.

The guard codifies the window the test made the program commit to: start < now <= end.

Note

The exclusive start: now <= start is closed, not open. At exactly start the poll is still shut, and it opens one second later. That off-by-one shows up only because the test walks the boundary second by second; without the boundary case it would sit unnoticed.

#![allow(unused)]
fn main() {
// examples/voting/programs/voting/src/instructions/vote.rs (in the vote handler)
let now: i64 = Clock::get()?.unix_timestamp;
if now > (self.poll_account.poll_voting_end as i64) {
    return Err(ErrorCode::VotingEnded.into());
}
if now <= (self.poll_account.poll_voting_start as i64) {
    return Err(ErrorCode::VotingNotStarted.into());
}
}

Now the before-start vote is rejected with the program’s own error:


── voting::Vote ────────────────────────────────────────────
Transaction  signers=[Alice]
└── voting::Vote [1] ✗ 13942cu  signer=Alice
    ├── System [2] ✓ (no cu)
    └── Error: VotingNotStarted
Error: InstructionError(0, Custom(6000))
Compute Units (this run): 13942
Fee: 5000 lamports
Legend (2):
  Alice  = HBc5Y5izwnuY55kUxmWurPx8ciFqavHj1eBzUqsWvWgz
  voting = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

The leaf is VotingNotStarted, the guard firing. The IDL did not change: a guard is behavior, and the interface Anchor reads for the IDL (the accounts and the signature) is untouched. voting_interface.rs proves it: it reads voting_vote and voting_guarded and asserts they are byte-identical. The frozen boundary is the same one step 3 pinned; step 4 only changed what happens inside it.

Step 5: what stops a double vote?

The forcing question: “what stops Mallory voting twice?” The test is written expecting to add a guard for it. Mallory votes once (fine), then tries again for a different candidate:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_voting_vote.rs
// Mallory votes once: fine.
ctx.tx(&[&mallory]).build(/* Vote for "Cat" */).send_ok();

// Mallory votes again, for a different candidate. Expect this to be rejected.
let ix = ctx.program().build_ix(
    vote_bundle(&mallory, "Dog"),
    voting_vote::client::args::Vote { poll_id: POLL_ID, candidate: "Dog".to_string() },
);
let result = ctx.send_err(ix, &[&mallory]);
}

It passes with no new code. The receipt account was there since step 3, seeded by [SEED_VOTE_RECEIPT, poll_id, signer]: the poll and the voter, and nothing about the candidate. A second vote from the same signer resolves to the same receipt PDA, and its init collides with the account already sitting there. The invariant was codified in the account model before anyone thought to write a guard for it.


── voting::Vote ────────────────────────────────────────────
Transaction  signers=[Mallory]
└── voting::Vote [1] ✗ 8286cu  signer=Mallory
    ├── System [2] ✗ (no cu)
    └── Error: InstructionError(0, Custom(0))
Error: InstructionError(0, Custom(0))
Compute Units (this run): 8286
Fee: 5000 lamports
Legend (2):
  Mallory = C5shb5FBLN6JbwbMMEMQEoQSGHoFpufvYGodVZx34uri
  voting  = GdPDj9mvShPP3EvnF8FZzRcLxJKxgQG7R3qAWr5R1tZU

The is not the program’s own logic. The failing leaf is System [2], and the error is InstructionError(0, Custom(0)): the System program’s AccountAlreadyInUse, raised when init asks it to create an account that already exists.

Tip

TDD surfaced a structural invariant. The double-vote test was written expecting to drive a new guard. It passed untouched: the receipt PDA, seeded by poll and signer, already made a second init collide. Sometimes the test’s job is not to drive new code but to prove the account model already carries the rule.

Where this leaves us

Five steps in, the program equals the capstone it was modeled on, and the IDL is the record of every boundary decision made along the way: three instructions, declared in the order the tests demanded them, then frozen. The three accounts and the seeds that key them, with the receipt keyed by voter rather than candidate (the one-vote-per-poll rule from step 5):

flowchart TD
    Poll["PollAccount<br/>seeds: poll + poll_id"]
    Cand["CandidateAccount<br/>seeds: poll_id + name"]
    Rcpt["VoteReceiptAccount<br/>seeds: vote_receipt + poll_id + signer"]
    Poll -->|"initialize_candidate, 1..*"| Cand
    Poll -->|"vote, one receipt per signer"| Rcpt

The full sources are in crates/anchor-litesvm/tests/:

  • book_voting_poll.rs, book_voting_candidate.rs, book_voting_vote.rs: the tests, one file per instruction (book_voting_vote.rs drives steps 3 through 5).
  • voting_interface.rs: the drift-checked boundary, asserting the IDL grows then freezes.

For testing finished programs, the way most tests start, the Vault and Escrow chapters are the place to go next.

Vault

Your starting point

The vault program’s full source, a standard Anchor program with no tests, at examples/vault/. Its built .so and IDL are committed too, so a fresh clone runs this chapter’s test without building anything:

git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_vault
examples/vault/                                the program source (no tests)
crates/anchor-litesvm/tests/fixtures/vault.so  the built program
crates/anchor-litesvm/idls/vault.json          its IDL
crates/anchor-litesvm/tests/book_vault.rs      this chapter's test

Changed the program? Rebuild the fixture with cd examples/vault && anchor build.

The vault program has four instructions. initialize creates a per-user vault_state PDA and its companion vault PDA; deposit moves lamports in and emits a Deposited event; withdraw moves them back out; close returns the rent.

This chapter drives two of those four, initialize and deposit, through anchor-litesvm. Then it turns to what this book calls the escape hatch: a way to build an instruction honestly from a bundle and then override exactly one account slot, so a test can submit the specific malformed transaction an attacker would send, without hand-assembling every other account itself. Vault is where that idea gets its first real workout, against an attacker who tries to substitute someone else’s account for her own.

Boot and deposit

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_vault.rs
anchor_lang::declare_program!(vault);
anchor_litesvm::bundles_from_idl!(vault);

fn boot() -> anchor_litesvm::AnchorContext {
    let mut ctx =
        AnchorLiteSVM::build_with_program(vault::ID, "vault", &common::fixture_bytes("vault"));
    // Decode `Deposited` badges from the committed IDL.
    ctx.register_events_from_idl(include_str!("../idls/vault.json"));
    ctx
}
}

declare_program! generates the typed client from the vault IDL; without it, you’d be building instructions by hand, the way the stake chapter does for a program with no IDL to read. bundles_from_idl! then generates an account bundle (InitializeBundle, DepositBundle, …) for each instruction, deriving PDAs so you only supply the accounts that vary per call. Here that’s just user: both vault_state and vault are PDAs derivable from it, so the bundle fills them in for you.

register_events_from_idl reads that same IDL and registers a decoder for every event the program declares. That’s what makes result.parse_event() below work: without a registered decoder for Deposited, there would be nothing for it to decode the event log line into.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_vault.rs
let mut ctx = boot();
let alice = ctx.cast_actor("Alice");

// initialize creates the vault_state + vault PDAs for Alice.
ctx.tx(&[&alice])
    .build(
        InitializeBundle {
            user: alice.pubkey(),
        },
        vault::client::args::Initialize {},
    )
    .send_ok();

// deposit 1 SOL; capture the rendered CPI tree (system transfer + Deposited badge).
let result = ctx
    .tx(&[&alice])
    .build(
        DepositBundle {
            user: alice.pubkey(),
        },
        vault::client::args::Deposit {
            amount: 1_000_000_000,
        },
    )
    .send_ok();

let ev: vault::events::Deposited = result.parse_event().expect("Deposited event present");
assert_eq!(ev.amount, 1_000_000_000);
}

result.tree_string() renders the transaction as a CPI tree:


── vault::Deposit ──────────────────────────────────────────
Transaction  signers=[Alice]
└── vault::Deposit [1] ✓ 6874cu  signer=Alice
    ├── System [2] ✓ (no cu)
    └── 🔔 Deposited { user: Alice, amount: 1000000000, vault_balance: 1000000000 }
Compute Units (this run): 6874
Fee: 5000 lamports
Legend (2):
  Alice = F1xntdTLP71JkUsheiwBUT4F5LnYgKe1NGPkceL6p6gc
  vault = 6RviLVy2WPGm7QYfCuZq66vKWF58WVTNWfFE7RgWxcfP

deposit’s own frame is [1]; the System [2] child one level deeper is the lamport transfer deposit makes via CPI into system_program. The 🔔 line is the decoded Deposited event, sitting inside deposit’s own frame since that’s where emit! was called. user prints as Alice rather than a raw pubkey because the decoder resolves pubkey fields through the same alias table cast_actor registered her into.

The escape hatch

build_ix derives every account from the bundle honestly, the same path initialize and deposit just took above. build_ix_with does the same derivation, then hands you a closure that overrides exactly one slot afterward. That one-slot override is the whole trick: it lets a test construct the specific malformed instruction an attacker would submit, identical to a legitimate call in every other account and in the instruction data, without hand-rolling every other account itself.

Mallory wants Alice’s deposit. You might wonder why she bothers initializing her own vault first rather than reusing some other account she already has lying around. Here’s the reason: Account<'info, VaultState> checks its owner and its discriminator before any explicit constraint on that field runs, so a plainly-wrong account, wrong owner or wrong discriminator, gets rejected on the spot, before the seeds check downstream even gets a chance to fire. To get past those two checks, Mallory needs the substitute to genuinely be a VaultState account owned by the vault program, so she runs her own initialize first (not shown in the excerpt below, since it is identical to Alice’s), which gives her exactly that: a real, program-owned, correctly-discriminated VaultState account at her PDA.

Then she submits a deposit into Alice’s vault, with the vault_state slot swapped for that account:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_vault.rs
let (mallory_state, _) = vault_state_pda(&mallory.pubkey());
let ix = ctx.program().build_ix_with(
    DepositBundle {
        user: alice.pubkey(),
    },
    vault::client::args::Deposit {
        amount: 1_000_000_000,
    },
    |accounts| accounts.vault_state = mallory_state,
);

let result = ctx.send_err_named(ix, &[&alice], "ConstraintSeeds");
}

── vault::Deposit ──────────────────────────────────────────
Transaction  signers=[Alice]
└── vault::Deposit [1] ✗ 5225cu  signer=Alice
    └── Error: ConstraintSeeds
Error: InstructionError(0, Custom(2006))
Compute Units (this run): 5225
Fee: 5000 lamports
Legend (2):
  Alice = F1xntdTLP71JkUsheiwBUT4F5LnYgKe1NGPkceL6p6gc
  vault = 6RviLVy2WPGm7QYfCuZq66vKWF58WVTNWfFE7RgWxcfP

Anchor loads Mallory’s account without complaint: right owner, the vault program; right discriminator, VaultState’s own. The leaf is ConstraintSeeds, though. The field’s seeds constraint re-derives the expected PDA from the seeds declared on vault_state, which include user’s key, Alice’s, since user wasn’t overridden, and compares that derivation to the address actually supplied for vault_state, Mallory’s. The two don’t match, so the constraint rejects the swap.

That’s the confused-deputy story: a substituted account can be valid in every way that matters to the deserializer, right owner, right type, and still belong to the wrong party. ConstraintSeeds is the one check here that ties this specific field to Alice’s key rather than anyone else’s, and it’s what catches the substitution.

The full test is crates/anchor-litesvm/tests/book_vault.rs.

Escrow

Your starting point

The escrow program’s full source, a standard Anchor program with no tests, at examples/escrow/. Its built .so and IDL are committed too, so a fresh clone runs this chapter’s test without building anything:

git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_escrow
examples/escrow/                                the program source (no tests)
crates/anchor-litesvm/tests/fixtures/escrow.so  the built program
crates/anchor-litesvm/idls/escrow.json          its IDL
crates/anchor-litesvm/tests/book_escrow.rs      this chapter's test

Changed the program? Rebuild the fixture with cd examples/escrow && anchor build.

The escrow program has three instructions. make creates an escrow PDA and deposits mint_a into its vault; take lets a counterparty pay mint_b and receive the vault’s mint_a; refund returns the deposit to the maker.

Every escrow carries a 90-day expiry, and take and refund sit on opposite sides of it: take stops working once the expiry passes, refund only starts working once it does. The Time-lock section below drives both sides of that boundary.

make and take also drive real SPL Token CPIs, transfers and init_if_needed associated-token-account creation, which makes this chapter a good place to read a multi-CPI tree once tokens are involved. This chapter drives all three instructions through anchor-litesvm.

Boot and make -> take

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
fn boot() -> anchor_litesvm::AnchorContext {
    AnchorLiteSVM::build_with_program(escrow::ID, "escrow", &common::fixture_bytes("escrow"))
}
}

One heads-up before the listing: bundles_from_idl! cannot derive every account for make and take. One field, escrow, needs computing by hand and passing in like any other bundle value; the code comment marks where, and the paragraph right after the listing explains why.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
let mut ctx = boot();
let maker = ctx.cast_actor("Alice"); // Alice makes the escrow
let taker = ctx.cast_actor("Bob"); // Bob takes it
let mint_a = ctx.cast_mint("MintA", &maker, 6);
let mint_b = ctx.cast_mint("MintB", &maker, 6);

// Fund Alice with MintA (offered) and Bob with MintB (wanted).
let _alice_a = ctx.fund_ata(&maker, &mint_a, &maker, 1_000_000);
let _bob_b = ctx.fund_ata(&taker, &mint_b, &maker, 1_000_000);

// `escrow`'s PDA seeds an ix-arg (`seed`) that the IDL's own emitted
// seed-path names `seeds` (a vendored-source quirk), so the macro can't
// resolve it at build time and demotes it to a plain bundle field: the
// caller derives and supplies it directly, here and again in `take`.
let seed = 42u64;
let (escrow_pda, _bump) = Pubkey::find_program_address(
    &[b"escrow", maker.pubkey().as_ref(), &seed.to_le_bytes()],
    &escrow::ID,
);

ctx.tx(&[&maker])
    .build(
        MakeBundle {
            maker: maker.pubkey(),
            mint_a,
            mint_b,
            token_program: TOKEN_PROGRAM,
            escrow: escrow_pda,
        },
        escrow::client::args::Make {
            seed,
            receive: 1_000_000,
            deposit: 1_000_000,
        },
    )
    .send_ok();

// take: Bob pays MintB to Alice and receives MintA from the vault.
let result = ctx
    .tx(&[&taker])
    .build(
        TakeBundle {
            taker: taker.pubkey(),
            maker: maker.pubkey(),
            mint_a,
            mint_b,
            token_program: TOKEN_PROGRAM,
            escrow: escrow_pda,
        },
        escrow::client::args::Take {},
    )
    .send_ok();
}

bundles_from_idl! derives most of make and take’s accounts, the vault, the ATAs, the escrow PDA itself, where it can. escrow is the one exception, and the code comment above flags why: the instruction takes an argument named seed, but the IDL’s own emitted seed path names that same argument seeds, a vendored-source quirk in how the IDL was generated. The macro matches a seed path’s arguments back to the instruction’s by name, so a name that doesn’t line up can’t be resolved automatically, and escrow gets demoted from a derived field to a plain one. The caller computes escrow_pda with find_program_address instead, and passes it in like any other bundle field.

result.tree_string() renders the transaction as a CPI tree:


── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✓ 66972cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── Token [2] ✓ 105cu
    ├── Token [2] ✓ 105cu
    └── Token [2] ✓ 118cu
Compute Units (this run): 66972
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

The two AssociatedToken frames are init_if_needed creating the taker’s and maker’s associated token accounts: init_if_needed means the constraint creates the account only if it doesn’t already exist, and does nothing if it does. Nested inside each AssociatedToken frame, the Token/System frames are the ATA program’s own calls to size, fund, and initialize that account.

The three Token frames after that are take’s own CPIs, and they run in the order the instruction issues them: Bob pays Alice mint_b, the vault pays Bob mint_a, and the vault account closes, rent back to Alice.

Time-lock

litesvm_utils::TestHelpers::advance_days warps the SVM clock forward by a given number of days, which is how this section gets to the far side of the 90-day expiry without waiting for it in real time. Push past that expiry and take gets rejected before either ATA transfer happens:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
let ix = ctx.program().build_ix(
    TakeBundle {
        taker: taker.pubkey(),
        maker: maker.pubkey(),
        mint_a,
        mint_b,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Take {},
);

// The escrow expires 90 days after make. Jump 91 days forward.
ctx.svm.advance_days(91);

let result = ctx.send_err_named(ix, &[&taker], "EscrowExpired");
}

── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✗ 58772cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    └── Error: EscrowExpired
Error: InstructionError(0, Custom(6000))
Compute Units (this run): 58772
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Both AssociatedToken frames still run and succeed: init_if_needed only checks whether the account already exists, nothing about the escrow’s expiry, so account creation goes ahead regardless. The leaf is the program’s own expiry check, EscrowExpired, and it runs after both ATAs are already created, which is why the transaction fails only at that point rather than upfront.

Refund is the mirror image of the same expiry check: it only works after the 90 days pass, so calling it while still inside the window is rejected too:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
// No time warp: still inside the 90-day window, so refund must be rejected.
// `refund` doesn't sign with `maker` (it's a plain `SystemAccount`), but the
// transaction still needs a fee-payer signer, so `maker` signs in that role.
let ix = ctx.program().build_ix(
    RefundBundle {
        maker: maker.pubkey(),
        mint_a,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Refund {},
);
let result = ctx.send_err_named(ix, &[&maker], "EscrowNotExpired");
}

── escrow::Refund ──────────────────────────────────────────
Transaction  signers=[Alice]
└── escrow::Refund [1] ✗ 10360cu  signer=Alice
    └── Error: EscrowNotExpired
Error: InstructionError(0, Custom(6001))
Compute Units (this run): 10360
Fee: 5000 lamports
Legend (2):
  Alice  = 3xMuErAPF3QduutXSFqLfsKhMbzhoUWKHjfbHz8VgSTG
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Same shape as the expiry check above, mirrored: the program’s own EscrowNotExpired guard rejects the call because the 90 days haven’t elapsed yet, and this time there’s no ATA creation racing ahead of it, since refund doesn’t touch the associated-token-account machinery at all.

The escape hatch

build_ix_with builds every account honestly, then hands you a closure to override exactly one slot, the same escape hatch the vault chapter used against vault_state.

Mallory wants Bob’s take to pay out to her instead of the vault. The swapped account can’t be just anything, though: the vault field is an InterfaceAccount<'info, TokenAccount>, which checks that the account is owned by a token program and that its data actually unpacks as an initialized token account, before any #[account(...)] constraint on that field runs. So Mallory’s setup is to initialize her own, genuinely-owned mint_a associated token account first, a real token account that passes those checks cleanly, then submit take with the vault slot pointed at it:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
// Mallory owns a real, initialized mint_a token account (the
// confused-deputy setup: valid in every way except its authority is
// Mallory, not the escrow PDA). Zero balance is fine; it only needs to
// exist and deserialize. `maker` is the mint authority as elsewhere.
let mallory_vault = ctx.fund_ata(&mallory, &mint_a, &maker, 0);

// Point vault at Mallory's ATA instead of the escrow PDA's. The bundle
// derives every account honestly; the closure then swaps exactly the
// vault slot.
let ix = ctx.program().build_ix_with(
    TakeBundle {
        taker: taker.pubkey(),
        maker: maker.pubkey(),
        mint_a,
        mint_b,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Take {},
    |accounts| accounts.vault = mallory_vault,
);

let result = ctx.send_err_named(ix, &[&taker], "ConstraintTokenOwner");
}

── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✗ 59470cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    └── Error: ConstraintTokenOwner
Error: InstructionError(0, Custom(2015))
Compute Units (this run): 59470
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Mallory’s ATA deserializes fine: real mint, real token account, right discriminator. Both AssociatedToken frames still succeed, same as the happy path, so nothing about account creation flags the substitution.

What catches it is vault’s own associated_token::authority = escrow constraint, checked once the account is already loaded: it reads the token account’s actual owner field and compares it to the escrow PDA. Mallory’s ATA is owned by Mallory, not by escrow, so the two don’t match and Anchor rejects with ConstraintTokenOwner.

Same confused-deputy lesson as the vault chapter’s ConstraintSeeds: a substituted account can be valid in every way that matters to the deserializer, and still belong to the wrong party. Here, as there, it’s one constraint, checked after the account is already loaded, that ties the field to the right owner and catches the swap.

The full test is crates/anchor-litesvm/tests/book_escrow.rs.

Stake

Your starting point

The staking program’s full source, a standard Anchor program with no tests, at examples/staking/. It CPIs into mpl-core, so that program’s .so is committed alongside. The built fixtures are committed too, so a fresh clone runs this chapter’s test without building anything:

git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_stake
examples/staking/                                the program source (no tests)
crates/anchor-litesvm/tests/fixtures/staking.so  the built program
crates/anchor-litesvm/tests/fixtures/mpl_core.so the mpl-core CPI callee
crates/anchor-litesvm/tests/book_stake.rs        this chapter's test

Changed the program? Rebuild the fixture with cd examples/staking && anchor build.

The staking program lets a holder stake an mpl-core NFT into a collection and earn rewards. create_collection and mint_asset set up the NFT side of things; initialize opens a config PDA on the collection with a rewards rate and a freeze period, in days; stake freezes an asset in place and records when; unstake, once that freeze period elapses, unfreezes it again and mints the rewards.

This is the deepest CPI tree in the book: mpl-core assets are only mutable through CPIs into the mpl-core program itself, so nearly everything stake and unstake do to the NFT shows up as a nested frame rather than as a direct account write in staking’s own frame.

The accounts

Three PDAs hang off the collection and the config, and the asset lives inside mpl-core; that shape is what makes the CPI tree deep.

flowchart TD
    Owner["(1) Owner (staker)"]
    Collection["(2) Collection<br/>mpl-core collection"]
    Asset["(3) Asset<br/>mpl-core NFT"]
    Config["(4) Config PDA<br/>seeds: config + collection<br/>rewards_bps, freeze_period"]
    UA["(5) update_authority PDA<br/>seeds: update_authority + collection"]
    Mint["(6) rewards_mint PDA<br/>seeds: rewards_mint + config"]
    Ata["(7) user_rewards_ata<br/>ATA of owner + rewards_mint"]

    Owner -->|"stakes / unstakes"| Asset
    Collection -->|contains| Asset
    Collection -->|seeds| Config
    Collection -->|seeds| UA
    Config -->|seeds| Mint
    UA -->|"mpl-core authority for"| Asset
    Mint -->|"unstake mints to"| Ata

The owner (1) stakes an asset (3), an mpl-core NFT that belongs to a collection (2). Two PDAs are seeded off that collection: config (4) holds the staking terms (the rewards rate and the freeze period), and update_authority (5) is a PDA the program controls, set as the collection’s mpl-core update authority when create_collection runs. That authority is what lets staking sign the plugin adds and updates that freeze and unfreeze the asset, which is what the deep CPI tree below is made of. rewards_mint (6) is seeded off config, and unstake mints from it into the staker’s ATA (7).

staking depends on mpl-core, whose crate is pinned to anchor 0.31, so it builds under its own 0.31 toolchain rather than in this anchor 1.0 workspace. That version gap looks like it should block the typed client; it does not. staking’s IDL is spec 0.1.0, the same format anchor 1.0 emits. The one snag is a name clash: the IDL embeds mpl-core’s Key enum, which collides with anchor_lang’s Key trait once declare_program! glob-imports both, and current rustc rejects the ambiguous glob. make fixtures runs the framework’s sanitize pass (anchor_litesvm::sanitize_idl) over idls/staking.json, which namespaces Key to StakingKey. With that, the typed client generates like vault’s and escrow’s, and this chapter drives staking the same way they drive their programs: a bundle and typed args per instruction, no hand-built bytes.

The typed client

declare_program!(staking) generates the typed client from the sanitized IDL, and bundles_from_idl!(staking) generates an account bundle per instruction. So a stake call is a StakeBundle plus its (empty) args:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
anchor_lang::declare_program!(staking);
anchor_litesvm::bundles_from_idl!(staking);

fn stake_bundle(admin: &Keypair, asset: &Keypair, collection: &Keypair) -> StakeBundle {
    StakeBundle {
        owner: admin.pubkey(),
        asset: asset.pubkey(),
        collection: collection.pubkey(),
    }
}
}

StakeBundle carries only the three accounts that vary per call: the owner and the two mpl-core assets. config and the update-authority PDA are both seeded off collection, so the bundle derives them from the IDL’s seeds; you never spell out the account list or the discriminator, and there is no positional slot to get wrong.

Two-program boot

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
/// Deploys both vendored programs and names the staking custom errors. The
/// framework has no errors-from-IDL helper yet, so `register_program_errors`
/// supplies the mapping (codes are declaration order from 6000, per
/// `error.rs`); that is what makes a failing leaf read as
/// `FreezePeriodNotElapsed` instead of `custom program error: 0x1775`.
fn boot() -> anchor_litesvm::AnchorContext {
    let mut ctx = AnchorLiteSVM::build_with_programs(&[
        (staking::ID, "staking", &common::fixture_bytes("staking")),
        (MPL_CORE_ID, "mpl_core", &common::fixture_bytes("mpl_core")),
    ]);
    ctx.register_program_errors(
        staking::ID,
        &[
            (6000, "InvalidOwner"),
            (6001, "InvalidUpdateAuthority"),
            (6002, "AlreadyStaked"),
            (6003, "AssetNotStaked"),
            (6004, "InvalidTimestamp"),
            (6005, "FreezePeriodNotElapsed"),
            (6006, "InvalidRewardsBps"),
            (6007, "NothingToClaim"),
        ],
    );
    ctx
}
}

build_with_programs deploys the staking program alongside mpl_core: staking CPIs into it for every NFT operation, so both programs need to be live on the SVM for any of this to run.

The IDL carries staking’s error names, but the framework has no helper to source them yet, so register_program_errors supplies the mapping, read straight off staking’s own error.rs. That’s what turns a failing leaf into FreezePeriodNotElapsed instead of the far less readable custom program error: 0x1775.

Happy path

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
let mut ctx = boot();
let admin = ctx.cast_actor("Alice");
let (collection, asset) = setup(&mut ctx, &admin);

let result = ctx
    .tx(&[&admin])
    .build(
        stake_bundle(&admin, &asset, &collection),
        staking::client::args::Stake {},
    )
    .send_ok();
}

setup runs the first three calls through their own bundles: create_collection mints a fresh mpl-core collection asset (the container stake attaches NFTs to), initialize opens the config PDA on it with a 500bps rewards rate and a 7-day freeze period, and mint_asset mints an NFT into the collection. Then stake freezes it in place.

result.tree_string() renders the last of those four calls, stake:


── staking::Stake ──────────────────────────────────────────
Transaction  signers=[Alice]
└── staking::Stake [1] ✓ 48001cu  signer=Alice
    ├── mpl_core::AddPlugin [2] ✓ 15421cu
    │   ├── System [3] ✓ (no cu)
    │   └── System [3] ✓ (no cu)
    └── mpl_core::AddPlugin [2] ✓ 11838cu
        └── System [3] ✓ (no cu)
Compute Units (this run): 48001
Fee: 5000 lamports
Legend (3):
  Alice    = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
  staking  = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg
  mpl_core = CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d

staking::Stake CPIs into mpl_core::AddPlugin twice, once per plugin it attaches: first the Attributes plugin, which records staked and staked_at as data on the asset, then the FreezeDelegate plugin, which is what actually freezes the asset in place. Both effects show up as nested frames rather than as writes inside staking’s own frame, because that’s the only way staking is allowed to touch someone else’s mpl-core asset.

Each AddPlugin call, in turn, touches System to resize the asset account: attaching a plugin grows the account’s stored data, and the extra rent that growth requires gets funded through a System transfer CPI.

Freeze lock

A staker who tries to unstake before the freeze period elapses gets turned away. unstake reads the current clock, works out how many days have passed since stake recorded staked_at, and requires that count to reach initialize’s 7-day freeze period before it will touch the asset at all:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
// Only 1 of the 7 freeze-period days has elapsed.
ctx.svm.advance_days(1);
let ix = ctx.program().build_ix(
    unstake_bundle(&admin, &asset, &collection),
    staking::client::args::Unstake {},
);
let result = ctx.send_err_named(ix, &[&admin], "FreezePeriodNotElapsed");
}

── staking::Unstake ────────────────────────────────────────
Transaction  signers=[Alice]
└── staking::Unstake [1] ✗ 49676cu  signer=Alice
    ├── AssociatedToken [2] ✓ 16416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    └── Error: FreezePeriodNotElapsed
Error: InstructionError(0, Custom(6005))
Compute Units (this run): 49676
Fee: 5000 lamports
Legend (2):
  Alice   = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
  staking = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg

The AssociatedToken frame still runs and succeeds: unstake creates the staker’s rewards ATA before it ever checks the freeze period, the same create-first, guard-second ordering the escrow chapter’s expiry check showed. Then the ✗ FreezePeriodNotElapsed leaf stops the transaction, with 6 of the 7 freeze-period days still owed.

Give unstake the days it’s owed, and the very same call succeeds:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
// 8 of the 7 freeze-period days have elapsed.
ctx.svm.advance_days(8);
let result = ctx
    .tx(&[&admin])
    .build(
        unstake_bundle(&admin, &asset, &collection),
        staking::client::args::Unstake {},
    )
    .send_ok();
}

── staking::Unstake ────────────────────────────────────────
Transaction  signers=[Alice]
└── staking::Unstake [1] ✓ 87846cu  signer=Alice
    ├── AssociatedToken [2] ✓ 16416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── mpl_core::UpdatePlugin [2] ✓ 18305cu
    │   └── System [3] ✓ (no cu)
    ├── mpl_core::UpdatePlugin [2] ✓ 11548cu
    └── Token [2] ✓ 161cu
Compute Units (this run): 87846
Fee: 5000 lamports
Legend (3):
  Alice    = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
  staking  = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg
  mpl_core = CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d

Past the freeze period, unstake runs to completion. The first mpl_core::UpdatePlugin call resets the Attributes plugin’s staked / staked_at values, undoing what stake’s first AddPlugin call recorded. The second UpdatePlugin call sets FreezeDelegate.frozen back to false, unfreezing the asset. The final Token call is the payoff: it mints the staking rewards to the staker’s ATA, at the rewards rate initialize set back at the start of the chapter.

The full test is crates/anchor-litesvm/tests/book_stake.rs.

Reference

A curated “which tool and why,” organized by the object you hold. Think of it as a map, not a signature dump: docs.rs already has the exact signatures, so this page has a narrower job. It tells you which method to reach for, and points at the example chapter that puts it to work.

AnchorLiteSVM (the builder)

This is the first line of every test: deploy the program(s), get back a ready AnchorContext.

  • build_with_program(id, name, &bytes): deploys one program. name gets registered as an alias, so when a tree fails, it names the program instead of showing you its raw pubkey. Used throughout Vault and Escrow.
  • build_with_programs(&[(id, name, &bytes), ...]): deploys several programs in one call, aliasing each. Reach for this when the program under test CPIs into another one your test must also deploy, as Stake does for mpl-core. The first entry becomes the context’s primary program_id.

AnchorContext (the World)

This is the cast-and-setup surface. See The World and Setup for the narrative version of the same ideas.

  • cast_actor(name): a deterministic, 100-SOL-funded, aliased signer. The default way to bring an actor (“Alice”, “Bob”, “Mallory”) into a scenario. See Aliases & Actors.
  • cast_actor_with_sol(name, lamports): same as cast_actor, with an explicit lamport balance instead of the 100 SOL default. Reach for it when a scenario asserts on an exact SOL amount.
  • cast_mint(name, &authority, decimals): casts a token mint under authority, aliased name. Used throughout Escrow (MintA, MintB).
  • fund_ata(&owner, &mint, &authority, amount): creates owner’s associated token account for mint, mints amount into it, and aliases the ATA "<owner>/<mint>". This is the funded-holder setup Escrow uses for both sides of the trade.
  • alias(pubkey, name) / alias_ata(&owner, &mint): register a pubkey (or a derived ATA) under a name directly, for accounts that didn’t come from a cast_* call.
  • register_events_from_idl(idl_json): registers a decoder for every event an Anchor IDL declares, so emit!ed logs decode into typed values and render as 🔔 badges. Vault’s Deposited event uses this (see Vault).
  • register_program_errors(program_id, &[(code, name), ...]): names a program’s custom error codes by hand. This is the tool for programs without an IDL: Stake’s mpl-core-dependent program can’t feed declare_program!, so this is the only way a failing leaf reads FreezePeriodNotElapsed instead of custom program error: 0x1770 (see Stake).
  • tx(signers): starts the fluent build-and-send chain. See Sending below.
  • load(&address) / try_load(&address): deserialize an Anchor account at address. load panics on failure (missing account, wrong discriminator (the 8-byte type tag Anchor writes at the front of an account’s data, so it can tell one account type from another), a deser error); that’s the idiomatic choice in a test, where the failure itself is the test failing. try_load returns a Result instead, for callers that want to handle it themselves. load_unchecked / try_load_unchecked skip that discriminator check, for the rare case where you need the raw bytes regardless of what type tag they carry.

Sending

Every send method asserts something specific, so a glance at the call name alone tells a reader what the test expects:

MethodAssertsReach for it when
send_ok(ix, signers)transaction succeedsthe happy path
send_err(ix, signers)transaction fails, any errorthe outcome alone is the contract (an authorization check, a generic constraint trip) and pinning to a specific error name would over-constrain the test
send_err_named(ix, signers, "Name")transaction fails and the failure resolves to (or its logs contain) "Name"you know exactly which error should fire, e.g. "EscrowExpired", "ConstraintSeeds"

All three live on AnchorContext two ways: as one-shot calls (ctx.send_ok(ix, &[&signer])), and as the fluent chain’s terminators:

#![allow(unused)]
fn main() {
ctx.tx(&[&signer])
   .build(bundle, args)
   .send_ok()
   .print_logs();
}

The Tx chain (ctx.tx(signers).build(bundle, args)) earns its keep once a test builds and sends several instructions: build/build_with share the same terminators, and remaining_accounts appends a dynamic account tail.

For a single one-off send, though, the one-shot ctx.send_ok(ix, signers) skips the chain entirely. Both return the same TransactionResult. See Structured Logs.

Building instructions

Both methods below take a bundle: a struct that groups the accounts one instruction needs, most of them defaulted so you only bind the ones your scenario cares about (the full story is in Bundle defaults & partial binding below). The choice between them is about whether every account should come straight from the bundle, or whether you need to swap exactly one of them out.

  • program().build_ix(bundle, args): derives every account from the bundle, no overrides. This is the path every happy-path call in this book uses.
  • program().build_ix_with(bundle, args, |accounts| ...): same derivation, plus a closure to override exactly one account before it’s sent. This is the negative-path escape hatch: it constructs the instruction an attacker would submit (a valid-but-wrong account swapped in) without making you hand-roll every other account yourself. See the Vault escape hatch (vault_state swapped for Mallory’s own) and the Escrow escape hatch (vault swapped for Mallory’s ATA).

bundles_from_idl!(program_name) reads a committed IDL and writes that bundle machinery for you. Two kinds of account never need to be named by a caller at all: a PDA (a Program Derived Address, one derivable from known seeds) gets computed on the spot, and a fixed program address the IDL pins (the token program, a specific CPI target) gets filled in automatically. This reference calls that second behavior injecting the account, since the caller never supplies it and never sees it as a bundle field.

Concretely, the macro generates one <Ix>Bundle struct per instruction (a plain-pubkey field only for the accounts a caller must actually supply), a <account>_pda(...) helper per derivable PDA, and a module-level injected_programs() listing every address it injects this way. The bundle’s From impl is what performs both the derivation and the injection, so build_ix only ever needs the accounts that vary per call. See Setup and the Quickstart.

When the program’s IDL can’t feed declare_program! / bundles_from_idl! at all (the anchor-version wall: Stake’s dependency on mpl-core pins it to anchor 0.31, while the host workspace is anchor 1.0), drop to hand-built solana_instruction::Instructions instead: compute the 8-byte discriminator as sha256("global:<name>")[..8], list account metas in the program’s #[derive(Accounts)] order, and derive PDAs by hand with find_program_address. That is exactly what bundles_from_idl! generates for you when an IDL is available.

Bundle defaults & partial binding

A bundle is a collection of the accounts an instruction needs, with sane defaults. Bind the accounts your scenario is actually about; let the rest default. Every generated bundle implements Default, so struct-update syntax binds only what matters:

#![allow(unused)]
fn main() {
// bind the roots this scenario touches; Default covers the rest.
let bundle = MakeBundle { maker, mint_a, mint_b, escrow, ..Default::default() };
}

What Default fills:

  • Derivable PDAs and fixed program addresses never appear as bundle fields at all; the From impl derives and injects them from the IDL.
  • Caller-supplied accounts you leave unbound fill with Pubkey::new_unique().
  • Token-program fields fill with their well-known programs (token_program with classic SPL, associated_token_program with the ATA program). These are overridable defaults, not injections: a scenario on a different token program overrides the field directly, and the generated rustdoc on each such field says so.
  • Optional accounts fill with None.

The unbound placeholder is Pubkey::new_unique(), not Pubkey::default() (the zero address), and that’s deliberate. The zero address gets rejected by nearly every program before anything interesting runs, while a fresh unique address behaves as an account that simply does not exist.

That is exactly the probe a partially-bound negative test wants: leave a field unbound to assert the program rejects a missing account. This is the complement to build_ix_with, which swaps in a valid-but-wrong account rather than an absent one.

Reading output

  • print_logs(): prints the run’s CPI tree to stdout, returns self so it chains at the end of a call. Reach for this when a human (you, mid-debug) is the reader.
  • tree_string(): same rendering, returned as a String instead of printed. Reach for this when the reader is code instead: asserting against the tree, or capturing it as a fixture. Every captured .txt fixture in this book is a tree_string() capture, verbatim. See Structured Logs for the full anatomy.
  • EventHelpers::parse_event::<T>() / parse_events::<T>(): deserialize a specific Anchor event type out of the logs (the first one, or all of them). Reach for these when the test needs the value itself, the way Vault’s deposit test checks ev.amount (see Vault).
  • assert_event_emitted::<T>() / assert_event_count::<T>(n): assert an event of type T was (or wasn’t, or was emitted exactly n times) without pulling the value out yourself. Reach for these instead of parse_event when firing at all (or firing the right number of times) is the whole assertion, and the payload doesn’t matter.

Clock & state helpers (TestHelpers)

Methods on ctx.svm (a LiteSVM), for time-locked program logic and token state:

  • advance_days(n) / advance_seconds(n): move the Clock sysvar’s unix_timestamp forward. This is the tool for time-locked constraints: Escrow’s 90-day expiry and Stake’s 7-day freeze period both drive their negative and positive paths by advancing past (or short of) the deadline. See Escrow’s time-lock and Stake’s freeze lock.
  • warp_to_timestamp(unix_timestamp): set the Clock’s unix_timestamp to an absolute value, when a test needs a deterministic wall-clock point rather than a relative jump.
  • token_balance(&ata): read an SPL Token account’s amount; None if no account exists there (so a post-close assertion reads is_none() rather than panicking).
  • create_token_mint(&authority, decimals): create and initialize a token mint with a freshly generated keypair. cast_mint (on AnchorContext) is the aliased, deterministic-keypair wrapper most scenarios reach for instead; use this directly when you don’t need either.