Build a SOL Vault on Solana

Build a personal SOL vault on Solana using PDAs and PDA-signed CPIs.

Introduction

30 Second Summary

Think of a personal safe deposit box at a bank. You put money in, take money out, and only you hold the key. On Solana, programs can create these same personal vaults for every user, secured by cryptographic keys instead of physical ones.

In this project, you will build a SOL Vault program using the Anchor framework. Each user gets their own on-chain vault where they can deposit SOL, withdraw it, and close the vault to reclaim all funds.

What You'll Build

A Solana program with four instructions (initialize, deposit, withdraw, close) that uses Program Derived Addresses to give every user a personal, program-controlled vault for holding SOL.

By the end of this project, you'll have:

  • A four-instruction Anchor program that initializes a vault, deposits SOL, withdraws SOL with a PDA-signed CPI, and closes the vault to recover rent.
  • Hands-on experience with the two-account vault pattern used in production DeFi programs for escrows, staking, and crowdfunding.
  • A TypeScript test suite that exercises every instruction end-to-end and verifies balances change correctly.
  • Secret Mission: add a deposit cap and owner-only withdrawal with custom error handling to harden your vault.

Are there any prerequisites?

This project assumes you have built at least one Solana program with Anchor before (for example, a Tip Jar). You should be familiar with #[account], #[derive(Accounts)], basic CPI transfers, and deploying to devnet or localnet.

You also need Rust, the Solana CLI, and Anchor CLI v1.0.0 installed on your machine.

Scaffold and Configure the Project

Every Anchor project starts from a scaffold that gives you the file structure, build tooling, and test harness out of the box.

In this step, you'll create the project, explore what Anchor generated, and strip out the default code so it's ready for your vault instructions.

In this step, get ready to:
  • Scaffold a new Anchor project.
  • Explore the modular file structure Anchor generates.
  • Clean up the default code so the project is ready for vault instructions.
Create a new Anchor project
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your vault project by running:
anchor init sol-vault

What does this command do?

anchor init creates a full Anchor workspace with a Rust program, TypeScript test file, build config, and deployment keys.

Anchor v1.0.0 generates a modular file structure by default, splitting instructions and state into separate folders.

You should see Anchor create the sol-vault folder on your Desktop with a series of generated files.

Seeing 'anchor: command not found'?

Make sure the Anchor CLI is installed by running anchor --version. You should see anchor-cli 1.0.0.

If the Solana CLI or Rust toolchain is missing, Anchor will also fail to scaffold. Confirm both are installed before retrying.

Help me troubleshoot my Anchor init

  • Move into the project folder by running:
cd sol-vault
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the sol-vault folder on your Desktop, then click Open.

Use any code editor

VS Code is shown here, but any code editor works. If you prefer Cursor, Zed, or another editor, open the sol-vault folder there instead.

In the file explorer sidebar, expand the programs/sol-vault/src/ folder. You should see this modular structure:

  • lib.rs is the main entry point. It declares your modules and program instructions.
  • instructions/ holds one file per instruction handler. Right now it has a default initialize.rs.
  • state/ is where your account structs will live.
  • error.rs is for custom error types.
  • constants.rs is for shared constants.

Why the modular structure?

Anchor v1.0.0 splits programs into small, focused files instead of cramming everything into one lib.rs. Each instruction gets its own file, and account structs live in state/.

This mirrors how production Solana programs are organized. As your program grows, you add files instead of scrolling through one giant file.

Clean up the default scaffolded code

The scaffold includes a default initialize instruction you don't need. You'll replace it with your own vault instructions in the next steps.

  • Open programs/sol-vault/src/lib.rs in your editor.
  • Select all the existing code and replace it with the following:
pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

// Keep the program ID that anchor init generated for you
declare_id!("your-program-id-here");

#[program]
pub mod sol_vault {
    use super::*;
    // Vault instructions will be added in later steps
}

Keep your generated program ID

Your declare_id! already contains a real program ID that anchor init generated from a keypair in target/deploy/. Replace the placeholder "your-program-id-here" shown above with the value already in your file.

What changed?

You removed the default initialize function from inside the #[program] block and the pub use instructions::* re-export. The module declarations (pub mod constants, pub mod instructions, etc.) stay because those modules still exist on disk.

  • Save lib.rs.

Now clear the instruction modules that reference the old default handler.

  • Open programs/sol-vault/src/instructions/mod.rs.
  • Select all the existing code and delete it so the file is completely empty.
  • Save instructions/mod.rs.
  • Delete the file programs/sol-vault/src/instructions/initialize.rs. Right-click it in the file explorer sidebar and select Delete.

Why delete initialize.rs?

The scaffold's initialize.rs contains a default Initialize accounts struct and handler you won't use. You'll create your own initialize_vault.rs with vault-specific logic in a later step.

✔️ Awesome, I've got everything!

Make sure you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is lib.rs after cleanup. Remember that your declare_id! value will be different from the placeholder shown.

pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

declare_id!("your-program-id-here");

#[program]
pub mod sol_vault {
    use super::*;
}

instructions/mod.rs should be completely empty.

Verify the project builds

Even though the program has no instructions yet, it should still compile. A clean build now means your module wiring is correct.

  • Build the project by running:
anchor build

What does anchor build do?

anchor build compiles your Rust program into a .so file (a Solana deployable binary) in target/deploy/.

It also generates an IDL (Interface Definition Language) file in target/idl/ that your TypeScript tests use to interact with the program.

  • Confirm you see programs/sol-vault/src/lib.rs in the compile output.
  • Verify the compiled binary exists by running:
ls target/deploy/

You should see sol_vault.so and sol_vault-keypair.json listed in the output.

Build failed?

If you see an error about unresolved modules, check that state/mod.rs exists in programs/sol-vault/src/state/. The scaffold creates it, but it may have been accidentally deleted.

If the error mentions instructions/initialize.rs, make sure instructions/mod.rs is empty. Any leftover reference to the deleted file causes a compile error.

Help me fix my anchor build error

Your project compiles cleanly with an empty program. Next up, you'll define the vault's state and error types so the program knows what data to store on-chain.

Define the Vault State and Error Types

Your project is scaffolded, cleaned up, and building successfully. Now it's time to define the data structures that your vault program needs to work with.

Every Solana program that manages on-chain state needs account structs to describe what data it stores. In this step, you'll create a VaultState struct and a custom error enum.

In this step, get ready to:
  • Create the VaultState account struct to store vault metadata.
  • Define a custom error type for edge cases.
  • Wire up module exports and verify the build.
Create the VaultState account struct

Your vault program uses two Program Derived Addresses (PDAs) per user: one for metadata and one that holds the actual SOL. Each PDA has a bump seed that Solana uses to derive a valid address off the Ed25519 curve.

Recalculating bumps costs compute units every time. Storing them in an account struct means your program can look them up directly, saving around 1,500 compute units per instruction.

  • Create a new file called vault_state.rs inside the programs/sol-vault/src/state/ folder.
  • Add the following code to define the VaultState struct:
use anchor_lang::prelude::*;

// Stores metadata for each user's vault
#[account]
#[derive(InitSpace)]
pub struct VaultState {
    // Bump seed for the vault PDA (holds SOL)
    pub vault_bump: u8,
    // Bump seed for this state PDA (holds metadata)
    pub state_bump: u8,
}

What does this code do?

#[account] tells Anchor that this struct represents an on-chain account. Anchor adds an 8-byte discriminator automatically so it can identify the account type at runtime.

#[derive(InitSpace)] lets Anchor calculate how many bytes the account needs. For two u8 fields, that's 8 (discriminator) + 1 + 1 = 10 bytes total.

vault_bump and state_bump cache the bump seeds for the two PDAs. Your future instructions will read these values instead of recalculating them.

  • Save vault_state.rs.
  • Confirm you can see vault_state.rs listed inside the state/ folder in your editor's sidebar.

Don't see the file?

Make sure you created the file inside programs/sol-vault/src/state/, not in the project root or another directory.

Check the file name is exactly vault_state.rs with an underscore, not a hyphen.

Help me find where my vault_state.rs file should go in the Anchor project structure.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct VaultState {
    pub vault_bump: u8,
    pub state_bump: u8,
}
Define custom errors and wire up the state module

Custom errors give your program meaningful failure messages instead of generic Anchor errors. You'll define a VaultError enum for edge cases, then wire up the state module so the rest of your program can access VaultState.

  • Open programs/sol-vault/src/error.rs.
  • Find the existing use anchor_lang::prelude::*; import, then add the following error enum below it:
#[error_code]
pub enum VaultError {
    // Returned when someone tries to interact with a closed vault
    #[msg("Vault has already been closed")]
    VaultAlreadyClosed,
}

What does this code do?

#[error_code] is an Anchor macro that turns your enum into proper Solana program errors. Each variant gets a unique error code and a human-readable message.

VaultAlreadyClosed will be returned if someone tries to interact with a vault that has already been closed. You'll use this in the close instruction later.

  • Save error.rs.

You won't see a change yet. The error enum is referenced later when you build the vault instructions.

✔️ Awesome, I've got everything!

Great. Double check you've saved error.rs before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;

#[error_code]
pub enum VaultError {
    #[msg("Vault has already been closed")]
    VaultAlreadyClosed,
}

Now wire up the state module so the rest of your program can find VaultState.

  • Open programs/sol-vault/src/state/mod.rs.
  • Replace the empty file contents with the following module declarations:
// Declare the vault_state module and re-export its contents
pub mod vault_state;

pub use vault_state::*;

What does this wiring do?

pub mod vault_state; tells Rust that the vault_state.rs file is part of the state module. pub use vault_state::*; re-exports everything from that file so other modules can import VaultState directly with use crate::state::VaultState.

  • Save mod.rs.

✔️ Awesome, I've got everything!

Great. Double check you've saved mod.rs before moving on.

ⓧ I'd like to double check the full code

pub mod vault_state;

pub use vault_state::*;
Verify the build compiles

All three files are saved. Time to make sure everything compiles together.

  • Build the project by running this command in your terminal:
anchor build
  • Confirm you see programs/sol-vault/src/lib.rs compile successfully in the terminal output.
  • Verify the .so file is present in target/deploy/.

Seeing a compilation error?

If you see unresolved import crate::state or similar, check that state/mod.rs has the pub mod vault_state; declaration. Without it, Rust cannot find the vault_state.rs file.

If you see file not found for module, make sure vault_state.rs is inside the state/ folder, not in src/ directly.

Help me debug my anchor build error for the sol-vault project.

Your data layer is locked in. Next up, you'll write the initialize and deposit instructions that create the vault and move SOL into it.

Implement Initialize and Deposit Instructions

Your VaultState struct and error types are defined and compiling cleanly. Now it is time to write the instructions that actually create vaults and move SOL into them.

Every vault starts with two Program Derived Addresses (PDAs): one that stores metadata and one that holds actual SOL. In this step, you will build the initialize_vault instruction to create both PDAs, and the deposit instruction to transfer SOL into the vault.

In this step, get ready to:
  • Create the initialize vault instruction with two PDA accounts.
  • Create the deposit instruction using a Cross-Program Invocation to transfer SOL.
  • Wire both instructions into the program entry point and verify the build.
Create the initialize vault instruction

The initialize instruction creates a personal vault for a user. It sets up two on-chain accounts derived from the user's public key: a vault_state account that stores metadata (the bump seeds) and a vault account that holds SOL.

Why two accounts instead of one?

The vault_state account stores structured data (bump seeds) using Anchor's serialization. The vault account is a plain SystemAccount with space = 0 because it only holds lamports, no serialized data.

This two-account vault pattern is the canonical architecture behind DeFi vaults, escrows, and staking programs on Solana.

  • Create a new file called initialize_vault.rs inside programs/sol-vault/src/instructions/.
  • Add the imports and accounts struct by pasting this code into the file:
use anchor_lang::prelude::*;
use crate::state::VaultState;

#[derive(Accounts)]
pub struct InitializeVault<'info> {
    // The user who creates and pays for the vault
    #[account(mut)]
    pub user: Signer<'info>,

    // Vault metadata account, derived from user's public key
    #[account(
        init,
        payer = user,
        space = 8 + VaultState::INIT_SPACE,
        seeds = [b"state", user.key().as_ref()],
        bump
    )]
    pub vault_state: Account<'info, VaultState>,

    // The actual vault that holds SOL, derived from vault_state's address
    #[account(
        init,
        payer = user,
        space = 0,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

What does this code do?

  • user is the wallet that signs the transaction and pays rent for both new accounts.
  • vault_state is a PDA derived from the seed "state" plus the user's public key. The init constraint tells Anchor to create this account on-chain. space = 8 + VaultState::INIT_SPACE allocates 8 bytes for Anchor's discriminator plus the auto-calculated size of your struct fields.
  • vault is a PDA derived from "vault" plus the vault_state address. It has space = 0 because it only stores lamports, not data.
  • system_program is required because Anchor uses the System Program to create accounts and transfer lamports.

The accounts struct defines what accounts the instruction needs. Now you need a handler function that runs the actual logic.

  • Add the handler function below the InitializeVault struct in the same file:
pub fn handler(ctx: Context<InitializeVault>) -> Result<()> {
    // Store the bump seeds so future instructions don't recalculate them
    ctx.accounts.vault_state.vault_bump = ctx.bumps.vault;
    ctx.accounts.vault_state.state_bump = ctx.bumps.vault_state;

    msg!("Vault initialized for user: {}", ctx.accounts.user.key());
    Ok(())
}

Why store bump seeds?

Every PDA has a bump value that Anchor calculates when the account is first created. Recalculating a bump later with find_program_address costs roughly 1,500 compute units each time.

By saving both bumps into vault_state now, every future instruction (deposit, withdraw, close) can reference the stored values with bump = vault_state.vault_bump instead of recalculating. This is the recommended best practice for Anchor programs.

  • Save initialize_vault.rs.

You will not see a visible change yet. The next substep creates the deposit instruction, and then you will wire everything into the program to build.

✔️ Awesome, I've got everything!

Make sure you have saved initialize_vault.rs before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;
use crate::state::VaultState;

#[derive(Accounts)]
pub struct InitializeVault<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        init,
        payer = user,
        space = 8 + VaultState::INIT_SPACE,
        seeds = [b"state", user.key().as_ref()],
        bump
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        init,
        payer = user,
        space = 0,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<InitializeVault>) -> Result<()> {
    ctx.accounts.vault_state.vault_bump = ctx.bumps.vault;
    ctx.accounts.vault_state.state_bump = ctx.bumps.vault_state;

    msg!("Vault initialized for user: {}", ctx.accounts.user.key());
    Ok(())
}
Create the deposit instruction

The deposit instruction moves SOL from the user's wallet into the vault. To do this, your program makes a Cross-Program Invocation (CPI) to the System Program's transfer function.

Since the user is the one sending SOL, they sign the transaction themselves. The program just orchestrates the CPI. This is simpler than the withdraw direction, which you will tackle in the next step.

  • Create a new file called deposit.rs inside programs/sol-vault/src/instructions/.
  • Add the imports and accounts struct by pasting this code into the file:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    // Re-derive vault_state using the stored bump instead of recalculating
    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
    )]
    pub vault_state: Account<'info, VaultState>,

    // Re-derive vault using the stored bump from vault_state
    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

What changed from the initialize struct?

  • No init constraint. The accounts already exist on-chain, so Anchor just validates that the PDA seeds match.
  • bump = vault_state.state_bump and bump = vault_state.vault_bump reference the stored values you saved during initialization. This is where the compute savings kick in.
  • Both vault_state and vault are marked mut because the vault's lamport balance will change.

Now add the handler function that performs the actual SOL transfer.

  • Add the handler function below the Deposit struct in the same file:
pub fn handler(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // Set up the accounts for the System Program transfer CPI
    let cpi_accounts = Transfer {
        from: ctx.accounts.user.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
    };

    // Create the CPI context with the System Program
    let cpi_context = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        cpi_accounts,
    );

    // Execute the transfer from user to vault
    transfer(cpi_context, amount)?;

    msg!("Deposited {} lamports into vault", amount);
    Ok(())
}

What does this code do?

  • Transfer is a struct that specifies the from and to accounts for a SOL transfer. Here, SOL flows from the user to the vault PDA.
  • CpiContext::new creates the CPI context. Because the user (the sender) already signed the transaction, a regular CpiContext::new is all you need.
  • transfer(cpi_context, amount)? executes the CPI. The ? propagates any error (like insufficient balance) back to the caller.
  • Save deposit.rs.

You will not see a change yet. The next substep wires these instructions into the program so you can build.

✔️ Awesome, I've got everything!

Make sure you have saved deposit.rs before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.user.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
    };

    let cpi_context = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        cpi_accounts,
    );

    transfer(cpi_context, amount)?;

    msg!("Deposited {} lamports into vault", amount);
    Ok(())
}
Wire both instructions into the program

Your instruction files exist but the program does not know about them yet. You need to register them in two places: the instructions module file and the main program entry point.

  • Open programs/sol-vault/src/instructions/mod.rs.
  • Replace the empty file with the following module declarations:
// Declare each instruction as a sub-module
pub mod initialize_vault;
pub mod deposit;

// Re-export so other files can use the structs directly
pub use initialize_vault::*;
pub use deposit::*;

What does this code do?

pub mod tells Rust that each file is a module in the instructions folder. pub use re-exports everything from those modules so that lib.rs can reference InitializeVault and Deposit without fully qualifying the path.

  • Save instructions/mod.rs.

Now update the main program entry point to expose these instructions to the Solana runtime.

  • Open programs/sol-vault/src/lib.rs.
  • Replace the entire file contents with the following code:
pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

// Re-export instruction account structs for the program macro
pub use instructions::*;

declare_id!("your-program-id-here");

#[program]
pub mod sol_vault {
    use super::*;

    // Each function maps to an on-chain instruction
    pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
        instructions::initialize_vault::handler(ctx)
    }

    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        instructions::deposit::handler(ctx, amount)
    }
}

What does this code do?

  • pub use instructions::* brings the account structs (InitializeVault, Deposit) into scope so the #[program] macro can find them.
  • Each function inside sol_vault becomes an instruction that clients can call. Anchor matches the function name to the instruction name automatically.
  • The functions delegate to instructions::initialize_vault::handler and instructions::deposit::handler to keep the entry point thin.
  • Save lib.rs.
  • Build the project to verify everything compiles by running:
anchor build
  • You should see programs/sol-vault/src/lib.rs compile successfully with no errors. The .so file appears in target/deploy/.

Seeing a compile error?

  • If you see unresolved import, check that instructions/mod.rs has both pub mod initialize_vault; and pub mod deposit; lines.
  • If you see cannot find type for VaultState, make sure both initialize_vault.rs and deposit.rs include use crate::state::VaultState; at the top.
  • If you see file not found, verify that your new files are named exactly initialize_vault.rs and deposit.rs (underscores, not hyphens) inside the instructions/ folder.

Why won't my Anchor program compile after adding initialize and deposit instructions?

✔️ Awesome, I've got everything!

Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;
use crate::state::VaultState;

#[derive(Accounts)]
pub struct InitializeVault<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        init,
        payer = user,
        space = 8 + VaultState::INIT_SPACE,
        seeds = [b"state", user.key().as_ref()],
        bump
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        init,
        payer = user,
        space = 0,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<InitializeVault>) -> Result<()> {
    ctx.accounts.vault_state.vault_bump = ctx.bumps.vault;
    ctx.accounts.vault_state.state_bump = ctx.bumps.vault_state;

    msg!("Vault initialized for user: {}", ctx.accounts.user.key());
    Ok(())
}
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.user.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
    };

    let cpi_context = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        cpi_accounts,
    );

    transfer(cpi_context, amount)?;

    msg!("Deposited {} lamports into vault", amount);
    Ok(())
}
pub mod initialize_vault;
pub mod deposit;

pub use initialize_vault::*;
pub use deposit::*;
pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

pub use instructions::*;

declare_id!("your-program-id-here");

#[program]
pub mod sol_vault {
    use super::*;

    pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
        instructions::initialize_vault::handler(ctx)
    }

    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        instructions::deposit::handler(ctx, amount)
    }
}

Your vault can now be created and funded. Next up, you will implement the withdraw and close instructions. Withdraw is where the real magic happens: the program has to sign on behalf of the vault PDA to release funds.

Implement Withdraw and Close Vault Instructions

Your vault can accept deposits, but SOL that goes in has no way to come back out. The deposit instruction works because the user signs the transaction themselves. Withdrawing is a different challenge.

The SOL now lives inside a PDA that only the program controls. To move funds out, the program itself must sign the transfer on behalf of the PDA. This pattern is called a PDA-signed CPI, and it is the mechanism behind every vault, escrow, and staking program on Solana.

In this step, get ready to:
  • Implement a withdraw instruction using a PDA-signed CPI.
  • Implement a close vault instruction that returns all remaining SOL and rent.
  • Wire up all four instructions and verify the program builds.
Create the withdraw instruction

The withdraw instruction mirrors deposit, but the flow of SOL is reversed. Instead of the user transferring SOL to the vault, the vault transfers SOL back to the user.

  • Create a new file called withdraw.rs inside programs/sol-vault/src/instructions/.
  • Add the imports and the Withdraw accounts struct by copying this code:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct Withdraw<'info> {
    // The user requesting the withdrawal
    #[account(mut)]
    pub user: Signer<'info>,

    // Vault metadata account, verified by PDA seeds
    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
    )]
    pub vault_state: Account<'info, VaultState>,

    // The PDA that holds the actual SOL
    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

What does this code do?

The Withdraw struct looks nearly identical to Deposit. The same three accounts appear with the same PDA seeds and stored bumps.

Using the stored bumps (bump = vault_state.state_bump and bump = vault_state.vault_bump) instead of recalculating them saves about 1,500 compute units per instruction. That is why you cached them during initialization.

You will not see a change yet. The handler function comes next.

The handler is where the magic happens. In your deposit handler, you used CpiContext::new because the user (a real signer) authorized the transfer. For withdrawal, the SOL lives inside a PDA. No human wallet can sign for it. The program must prove it owns the PDA by providing the original seeds and bump that derived the address.

  • Below the Withdraw struct in withdraw.rs, add the handler function:
pub fn handler(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    // Set up the transfer: vault (PDA) -> user
    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.user.to_account_info(),
    };

    // Reconstruct the vault PDA's seeds + bump so the program can sign
    let seeds = &[
        b"vault",
        ctx.accounts.vault_state.to_account_info().key.as_ref(),
        &[ctx.accounts.vault_state.vault_bump],
    ];
    let signer_seeds = &[&seeds[..]];

    // Create a CPI context where the program signs on behalf of the PDA
    let cpi_context = CpiContext::new_with_signer(
        ctx.accounts.system_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );

    transfer(cpi_context, amount)?;

    msg!("Withdrew {} lamports from vault", amount);
    Ok(())
}

What does this handler do?

The Transfer struct flips the direction compared to deposit. from is now the vault PDA, and to is the user.

The seeds array reconstructs the exact inputs that derived the vault PDA: the string "vault", the vault_state's public key, and the stored bump. The Solana runtime uses these to verify the program actually owns this PDA.

CpiContext::new_with_signer is the key difference from deposit. It passes the signer seeds to the System Program, allowing the transfer to go through even though no human wallet signed for the vault.

  • Save withdraw.rs.

You will not see a build change yet because the file is not wired into the program. That comes in the final substep.

Seeing red squiggles in your editor?

Your editor's Rust analyzer may show errors because withdraw.rs is not yet declared as a module in mod.rs. This is expected. The errors disappear once you wire the module in the final substep.

If you see errors about VaultState not being found, double check that the import line reads use crate::state::VaultState; exactly.

Help me debug red squiggles in withdraw.rs

Why does the program need to sign for the PDA?

On Solana, every account transfer requires authorization from the sender. When a user sends SOL, their wallet signs the transaction. But a PDA has no private key. No wallet can sign for it.

PDA-signed CPIs solve this. The program provides the seeds and bump that originally derived the PDA address. The Solana runtime verifies those seeds match, and if they do, the transfer is authorized. This is the core pattern behind every vault, escrow, and staking program on Solana.

✔️ Awesome, I've got everything!

Great. Double check you have saved withdraw.rs before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.vault.to_account_info(),
        to: ctx.accounts.user.to_account_info(),
    };

    let seeds = &[
        b"vault",
        ctx.accounts.vault_state.to_account_info().key.as_ref(),
        &[ctx.accounts.vault_state.vault_bump],
    ];
    let signer_seeds = &[&seeds[..]];

    let cpi_context = CpiContext::new_with_signer(
        ctx.accounts.system_program.to_account_info(),
        cpi_accounts,
        signer_seeds,
    );

    transfer(cpi_context, amount)?;

    msg!("Withdrew {} lamports from vault", amount);
    Ok(())
}
Create the close vault instruction

A vault that can only deposit and withdraw is useful, but users also need a way to shut it down entirely. Closing the vault involves two things: draining any remaining SOL from the vault PDA back to the user, and closing the vault_state account to recover its rent.

  • Create a new file called close_vault.rs inside programs/sol-vault/src/instructions/.
  • Add the imports and the CloseVault accounts struct:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct CloseVault<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    // close = user sends rent-exempt lamports back to user
    // and zeroes out the account data
    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
        close = user,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

What does close = user do?

The close = user constraint on vault_state tells Anchor to automatically close this account at the end of the instruction. Anchor zeroes out the account data and sends the rent-exempt lamports back to the user account.

Notice that only vault_state has this constraint. The vault PDA needs to be drained manually via a CPI transfer first, because it is a separate account that holds actual SOL.

You will not see a change yet. The handler comes next.

The close vault handler needs to do two things in order. First, drain any remaining SOL from the vault PDA back to the user using a PDA-signed CPI (the same pattern you just wrote in withdraw). Then, let Anchor's close constraint handle the vault_state cleanup automatically.

  • Below the CloseVault struct in close_vault.rs, add the handler function:
pub fn handler(ctx: Context<CloseVault>) -> Result<()> {
    // Check if the vault still holds any SOL
    let vault_lamports = ctx.accounts.vault.lamports();

    if vault_lamports > 0 {
        // Drain remaining SOL from vault PDA back to user
        let cpi_accounts = Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.user.to_account_info(),
        };

        // Same signer seeds pattern as withdraw
        let seeds = &[
            b"vault",
            ctx.accounts.vault_state.to_account_info().key.as_ref(),
            &[ctx.accounts.vault_state.vault_bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.system_program.to_account_info(),
            cpi_accounts,
            signer_seeds,
        );

        transfer(cpi_context, vault_lamports)?;
    }

    // Anchor's close = user constraint handles vault_state cleanup
    msg!("Vault closed. All funds returned to user.");
    Ok(())
}

What does this handler do?

The handler first checks the vault's lamport balance. If there is any SOL left, it performs a PDA-signed CPI transfer to send it all back to the user. This is the same CpiContext::new_with_signer pattern you used in the withdraw handler.

Notice the handler does not explicitly close the vault_state account. Anchor handles that automatically because of the close = user constraint in the accounts struct. By the time the instruction finishes, both the vault SOL and the vault_state rent have been returned to the user.

  • Save close_vault.rs.

You will not see a build change yet. The next substep wires both new files into the program.

Confused by the two-step close pattern?

The vault and vault_state are separate accounts. Anchor's close constraint only works on the account it is attached to (vault_state). The vault PDA must be drained manually with a CPI transfer.

Help me understand why close_vault drains SOL separately from the close constraint

✔️ Awesome, I've got everything!

Great. Double check you have saved close_vault.rs before moving on.

ⓧ I'd like to double check the full code

use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::state::VaultState;

#[derive(Accounts)]
pub struct CloseVault<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"state", user.key().as_ref()],
        bump = vault_state.state_bump,
        close = user,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(
        mut,
        seeds = [b"vault", vault_state.key().as_ref()],
        bump = vault_state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<CloseVault>) -> Result<()> {
    let vault_lamports = ctx.accounts.vault.lamports();

    if vault_lamports > 0 {
        let cpi_accounts = Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.user.to_account_info(),
        };

        let seeds = &[
            b"vault",
            ctx.accounts.vault_state.to_account_info().key.as_ref(),
            &[ctx.accounts.vault_state.vault_bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.system_program.to_account_info(),
            cpi_accounts,
            signer_seeds,
        );

        transfer(cpi_context, vault_lamports)?;
    }

    msg!("Vault closed. All funds returned to user.");
    Ok(())
}
Wire up the new instructions and build

Both instruction files exist, but Rust does not know about them until you declare them as modules. You need to update two files: instructions/mod.rs and lib.rs.

  • Open programs/sol-vault/src/instructions/mod.rs.
  • Find this code:
pub mod initialize_vault;
pub mod deposit;

pub use initialize_vault::*;
pub use deposit::*;
  • Replace it with the following to register all four instruction modules:
pub mod initialize_vault;
pub mod deposit;
pub mod withdraw;
pub mod close_vault;

pub use initialize_vault::*;
pub use deposit::*;
pub use withdraw::*;
pub use close_vault::*;
  • Save mod.rs.
  • Open programs/sol-vault/src/lib.rs.
  • Find the deposit function inside the #[program] module:
    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        instructions::deposit::handler(ctx, amount)
    }
  • Add the withdraw and close_vault functions directly below the deposit function:
    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        instructions::withdraw::handler(ctx, amount)
    }

    pub fn close_vault(ctx: Context<CloseVault>) -> Result<()> {
        instructions::close_vault::handler(ctx)
    }

What does this code do?

Each function in the #[program] module is a public instruction that clients can call. The function signature defines which accounts struct to use and what parameters the instruction accepts.

Notice that close_vault takes no amount parameter. It always drains the entire vault balance.

  • Save lib.rs.

Now it is time to verify the complete program compiles.

  • Build the program by running:
anchor build
  • You should see programs/sol-vault/src/lib.rs compile successfully with no errors.
  • Confirm the .so file exists in target/deploy/ by running:
ls target/deploy/
  • You should see sol_vault.so in the output.

Build failing?

If you see a cannot find module error, check that instructions/mod.rs has all four pub mod and pub use lines. The module names must exactly match the file names (withdraw and close_vault).

If you see a type mismatch on Context<Withdraw> or Context<CloseVault> in lib.rs, make sure the function signatures match exactly. withdraw takes amount: u64 while close_vault takes no extra parameter.

Help me fix my anchor build errors for the vault program

✔️ Awesome, I've got everything!

Great. Double check you have saved both mod.rs and lib.rs.

ⓧ I'd like to double check the full code

Here is the complete instructions/mod.rs:

pub mod initialize_vault;
pub mod deposit;
pub mod withdraw;
pub mod close_vault;

pub use initialize_vault::*;
pub use deposit::*;
pub use withdraw::*;
pub use close_vault::*;

Here is the complete lib.rs:

pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

pub use instructions::*;

declare_id!("your-program-id-here");

#[program]
pub mod sol_vault {
    use super::*;

    pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
        instructions::initialize_vault::handler(ctx)
    }

    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        instructions::deposit::handler(ctx, amount)
    }

    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        instructions::withdraw::handler(ctx, amount)
    }

    pub fn close_vault(ctx: Context<CloseVault>) -> Result<()> {
        instructions::close_vault::handler(ctx)
    }
}

Your program is complete. All four instructions compile and the vault can handle its full lifecycle: initialize, deposit, withdraw, and close. Next up, you will write TypeScript tests to exercise every instruction and verify balances change correctly.

Write Tests and Run the Full Vault Lifecycle

Your vault program compiles cleanly with all four instructions wired up. The Rust side is done. Now you need to prove it actually works on-chain.

You'll write a TypeScript test suite that exercises the full vault lifecycle: create a vault, deposit SOL, withdraw SOL, and close the vault. Each test builds on the previous one, mirroring how a real user would interact with your program.

In this step, get ready to:
  • Derive PDA addresses in TypeScript and write the initialize test.
  • Write deposit and withdraw tests with balance assertions.
  • Write the close test and run the full suite with anchor test.
Set up the test file and write the initialize test

Anchor tests run on a local validator that anchor test spins up automatically. Your test file needs to derive the same PDA addresses your Rust program uses so it can pass the right accounts to each instruction.

  • Open tests/sol-vault.ts in your editor.
  • Replace the entire file contents with the following code:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SolVault } from "../target/types/sol_vault";
import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { assert } from "chai";

describe("sol-vault", () => {
  // Set up the Anchor provider and program reference
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.solVault as Program<SolVault>;
  const user = provider.wallet as anchor.Wallet;

  // Derive PDA addresses using the same seeds as the Rust program
  const [vaultStatePda] = PublicKey.findProgramAddressSync(
    [Buffer.from("state"), user.publicKey.toBuffer()],
    program.programId
  );

  const [vaultPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("vault"), vaultStatePda.toBuffer()],
    program.programId
  );

  it("Initializes the vault", async () => {
    const tx = await program.methods
      .initializeVault()
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    console.log("Initialize tx:", tx);

    // Fetch the vault state and verify bumps were stored
    const vaultState = await program.account.vaultState.fetch(vaultStatePda);
    assert.isAbove(vaultState.vaultBump, 0, "vault_bump should be set");
    assert.isAbove(vaultState.stateBump, 0, "state_bump should be set");
  });
});

What does this code do?

The imports pull in the Anchor testing framework, your program's auto-generated TypeScript types, and Solana's web3 library for PDA derivation.

PublicKey.findProgramAddressSync derives PDA addresses on the client side using the exact same seeds you defined in Rust: [b"state", user.key()] for the state account and [b"vault", vault_state.key()] for the vault. If these seeds don't match, the program will reject the accounts.

The initialize test calls initializeVault(), then fetches the on-chain vaultState account and asserts that both bumps were stored with non-zero values.

Why does the test only pass user as an account?

Anchor's client can auto-resolve PDA accounts when their seeds are defined in the program. It figures out vaultStatePda, vaultPda, and system_program automatically. You only need to pass accounts the client can't derive on its own, like the user signer.

  • Save tests/sol-vault.ts.

You won't see a change yet. The next chunks add the deposit, withdraw, and close tests before you run the full suite.

Write the deposit and withdraw tests

The deposit test sends SOL from the user's wallet into the vault. The withdraw test pulls SOL back out using the PDA-signed CPI you built earlier.

  • In tests/sol-vault.ts, find the closing }); of the initialize test's it() block.
  • Add the deposit test below it, still inside the describe() block:
  it("Deposits SOL into the vault", async () => {
    // 1 SOL in lamports
    const depositAmount = 1 * LAMPORTS_PER_SOL;

    // Snapshot the vault balance before deposit
    const vaultBalanceBefore = await provider.connection.getBalance(vaultPda);

    await program.methods
      .deposit(new anchor.BN(depositAmount))
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    // Verify vault balance increased by the exact deposit amount
    const vaultBalanceAfter = await provider.connection.getBalance(vaultPda);
    assert.equal(
      vaultBalanceAfter - vaultBalanceBefore,
      depositAmount,
      "Vault balance should increase by deposit amount"
    );
  });

What does this test do?

The test snapshots the vault's lamport balance before depositing, calls deposit with 1 SOL (1,000,000,000 lamports), then checks that the vault balance increased by exactly that amount.

new anchor.BN(depositAmount) wraps the number as a big number. Solana amounts are u64 on-chain, and JavaScript's native numbers can lose precision at that scale.

  • Add the withdraw test directly below the deposit test:
  it("Withdraws SOL from the vault", async () => {
    // 0.5 SOL in lamports
    const withdrawAmount = 0.5 * LAMPORTS_PER_SOL;

    // Snapshot balances before withdrawal
    const vaultBalanceBefore = await provider.connection.getBalance(vaultPda);
    const userBalanceBefore = await provider.connection.getBalance(
      user.publicKey
    );

    await program.methods
      .withdraw(new anchor.BN(withdrawAmount))
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    // Verify vault balance decreased by the withdraw amount
    const vaultBalanceAfter = await provider.connection.getBalance(vaultPda);
    assert.equal(
      vaultBalanceBefore - vaultBalanceAfter,
      withdrawAmount,
      "Vault balance should decrease by withdraw amount"
    );
  });

What does this test do?

This test withdraws 0.5 SOL from the vault. Behind the scenes, your Rust program constructs signer_seeds for the vault PDA and calls CpiContext::new_with_signer to authorize the transfer. The test proves that PDA-signed CPI is working correctly by checking the vault's balance dropped by exactly 0.5 SOL.

  • Save tests/sol-vault.ts.

You won't see results yet. The next chunk adds the close test, then you'll run the full suite.

Seeing red squiggles in your editor?

If your editor shows type errors on SolVault or program.account.vaultState, make sure you've run anchor build at least once. The build generates the TypeScript types at target/types/sol_vault.ts that your test file imports.

Help me fix type errors in my Anchor test file

Write the close test and run the full suite

The close test is the most thorough one. It verifies that the vault drains all remaining SOL back to the user and that the vaultState account is fully deleted from the blockchain.

  • Add the close test below the withdraw test, still inside the describe() block:
  it("Closes the vault and returns all funds", async () => {
    await program.methods
      .closeVault()
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    // Verify vault is completely empty
    const vaultBalance = await provider.connection.getBalance(vaultPda);
    assert.equal(vaultBalance, 0, "Vault should have 0 balance after close");

    // Verify vault state account no longer exists on-chain
    try {
      await program.account.vaultState.fetch(vaultStatePda);
      assert.fail("VaultState account should not exist after close");
    } catch (err) {
      assert.ok(err, "Fetching closed account should throw");
    }
  });

What does this test do?

The test calls closeVault, then checks two things. First, the vault's lamport balance should be 0 (the PDA-signed CPI drained remaining SOL). Second, fetching the vaultState account should throw an error because Anchor's close = user constraint zeroed and deallocated it.

The try/catch pattern is how you assert that an account no longer exists. If the fetch succeeds, assert.fail() forces the test to fail.

  • Save tests/sol-vault.ts.

✔️ Awesome, I've got everything!

Double check you've saved your file before running the tests.

ⓧ I'd like to double check the full code

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SolVault } from "../target/types/sol_vault";
import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { assert } from "chai";

describe("sol-vault", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.solVault as Program<SolVault>;
  const user = provider.wallet as anchor.Wallet;

  const [vaultStatePda] = PublicKey.findProgramAddressSync(
    [Buffer.from("state"), user.publicKey.toBuffer()],
    program.programId
  );

  const [vaultPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("vault"), vaultStatePda.toBuffer()],
    program.programId
  );

  it("Initializes the vault", async () => {
    const tx = await program.methods
      .initializeVault()
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    console.log("Initialize tx:", tx);

    const vaultState = await program.account.vaultState.fetch(vaultStatePda);
    assert.isAbove(vaultState.vaultBump, 0, "vault_bump should be set");
    assert.isAbove(vaultState.stateBump, 0, "state_bump should be set");
  });

  it("Deposits SOL into the vault", async () => {
    const depositAmount = 1 * LAMPORTS_PER_SOL;

    const vaultBalanceBefore = await provider.connection.getBalance(vaultPda);

    await program.methods
      .deposit(new anchor.BN(depositAmount))
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    const vaultBalanceAfter = await provider.connection.getBalance(vaultPda);
    assert.equal(
      vaultBalanceAfter - vaultBalanceBefore,
      depositAmount,
      "Vault balance should increase by deposit amount"
    );
  });

  it("Withdraws SOL from the vault", async () => {
    const withdrawAmount = 0.5 * LAMPORTS_PER_SOL;

    const vaultBalanceBefore = await provider.connection.getBalance(vaultPda);
    const userBalanceBefore = await provider.connection.getBalance(
      user.publicKey
    );

    await program.methods
      .withdraw(new anchor.BN(withdrawAmount))
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    const vaultBalanceAfter = await provider.connection.getBalance(vaultPda);
    assert.equal(
      vaultBalanceBefore - vaultBalanceAfter,
      withdrawAmount,
      "Vault balance should decrease by withdraw amount"
    );
  });

  it("Closes the vault and returns all funds", async () => {
    await program.methods
      .closeVault()
      .accounts({
        user: user.publicKey,
      })
      .rpc();

    const vaultBalance = await provider.connection.getBalance(vaultPda);
    assert.equal(vaultBalance, 0, "Vault should have 0 balance after close");

    try {
      await program.account.vaultState.fetch(vaultStatePda);
      assert.fail("VaultState account should not exist after close");
    } catch (err) {
      assert.ok(err, "Fetching closed account should throw");
    }
  });
});

Time to run the full lifecycle. The anchor test command starts a local validator, deploys your program, runs every test in sequence, and shuts the validator down.

  • Run the full test suite by running this command:
anchor test

This takes a few seconds. Anchor builds your program, spins up a local Solana validator, deploys, and executes all four tests.

You should see output ending with all four tests passing:

sol-vault
  ✔ Initializes the vault
  ✔ Deposits SOL into the vault
  ✔ Withdraws SOL from the vault
  ✔ Closes the vault and returns all funds

4 passing

Tests failing or not running?

  • If you see Error: Account does not exist, make sure you ran anchor build before anchor test. The build generates the program keypair and IDL that tests rely on.
  • If the initialize test passes but deposit fails, check that you placed each it() block inside the describe() block and not outside it. Use the double-check tab above to compare your file.
  • If you see Error: failed to send transaction, the program ID in lib.rs might not match the keypair in target/deploy/. Run anchor keys sync to fix this, then anchor build again.

Help me debug my failing Anchor vault tests

Four passing tests. Your vault program handles the complete lifecycle: create, deposit, withdraw, and close with full rent recovery. These are the exact same patterns that power DeFi vaults, escrows, and staking programs in production. Next up, the Secret Mission challenges you to harden this vault with deposit caps and access control.

Secret mission

Add a Deposit Cap and Owner-Only Withdrawal

Your vault currently accepts any deposit amount and has no access control beyond PDA derivation. Time to harden it. Add a configurable deposit cap with balance tracking, and enforce owner-only withdrawals with a custom constraint and error handling.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local machine using a local Solana validator, so there are no ongoing costs.

Resources you used:

  • The sol-vault Anchor project folder on your Desktop.
  • Local Solana validator (started and stopped automatically by anchor test).
  • Compiled program artifacts in target/ (the .so file, IDL, and TypeScript types).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the vault with the Secret Mission.

  • Your sol-vault folder stays on your Desktop, ready for you to keep working.
  • The local validator only runs during anchor test, so it is not using resources when idle.

Pause - I'll come back to this later

Free up disk space from compiled artifacts while keeping your source code intact.

  • Delete the target/ folder inside sol-vault. This removes compiled artifacts and frees several hundred MB.
  • When you come back, run anchor build to regenerate everything.

Delete - I don't want to use this again

Remove all project files and start fresh.

  • Delete the entire sol-vault folder from your Desktop.
  • If you want to remove Anchor and Solana CLI tools as well, run avm uninstall 1.0.0 and solana-install update to clean up.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-pattern SOL vault on Solana using the Anchor framework.

You've learned how to:

  • Built a four-instruction Anchor program (initialize, deposit, withdraw, close) using the two-account vault pattern that powers real DeFi vaults, escrows, and staking programs.
  • Implemented PDA-signed CPIs with CpiContext::new_with_signer to let your program release funds from a vault on behalf of a Program Derived Address.
  • Managed the full account lifecycle from creation to closure with rent recovery, and verified everything with end-to-end TypeScript tests.
  • Secret Mission: Added deposit caps, balance tracking, and owner-only withdrawal with custom error handling.

Ready to quiz yourself?