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

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.