Build a SOL Vault on Solana
Write and deploy an Anchor program with PDAs, CPIs, and access control.
Introduction
30 Second Summary
Think about how a bank vault keeps your money safe and only lets you access it with the right credentials. On the blockchain, smart contracts can enforce those same rules automatically, holding funds securely and controlling exactly who can deposit, withdraw, and close accounts.
In this project, you will build a SOL Vault program on Solana using the Anchor framework. You'll write, test, and deploy a smart contract that lets users create personal vaults to deposit and withdraw SOL.
What You'll Build
A fully functional Anchor program deployed to Solana devnet with four instructions: initialize, deposit, withdraw, and close.
By the end of this project, you'll have:
- A deployed Anchor program on Solana devnet with four instructions for creating, funding, draining, and closing personal SOL vaults.
- Program Derived Addresses (PDAs) that securely store vault metadata and hold SOL without needing a private key.
- Cross-Program Invocations (CPIs) that transfer SOL through the System Program, including PDA-signed transfers where a program-controlled account authorizes outbound funds.
- Rust-based tests using LiteSVM that verify the full vault lifecycle, from initialization to closing.
- Secret Mission: Extend the vault to accept SPL tokens like USDC using the Token Program and Associated Token Accounts.
Are there any prerequisites?
You need basic programming experience (variables, functions, if/else). No Rust or Solana experience is required.
This project assumes you already have WSL (Ubuntu), Rust 1.89.0, and Solana CLI 3.1.14 installed. Step 1 walks you through upgrading Anchor CLI and installing the remaining tools.
Upgrade Anchor and Set Up the Project
Before you can write any vault code, you need the right tools installed and a project scaffolded. Anchor provides the framework and CLI that make Solana program development manageable.
In this step, you'll install the Anchor CLI using its version manager, scaffold a new project, and configure your wallet for Solana's devnet.
In this step, get ready to:
- Install Anchor CLI 1.0.1 using AVM (Anchor Version Manager).
- Scaffold a new Anchor project called sol-vault.
- Configure your Solana wallet for devnet with free airdropped SOL.
Install AVM and upgrade Anchor CLI to 1.0.1
Anchor uses a version manager called AVM (Anchor Version Manager) to manage CLI installations. You'll use AVM to install and activate Anchor CLI 1.0.1.
- Install AVM by running this command:
cargo install --git https://github.com/solana-foundation/anchor avm --force
What does this command do?
This uses Cargo (Rust's package manager) to build AVM directly from the official Anchor repository. The --force flag ensures a clean install even if you have an older version.
- Confirm AVM is installed by running:
avm --version
You should see a version number in the output.
Not seeing a version number?
Make sure Cargo's bin directory (~/.cargo/bin) is in your PATH. Try closing and reopening your terminal, then run avm --version again.
Help me fix AVM not being found after cargo install.
- Install Anchor CLI 1.0.1 and set it as your active version by running these commands:
avm install 1.0.1
avm use 1.0.1
What do these commands do?
avm install 1.0.1 downloads and compiles Anchor CLI version 1.0.1. avm use 1.0.1 sets it as the active version on your system.
- Verify the Anchor CLI version by running:
anchor --version
You should see anchor-cli 1.0.1 in the output.
Seeing a different version?
Make sure you ran both avm install 1.0.1 and avm use 1.0.1. The install step downloads the binary, but use is what activates it.
Help me fix my Anchor CLI version.
- Verify your Rust and Solana CLI versions by running:
rustc --version
solana --version
You should see Rust 1.89.0 (or newer) and Solana CLI 3.1.14 (or newer).
Scaffold a new Anchor project
The anchor init command creates a complete project skeleton with all the files and directories you need. You'll use the --template single flag to start with a minimal single-file setup, then build the modular structure yourself in later steps.
Why start with a single template?
The default Anchor template generates multiple files including a pre-built initialize instruction. Starting from a single lib.rs gives you a clean slate. You'll understand exactly how each file connects as you create them.
anchor init needs Node.js and Yarn to set up the project structure, even though your tests will be written in Rust.
- Check if Node.js is installed by running:
node --version
✔️ I see a version number
Node.js is ready. Move on to the Yarn check below.
ⓧ Command not found
- Install Node.js by running these commands:
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
- Verify the installation by running:
node --version
You should see a version number in the output.
- Check if Yarn is installed by running:
yarn --version
✔️ I see a version number
Yarn is ready. Move on to creating the project below.
ⓧ Command not found
- Install Yarn by running this command:
npm install -g yarn
- Verify the installation by running:
yarn --version
You should see a version number in the output.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Anchor project by running this command:
anchor init sol-vault --template single
What should I see?
This command takes a minute or two to complete. It downloads dependencies and sets up the project structure. When it finishes, you'll see a sol-vault directory on your Desktop.
- Move into your new project directory by running:
cd sol-vault
- View the project structure by running:
ls
You should see these key files and directories:
- Anchor.toml controls your project configuration, including which cluster to deploy to and your wallet path.
- Cargo.toml defines the Rust workspace and its dependencies.
- programs/sol-vault/ is where your program's Rust source code lives.
- tests/ is where your test files go.
- View the auto-generated starter code by running:
cat programs/sol-vault/src/lib.rs
You should see something like this:
use anchor_lang::prelude::*;
declare_id!("YourAutoGeneratedProgramId");
#[program]
pub mod sol_vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
What is this starter code?
declare_id! sets your program's unique address on Solana. This was auto-generated when you ran anchor init. Your value will be different from anyone else's.
The #[program] module is where your instructions live. Right now it has a placeholder initialize function that does nothing. You'll replace all of this with your vault logic in the next steps.
Anchor init failed?
If you see errors about missing node or yarn, scroll back up and follow the Node.js and Yarn installation steps above.
If you see a permissions error, make sure you're not running the command with sudo. Anchor init should be run as your normal user.
Help me fix anchor init errors.
Configure Solana CLI for devnet and fund your wallet
Solana devnet is a free test network where you can deploy and test programs without spending real money. You'll point both the Solana CLI and your project configuration to devnet.
- Configure your Solana CLI to use devnet by running:
solana config set -ud
What does -ud mean?
The -u flag sets the RPC URL, and d is shorthand for devnet. This points your Solana CLI to https://api.devnet.solana.com.
- Verify the configuration by running:
solana config get
You should see RPC URL: https://api.devnet.solana.com in the output.
- Check if you already have a wallet keypair by running:
solana address
✔️ I see a wallet address
Your keypair is ready. Move on to airdropping SOL below.
ⓧ No such file or directory
- Generate a new keypair by running:
solana-keygen new
This command asks you to set a BIP39 passphrase. Press Enter to skip it for a devnet wallet.
- Verify the keypair was created by running:
solana address
You should see your new wallet address in the output.
- Airdrop free devnet SOL to your wallet by running:
solana airdrop 2
- Confirm your balance by running:
solana balance
You should see 2 SOL in the output.
Airdrop failed?
Devnet airdrops are rate-limited. If you see a rate limit error, wait about 30 seconds and try again.
You can also try a smaller amount like solana airdrop 1. As long as you have at least 1 SOL, you have enough for deployment and testing.
Help me fix my devnet airdrop.
Your Solana CLI now points to devnet. You also need to update your project's configuration file to match.
- Open Anchor.toml in your code editor.
- Find this line:
cluster = "Localnet"
- Replace Localnet with devnet, so the line now looks like this:
cluster = "devnet"
- Also find this line near the top of the file:
[programs.localnet]
- Replace localnet with devnet, so it looks like this:
[programs.devnet]
- Save Anchor.toml.
✔️ Awesome, I've got everything!
Make sure you've saved Anchor.toml with both changes.
ⓧ I'd like to double check the full code
Your Anchor.toml should look like this. Your program ID (the value next to sol_vault) will be different from what is shown here.
[toolchain]
channel = "1.0.1"
[features]
resolution = true
skip-lint = false
[programs.devnet]
sol_vault = "YourAutoGeneratedProgramId"
[registry]
url = "https://api.apr.dev"
[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"
[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
What is the program ID?
The value next to sol_vault under [programs.devnet] is your program's unique address on Solana. It was auto-generated by anchor init. Every program gets its own address, so yours will be different from anyone else's.
Your tools are installed and your project is ready. Next up, you'll define the data structures and custom errors that power your vault.
Define the Vault State and Custom Errors
Your Anchor project is scaffolded and your wallet is funded with devnet SOL. Before you can write the vault's instructions (initialize, deposit, withdraw, close), you need to define the data structures those instructions will work with.
Every Solana program stores data in accounts. In this step, you'll create a VaultState struct that defines what data each vault stores on-chain. You'll also define custom errors so your program can reject invalid operations with clear messages.
In this step, get ready to:
- Create a VaultState account struct that stores the vault owner and PDA bumps.
- Define custom error types for invalid deposits and withdrawals.
- Register both new modules in lib.rs.
Create the VaultState account struct
On Solana, programs don't store data inside themselves. They store data in separate accounts, each of which is a blob of raw bytes. Anchor's #[account] macro gives that blob structure by mapping it to a Rust struct.
- Create the two new source files your project needs by running this command:
touch programs/sol-vault/src/state.rs programs/sol-vault/src/error.rs
- Confirm both files exist by running:
ls programs/sol-vault/src/
You should see state.rs and error.rs alongside the existing lib.rs.
Don't see the new files?
Make sure your terminal is in the sol-vault project root directory, not inside the programs/sol-vault/src/ folder. The touch command uses a relative path from wherever your terminal currently is.
help me figure out why my touch command didn't create state.rs and error.rs in my Anchor project
- Open programs/sol-vault/src/state.rs in your editor and add the following code:
use anchor_lang::prelude::*;
#[account]
#[derive(InitSpace)]
pub struct VaultState {
// The wallet address that owns this vault
pub owner: Pubkey,
// Bump seed for the vault_state PDA
pub state_bump: u8,
// Bump seed for the vault PDA
pub vault_bump: u8,
}
What does this code do?
#[account] tells Anchor this struct represents on-chain account data. Anchor automatically adds an 8-byte discriminator at the start of the data. This discriminator acts like a type tag so the program can tell different account types apart.
#[derive(InitSpace)] lets Anchor calculate how many bytes this struct needs automatically. Pubkey is 32 bytes and each u8 is 1 byte. That's 34 bytes of data plus the 8-byte discriminator, for 42 bytes total.
owner stores who created the vault. state_bump and vault_bump store the bump seeds for both PDAs so they don't need to be recalculated every time an instruction runs.
What is a PDA?
A Program Derived Address (PDA) is an account address that is deterministically generated from a set of seeds and a program ID. PDAs don't have private keys, which means only the program that owns them can authorize changes to their data.
The "bump" is a small number Solana adds to the seeds to guarantee the derived address falls off the cryptographic curve (i.e., has no private key). Storing the bump in VaultState means future instructions can look it up instead of recalculating it.
- Save state.rs.
You won't see a visible change yet. The program will use this struct when you write the initialize instruction in the next step.
Seeing red squiggles in your editor?
Your editor's Rust analyzer might show errors because the module isn't registered in lib.rs yet. That's expected. You'll wire up the modules at the end of this step.
help me understand the red squiggles in my Anchor project
✔️ Awesome, I've got everything!
Great. Double check you've saved state.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete state.rs file:
use anchor_lang::prelude::*;
#[account]
#[derive(InitSpace)]
pub struct VaultState {
pub owner: Pubkey,
pub state_bump: u8,
pub vault_bump: u8,
}
Define custom error types
Anchor programs return a Result type from every instruction. When something goes wrong, you return an error. Anchor's #[error_code] macro lets you define descriptive error variants with human-readable messages, so callers know exactly why a transaction failed.
- Open programs/sol-vault/src/error.rs in your editor and add the following code:
use anchor_lang::prelude::*;
#[error_code]
pub enum VaultError {
// Triggered when a deposit or withdraw amount is zero
#[msg("Amount must be greater than zero.")]
ZeroAmount,
// Triggered when a withdrawal exceeds the vault balance
#[msg("Insufficient funds in the vault.")]
InsufficientFunds,
}
What does this code do?
#[error_code] turns a Rust enum into a set of program errors that Anchor can return from instructions. Each variant gets an error code number automatically. The #[msg("...")] attribute attaches a human-readable message to each variant.
ZeroAmount prevents users from depositing or withdrawing zero SOL. InsufficientFunds blocks withdrawals that exceed the vault's balance. You'll use these with Anchor's require! macro in the deposit and withdraw instructions later.
- Save error.rs.
You won't see a change yet. These errors will be referenced by the deposit and withdraw instructions you write in later steps.
✔️ 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
Here is the complete error.rs file:
use anchor_lang::prelude::*;
#[error_code]
pub enum VaultError {
#[msg("Amount must be greater than zero.")]
ZeroAmount,
#[msg("Insufficient funds in the vault.")]
InsufficientFunds,
}
Register the new modules in lib.rs
Rust organizes code into modules. Your new files won't be part of the program until you declare them as modules in the crate's entry point, lib.rs.
- Open programs/sol-vault/src/lib.rs in your editor.
- If you see a comment at the very top that says // Auto-generated by anchor init --template single, delete that line.
- Find the declare_id! line:
declare_id!("your-program-id-here");
- Add these two module declarations directly below the declare_id! line, with an empty line between them:
pub mod error;
pub mod state;
The top of your lib.rs should now look like this:
use anchor_lang::prelude::*;
declare_id!("your-program-id-here");
pub mod error;
pub mod state;
What do these lines do?
pub mod error; tells the Rust compiler to include error.rs as a module. pub mod state; does the same for state.rs. The pub keyword makes the modules public so other parts of the program (like the instructions you'll write next) can import from them.
- Save lib.rs.
Any red squiggles in your editor for state.rs and error.rs should clear up now that the modules are registered.
Still seeing errors in your editor?
Make sure the pub mod error; and pub mod state; lines are placed outside the #[program] block. They should sit between declare_id! and #[program], not inside the pub mod sol_vault { ... } braces.
Also check that all three files (lib.rs, state.rs, error.rs) are saved. Rust analyzer only picks up changes from saved files.
help me fix the module errors in my Anchor project
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete lib.rs file. Your declare_id! value will be your own auto-generated program ID, not the placeholder shown below.
use anchor_lang::prelude::*;
declare_id!("11111111111111111111111111111111");
pub mod error;
pub mod state;
#[program]
pub mod sol_vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
Your data structures and error types are locked in. Next up, you'll write the initialize instruction that creates a vault using these structures and brings PDAs to life.
Write the Initialize Instruction
You have a VaultState struct that defines what your vault stores, and custom errors ready to catch bad inputs. Now you need the instruction that actually creates a vault on-chain.
This step introduces Program Derived Addresses (PDAs), the addressing mechanism that lets your program own accounts without a private key. You will define the accounts your initialize instruction needs, write the handler logic, and compile everything.
In this step, get ready to:
- Define the Initialize accounts struct with two Program Derived Addresses.
- Write the initialize handler and emit events.
- Build the program and sync the program ID.
Define the Initialize accounts struct
Every Anchor instruction needs an accounts struct that declares which accounts the instruction reads from or writes to. For your initialize instruction, the program needs the user's wallet, two special addresses called PDAs, and the System Program.
What is a Program Derived Address?
A PDA is an address that is mathematically derived from a combination of "seeds" (like b"vault" and the owner's public key) plus a "bump" number. Because a PDA is derived from seeds, anyone can re-derive the same address given the same inputs.
PDAs have no private key. Only the program that derived them can authorize changes to them. This makes PDAs perfect for storing program-owned data like your vault metadata and held SOL.
Before writing the accounts struct, you need to set up the instructions module. Your vault program will have four instructions (initialize, deposit, withdraw, close), each in its own file inside an instructions/ directory.
- Create the instructions directory and three placeholder stub files by running these commands:
mkdir -p programs/sol-vault/src/instructions
echo "// stub - to be implemented in next step" > programs/sol-vault/src/instructions/deposit.rs
echo "// stub - to be implemented in next step" > programs/sol-vault/src/instructions/withdraw.rs
echo "// stub - to be implemented in next step" > programs/sol-vault/src/instructions/close.rs
- Confirm the files were created by running:
ls programs/sol-vault/src/instructions/
You should see close.rs, deposit.rs, and withdraw.rs listed in the output.
- Create a new file called mod.rs inside programs/sol-vault/src/instructions/ and paste the following code:
pub mod initialize;
pub mod deposit;
pub mod withdraw;
pub mod close;
pub use initialize::*;
pub use deposit::*;
pub use withdraw::*;
pub use close::*;
What does this file do?
This is the module index for your instructions directory. The pub mod lines declare each instruction file as a submodule. The pub use lines re-export everything from each submodule so that lib.rs can import all instruction types with a single use instructions::* statement.
- Save mod.rs.
Now for the main event. The Initialize accounts struct defines the two PDAs your vault needs: one to store metadata (vault_state) and one to hold actual SOL deposits (vault).
- Create a new file called initialize.rs inside programs/sol-vault/src/instructions/ and paste the following code:
use anchor_lang::prelude::*;
use crate::events::VaultInitialized;
use crate::state::VaultState;
// Define the accounts the initialize instruction expects
#[derive(Accounts)]
pub struct Initialize<'info> {
// The user creating the vault, marked mut because they pay for account creation
#[account(mut)]
pub owner: Signer<'info>,
// PDA that stores vault metadata (owner pubkey and bumps)
#[account(
init,
payer = owner,
space = 8 + VaultState::INIT_SPACE,
seeds = [b"state", owner.key().as_ref()],
bump,
)]
pub vault_state: Account<'info, VaultState>,
// PDA that will hold the actual SOL deposits
#[account(
seeds = [b"vault", owner.key().as_ref()],
bump,
)]
pub vault: SystemAccount<'info>,
// Required for creating new accounts on Solana
pub system_program: Program<'info, System>,
}
What does this accounts struct do?
- owner is the wallet creating the vault. The Signer type means Anchor verifies this account actually signed the transaction. The mut attribute marks the account as mutable because creating the vault deducts rent from the owner's balance.
- vault_state is a PDA derived from seeds [b"state", owner.key()]. The init constraint tells Anchor to create this account on-chain. The space is 8 bytes (Anchor's discriminator) plus the size of your VaultState fields.
- vault is a second PDA derived from seeds [b"vault", owner.key()]. This is a plain SystemAccount (not initialized with init) that acts as the actual SOL holding address. SOL gets transferred into this PDA during deposits.
- system_program is Solana's built-in System Program, required whenever you create accounts or transfer SOL.
Why two separate PDAs?
vault_state is a program-owned account that stores structured data (the owner's pubkey and bumps). vault is a plain system account that holds raw SOL. Keeping them separate means the vault's SOL balance is isolated from the metadata account's rent balance. This is the standard vault pattern used by production DeFi protocols on Solana.
- Save initialize.rs.
Your editor may show a red underline on the crate::events import. That is expected because you have not created the events file yet.
Write the initialize handler and events
The accounts struct declares what the instruction needs. Now you need the handler function that runs when someone calls initialize. The handler stores the owner's public key and PDA bumps into vault_state, then emits an event so off-chain applications can track vault creation.
Before writing the handler, you need to create the events it emits. Your program will emit events for all four instructions, so you will define them all now.
- Create a new file called events.rs inside programs/sol-vault/src/ and paste the following code:
use anchor_lang::prelude::*;
// Emitted when a new vault is created
#[event]
pub struct VaultInitialized {
pub owner: Pubkey,
pub vault: Pubkey,
}
// Emitted when SOL is deposited into a vault
#[event]
pub struct Deposited {
pub owner: Pubkey,
pub amount: u64,
pub vault_balance: u64,
}
// Emitted when SOL is withdrawn from a vault
#[event]
pub struct Withdrawn {
pub owner: Pubkey,
pub amount: u64,
pub vault_balance: u64,
}
// Emitted when a vault is closed
#[event]
pub struct VaultClosed {
pub owner: Pubkey,
}
What does the #[event] macro do?
The #[event] macro tells Anchor to serialize the struct and log it as a program event on-chain. Off-chain applications (frontends, indexers, analytics tools) can subscribe to these events to react in real time when users interact with your vault.
- Save events.rs.
✔️ Awesome, I've got everything!
Great. Double check you have saved events.rs.
ⓧ I'd like to double check the full code
use anchor_lang::prelude::*;
#[event]
pub struct VaultInitialized {
pub owner: Pubkey,
pub vault: Pubkey,
}
#[event]
pub struct Deposited {
pub owner: Pubkey,
pub amount: u64,
pub vault_balance: u64,
}
#[event]
pub struct Withdrawn {
pub owner: Pubkey,
pub amount: u64,
pub vault_balance: u64,
}
#[event]
pub struct VaultClosed {
pub owner: Pubkey,
}
Now add the handler logic to initialize.rs. The handler receives the bumps that Anchor automatically computed for both PDAs and stores them in vault_state for future instructions to reference.
- Open programs/sol-vault/src/instructions/initialize.rs and add the following code below the closing brace of the Initialize struct:
impl<'info> Initialize<'info> {
pub fn initialize(&mut self, bumps: &InitializeBumps) -> Result<()> {
// Store the owner's pubkey and both PDA bumps in the vault state
self.vault_state.set_inner(VaultState {
owner: self.owner.key(),
state_bump: bumps.vault_state,
vault_bump: bumps.vault,
});
// Emit an event so off-chain apps can track vault creation
emit!(VaultInitialized {
owner: self.owner.key(),
vault: self.vault.key(),
});
Ok(())
}
}
What does the handler do?
- InitializeBumps is a struct Anchor auto-generates from your accounts struct. It contains the bump values for each PDA. You store these bumps in vault_state so that future instructions (deposit, withdraw, close) can re-derive the PDA addresses without recomputing bumps.
- set_inner() writes all fields to the account in one call. Anchor serializes the data and saves it on-chain when the instruction finishes.
- emit!() logs the VaultInitialized event to the transaction log so indexers and frontends can detect new vaults.
- Save initialize.rs.
The red underline on crate::events should now be gone because the events module exists.
✔️ Awesome, I've got everything!
Great. Double check you have saved initialize.rs.
ⓧ I'd like to double check the full code
use anchor_lang::prelude::*;
use crate::events::VaultInitialized;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
init,
payer = owner,
space = 8 + VaultState::INIT_SPACE,
seeds = [b"state", owner.key().as_ref()],
bump,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
seeds = [b"vault", owner.key().as_ref()],
bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
impl<'info> Initialize<'info> {
pub fn initialize(&mut self, bumps: &InitializeBumps) -> Result<()> {
self.vault_state.set_inner(VaultState {
owner: self.owner.key(),
state_bump: bumps.vault_state,
vault_bump: bumps.vault,
});
emit!(VaultInitialized {
owner: self.owner.key(),
vault: self.vault.key(),
});
Ok(())
}
}
The last file to update is lib.rs. The current version has a placeholder Initialize struct and a stub handler left over from the scaffold. You need to replace its contents so it imports from your new instructions module and dispatches all four instructions.
- Open programs/sol-vault/src/lib.rs and replace the entire contents with the following code:
use anchor_lang::prelude::*;
declare_id!("11111111111111111111111111111111");
pub mod error;
pub mod events;
pub mod instructions;
pub mod state;
// Import all instruction types from the instructions module
use instructions::*;
#[program]
pub mod sol_vault {
use super::*;
// Each function delegates to the handler defined in its instruction file
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.initialize(&ctx.bumps)
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
ctx.accounts.deposit(amount)
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.withdraw(amount)
}
pub fn close(ctx: Context<Close>) -> Result<()> {
ctx.accounts.close_vault()
}
}
What changed in lib.rs?
- pub mod events and pub mod instructions register the new modules you created.
- use instructions::* brings all instruction types (Initialize, Deposit, Withdraw, Close) into scope.
- Each function delegates to the handler method on its accounts struct. For example, ctx.accounts.initialize(&ctx.bumps) calls the initialize() method you just wrote on the Initialize struct.
- The old placeholder Initialize struct that was defined at the bottom of lib.rs is gone. It now lives in instructions/initialize.rs.
- Save lib.rs.
Why does lib.rs reference Deposit, Withdraw, and Close already?
You are wiring up all four instruction dispatchers now so that lib.rs is in its final form. The stub files (deposit.rs, withdraw.rs, close.rs) do not define these types yet, so the program will not compile until you implement each instruction in the next steps. Think of this as setting up the skeleton first.
✔️ Awesome, I've got everything!
Great. Double check you have saved lib.rs.
ⓧ I'd like to double check the full code
use anchor_lang::prelude::*;
declare_id!("11111111111111111111111111111111");
pub mod error;
pub mod events;
pub mod instructions;
pub mod state;
use instructions::*;
#[program]
pub mod sol_vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.initialize(&ctx.bumps)
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
ctx.accounts.deposit(amount)
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.withdraw(amount)
}
pub fn close(ctx: Context<Close>) -> Result<()> {
ctx.accounts.close_vault()
}
}
Build and sync the program ID
The declare_id! at the top of lib.rs still has a placeholder value. When you first scaffolded the project, Anchor generated a keypair for your program. The anchor keys sync command reads that keypair and updates your code with the real program ID.
- Sync the program ID by running this command:
anchor keys sync
- You should see output that includes your program's public key and confirms the ID was written to lib.rs and Anchor.toml.
What is a program ID?
Every Solana program has a unique public key that identifies it on the network. This is the address users and other programs use to call your vault's instructions. The declare_id! macro hardcodes this address into your compiled program so Solana can verify the program is running at the correct address.
The program will not compile with anchor build yet because lib.rs references Deposit, Withdraw, and Close types that the stub files do not define yet. You will implement these in the next steps, and the program will compile once all four instructions are in place.
- Verify your file structure matches the expected layout by running:
ls programs/sol-vault/src/instructions/
You should see close.rs, deposit.rs, initialize.rs, mod.rs, and withdraw.rs listed.
Missing files or module errors?
- Make sure mod.rs is inside the instructions/ directory, not in src/ directly.
- Check that events.rs is in programs/sol-vault/src/ (the same folder as lib.rs), not inside instructions/.
- Verify that lib.rs has pub mod events; and pub mod instructions; lines. Rust will not find the modules without these declarations.
Still stuck? Ask for help: My Anchor project has module errors after adding the instructions directory and events.rs. Help me debug the file structure.
Your initialize instruction is fully written. The accounts struct defines the two PDAs, and the handler stores the owner and bumps then emits an event. Next up, you will write the deposit instruction so users can actually put SOL into their vault.
Write the Deposit Instruction
Your vault exists on-chain, but it is completely empty. The initialize instruction created the two PDA accounts. Now you need a way to actually move SOL into the vault.
In this step, you'll write the deposit instruction. This introduces two important Solana concepts: the has_one constraint for access control, and Cross-Program Invocations (CPIs) for transferring SOL through the System Program.
In this step, get ready to:
- Define the Deposit accounts struct with ownership enforcement.
- Write the deposit handler that transfers SOL into the vault using a CPI.
Define the Deposit accounts struct
The Deposit accounts struct tells Anchor which accounts the deposit instruction needs. It looks similar to Initialize, but with a key difference: instead of creating new PDAs, it references the ones that already exist and uses stored bumps to verify them.
- Open deposit.rs inside programs/sol-vault/src/instructions/.
- Replace the stub comment with the following code:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::error::VaultError;
use crate::events::Deposited;
use crate::state::VaultState;
// Define the accounts required for the deposit instruction
#[derive(Accounts)]
pub struct Deposit<'info> {
// The vault owner who is depositing SOL
#[account(mut)]
pub owner: Signer<'info>,
// The vault's metadata account - validates the owner matches
#[account(
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
)]
pub vault_state: Account<'info, VaultState>,
// The vault PDA that holds the actual SOL
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
What does this code do?
- The imports bring in transfer and Transfer from Anchor's System Program module. You'll use these in the next substep to move SOL.
- bump = vault_state.state_bump tells Anchor to use the bump value stored during initialization instead of recalculating it. This is cheaper and faster.
- has_one = owner checks that vault_state.owner matches the owner account passed in. If someone else tries to deposit into your vault, the transaction fails.
- The vault account is marked mut because its lamport balance will change when SOL is deposited.
- Save deposit.rs.
You won't see a visible change yet. The next substep adds the handler logic that makes this struct useful.
Seeing red squiggles in your editor?
- Make sure you replaced the entire stub comment, not just added code below it.
- Check that all five use statements at the top are present. Missing any one of them causes unresolved import errors.
- Some editor warnings are expected right now because the impl block is not written yet. These will go away in the next substep.
Help me fix import errors in my Anchor deposit accounts struct.
What does has_one = owner do?
The has_one constraint is Anchor's way of enforcing ownership. When you write has_one = owner on vault_state, Anchor automatically checks that vault_state.owner == owner.key().
Without this, anyone could pass in someone else's vault and interact with it. The has_one constraint is a single line that replaces a manual if check you would otherwise need to write yourself.
Write the deposit handler
On Solana, programs cannot move SOL between accounts directly. They need to ask the System Program to do it on their behalf. This pattern is called a Cross-Program Invocation (CPI).
The deposit handler uses CpiContext::new to build a transfer request and sends it to the System Program. Since the owner (a regular wallet) is signing the transaction, no special PDA signing is needed here.
- In deposit.rs, add this impl block below the Deposit struct (after the closing }):
impl<'info> Deposit<'info> {
pub fn deposit(&mut self, amount: u64) -> Result<()> {
// Reject zero-amount deposits
require!(amount > 0, VaultError::ZeroAmount);
// Transfer SOL from the owner's wallet to the vault PDA
transfer(
CpiContext::new(
self.system_program.to_account_info(),
Transfer {
from: self.owner.to_account_info(),
to: self.vault.to_account_info(),
},
),
amount,
)?;
// Log the deposit details as an on-chain event
emit!(Deposited {
owner: self.owner.key(),
amount,
vault_balance: self.vault.lamports(),
});
Ok(())
}
}
What does this code do?
- require!(amount > 0, VaultError::ZeroAmount) checks the deposit amount before doing anything else. If someone passes 0, the transaction fails with the custom error you defined in error.rs.
- CpiContext::new() builds a CPI request. The first argument is the program to call (the System Program). The second is a Transfer struct specifying the from and to accounts.
- The ? at the end of transfer(...)? propagates any error from the CPI call. If the transfer fails, the whole transaction rolls back.
- emit!(Deposited { ... }) logs an on-chain event with the owner, deposit amount, and resulting vault balance. Off-chain apps can listen for these events.
What is a Cross-Program Invocation?
A CPI is when one Solana program calls another program during execution. Your vault program does not have the ability to move SOL on its own. It must ask the System Program to perform the transfer.
Think of it like writing a check. Your program fills in the details (from, to, amount), but the System Program is the bank that actually moves the funds. The CpiContext is the envelope that packages everything together.
- Save deposit.rs.
The program will not compile yet because withdraw.rs and close.rs are still stubs. You'll build the full program after implementing those in the next steps.
✔️ Awesome, I've got everything!
Double check you've saved deposit.rs.
ⓧ I'd like to double check the full code
Here is the complete deposit.rs file. Make sure yours matches exactly.
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::error::VaultError;
use crate::events::Deposited;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
impl<'info> Deposit<'info> {
pub fn deposit(&mut self, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
transfer(
CpiContext::new(
self.system_program.to_account_info(),
Transfer {
from: self.owner.to_account_info(),
to: self.vault.to_account_info(),
},
),
amount,
)?;
emit!(Deposited {
owner: self.owner.key(),
amount,
vault_balance: self.vault.lamports(),
});
Ok(())
}
}
Code not matching or seeing unexpected errors?
- Make sure the impl block is outside the Deposit struct, not inside it. The struct defines accounts. The impl block defines behavior.
- Check that Transfer (capital T) is used for the struct and transfer (lowercase t) for the function call. Rust is case-sensitive.
- If you see errors about Withdraw or Close not being found, that is expected. Those stubs are implemented in the next steps.
Help me debug my Anchor deposit instruction.
Your vault can now accept deposits. Next up, you'll write the withdraw instruction, where things get more interesting. The vault PDA itself needs to "sign" the outbound transfer, and that is where PDA signing truly clicks.
Write the Withdraw Instruction
In the last step, you built the deposit instruction to move SOL from a user's wallet into the vault PDA. Now it's time to get that SOL back out.
Withdrawing introduces a new challenge. The vault PDA holds the SOL, but PDAs don't have private keys. Your program needs a way to authorize transfers on behalf of the vault, and that's where PDA signing comes in.
In this step, get ready to:
- Define the Withdraw accounts struct.
- Write the withdraw handler with PDA signing using signer seeds.
Define the Withdraw accounts struct
The Withdraw accounts struct uses the same accounts and constraints as Deposit. The owner must sign, vault_state is verified with has_one = owner, and the vault PDA holds the SOL. The difference shows up in the handler, not the struct.
- Open programs/sol-vault/src/instructions/withdraw.rs in your editor and replace the stub comment with the following code:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::error::VaultError;
use crate::events::Withdrawn;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Withdraw<'info> {
// The vault owner, must sign the transaction
#[account(mut)]
pub owner: Signer<'info>,
// Vault metadata account, verified by seeds and owner check
#[account(
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
)]
pub vault_state: Account<'info, VaultState>,
// The vault PDA that holds SOL
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
What does this code do?
This struct is identical to Deposit. The same four accounts are required, and the same constraints apply.
- has_one = owner ensures only the vault creator can withdraw.
- The vault PDA is marked mut because SOL will leave the account.
- Both PDA bumps are loaded from vault_state so Anchor can verify the addresses on-chain.
You won't see a change yet. The next chunk adds the handler that brings this struct to life.
Write the withdraw handler with PDA signing
When you deposited SOL, the owner's wallet signed the transfer. The owner is a real person with a private key, so CpiContext::new was enough.
Withdrawing flips the direction. Now the vault PDA needs to send SOL back, but a PDA has no private key. Instead, your program reconstructs the original seeds that derived the PDA and hands them to the Solana runtime. The runtime verifies that those seeds produce the correct address, then authorizes the transfer.
- Below the Withdraw struct in withdraw.rs, add the handler implementation:
impl<'info> Withdraw<'info> {
pub fn withdraw(&mut self, amount: u64) -> Result<()> {
// Reject zero-amount withdrawals
require!(amount > 0, VaultError::ZeroAmount);
// Check the vault has enough SOL
require!(
self.vault.lamports() >= amount,
VaultError::InsufficientFunds
);
// Build the signer seeds so the vault PDA can authorize the transfer
let owner_key = self.owner.key();
let seeds = &[
b"vault",
owner_key.as_ref(),
&[self.vault_state.vault_bump],
];
let signer_seeds = &[&seeds[..]];
// Transfer SOL from the vault back to the owner
transfer(
CpiContext::new_with_signer(
self.system_program.to_account_info(),
Transfer {
from: self.vault.to_account_info(),
to: self.owner.to_account_info(),
},
signer_seeds,
),
amount,
)?;
emit!(Withdrawn {
owner: self.owner.key(),
amount,
vault_balance: self.vault.lamports(),
});
Ok(())
}
}
What does this code do?
This handler has three sections: validation, the PDA-signed transfer, and an event emission.
- require!(amount > 0, ...) rejects zero-amount withdrawals, just like in deposit.
- require!(self.vault.lamports() >= amount, ...) checks that the vault actually has enough SOL before attempting the transfer.
- The seeds array reconstructs the exact same inputs that derived the vault PDA: b"vault", the owner's public key, and the bump. The runtime uses these to verify the PDA's identity.
- CpiContext::new_with_signer passes those seeds to the System Program so the vault PDA can "sign" the outbound transfer. This is the only way a PDA can authorize moving its SOL.
Why CpiContext::new_with_signer instead of CpiContext::new?
CpiContext::new works when a real wallet (like the owner) signs the transaction. The wallet's private key proves ownership.
A PDA has no private key. Instead, your program proves ownership by providing the original seeds and bump that derived the PDA address. CpiContext::new_with_signer passes those seeds to the runtime, which re-derives the address and confirms the match. If the seeds are wrong, the transaction fails.
- Save withdraw.rs.
- Verify the program compiles by running:
anchor build
You should see Finished `release` profile at the end of the output with no errors.
Seeing a compile error?
- If you see cannot find value `owner_key`, make sure let owner_key = self.owner.key(); appears before the seeds array. Rust requires variables to be declared before they're used.
- If you see unresolved import errors, check that your use statements at the top of the file match exactly, including use anchor_lang::system_program::{transfer, Transfer};.
- If the error mentions Withdrawn not found, confirm the import use crate::events::Withdrawn; is present and that events.rs defines the Withdrawn event struct.
Still stuck? Ask for help.
✔️ Awesome, I've got everything!
Great. Double check you've saved withdraw.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete withdraw.rs file. Compare it with yours line by line.
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::error::VaultError;
use crate::events::Withdrawn;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
impl<'info> Withdraw<'info> {
pub fn withdraw(&mut self, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
require!(
self.vault.lamports() >= amount,
VaultError::InsufficientFunds
);
let owner_key = self.owner.key();
let seeds = &[
b"vault",
owner_key.as_ref(),
&[self.vault_state.vault_bump],
];
let signer_seeds = &[&seeds[..]];
transfer(
CpiContext::new_with_signer(
self.system_program.to_account_info(),
Transfer {
from: self.vault.to_account_info(),
to: self.owner.to_account_info(),
},
signer_seeds,
),
amount,
)?;
emit!(Withdrawn {
owner: self.owner.key(),
amount,
vault_balance: self.vault.lamports(),
});
Ok(())
}
}
Your vault can now accept and return SOL. Next up, you'll write the close instruction to shut down a vault entirely and reclaim its rent.
Write the Close Instruction and Test Everything
Your vault can initialize, deposit, and withdraw. There is one operation left: closing the vault so the owner can reclaim all their SOL, including the rent stored in the vault_state account.
In this step, you will write the close instruction, then write Rust tests using LiteSVM to verify the full vault lifecycle. Finally, you will deploy the finished program to Solana devnet.
In this step, get ready to:
- Write the close instruction that drains the vault and reclaims rent.
- Write Rust tests covering the full vault lifecycle with LiteSVM.
- Run tests and deploy the program to devnet.
Write the close instruction
When a user is done with their vault, they should be able to close it and get all their SOL back. This means two things need to happen: any remaining SOL in the vault PDA gets transferred back to the owner, and the vault_state account gets closed to reclaim its rent.
What is rent on Solana?
Every account on Solana must hold a minimum balance of SOL to exist. This is called rent exemption. When you close an account, that SOL gets returned to whoever you specify.
Anchor's close = owner constraint handles this automatically. It zeros out the account data, transfers its lamports to the owner, and marks the discriminator as closed.
- Open programs/sol-vault/src/instructions/close.rs in your editor.
- Replace the stub comment with the following code for the Close accounts struct:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::events::VaultClosed;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Close<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
mut,
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
close = owner,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
What does this code do?
The Close accounts struct looks similar to Withdraw, with one key addition: the close = owner constraint on vault_state.
This tells Anchor to zero out the vault_state account data and transfer all its lamports (rent) to the owner at the end of the instruction. The has_one = owner constraint still enforces that only the vault creator can close it.
- Below the Close struct, add the handler implementation:
impl<'info> Close<'info> {
pub fn close_vault(&mut self) -> Result<()> {
// Check if the vault PDA still holds any SOL
let lamports = self.vault.lamports();
if lamports > 0 {
// Build signer seeds so the vault PDA can authorize the transfer
let owner_key = self.owner.key();
let seeds = &[
b"vault",
owner_key.as_ref(),
&[self.vault_state.vault_bump],
];
let signer_seeds = &[&seeds[..]];
// Transfer remaining SOL from vault back to owner
transfer(
CpiContext::new_with_signer(
self.system_program.to_account_info(),
Transfer {
from: self.vault.to_account_info(),
to: self.owner.to_account_info(),
},
signer_seeds,
),
lamports,
)?;
}
// Emit event for off-chain tracking
emit!(VaultClosed {
owner: self.owner.key(),
});
Ok(())
}
}
What does this handler do?
The handler first checks if the vault PDA still holds any SOL. If it does, it uses CpiContext::new_with_signer with the vault's signer seeds to transfer all remaining lamports back to the owner. This is the same PDA signing pattern you used in the withdraw instruction.
The close = owner constraint on vault_state runs automatically after the handler returns. It reclaims the rent from the metadata account. The vault PDA itself is a SystemAccount with zero lamports after the transfer, so no special cleanup is needed for it.
- Save close.rs.
- Build the program to make sure everything compiles by running:
anchor build
You should see a Finished message with no errors.
Seeing a compile error?
Make sure your imports at the top of close.rs include both transfer and Transfer from anchor_lang::system_program. Also check that VaultClosed is imported from crate::events.
help me debug my close.rs compile error
✔️ Awesome, I've got everything!
Great. Double check you have saved close.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete close.rs file:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
use crate::events::VaultClosed;
use crate::state::VaultState;
#[derive(Accounts)]
pub struct Close<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
mut,
seeds = [b"state", owner.key().as_ref()],
bump = vault_state.state_bump,
has_one = owner,
close = owner,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", owner.key().as_ref()],
bump = vault_state.vault_bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
impl<'info> Close<'info> {
pub fn close_vault(&mut self) -> Result<()> {
let lamports = self.vault.lamports();
if lamports > 0 {
let owner_key = self.owner.key();
let seeds = &[
b"vault",
owner_key.as_ref(),
&[self.vault_state.vault_bump],
];
let signer_seeds = &[&seeds[..]];
transfer(
CpiContext::new_with_signer(
self.system_program.to_account_info(),
Transfer {
from: self.vault.to_account_info(),
to: self.owner.to_account_info(),
},
signer_seeds,
),
lamports,
)?;
}
emit!(VaultClosed {
owner: self.owner.key(),
});
Ok(())
}
}
Write Rust tests with LiteSVM
LiteSVM is an in-process Solana VM that runs tests without spinning up a local validator. It is dramatically faster than solana-test-validator and is the default test backend in Anchor 1.0.
You will use the anchor-litesvm crate, which wraps LiteSVM with Anchor-aware helpers for building instructions, parsing events, and checking accounts.
- Open programs/sol-vault/Cargo.toml in your editor.
- Add the following dev-dependencies below the existing [dependencies] section:
[dev-dependencies]
anchor-lang = "1.0"
anchor-litesvm = "0.4"
litesvm = "0.11"
solana-sdk = "~3.0"
What are these dependencies?
anchor-lang is needed so the test can use declare_program! to generate typed client code from your program's IDL.
anchor-litesvm provides the AnchorLiteSVM test context with helpers for creating accounts, building instructions, and parsing events. litesvm is the underlying VM, and solana-sdk provides types like Pubkey and LAMPORTS_PER_SOL.
Matching dependency versions
Your program and test dependencies must use the same anchor-lang version. If they differ, you will get AccountDeserialize trait conflicts at compile time.
- Save Cargo.toml.
- Create a new test file at programs/sol-vault/tests/vault_tests.rs by running:
mkdir -p programs/sol-vault/tests
touch programs/sol-vault/tests/vault_tests.rs
- Open programs/sol-vault/tests/vault_tests.rs and add the imports and setup function:
use anchor_litesvm::{AnchorLiteSVM, Signer};
use litesvm_utils::{AssertionHelpers, TestHelpers};
use solana_sdk::native_token::LAMPORTS_PER_SOL;
use solana_sdk::pubkey::Pubkey;
use solana_sdk::system_program;
// Generate typed client code from the program IDL
anchor_lang::declare_program!(sol_vault);
// Helper to create a fresh test context with the deployed program
fn setup() -> anchor_litesvm::AnchorContext {
AnchorLiteSVM::build_with_program(
sol_vault::ID,
include_bytes!("../../../target/deploy/sol_vault.so"),
)
}
What does this setup do?
declare_program!(sol_vault) reads the compiled program's IDL and generates typed account structs and argument structs under sol_vault::client::accounts and sol_vault::client::args.
The setup() function loads the compiled .so binary into an in-process LiteSVM instance. Each test call to setup() creates a completely fresh environment.
- Below the setup function, add the full lifecycle test:
#[test]
fn test_vault_lifecycle() {
let mut ctx = setup();
// Create a funded owner account with 10 SOL
let owner = ctx.svm.create_funded_account(10 * LAMPORTS_PER_SOL).unwrap();
// Derive PDAs for vault_state and vault
let (vault_state_pda, _) = Pubkey::find_program_address(
&[b"state", owner.pubkey().as_ref()],
&sol_vault::ID,
);
let (vault_pda, _) = Pubkey::find_program_address(
&[b"vault", owner.pubkey().as_ref()],
&sol_vault::ID,
);
// --- Initialize ---
let init_ix = ctx.program()
.accounts(sol_vault::client::accounts::Initialize {
owner: owner.pubkey(),
vault_state: vault_state_pda,
vault: vault_pda,
system_program: system_program::id(),
})
.args(sol_vault::client::args::Initialize {})
.instruction()
.unwrap();
ctx.execute_instruction(init_ix, &[&owner])
.unwrap()
.assert_success();
// Verify vault_state account was created
ctx.svm.assert_account_exists(&vault_state_pda);
// --- Deposit 2 SOL ---
let deposit_amount = 2 * LAMPORTS_PER_SOL;
let deposit_ix = ctx.program()
.accounts(sol_vault::client::accounts::Deposit {
owner: owner.pubkey(),
vault_state: vault_state_pda,
vault: vault_pda,
system_program: system_program::id(),
})
.args(sol_vault::client::args::Deposit { amount: deposit_amount })
.instruction()
.unwrap();
ctx.execute_instruction(deposit_ix, &[&owner])
.unwrap()
.assert_success();
// Verify vault received the SOL
ctx.svm.assert_sol_balance(&vault_pda, deposit_amount);
// --- Withdraw 0.5 SOL ---
let withdraw_amount = LAMPORTS_PER_SOL / 2;
let withdraw_ix = ctx.program()
.accounts(sol_vault::client::accounts::Withdraw {
owner: owner.pubkey(),
vault_state: vault_state_pda,
vault: vault_pda,
system_program: system_program::id(),
})
.args(sol_vault::client::args::Withdraw { amount: withdraw_amount })
.instruction()
.unwrap();
ctx.execute_instruction(withdraw_ix, &[&owner])
.unwrap()
.assert_success();
// Verify vault balance reduced
ctx.svm.assert_sol_balance(&vault_pda, deposit_amount - withdraw_amount);
// --- Close vault ---
let close_ix = ctx.program()
.accounts(sol_vault::client::accounts::Close {
owner: owner.pubkey(),
vault_state: vault_state_pda,
vault: vault_pda,
system_program: system_program::id(),
})
.args(sol_vault::client::args::Close {})
.instruction()
.unwrap();
ctx.execute_instruction(close_ix, &[&owner])
.unwrap()
.assert_success();
// Verify vault_state account was closed
assert!(!ctx.account_exists(&vault_state_pda));
// Verify vault PDA has zero balance
ctx.svm.assert_sol_balance(&vault_pda, 0);
}
What does this test cover?
This single test exercises the full vault lifecycle. It creates a funded account, initializes a vault, deposits 2 SOL, withdraws 0.5 SOL, then closes the vault.
After each operation, it asserts a concrete result: the vault_state account exists after init, the vault balance matches after deposit and withdraw, and the vault_state account is gone after close.
- Save vault_tests.rs.
File not found error?
Make sure the tests/ directory is inside programs/sol-vault/, not at the project root. The path should be programs/sol-vault/tests/vault_tests.rs.
help me fix my LiteSVM test file location
Run tests and deploy to devnet
The test file is ready. LiteSVM tests run as standard Rust integration tests, so you use cargo test instead of anchor test. The tests execute in-process with no validator needed.
- First, rebuild the program so the .so binary is up to date by running:
anchor build
- Sync the program ID with your generated keypair by running:
anchor keys sync
What does anchor keys sync do?
When you first ran anchor build, it generated a keypair at target/deploy/sol_vault-keypair.json. The anchor keys sync command updates the declare_id! macro in lib.rs and the [programs.devnet] entry in Anchor.toml to match the actual generated program ID.
- Rebuild one more time after syncing keys so the binary includes the correct program ID:
anchor build
- Run the tests by navigating into the program directory and running:
cd programs/sol-vault
cargo test
You should see output similar to this:
running 1 test
test test_vault_lifecycle ... ok
test result: ok. 1 passed; 0 failed; 0 ignored
- Navigate back to the project root:
cd ../..
Tests failing to compile?
The most common cause is mismatched anchor-lang versions between your program and test dependencies. Both must be 1.0. Check that your [dev-dependencies] section specifies anchor-lang = "1.0".
If you see file not found for module errors, make sure the .so file exists at target/deploy/sol_vault.so. Run anchor build before cargo test.
help me debug my LiteSVM test compile errors
Tests pass. Now deploy the program to Solana devnet.
- Make sure your wallet has enough devnet SOL for deployment by running:
solana airdrop 2
Airdrop rate limits
The devnet faucet limits airdrops to 5 SOL per request. If the command fails due to rate limiting, wait a minute and try again, or use the web faucet at faucet.solana.com.
- Deploy the program to devnet by running:
anchor deploy
You should see output that includes your Program ID and a Deploy success message.
Why anchor deploy and not anchor test?
Your Anchor.toml is configured with cluster = "devnet". The anchor deploy command reads this config and deploys directly to devnet. Your LiteSVM tests already ran in-process locally with cargo test, so no validator is needed.
- Copy the Program ID from the deploy output.
- Open Solana Explorer in your browser.
- Make sure the cluster dropdown at the top right is set to Devnet.
- Paste your Program ID into the search bar and press Enter.
You should see your program's account page, confirming it is deployed as an executable program on devnet.
Deploy failed or not found on Explorer?
If the deploy fails with an insufficient funds error, run solana airdrop 2 again. Program deployment requires SOL for the program account's rent exemption.
If the program does not show up on Solana Explorer, make sure you selected Devnet in the cluster dropdown. The default is Mainnet, which will not show your devnet program.
help me troubleshoot my Solana devnet deployment
Your SOL vault is complete and live on devnet. You have a working program with four instructions, Rust tests that validate the full lifecycle, and a deployed program anyone can interact with. Next up, the Secret Mission challenges you to extend this vault to handle SPL tokens.
Secret mission
Extend the Vault to Accept SPL Tokens
Your vault handles SOL, but real DeFi protocols handle tokens like USDC and BONK too. Can you upgrade your vault to accept any SPL token using the Token Program and Associated Token Accounts?
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 Solana devnet with free airdropped SOL, so there are no ongoing costs.
Resources you used:
- The sol-vault Anchor project folder on your Desktop.
- A Solana keypair stored locally (used for devnet deployment).
- A deployed program on Solana devnet.
- Devnet SOL from airdrops (no real monetary value).
- Anchor CLI (via AVM), Node.js, and Yarn installations.
Keep everything running
No action needed. Choose this if you are still actively building on your vault or plan to extend it with the Secret Mission.
- Your sol-vault project folder stays on your Desktop, ready to open and edit.
- Your Solana keypair remains configured for devnet.
- Your deployed program stays on devnet indefinitely at no cost.
Pause - I'll come back to this later
Free up disk space while keeping everything you need to pick up later.
- Keep the sol-vault folder as-is. Your source code and Anchor config are all inside it.
- Delete the target/ folder inside sol-vault to reclaim disk space. You can rebuild it later with anchor build.
- Your Solana keypair and CLI config remain in place for when you return.
- Your deployed program stays on devnet. No action needed there.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the entire sol-vault folder from your Desktop.
- Optionally, remove Anchor CLI by deleting the AVM binary and its managed versions. You can always reinstall with cargo install later.
- Your deployed devnet program cannot be deleted, but it costs nothing and will remain inactive.
- If you no longer need Solana CLI or Rust for other projects, you can uninstall them through your system's package manager.
Mission accomplished
That's a Wrap!
Nice work! You just built and deployed a fully functional SOL Vault program on Solana devnet using Anchor 1.0. This is the same vault pattern used by production DeFi protocols like Drift and Mango.
You've learned how to:
- Written a complete Anchor program with four instructions (initialize, deposit, withdraw, and close) and deployed it to Solana devnet.
- Created and managed Program Derived Addresses (PDAs) for secure account storage, and used Cross-Program Invocations (CPIs) with PDA signing to transfer SOL through the System Program.
- Enforced ownership and access control with has_one constraints, custom error codes, and validated everything with Rust-based LiteSVM tests.
- Secret Mission: Extended the vault to handle SPL tokens using the Token Program and Associated Token Accounts.
Ready to quiz yourself?