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, theAnchorContextworld.litesvm-utils: the framework-agnostic helpers underneath it (actors, aliases, token setup, clock warping, the log renderer).anchor-litesvmre-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 aDepositedevent): 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
SystemandTokenare aliased by default too, but only non-default entries make the legend; a run touching onlySystemprints 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 underauthority, aliasedname.fund_ata(&owner, &mint, &authority, amount)createsowner’s associated token account formint, mintsamountinto it fromauthority, 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 (aliasvault) 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=Aliceis the top-level frame:[1]is the invoke depth,✓the outcome,6874cuthe compute units this frame consumed, andsigner=Alicenames 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 transferdepositmakes into the vault PDA viasystem_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 forDeposited, the raw base64 payload would print instead.userreadsAlicefor 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,Aliceandvault, next to their real addresses. See Aliases & Actors for whySystemdoesn’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:
- Anchor generates the IDL from the program’s interface: the
#[derive(Accounts)]structs and the#[program]function signatures, not the handler bodies. declare_program!generates the typed client from that IDL.- 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 undercrates/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 intobook_voting_{poll,candidate,vote}.rs, one per instruction. To build your own from scratch, start withanchor init votingand 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
votehas 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 <= startis closed, not open. At exactlystartthe 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
initcollide. 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.rsdrives 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.namegets 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 formpl-core. The first entry becomes the context’s primaryprogram_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 ascast_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 underauthority, aliasedname. Used throughout Escrow (MintA,MintB).fund_ata(&owner, &mint, &authority, amount): createsowner’s associated token account formint, mintsamountinto 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 acast_*call.register_events_from_idl(idl_json): registers a decoder for every event an Anchor IDL declares, soemit!ed logs decode into typed values and render as🔔badges. Vault’sDepositedevent 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’smpl-core-dependent program can’t feeddeclare_program!, so this is the only way a failing leaf readsFreezePeriodNotElapsedinstead ofcustom 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 ataddress.loadpanics 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_loadreturns aResultinstead, for callers that want to handle it themselves.load_unchecked/try_load_uncheckedskip 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:
| Method | Asserts | Reach for it when |
|---|---|---|
send_ok(ix, signers) | transaction succeeds | the happy path |
send_err(ix, signers) | transaction fails, any error | the 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_stateswapped for Mallory’s own) and the Escrow escape hatch (vaultswapped 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
Fromimpl 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_programwith classic SPL,associated_token_programwith 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, returnsselfso 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 aStringinstead of printed. Reach for this when the reader is code instead: asserting against the tree, or capturing it as a fixture. Every captured.txtfixture in this book is atree_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 checksev.amount(see Vault).assert_event_emitted::<T>()/assert_event_count::<T>(n): assert an event of typeTwas (or wasn’t, or was emitted exactlyntimes) without pulling the value out yourself. Reach for these instead ofparse_eventwhen 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’sunix_timestampforward. 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’sunix_timestampto 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;Noneif no account exists there (so a post-close assertion readsis_none()rather than panicking).create_token_mint(&authority, decimals): create and initialize a token mint with a freshly generated keypair.cast_mint(onAnchorContext) is the aliased, deterministic-keypair wrapper most scenarios reach for instead; use this directly when you don’t need either.